Compare commits

..
38 Commits
Author SHA1 Message Date
Trent d90fc13955 Docs for ci/cd setup
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 5s
2026-08-11 08:06:00 +08:00
Trent 8f36ffbb02 Allow simulation for UAT and add more mimic-iv scenarios
CI / backend (push) Successful in 9m56s
CI / frontend (push) Successful in 1m54s
2026-08-11 07:25:55 +08:00
Trent 87e551c0d7 Refractor. Update styling
CI / backend (push) Successful in 10m14s
CI / frontend (push) Successful in 1m55s
2026-08-11 06:48:07 +08:00
Trent e3f3a3ced8 fis issue with blocked js files
CI / backend (push) Failing after 42s
CI / frontend (push) Canceled after 5s
2026-08-11 05:53:36 +08:00
Trent 8b1eddb587 Update to allow for seeding of database
CI / backend (push) Successful in 9m43s
CI / frontend (push) Successful in 1m52s
2026-08-11 05:29:51 +08:00
Trent 1ade75b635 Update for dns changes
CI / backend (push) Successful in 10m2s
CI / frontend (push) Successful in 1m56s
2026-08-11 04:32:48 +08:00
Trent 223a0d1664 fix: deployment
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 17s
2026-08-10 19:45:04 +08:00
Trent d41aef0898 Fix: change dashboard port to prevent clashing
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 1m44s
2026-08-10 18:05:28 +08:00
Trent 464160d7e2 Fix: network should be shared-services
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 1m49s
2026-08-10 17:52:16 +08:00
Trent 2e66d40248 Fix issue with Unable to create a 'DbContext' of type 'AppDbContext'
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 11s
2026-08-10 17:40:09 +08:00
Trent 22094a6b16 fix dotnet tools missing in migration bundle in cd
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 26s
2026-08-10 17:30:37 +08:00
Trent 9144ca7ac1 Fix cd missing node in migrate
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 31s
2026-08-10 17:20:46 +08:00
Trent d124b83cd4 Add missing simulator core from docker builder
CI / backend (push) Successful in 8m52s
CI / frontend (push) Canceled after 15s
2026-08-10 16:58:22 +08:00
Trent 5c28516412 Fix most recent fix for ci test
CI / backend (push) Successful in 9m19s
CI / frontend (push) Successful in 1m43s
2026-08-10 15:24:47 +08:00
voltsrage aee4cadf69 Add ssh keys for deployment
CI / backend (push) Failing after 5m8s
CI / frontend (push) Successful in 1m37s
2026-08-07 19:36:57 +08:00
voltsrage 5a7fc2790f Deployment updates
CI / backend (push) Successful in 10m44s
CI / frontend (push) Successful in 1m51s
2026-08-07 19:33:37 +08:00
voltsrage cd29c57b55 Ssh setup for gitea cd
CI / backend (push) Successful in 11m17s
CI / frontend (push) Successful in 1m51s
2026-08-06 17:16:07 +08:00
voltsrage de7f792947 fix feedback test issues
CI / frontend (push) Successful in 2m4s
CI / backend (push) Canceled after 10s
2026-08-06 16:24:19 +08:00
voltsrage 80b009fd23 feature: Self-Service Clinical Testing Sessions
CI / backend (push) Successful in 8m52s
CI / frontend (push) Failing after 1m39s
2026-08-06 04:03:04 +08:00
voltsrage 1d28880920 feature: Simulation Control Center (Dashboard)
CI / backend (push) Successful in 8m43s
CI / frontend (push) Successful in 2m0s
2026-08-06 02:22:55 +08:00
voltsrage 24f45851e9 feature: In-App Simulation Runner (Backend)
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 8m32s
2026-08-06 01:52:53 +08:00
voltsrage 943d41339c Update frontend test for ci
CI / backend (push) Canceled after 19s
CI / frontend (push) Successful in 4m39s
2026-08-05 22:20:05 +08:00
voltsrage d307b915cb Update fix
CI / backend (push) Successful in 12m24s
CI / frontend (push) Failing after 1m41s
2026-08-05 21:49:23 +08:00
voltsrage 6d6bee9cb5 Test fix on local
CI / backend (push) Failing after 6m14s
CI / frontend (push) Failing after 1m48s
2026-08-05 21:10:21 +08:00
voltsrage a6a491a2da Fix tests
CI / backend (push) Failing after 8m50s
CI / frontend (push) Failing after 1m45s
2026-08-05 20:21:23 +08:00
voltsrage b44954545b Fix issues with Gitea using workers
CI / backend (push) Failing after 4m41s
CI / frontend (push) Failing after 1m32s
2026-08-05 19:22:54 +08:00
voltsrage 6b22432c28 Add Kafka fix ci.yml wait fix
CI / frontend (push) Failing after 1m28s
CI / backend (push) Canceled after 5m56s
2026-08-05 18:43:49 +08:00
voltsrage b8ee572194 Change localhost to host.internal in ci.yml
CI / backend (push) Failing after 6m13s
CI / frontend (push) Failing after 1m28s
2026-08-05 18:21:07 +08:00
voltsrage 2a3ef62a7d Add deployment
CI / frontend (push) Failing after 57s
CI / backend (push) Failing after 6m27s
2026-08-05 00:26:20 +08:00
voltsrage 9e88ff6113 fix: Acknowledge closing on alert polling update 2026-06-26 03:02:15 +08:00
voltsrage 131a04630d Add license 2026-06-25 15:30:21 +08:00
voltsrage 26bef39aae Update handoff test 2026-06-25 15:25:24 +08:00
voltsrage d48e1737b5 Add dashboard chart guide 2026-06-25 15:24:08 +08:00
voltsrage fdcc646fae fix: No token refresh or revocation mechanism~ 2026-06-25 14:14:20 +08:00
voltsrage a8964381a2 feature: MIMIC-IV Replay Scenario Generator 2026-06-25 13:35:09 +08:00
voltsrage 069881991a Fix issues with initial seeding 2026-06-25 02:53:21 +08:00
voltsrage 09a84f34ba Fix issues with critical alerts breaking the simulation 2026-06-25 02:06:35 +08:00
voltsrage df6fbed401 chore: update docs 2026-06-25 00:46:54 +08:00
431 changed files with 953610 additions and 1130 deletions
+25
View File
@@ -0,0 +1,25 @@
**/bin/
**/obj/
**/node_modules/
**/dist/
**/TestResults/
**/data-protection-keys/
.git/
.vs/
.idea/
.claude/
.cursor/
docs/
scripts/
infra/
VigilCareClinicalAPI.Tests/
VigilCare.WardGateway.Tests/
VigilCare.ClinicalContracts.Tests/
# Keep scenario JSON for the API image; exclude the rest of the Simulator tree.
VigilCare.Simulator/**
!VigilCare.Simulator/Scenarios/
!VigilCare.Simulator/Scenarios/**
vigilcare-dashboard/
*.log
.env
.env.*
+95
View File
@@ -0,0 +1,95 @@
# ---- image coordinates ----
REGISTRY=gitea.example.com/vigilcare
IMAGE_TAG=v1.0.0
# ---- exposed ports on the production host ----
API_PORT=5270
GATEWAY_PORT=5081
DASHBOARD_PORT=8088
DASHBOARD_ORIGIN=https://vigilcare-clinical.vectur45.com
# Set Gitea Actions variable PROD_API_URL to the public API origin (baked into
# the dashboard image at build). Example:
# PROD_API_URL=https://api.vigilcare-clinical.vectur45.com
# Gateway public host (nginx only; compose keeps CentralApi on the Docker network):
# https://gateway.vigilcare-clinical.vectur45.com
# ---- external PostgreSQL ----
# Runtime (DML-only) connection used by the API container.
PG_CONNECTION=Host=pg.internal;Port=5432;Database=vigilcare;Username=vigilcare_app;Password=CHANGE_ME;SSL Mode=Require;Trust Server Certificate=false
# DDL-privileged connection used ONLY by the EF migration bundle (Step 6 / CD migrate job).
# Never put this credential in the API container environment.
PG_CONNECTION_DDL=Host=pg.internal;Port=5432;Database=vigilcare;Username=vigilcare_migrator;Password=CHANGE_ME;SSL Mode=Require;Trust Server Certificate=false
GATEWAY_PG_CONNECTION=Host=pg.internal;Port=5432;Database=vigilcare_ward;Username=vigilcare_app;Password=CHANGE_ME;SSL Mode=Require
# ---- external Redis ----
REDIS_CONNECTION=redis.internal:6379,password=CHANGE_ME,ssl=True,abortConnect=false
GATEWAY_REDIS_CONNECTION=redis.internal:6379,password=CHANGE_ME,ssl=True,abortConnect=false,defaultDatabase=1
# ---- external Seq ----
SEQ_URL=https://seq.internal
SEQ_API_KEY=CHANGE_ME
# ---- external Kafka ----
KAFKA_BOOTSTRAP=kafka1.internal:9093,kafka2.internal:9093,kafka3.internal:9093
KAFKA_REPLICATION_FACTOR=3
KAFKA_SECURITY_PROTOCOL=SaslSsl
KAFKA_SASL_MECHANISM=ScramSha512
KAFKA_SASL_USERNAME=vigilcare
KAFKA_SASL_PASSWORD=CHANGE_ME
# ---- external Elasticsearch ----
ES_URI=https://es.internal:9200
ES_API_KEY=CHANGE_ME
# or ES_USERNAME / ES_PASSWORD
# ---- external RabbitMQ ----
RABBITMQ_HOST=rabbit.internal
RABBITMQ_PORT=5671
RABBITMQ_USERNAME=vigilcare
RABBITMQ_PASSWORD=CHANGE_ME
RABBITMQ_USE_SSL=true
GATEWAY_RABBITMQ_HOST=rabbit.internal
GATEWAY_RABBITMQ_PORT=5671
GATEWAY_RABBITMQ_USERNAME=vigilcare_gw
GATEWAY_RABBITMQ_PASSWORD=CHANGE_ME
GATEWAY_RABBITMQ_USE_SSL=true
# ---- external MinIO ----
MINIO_ENDPOINT=minio.internal:9000
MINIO_ACCESS_KEY=CHANGE_ME
MINIO_SECRET_KEY=CHANGE_ME
MINIO_USE_SSL=true
# ---- application secrets (generate with: openssl rand -base64 48) ----
JWT_SIGNING_KEY=CHANGE_ME_MINIMUM_32_BYTES
# WARNING: rotating PHI_SEARCH_TOKEN_KEY invalidates every stored patient
# search token. See docs/ops/phi-encryption-runbook.md before changing it.
PHI_SEARCH_TOKEN_KEY=CHANGE_ME_32_BYTES
GATEWAY_API_KEY=CHANGE_ME
FHIR_API_KEY=CHANGE_ME
GATEWAY_JWT_SIGNING_KEY=CHANGE_ME_MINIMUM_32_BYTES
# ---- gateway identity ----
GATEWAY_ID=00000000-0000-0000-0000-000000000000
GATEWAY_SITE_ID=00000000-0000-0000-0000-000000000000
GATEWAY_DEPARTMENT=ICU
GATEWAY_CODE=GW-ICU-1
GATEWAY_SITE_CODE=SITE-01
GATEWAY_SITE_NAME=Primary Site
# GATEWAY_SITE_ADDRESS=
# ---- production bootstrap users (API seeds when missing; change before first deploy) ----
SEED_ADMIN_USERNAME=admin
SEED_ADMIN_PASSWORD=CHANGE_ME
SEED_ADMIN_DISPLAY_NAME=System Admin
SEED_NURSE_USERNAME=nurse
SEED_NURSE_PASSWORD=CHANGE_ME
SEED_NURSE_DISPLAY_NAME=Charge Nurse
SEED_PHYSICIAN_USERNAME=physician
SEED_PHYSICIAN_PASSWORD=CHANGE_ME
SEED_PHYSICIAN_DISPLAY_NAME=Attending Physician
# ---- clinical simulation (UAT / training; leave enabled only on synthetic data) ----
SIMULATION_ENABLED=true
SIMULATION_RUNNER_PASSWORD=CHANGE_ME
# SIMULATION_MAX_CONCURRENT_RUNS=8
+185
View File
@@ -0,0 +1,185 @@
# Phase 36 Step 12 — 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: PROD_API_URL (e.g. https://api.vigilcare-clinical.vectur45.com),
# 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: gitea.example.com/vigilcare
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 clinical-api
# Context MUST be repo root — ClinicalContracts is a sibling ProjectReference.
run: |
docker build -f VigilCareClinicalAPI/Dockerfile \
-t "${REGISTRY}/clinical-api:${{ steps.meta.outputs.tag }}" \
-t "${REGISTRY}/clinical-api:latest" .
docker push "${REGISTRY}/clinical-api:${{ steps.meta.outputs.tag }}"
docker push "${REGISTRY}/clinical-api:latest"
- name: Build and push ward-gateway
# Same repo-root context as docker-compose.yml's ward-gateway-api service.
run: |
docker build -f VigilCare.WardGateway/Dockerfile \
-t "${REGISTRY}/ward-gateway:${{ steps.meta.outputs.tag }}" \
-t "${REGISTRY}/ward-gateway:latest" .
docker push "${REGISTRY}/ward-gateway:${{ steps.meta.outputs.tag }}"
docker push "${REGISTRY}/ward-gateway:latest"
- name: Build and push dashboard
# Context is vigilcare-dashboard/ — package.json and nginx.conf live there.
run: |
docker build -f vigilcare-dashboard/Dockerfile \
--build-arg VITE_API_URL="${{ vars.PROD_API_URL }}" \
-t "${REGISTRY}/dashboard:${{ steps.meta.outputs.tag }}" \
-t "${REGISTRY}/dashboard:latest" vigilcare-dashboard
docker push "${REGISTRY}/dashboard:${{ steps.meta.outputs.tag }}"
docker push "${REGISTRY}/dashboard:latest"
migrate:
needs: build-and-push
runs-on: ubuntu-latest
# Checkout must run on the job host (Node). Do not use job-level container:.
# Build the EF bundle via Dockerfile --target migrate (context upload), not
# docker run -v — under act_runner bind mounts resolve on the Docker host.
steps:
- uses: actions/checkout@v4
- name: Build migration bundle
run: |
docker build -f VigilCareClinicalAPI/Dockerfile --target migrate \
-t vigilcare-migrate-bundle:local .
cid=$(docker create vigilcare-migrate-bundle:local)
docker cp "$cid:/out/migrate-api" ./migrate-api
docker rm "$cid"
chmod +x ./migrate-api
# Runs while the previous release is still serving traffic, so every
# migration must be backwards-compatible with the outgoing image.
# See Step 6 — expand-then-contract.
# Self-contained linux-x64 binary — runs on the job host.
- name: Apply migrations
run: ./migrate-api --connection "${{ secrets.PG_CONNECTION_DDL }}"
deploy:
needs: [build-and-push, migrate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- name: Copy compose file
run: |
scp -i ~/.ssh/id_ed25519 docker-compose.prod.yml \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/vigilcare/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
# 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
# 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:-5270}"
GATEWAY_PORT="$(env_val GATEWAY_PORT)"; GATEWAY_PORT="${GATEWAY_PORT:-5081}"
DASHBOARD_PORT="$(env_val DASHBOARD_PORT)"; DASHBOARD_PORT="${DASHBOARD_PORT:-8080}"
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:${GATEWAY_PORT}/health/live" >/dev/null && echo "Gateway live."
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
# 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
+149
View File
@@ -0,0 +1,149 @@
# Phase 36 Step 11 — build and test on every push/PR.
# Fixtures read ConnectionStrings__* / Redis__* / RabbitMq__* / Kafka__* from the
# environment (see ApiFixture / GatewayApiFixture). Dependencies come from
# docker-compose.yml so Kafka, ES, and MinIO match local integration tests —
# ApiFixture starts hosted services that require those brokers.
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
backend:
runs-on: ubuntu-latest
env:
# Kafka advertises this host on its EXTERNAL listener. The test process runs in
# a job container, so "localhost" (the compose default, correct for a dev box)
# would point the client at itself after bootstrap.
KAFKA_EXTERNAL_HOST: host.docker.internal
steps:
- uses: actions/checkout@v4
- name: Start dependency stack
run: |
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
- name: Wait for Postgres (API + gateway)
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
docker compose exec -T ward-gateway-db pg_isready -U postgres
- name: Wait for Kafka
run: |
for i in $(seq 1 90); do
# Probe the INTERNAL listener: the EXTERNAL one advertises
# host.docker.internal, which does not resolve inside the broker container.
if docker compose exec -T kafka \
/opt/kafka/bin/kafka-broker-api-versions.sh \
--bootstrap-server kafka:29092 >/dev/null 2>&1; then
echo "Kafka is ready"
exit 0
fi
echo "waiting for Kafka ($i)..."
sleep 2
done
echo "Kafka failed to become ready" >&2
docker compose logs --tail=100 kafka
exit 1
- name: Wait for Redis / RabbitMQ / Elasticsearch
# --user rabbitmq is required: docker exec defaults to root, and Erlang tooling
# creates $HOME/.erlang.cookie mode 400 when it is missing. Probing a broker
# that is still booting would leave a root-owned cookie the server itself
# cannot read, crashing it with "reading .erlang.cookie: eacces".
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 ward-gateway-redis redis-cli ping 2>/dev/null | grep -q PONG \
&& docker compose exec -T --user rabbitmq rabbitmq rabbitmq-diagnostics -q ping \
&& docker compose exec -T --user rabbitmq ward-gateway-rabbitmq rabbitmq-diagnostics -q ping \
&& docker compose exec -T elasticsearch curl -sf http://localhost:9200/_cluster/health >/dev/null \
&& echo "Redis, RabbitMQ, and Elasticsearch are ready" && exit 0
echo "waiting for brokers ($i)..."
sleep 2
done
echo "Brokers failed to become ready" >&2
docker compose ps
docker compose logs --tail=50 redis rabbitmq elasticsearch ward-gateway-redis ward-gateway-rabbitmq
exit 1
- name: Create test databases
run: |
docker compose exec -T postgres psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='vigilcare_test'" \
| grep -q 1 \
|| docker compose exec -T postgres psql -U postgres -c "CREATE DATABASE vigilcare_test"
docker compose exec -T ward-gateway-db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='vigilcare_ward_test'" \
| grep -q 1 \
|| docker compose exec -T ward-gateway-db psql -U postgres -c "CREATE DATABASE vigilcare_ward_test"
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Restore
run: dotnet restore VigilCareClinical.sln
- name: Build
run: dotnet build VigilCareClinical.sln -c Release --no-restore
- name: Test
env:
# act_runner runs in its own container; Compose publishes ports on the VM.
# host.docker.internal reaches those published ports (requires extra_hosts
# host-gateway on the runner). RabbitMQ guest remote access is enabled in
# infra/rabbitmq/rabbitmq.conf for this topology.
ConnectionStrings__DefaultConnection: "Host=host.docker.internal;Port=5436;Database=vigilcare_test;Username=postgres;Password=password"
ConnectionStrings__GatewayDb: "Host=host.docker.internal;Port=5437;Database=vigilcare_ward_test;Username=postgres;Password=password"
Redis__ConnectionString: "host.docker.internal:6382,defaultDatabase=1,allowAdmin=true"
Gateway__Redis__ConnectionString: "host.docker.internal:6383,defaultDatabase=2,allowAdmin=true"
RabbitMq__Host: "host.docker.internal"
RabbitMq__Port: "5674"
Gateway__RabbitMq__Host: "host.docker.internal"
Gateway__RabbitMq__Port: "5675"
Kafka__BootstrapServers: "host.docker.internal:9092"
Kafka__ReplicationFactor: "1"
Kafka__SecurityProtocol: "Plaintext"
Elasticsearch__Uri: "http://host.docker.internal:9200"
Minio__Endpoint: "host.docker.internal:9005"
Minio__UseSSL: "false"
ASPNETCORE_ENVIRONMENT: "Testing"
run: |
dotnet test VigilCareClinical.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 --profile ward-gateway down -v
frontend:
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- uses: actions/checkout@v4
- name: Install
working-directory: vigilcare-dashboard
run: npm ci
- name: Test
working-directory: vigilcare-dashboard
run: npm run test
- name: Build
working-directory: vigilcare-dashboard
run: npm run build
+1
View File
@@ -5,6 +5,7 @@ bin/
obj/
out/
publish/
artifacts/
# =========================
# User-specific files
+89
View File
@@ -0,0 +1,89 @@
Proprietary Software License Agreement
Copyright (c) 2024-2026 voltsrage. All Rights Reserved.
NOTICE: This software and all associated documentation, source code, object
code, APIs, designs, algorithms, data models, and related materials
(collectively, the "Software") are the exclusive property of voltsrage and
are protected by copyright law, trade secret law, and international treaties.
1. LICENSE GRANT
No license, right, or interest in the Software is granted except under a
separate, signed commercial license agreement between voltsrage and the
licensee. Possession of a copy of the Software does not convey any rights
to use, modify, distribute, sublicense, or create derivative works from
the Software.
2. RESTRICTIONS
Without a valid commercial license, you may NOT:
a. Use the Software or any portion thereof for any purpose, including but
not limited to commercial, personal, educational, or evaluation use;
b. Copy, reproduce, or duplicate the Software in whole or in part;
c. Modify, adapt, translate, reverse engineer, decompile, disassemble, or
create derivative works based on the Software;
d. Distribute, sublicense, lease, rent, loan, sell, or otherwise transfer
the Software or any rights therein to any third party;
e. Remove, alter, or obscure any proprietary notices, labels, or marks on
the Software;
f. Use the Software to provide services to third parties (including but not
limited to SaaS, hosting, or managed services) without a separate
service provider license.
3. CONFIDENTIALITY
The Software contains trade secrets and proprietary information of
voltsrage. You agree to hold the Software in strict confidence and not to
disclose it to any third party without prior written consent from
voltsrage.
4. INTELLECTUAL PROPERTY
All title, ownership rights, and intellectual property rights in and to the
Software, including but not limited to patents, copyrights, trademarks,
trade secrets, and any improvements or modifications thereto, shall remain
the sole and exclusive property of voltsrage.
5. COMMERCIAL LICENSING
Commercial licenses for the Software, including licenses for individual
components, modules, or the complete system, are available from voltsrage.
Contact voltsrage for licensing terms, pricing, and permitted use.
6. TERMINATION
Any unauthorized use, reproduction, or distribution of the Software
automatically terminates any implied rights and may result in civil and
criminal penalties. voltsrage reserves the right to pursue all available
legal remedies.
7. WARRANTY DISCLAIMER
THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. VOLTSRAGE DOES NOT
WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE, UNINTERRUPTED, OR FREE OF
HARMFUL COMPONENTS.
8. LIMITATION OF LIABILITY
IN NO EVENT SHALL VOLTSRAGE BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS,
REVENUE, DATA, OR USE, ARISING OUT OF OR RELATED TO THE SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
9. GOVERNING LAW
This Agreement shall be governed by and construed in accordance with
applicable law, without regard to conflict of law principles.
10. ENTIRE AGREEMENT
This License constitutes the entire agreement regarding the Software and
supersedes all prior agreements, understandings, and representations. No
modification of this License shall be binding unless in writing and signed
by voltsrage.
For licensing inquiries, contact: voltsrage
+186 -56
View File
@@ -2,11 +2,11 @@
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
**Implementation status:** Thirty-one planned phases are complete through Phase 33 (plus Phases 2023) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, **Role-Based Access Control (RBAC) with clinical audit logging**, the **Dashboard Gap Analysis Fixes** (SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers on vital charts), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry form, sortable/filterable ward table), **Degraded Operations Visibility** (gateway fleet operations panel, stale gateway auto-detection, discharge summary API, admin panels for user/threshold/audit/reconciliation management, degraded-mode banner), and **Alert Quality Analytics** (server-side clinician feedback with `AlertFeedback` entity, `AlertQualityAggregatorService` background metrics, quality metrics API, Grafana alert quality dashboard). Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
**Implementation status:** Phases **138** are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, **Role-Based Access Control (RBAC) with clinical audit logging**, the **Dashboard Gap Analysis Fixes** (SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers on vital charts), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry form, sortable/filterable ward table), **Degraded Operations Visibility** (gateway fleet operations panel, stale gateway auto-detection, discharge summary API, admin panels for user/threshold/audit/reconciliation management, degraded-mode banner), **Alert Quality Analytics** (server-side clinician feedback with `AlertFeedback` entity, `AlertQualityAggregatorService` background metrics, quality metrics API, Grafana alert quality dashboard, **scenario-attributed feedback** for simulated alerts), **Explainable Alerts** (immutable JSONB `explanation` on composite alerts with score contributors, trend context, structured medication context, and bedside `NarrativeSummary`; `AlertResponse` DTO on GET/list/acknowledge/resolve; dashboard `AlertReasoning.vue`; ES indexer and data lake propagation; ward gateway sync), **MIMIC-IV Replay Scenario Generator** (offline CLI tool converting real de-identified ICU data from MIT PhysioNet into VigilCare scenario JSONs; streaming CSV parser for 668K-row chartevents; 17 chart + 8 lab item ID mappings to VigilCare observation codes; GCS text-to-numeric conversion; Fahrenheit-to-Celsius; blood pressure deduplication preferring non-invasive over arterial; 10-observation cluster limit enforcement; `mimic-list` and `mimic-generate` CLI commands with Spectre.Console output; 100 patients / 140 ICU stays available for replay through NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting), and **self-service clinical simulation** (Phases 3638: default-off in-app runner, Simulation control UI, session presets AD, ward purge/reset, and a rewritten clinical testing guide so clinicians complete sessions with no terminal). Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, **token refresh and revocation** (short-lived access tokens with rotating refresh tokens, server-side logout, proactive frontend refresh), and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses — self-service Simulation sessions), [simulator-guide.md](docs/simulator-guide.md) (CLI / CI), [vigilcare-clinical-roadmap.md](docs/plans/vigilcare-clinical-roadmap.md) (Phases 3638 order).
## Domain Model — How It Maps to a Real Clinical System
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter — either directly via the REST API, through the FHIR R4 facade that maps HL7 FHIR resources from integration engines (Mirth Connect, Rhapsody), or via ward gateway edge nodes that buffer observations locally during connectivity loss and sync to the central API when the link recovers. The FHIR facade also exposes read and search interactions so EHR systems can query patient and encounter data back in standard FHIR R4 format. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, qSOFA) aggregate multiple vitals and labs into acuity scores. The sepsis pathway follows Sepsis-3 consensus: qSOFA ≥ 2 creates a bedside screening alert recommending SOFA labs; when SOFA delta ≥ 2 from baseline confirms organ dysfunction, a `SOFA_SEPSIS` alert triggers the treatment bundle. Clinicians authenticate via JWT, and role-based access control (RBAC) gates every endpoint by clinical role (Nurse, Physician, Admin, Integration). Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All clinical write actions — patient registration, alert acknowledgment, threshold changes, encounter transitions — are recorded in an append-only audit log with user identity, IP address, correlation ID, and before/after state. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently.
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter — either directly via the REST API, through the FHIR R4 facade that maps HL7 FHIR resources from integration engines (Mirth Connect, Rhapsody), or via ward gateway edge nodes that buffer observations locally during connectivity loss and sync to the central API when the link recovers. The FHIR facade also exposes read and search interactions so EHR systems can query patient and encounter data back in standard FHIR R4 format. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, qSOFA) aggregate multiple vitals and labs into acuity scores. The sepsis pathway follows Sepsis-3 consensus: qSOFA ≥ 2 creates a bedside screening alert recommending SOFA labs; when SOFA delta ≥ 2 from baseline confirms organ dysfunction, a `SOFA_SEPSIS` alert triggers the treatment bundle. Clinicians authenticate via JWT with short-lived access tokens (15 min) and rotating refresh tokens (7 days), and role-based access control (RBAC) gates every endpoint by clinical role (Nurse, Physician, Admin, Integration). Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All clinical write actions — patient registration, alert acknowledgment, threshold changes, encounter transitions — are recorded in an append-only audit log with user identity, IP address, correlation ID, and before/after state. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently.
```
Patient ─────────────────────────── one patient = one MRN, many lifetime encounters
@@ -38,7 +38,7 @@ An `idempotencyKey` (partial unique index) prevents duplicate observations when
### ClinicalAlert
A `ClinicalAlert` is generated when an observation breaches a threshold, when the qSOFA engine detects two or more organ-dysfunction criteria (screening), when SOFA delta ≥ 2 from baseline confirms sepsis, or when the NEWS2 engine computes a medium/high-risk composite score (or a single-parameter score of 3). Lifecycle: `open → acknowledged → resolved` (or `escalated` after a five-minute NACK cycle through the RabbitMQ dead-letter queue). Alerts carry an audit trail: who acknowledged, when, and with what note. Only `SOFA_SEPSIS` alerts trigger automatic sepsis bundle creation.
A `ClinicalAlert` is generated when an observation breaches a threshold, when the qSOFA engine detects two or more organ-dysfunction criteria (screening), when SOFA delta ≥ 2 from baseline confirms sepsis, or when the NEWS2 engine computes a medium/high-risk composite score (or a single-parameter score of 3). Lifecycle: `open → acknowledged → resolved` (or `escalated` after a five-minute NACK cycle through the RabbitMQ dead-letter queue). Alerts carry an audit trail: who acknowledged, when, and with what note. Composite alerts from NEWS2, SOFA, GCS, and trend detection also carry an immutable JSONB `explanation` snapshot — score contributors, trend context, medication context, and a bedside narrative — frozen at alert creation time. The human-readable `details` string remains for backward compatibility. Only `SOFA_SEPSIS` alerts trigger automatic sepsis bundle creation.
### SepsisBundle
@@ -70,13 +70,13 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Trend Detection Engine** — `TrendAnalyzerService` Kafka consumer (`trend-analyzer`) tracks rate-of-change for five vital parameters (`HEART_RATE`, `RESP_RATE`, `SYSTOLIC_BP`, `TEMP_C`, `SPO2`) using Redis sliding-window history; when velocity exceeds configured thresholds (e.g. 72→95 bpm in 30 min), creates a `RAPID_DETERIORATION` alert even if the current value is below warning thresholds; Prometheus `trend_alerts_total` and `trend_analysis_duration_seconds`
- **Alert Suppression Windows** — acknowledging a suppressible alert (`WARNING_*`, `NEWS2_WARNING`) sets a Redis key `suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min from `AlertSuppression` config; optional per-code override via `alert_thresholds.suppression_window_minutes`); `WarningEvaluator` and `News2Detector` check suppression before creating new warning alerts; critical alerts (`CRITICAL_*`, `NEWS2_EMERGENCY`, `SEPSIS_WARNING`, `RAPID_DETERIORATION`) are never suppressed; observations and NEWS2 scores continue to persist during suppression; Prometheus `alert_suppressions_total`
- **Medication Administration** — `POST /encounters/:id/medications` records drug administrations (name, dose, route, timestamp, administered-by); `GET /encounters/:id/medications` lists with optional `since` filter; `GET /medications/:id` detail; active-encounter guard; FluentValidation on request DTOs
- **Medication Correlation Annotations** — `MedicationCorrelationHelper` appends medication context to warning and NEWS2 alert details when a mapped drug was administered within the correlation window (default 90 min); drug-to-vital mappings in `MedicationCorrelation` config (`appsettings.json`); annotates rather than suppresses — alerts still fire; sepsis, trend, and critical sync-path alerts are never annotated; design rationale in `docs/decisions/medication-correlation-design.md`
- **Medication Correlation Annotations** — `MedicationCorrelationHelper` appends medication context to warning alert `details` when a mapped drug was administered within the correlation window (default 90 min); explainable alerts (NEWS2, SOFA, GCS, rapid deterioration) receive structured `MedicationContext` in the JSONB `explanation` via `TryGetContextAsync()`; drug-to-vital mappings in `MedicationCorrelation` config (`appsettings.json`); annotates rather than suppresses — alerts still fire; sepsis, trend, and critical sync-path alerts are never annotated; design rationale in `docs/decisions/medication-correlation-design.md`
- **Ward Dashboard APIs** — `GET /encounters` returns paginated `WardEncounterSummary` rows (patient name/MRN, room/bed, department, status, latest NEWS2 score, live qSOFA criteria count from Redis, sepsis bundle status, open alert count, SOFA score/delta, GCS score/classification, attending physician, admitted-at, last observation time); filterable by `status` and `department`; `GET /encounters/:id/qsofa/current` exposes Redis-backed qSOFA state; `GET /sepsis-bundles` lists bundles hospital-wide with optional `status` filter (returns `SepsisBundleSummary` with patient demographics, elements, and deadlines); CORS policy `Dashboard` allows configured origins (default `http://localhost:5173`)
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (multi-column sortable, patient search, quick-filters for critical/alerts/sepsis), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel, patient banner with demographics/allergies/emergency contact, encounter timeline, vitals entry form for manual observation recording, discharge summary panel), alert center (global acknowledge/resolve with role-aware modal and acknowledgment note preview), department overview (unit-level snapshot cards with acuity bars, patient/alert/bundle counts per department), sepsis bundle board (real-time countdown timers, on-track/at-risk/overdue urgency sorting), critical alert banner with browser notifications and audible tone, shift handoff report generator (SBAR format with ward summary, exportable via print/PDF), vital sign trend charts with medication administration markers and local replay scrubbing, NEWS2 history chart, SOFA history chart with organ-system breakdown, GCS history chart with component tracking, qSOFA evaluation history, alert reasoning with optional medication context, clinician feedback on every alert, admin panels (threshold management, user management, audit log viewer, reconciliation viewer), gateway operations dashboard with degraded-mode banner, and alert quality analytics with quality charts; role-aware sidebar navigation; polls API every 510 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (multi-column sortable, patient search, quick-filters for critical/alerts/sepsis, **SIM badge** on simulated patients), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel, patient banner with demographics/allergies/emergency contact, encounter timeline, vitals entry form for manual observation recording, discharge summary panel), alert center (global acknowledge/resolve with role-aware modal and acknowledgment note preview), department overview (unit-level snapshot cards with acuity bars, patient/alert/bundle counts per department), sepsis bundle board (real-time countdown timers, on-track/at-risk/overdue urgency sorting), critical alert banner with browser notifications and audible tone, shift handoff report generator (SBAR format with ward summary, exportable via print/PDF), vital sign trend charts with medication administration markers and local replay scrubbing, NEWS2 history chart, SOFA history chart with organ-system breakdown, GCS history chart with component tracking, qSOFA evaluation history, structured alert reasoning (`AlertReasoning.vue` — score contributors, trend context, medication context, narrative summary from `explanation`), clinician feedback on every alert, **Simulation control** (`SimulationControlView` — Sessions / Scenarios tabs, speed control, run progress, typed ward reset), admin panels (threshold management, user management, audit log viewer, reconciliation viewer), gateway operations dashboard with degraded-mode banner, and alert quality analytics with quality charts **and by-scenario breakdown**; role-aware sidebar navigation; polls API every 510 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyOrJwtMiddleware` authenticates via JWT bearer or `X-Api-Key` header (supports multiple keys via `Fhir:ApiKeys` array for zero-downtime rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals`); `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md`
- **FHIR R4 Read/Search** — `GET /fhir/R4/Patient/{id}` reads a Patient by internal ID; `GET /fhir/R4/Patient` searches by `identifier` (system|value) or lists all patients; `GET /fhir/R4/Encounter/{id}` reads an Encounter by internal ID; `GET /fhir/R4/Encounter` searches by `patient` (UUID) and/or `status` (`in-progress`, `finished`, `cancelled`); all return FHIR R4 JSON (`application/fhir+json`); search endpoints return `Bundle.type=searchset`; requires `fhir:read` permission (Admin and Integration roles); internal resources mapped back to FHIR via `PatientFhirMapper.ToFhirResponse` / `EncounterFhirMapper.ToFhirResponse` with hospital identifier resolution; Prometheus `fhir_read_total` counter with `resource_type`, `interaction`, `outcome` labels
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions (`patients:read`, `alerts:acknowledge`, `alerts:feedback`, `thresholds:write`, `fhir:ingest`, `fhir:read`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap` and logs authorization failures with structured details (user, role, permission, endpoint) plus `authorization_failures_total` Prometheus counter; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `fhir:read`, `audit:read`, and `users:admin`; integration accounts get FHIR ingest and read access; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; startup validates JWT signing key is at least 256 bits (HMAC-SHA256 minimum); four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`); frontend login page with `localStorage` token persistence and automatic `Authorization: Bearer` header injection
- **Clinical Audit Logging** — append-only `clinical_audit_logs` table records clinical write actions with user identity, entity type/ID, before/after state (JSONB), reason, IP address, and correlation ID; ten audit actions (`THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`); `AuditService` writes log entries inline with domain operations; `GET /audit-logs` admin-only query endpoint with filters by entity type, entity ID, user ID, action, and time range; indexed on entity type, entity ID, user ID, and timestamp
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions (`patients:read`, `alerts:acknowledge`, `alerts:feedback`, `thresholds:write`, `fhir:ingest`, `fhir:read`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap` and logs authorization failures with structured details (user, role, permission, endpoint) plus `authorization_failures_total` Prometheus counter; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `fhir:read`, `audit:read`, and `users:admin`; integration accounts get FHIR ingest and read access; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; startup validates JWT signing key is at least 256 bits (HMAC-SHA256 minimum); **token refresh and revocation** — short-lived access tokens (15 min) paired with rotating opaque refresh tokens (7 days) stored in the `refresh_tokens` table; `POST /auth/refresh` exchanges a valid refresh token for a new access + refresh token pair (rotation on every use revokes the previous token); `POST /auth/logout` revokes the refresh token server-side with `USER_LOGOUT` audit log; frontend auto-refreshes 1 minute before expiry, retries on 401, and redirects to login when the refresh token is exhausted; logout button in header, sidebar, and mobile nav; four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`)
- **Clinical Audit Logging** — append-only `clinical_audit_logs` table records clinical write actions with user identity, entity type/ID, before/after state (JSONB), reason, IP address, and correlation ID; twelve audit actions (`THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`, `USER_LOGOUT`, `TOKEN_REFRESHED`); `AuditService` writes log entries inline with domain operations; `GET /audit-logs` admin-only query endpoint with filters by entity type, entity ID, user ID, action, and time range; indexed on entity type, entity ID, user ID, and timestamp
- **Site & Gateway Registry** — `ClinicalSite` and `WardGateway` domain entities model ward edge nodes that buffer clinical data during connectivity loss; `POST /sites` creates clinical sites; `POST /sites/{siteId}/gateways` registers gateways under a site; `PATCH /gateways/{gatewayId}/heartbeat` (gateway API key auth) updates status (`ONLINE`, `DEGRADED`, `OFFLINE`) and reported buffer depth; `GET /sites/{siteId}/gateways` lists gateways with optional `?department=` filter; dual authentication — JWT + RBAC (`users:admin`) for admin CRUD, `GatewayApiKeyAuthenticationHandler` (`X-Api-Key` + `X-Gateway-Id`) for gateway heartbeat and future sync upload; constant-time key comparison via `CryptographicOperations.FixedTimeEquals`; `VigilCare.ClinicalContracts` shared class library with sync DTOs (`ClinicalSyncBatchRequest`, `SyncedObservation`, `SyncedAlertEvent`, `GatewayHeartbeatRequest`) consumed by both central API and ward gateway projects; Prometheus `ward_gateways_offline_gauge` and `ward_gateway_buffer_depth` via `WardGatewayMetricsCollector` (60s periodic); `GatewayRegistrySeeder` provides demo site and gateway for Docker Compose and tests; FluentValidation on all request DTOs; `GatewayRegistryTests` and `ClinicalContractsTests` integration tests
- **Ward Gateway Service** — `VigilCare.WardGateway` (`http://localhost:5081`) is a standalone ASP.NET Core 8 deployable with its own PostgreSQL, Redis, and RabbitMQ; ingests observations locally via `POST /encounters/:id/observations` with plausibility validation, Redis-cached threshold evaluation, and synchronous critical alert creation; `LocalWarningEvaluator` creates warning-range alerts; `BufferedSyncWriter` writes all clinical events to `buffered_sync_items` for central upload; `EncounterReplicaSyncService` pulls patient/encounter data from central API; `ThresholdCacheLoader` fetches thresholds from central into local Redis; `CentralReachabilityService` tracks central API connectivity; `GatewayHeartbeatService` reports status and buffer depth; `SyncUploaderService` batches and uploads buffered items when online; local RabbitMQ paging and escalation queues; `GET /encounters` ward list and `GET /encounters/:id` detail; `GET /health/live` and `GET /health/ready` (Redis, RabbitMQ, encounter replica readiness); Docker Compose `ward-gateway` profile
- **Health Check Endpoints** — `GET /health/live` (liveness — always returns 200 if the process is running) and `GET /health/ready` (readiness — checks PostgreSQL, Redis, Kafka, RabbitMQ, and Elasticsearch connectivity); both return structured JSON with per-check status and duration; anonymous access; suitable for Kubernetes probes and load balancer health checks
@@ -87,11 +87,14 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **MRN Sequence Generation** — MRN numbers are generated via a PostgreSQL sequence (`mrn_seq`) instead of MAX+1 queries; eliminates race conditions under concurrent patient registration; configurable prefix and digit count via `PatientOptions`
- **FHIR Bundle Transaction Rollback** — `FhirBundleProcessor` wraps all bundle entry processing in a database transaction; on any entry failure, the transaction is rolled back and the response includes the `OperationOutcome` for the failed entry; prevents partial state from orphaned Patient/Encounter records
- **Clinician Feedback Mode** — six quick ratings per alert (useful, too early, too late, false positive, missing context, would act); optional notes; server-side `AlertFeedback` entity persisted per user per alert (`POST /alerts/{id}/feedback`); `alerts:feedback` permission for Nurse, Physician, and Admin roles; Feedback Summary with aggregate stats and JSON/CSV export; client-side persistence for product research
- **Alert Quality Analytics** — `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve) into `alert_quality_metrics` table; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range filterable, optional alert type) and `GET /alerts/quality-metrics/summary`; Grafana alert quality dashboard (`infra/grafana/dashboards/alert-quality-dashboard.json`); frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue`; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges
- **Alert Quality Analytics** — `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve) into `alert_quality_metrics` table; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range filterable, optional alert type), `GET /alerts/quality-metrics/summary`, and `GET /alerts/quality-metrics/feedback` (per-alert feedback rows with optional `?scenarioId=` filter and `scenarioId`/`sessionId` attribution when simulation is enabled); Grafana alert quality dashboard (`infra/grafana/dashboards/alert-quality-dashboard.json`); frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue`, scenario filter, by-scenario breakdown, and CSV export including attribution columns; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges
- **Explainable Alerts** — `AlertExplanation` value object (`ScoreContributor`, `TrendContext`, `MedicationContext`, `NarrativeSummary`) serialized as JSONB on `ClinicalAlert.Explanation` at creation time; `AlertExplanationBuilder` and contributor builders (NEWS2, SOFA, GCS, trend) assemble explanation from scoring outputs; `ClinicalAlertFactory` idempotent INSERT with explanation; NEWS2, SOFA, GCS, and `TrendDetector` wire explanation and include `explanation` in `alert.generated` outbox payloads; `AlertResponse` DTO exposes optional `Explanation` on GET/list/acknowledge/resolve; Elasticsearch indexes `NarrativeSummary`; data lake Parquet includes `explanation_json`; ward gateway `LocalClinicalAlert.ExplanationJson` synced via `ClinicalSyncBatchProcessor`; dashboard `AlertReasoning.vue` + `alertExplanation.js` composable render structured reasoning; simulator `ExpectedOutcomeValidator` supports `narrativeContains` on key scenarios; `ExplainableAlertsTests` (10 tests) + `run-phase34-verification.sh`
- **Degraded Operations Visibility** — `GatewayStaleDetectorService` auto-marks gateways OFFLINE when heartbeat exceeds configurable `StaleThresholdMinutes`; `OperationsController` (`GET /operations/gateways`, `GET /operations/gateways/{id}`, `GET /operations/sites/{siteId}/summary`) provides fleet management API; `DischargeSummaryService` with `GET /encounters/{id}/discharge-summary` (info) and `GET /encounters/{id}/discharge-summary/content` (MinIO PDF download); `DischargeSummaryPanel.vue` on patient detail; `DegradedModeBanner.vue` warns when gateways are offline; `GatewayOperations.vue` operations dashboard
- **User Management** — `UsersController` (`GET /users`, `POST /users`, `PATCH /users/{id}`) for admin user account CRUD; `UserService` with BCrypt password hashing; `UserManagementView.vue` with `UserFormModal.vue` (create/edit users, role assignment, active toggle)
- **Admin Dashboard Panels** — `ThresholdManagementView.vue` with `ThresholdFormModal.vue` (create/edit alert thresholds); `AuditLogView.vue` (filterable audit log viewer with action/entity/user/date filters); `ReconciliationView.vue` (safety finding viewer); sidebar navigation with role-aware admin section; `CollapsibleSection.vue` and `SeverityBadge.vue` UI components
- **Console Replay Simulator** — standalone `VigilCare.Simulator` .NET console app replays JSON scenario files against the live API with configurable speed (`--speed 0` instant, `60` = 60× faster); commands: `replay`, `replay-all`, `validate`, `dry-run`; optional `--poll` shows alerts, NEWS2, GCS, SOFA, and sepsis bundle state during replay; `--gateway` targets the ward gateway (`http://localhost:5081`) with `--encounter-id`, `--skip-setup`, and `--gateway-token`; `alert_ack` events poll for open alerts on central before acknowledging (handles async alert pipeline at `--speed 0`); twelve sample scenarios in `VigilCare.Simulator/Scenarios/List/` (including ward outage reconnect, GCS neurological decline, SOFA sepsis progression, and SpO₂/FiO₂ fallback); user guide in `docs/simulator-guide.md`
- **In-App Clinical Simulation (Phases 3638)** — default-off `Simulation:Enabled` gate; `ScenarioCatalog` / `SessionCatalog` load scenario JSON and `sessions.json` presets; `SimulationRunner` hosted service replays via loopback API client; patients marked `IsSimulated`; `simulation_runs` with optional `SessionId`; REST under `/api/v1/simulation` (config, scenarios, sessions start, runs, data summary, purge); dashboard **Simulation** page (Sessions default tab, Scenarios catalogue, speed control, run panel, typed **Reset ward**); all-or-nothing multi-run session start with staggered launch; purge deletes only simulated patients and dependents and audits `SIMULATION_DATA_PURGED`; clinicians follow `docs/clinical-testing-guide.md` with no terminal
- **Console Replay Simulator** — standalone `VigilCare.Simulator` .NET console app (developer/CI tool — clinicians use the in-app Simulation page) replays JSON scenario files against the live API with configurable speed (`--speed 0` instant, `60` = 60× faster); commands: `replay`, `replay-all`, `validate`, `dry-run`, `mimic-list`, `mimic-generate`; optional `--poll` shows alerts, NEWS2, GCS, SOFA, and sepsis bundle state during replay; `--gateway` targets the ward gateway (`http://localhost:5081`) with `--encounter-id`, `--skip-setup`, and `--gateway-token`; `alert_ack` events poll for open alerts on central before acknowledging (handles async alert pipeline at `--speed 0`); `ExpectedOutcomeValidator` validates alert `narrativeContains` on key scenarios; twelve sample scenarios in `VigilCare.Simulator/Scenarios/List/` (including ward outage reconnect, GCS neurological decline, SOFA sepsis progression, and SpO₂/FiO₂ fallback); session presets in `VigilCare.Simulator/Scenarios/sessions.json`; MIMIC-IV scenario generation from real ICU data; user guide in `docs/simulator-guide.md`
- **MIMIC-IV Scenario Generator** — offline CLI tool that reads MIMIC-IV CSV files (`docs/MIMIC-IV/`, 100 patients, 140 ICU stays, 668K chart events, 107K lab events) and generates VigilCare scenario JSONs; `mimic-list <dir>` displays a Spectre.Console table of available stays with demographics, care unit, LOS, and outcome; `mimic-generate <dir> --stay-id <id>` produces a scenario with options for `--max-hours`, `--no-medications`, `--no-labs`, `--validate`; streaming CSV parser (`MimicCsvReader`) filters 668K-row chartevents by stay_id and item ID set at the string level before allocating records; `MimicItemMap` maps 17 chart event items (vitals, GCS, FiO₂, PaO₂, labs) and 8 lab event items to VigilCare observation codes; GCS text labels ("Obeys Commands" → 6, "To Speech" → 3) resolved from `valuenum` with text-to-numeric fallback dictionary; Fahrenheit temperature converted to Celsius; blood pressure deduplication prefers non-invasive (NBP) over arterial (ABP); 10-observation-per-cluster limit enforced by priority-based splitting (vitals first, labs spill to next offset); medications from prescriptions with dose parsing; generated scenarios pass `ScenarioValidator` and replay through the standard `replay` command
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
- **Data Lake Writer** — `DataLakeWriterService` (consumer group `data-lake-writer`) buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes date-partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/`), and commits Kafka offsets only for topic-partitions where all uploads succeeded; failed partition buffers are retained in memory and retried on the next flush cycle (prevents data loss from partial upload failures); shutdown flush uses an uncanceled token so MinIO writes complete on Ctrl+C; `kafka_partition` and `kafka_offset` columns provide audit lineage
- **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ
@@ -113,6 +116,7 @@ HTTP request
→ Controllers (REST API + FHIR R4 ingest + Site/Gateway registry)
→ Services
├── CurrentUserService (authenticated user identity from JWT claims)
├── AuthService (login, refresh token rotation, logout with revocation)
├── AuditService (append-only clinical_audit_logs on write actions)
├── PostgreSQL (EF Core — writes, keyed reads)
├── Redis (threshold cache, qSOFA state, NEWS2 parameter state, GCS state, SOFA lab cache, trend history, alert suppression keys)
@@ -146,6 +150,7 @@ IHostedServices (background):
WardGatewayMetricsCollector → polls gateway status/buffer depth every 60s → ward_gateways_offline_gauge, ward_gateway_buffer_depth
GatewayStaleDetectorService → polls gateways on interval → marks OFFLINE when heartbeat exceeds StaleThresholdMinutes
AlertQualityAggregatorService → periodically computes per-alert-type quality metrics → alert_quality_metrics table + Prometheus gauges
SimulationRunner (when Simulation:Enabled) → in-app scenario/session replay via loopback API; marks patients IsSimulated
ClinicalMetrics (singleton) → inline counters/histogram from ingest, qSOFA, NEWS2, GCS, SOFA, trend, suppression, bundle compliance, escalation paths, alert quality
Ward Gateway (VigilCare.WardGateway — separate deployable on port 5081):
@@ -202,7 +207,7 @@ VigilCareClinicalAPI/
├── Program.cs # Service registration, middleware, seed on startup
├── appsettings.json # Connection strings, Kafka, Elasticsearch, RabbitMQ, MinIO, Serilog, ReconciliationJobs
├── Controllers/
│ ├── AuthController.cs # JWT login + authenticated user profile (GET /auth/me)
│ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
│ ├── AuditLogsController.cs # Clinical audit log query (Admin only)
│ ├── PatientsController.cs # Patient CRUD, search by name/MRN
│ ├── EncountersController.cs # Encounter list (ward summary), get, status PATCH, timeline
@@ -210,7 +215,7 @@ VigilCareClinicalAPI/
│ ├── QsofaController.cs # Current qSOFA criteria count (Redis-backed) + cursor-paginated evaluation history
│ ├── ObservationsController.cs # Ingest POST, cursor-paginated GET
│ ├── AlertThresholdsController.cs # Threshold CRUD + cache invalidation
│ ├── AlertsController.cs # Alert list (global + per-encounter), acknowledge, resolve
│ ├── AlertsController.cs # Alert list (global + per-encounter), get by ID, acknowledge, resolve → AlertResponse
│ ├── OrdersController.cs # Order create, list, get, status transition, record result
│ ├── News2Controller.cs # Current NEWS2 score and cursor-paginated history
│ ├── GcsController.cs # Latest GCS score and cursor-paginated history per encounter
@@ -231,7 +236,7 @@ VigilCareClinicalAPI/
│ │ ├── Encounter.cs # Status machine; SetStatus() enforces transition matrix
│ │ ├── AlertThreshold.cs
│ │ ├── Observation.cs # Append-only; IdempotencyKey; partial unique index
│ │ ├── ClinicalAlert.cs # open → acknowledged → resolved / escalated
│ │ ├── ClinicalAlert.cs # open → acknowledged → resolved / escalated; JSONB Explanation snapshot
│ │ ├── Order.cs
│ │ ├── News2Score.cs # Composite score with seven component scores + risk level
│ │ ├── GcsScore.cs # Eye/verbal/motor components, total, classification
@@ -246,13 +251,16 @@ VigilCareClinicalAPI/
│ │ ├── ClinicalSite.cs # Hospital site with site code, name, address
│ │ ├── WardGateway.cs # Ward edge node with status, buffer depth, heartbeat, sync timestamps
│ │ ├── ClinicalUser.cs # Username, BCrypt password hash, display name, role, active flag
│ │ ├── RefreshToken.cs # Opaque refresh token with user FK, expiry, revocation timestamp
│ │ ├── ClinicalAuditLog.cs # Append-only audit trail: action, entity, user, before/after JSONB, IP, correlation ID
│ │ ├── AlertFeedback.cs # Clinician feedback per alert (one per user per alert)
│ │ └── AlertQualityMetric.cs # Per-alert-type quality metric snapshots (acknowledgement/false-positive/useful rates)
│ ├── ValueObjects/
│ │ └── AlertExplanation.cs # ScoreContributor, TrendContext, MedicationContext, NarrativeSummary
│ └── Enums/
│ ├── EncounterStatus.cs # Scheduled, Active, Discharged, Cancelled
│ ├── ClinicalRole.cs # Nurse, Physician, Admin, Integration
│ ├── AuditAction.cs # ThresholdCreated/Updated/Deleted, AlertAcknowledged/Resolved, EncounterStatusChanged, PatientRegistered, SuppressionWindowSet, UserLogin, AuthorizationDenied
│ ├── AuditAction.cs # ThresholdCreated/Updated/Deleted, AlertAcknowledged/Resolved, EncounterStatusChanged, PatientRegistered, SuppressionWindowSet, UserLogin, AuthorizationDenied, UserLogout, TokenRefreshed
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
│ ├── AlertSeverity.cs # Warning, Critical
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
@@ -297,7 +305,7 @@ VigilCareClinicalAPI/
│ └── GatewayApiKeyAuthenticationHandler.cs # X-Api-Key + X-Gateway-Id auth for gateway heartbeat/sync routes
├── Services/
│ ├── Interfaces/ # IPatientService, IEncounterService, IAuditService, IAuthService, ICurrentUserService, ISiteService, IGatewayRegistryService, …
│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, login audit log
│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh token rotation, logout revocation, audit logging
│ ├── AuditService.cs # Append-only clinical audit log writer (user, entity, before/after, IP, correlation ID)
│ ├── CurrentUserService.cs # Extracts authenticated user identity from JWT claims (HttpContext)
│ ├── PatientService.cs
@@ -305,7 +313,7 @@ VigilCareClinicalAPI/
│ ├── AlertThresholdService.cs # CRUD + Redis write-through invalidation
│ ├── ObservationService.cs # Ingest transaction: idempotency → plausibility → threshold → alert → outbox; emits Prometheus counters
│ ├── ObservationQueryService.cs # Cursor-paginated history
│ ├── AlertService.cs # Acknowledge (sets suppression), resolve, list
│ ├── AlertService.cs # Acknowledge (sets suppression), resolve, list → AlertResponse with optional Explanation
│ ├── AlertSuppressionService.cs # Redis suppress:{enc}:{type} TTL keys
│ ├── OrderService.cs # Order lifecycle; status machine; calls SepsisBundleService.OnOrderResultedAsync on result
│ ├── News2Service.cs # Current score + cursor-paginated history from PostgreSQL
@@ -324,11 +332,14 @@ VigilCareClinicalAPI/
│ ├── WarningEvaluator.cs # Warning-range evaluation; suppression + medication annotation; idempotent INSERT
│ ├── AnalyticsService.cs # Elasticsearch query wrappers
│ └── PlausibilityValidator.cs # Per-code numeric range guard
├── Alerts/
│ ├── ClinicalAlertFactory.cs # Idempotent alert INSERT with explanation JSON; outbox payload serialization
│ └── AlertExplanationBuilder.cs # Assembles explanation from contributors, trend, medication context
├── Trend/
│ ├── TrendCalculator.cs # Pure static rate-of-change logic
│ └── TrendDetector.cs # Redis history + RAPID_DETERIORATION alert creation
├── Medication/
│ └── MedicationCorrelationHelper.cs # Appends drug context to warning/NEWS2 alert details
│ └── MedicationCorrelationHelper.cs # String annotation on warning details; structured MedicationContext for explanations
├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, CreateMedicationAdministration, CreateSiteRequest, RegisterGatewayRequest, GatewayHeartbeatRequest, …
├── Observability/
│ └── Metrics/
@@ -379,7 +390,7 @@ VigilCareClinicalAPI/
│ ├── MedicationCorrelationOptions.cs # Drug-vital mappings + correlation window
│ ├── PatientOptions.cs # MRN prefix + digit count for sequence-based generation
│ ├── FhirOptions.cs # API key (single + rotation array), identifier systems, department/class maps, defaults
│ ├── JwtOptions.cs # Issuer, audience, signing key, expiration (default 8 hours)
│ ├── JwtOptions.cs # Issuer, audience, signing key, access token expiration (15 min), refresh token expiration (7 days)
│ ├── DashboardOptions.cs # CORS origins for ward dashboard frontend
│ ├── GatewayMonitoringOptions.cs # Stale gateway detection interval and threshold
│ └── AlertQualityOptions.cs # Alert quality aggregation interval
@@ -422,7 +433,7 @@ VigilCareClinicalAPI/
│ └── Sepsis/QsofaResult.cs, QsofaOutcome.cs # qSOFA detector result and screening outcome enum
├── Data/
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
│ ├── Configurations/ # IEntityTypeConfiguration per entity; ClinicalUserConfiguration, ClinicalAuditLogConfiguration, ClinicalSiteConfiguration, WardGatewayConfiguration, QsofaEvaluationConfiguration, AlertFeedbackConfiguration, AlertQualityMetricConfiguration; ElasticsearchOptions, ElasticIndexOptions
│ ├── Configurations/ # IEntityTypeConfiguration per entity; ClinicalUserConfiguration, RefreshTokenConfiguration, ClinicalAuditLogConfiguration, ClinicalSiteConfiguration, WardGatewayConfiguration, QsofaEvaluationConfiguration, AlertFeedbackConfiguration, AlertQualityMetricConfiguration; ElasticsearchOptions, ElasticIndexOptions
│ └── Seed/
│ ├── DataSeeder.cs # Seeds patients, encounters, thresholds, observations
│ ├── GatewayRegistrySeeder.cs # Seeds demo site (SITE-DEMO) and gateway (GW-ICU-3B) with fixed GUIDs
@@ -485,7 +496,8 @@ tests/
├── OperationsApiTests.cs # Operations fleet listing, gateway detail, site summary
├── Helpers/GatewayAuthHelper.cs # WithGatewayApiKey extension method for test clients
├── Alerts/
── AlertQualityAnalyticsTests.cs # Alert feedback submission, quality aggregation, metrics API
── AlertQualityAnalyticsTests.cs # Alert feedback submission, quality aggregation, metrics API
│ └── ExplainableAlertsTests.cs # Explanation JSONB, AlertResponse mapping, medication context, legacy null
├── Auth/
│ └── RbacTests.cs # RBAC — unauthenticated 401, nurse 403 on threshold write, admin audit log creation
└── Fhir/
@@ -562,35 +574,45 @@ VigilCare.WardGateway.Tests/ # Phase 21 — ward gateway i
├── WardGatewayLocalPathTests.cs # Local observation ingest, warning alerts, buffered sync items
└── WardGatewayPartitionTests.cs # Network partition simulation — offline buffering and sync upload
VigilCare.Simulator/ # Phase 16 — console replay simulator (HTTP-only, no direct DB/Kafka)
├── Program.cs # CLI: replay, replay-all, validate, dry-run
VigilCare.Simulator/ # Phase 16, 29, 34, 35 — console replay simulator (HTTP-only, no direct DB/Kafka)
├── Program.cs # CLI: replay, replay-all, validate, dry-run, mimic-list, mimic-generate
├── Commands/ # System.CommandLine command handlers
├── Client/VigilCareApiClient.cs # Typed HTTP client for all API endpoints (incl. GCS, SOFA)
├── Client/
│ ├── VigilCareApiClient.cs # Typed HTTP client for all API endpoints (incl. GCS, SOFA)
│ └── Models/AlertExplanation.cs # Explanation DTO for poll/validation
├── Engine/ReplayEngine.cs # Scenario replay with speed multiplier + event logging
├── Output/SimulatorConsole.cs # Colored output with GCS/SOFA score display
├── Output/SimulatorConsole.cs # Colored output with GCS/SOFA score + explanation display
├── Polling/ApiPoller.cs # Optional post-event alert/score/bundle/GCS/SOFA polling
├── Scenarios/ # schema.json, ScenarioLoader, ScenarioValidator
└── Scenarios/List/ # Twelve sample scenarios (sepsis, GCS, SOFA, NEWS2, stable, ward outage, …)
├── Mimic/ # Phase 35 — MIMIC-IV scenario generator
│ ├── MimicCsvReader.cs # Streaming CSV parser with header-index lookup and filter predicate
│ ├── MimicItemMap.cs # MIMIC item ID → VigilCare observation code mappings (17 chart + 8 lab)
│ ├── MimicCareUnitMap.cs # ICU care unit → department + tag mapping
│ ├── MimicDataLoader.cs # Data access layer with streaming filters for chartevents/labevents
│ ├── MimicScenarioBuilder.cs # Core algorithm: BP dedup, cluster limits, offset conversion
│ ├── MimicListCommand.cs # CLI: mimic-list — Spectre.Console table of available stays
│ └── MimicGenerateCommand.cs # CLI: mimic-generate — produces scenario JSON from a stay ID
├── Scenarios/ # schema.json, ScenarioLoader, ScenarioValidator, ExpectedOutcomeValidator
└── Scenarios/List/ # Twelve sample scenarios + MIMIC-generated scenarios
vigilcare-dashboard/ # Phases 1719, 22, 23, 2728, 31, 33 — Vue 3 ward dashboard SPA
vigilcare-dashboard/ # Phases 1719, 22, 23, 2728, 31, 3334 — Vue 3 ward dashboard SPA
├── src/
│ ├── api/ # HTTP client (auto Bearer header), encounters, clinical (GCS, SOFA, qSOFA history), alerts, analytics, sepsis, thresholds, users, audit, reconciliation, operations, alertQuality, normalize
│ ├── api/ # HTTP client (auto Bearer header, 401 auto-refresh), encounters, clinical (GCS, SOFA, qSOFA history), alerts, analytics, sepsis, thresholds, users, audit, reconciliation, operations, alertQuality, normalize
│ ├── components/
│ │ ├── admin/ # ThresholdFormModal, UserFormModal (admin CRUD modals)
│ │ ├── alerts/ # AlertCard, AcknowledgeModal (role-aware), CriticalAlertBanner (browser notifications + audible tone)
│ │ ├── alerts/ # AlertCard, AlertReasoning (structured explanation), AcknowledgeModal (role-aware), CriticalAlertBanner (browser notifications + audible tone)
│ │ ├── charts/ # SofaHistory, GcsHistory, QsofaHistory, VitalChart with medication markers, AlertQualityChart
│ │ ├── departments/ # DepartmentCard, AcuityBar (unit-level snapshot)
│ │ ├── feedback/ # FeedbackButtons, FeedbackSummary
│ │ ├── layout/ # AppShell, AppHeader, AppSidebar (role-aware admin section)
│ │ ├── layout/ # AppShell, AppHeader (user + logout), AppSidebar (user + logout + role-aware admin), MobileNav (logout)
│ │ ├── patient/ # GcsEntryForm, SofaScorePanel, PatientBanner, EncounterTimeline, VitalsEntryForm, VitalsPanel, AlertsList, DischargeSummaryPanel
│ │ ├── replay/ # ReplayControls
│ │ ├── sepsis/ # SepsisBundleTable, SepsisBundleRow, SepsisBundleCard (countdown timer)
│ │ ├── ward/ # WardTable (sortable headers), PatientRow, PatientCard, WardToolbar, SortableHeader, HandoffReport (SBAR + print)
│ │ └── ui/ # Button, Card, Badge, Skeleton, EmptyState, Modal, CollapsibleSection, SeverityBadge, DegradedModeBanner
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, useApiMode, useChartTheme, useFocusTrap, chartFormat, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, useAlertNotification, useCriticalAlertPolling, handoffReport, vitalsForm, roleAccess, auditFormat, reconciliationFormat, thresholdForm, userForm
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, useApiMode, useChartTheme, useFocusTrap, chartFormat, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, alertExplanation, criticalAlertDetect, useAlertNotification, useCriticalAlertPolling, handoffReport, vitalsForm, roleAccess, auditFormat, reconciliationFormat, thresholdForm, userForm
│ ├── plugins/ # medicationMarkerPlugin (Chart.js plugin for medication administration markers on vital charts)
│ ├── stores/ # Pinia — ward (sort + filter + search), alerts (banner + polling), settings (sort prefs + sound mute), feedback, scoring, auth, departments, sepsis, operationsStore, alertQuality
│ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary, DepartmentOverviewView, SepsisBoardView, ThresholdManagementView, UserManagementView, AuditLogView, ReconciliationView, GatewayOperations, AlertQualityAnalytics
│ ├── stores/ # Pinia — ward (sort + filter + search), alerts (banner + polling), settings (sort prefs + sound mute), feedback, scoring, auth (login + refresh token rotation + logout + expiry redirect), departments, sepsis, operationsStore, alertQuality
│ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary, DepartmentOverviewView, SepsisBoardView, SimulationControlView, ThresholdManagementView, UserManagementView, AuditLogView, ReconciliationView, GatewayOperations, AlertQualityAnalytics
│ └── __tests__/ # Vitest — tests (store, feedback, replay, charts, alerts, ward, GCS, SOFA, qSOFA, PatientBanner, EncounterTimeline, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, HandoffReport, handoffReport, VitalsEntryForm, vitalsForm, useAlertStore, useWardStore, DepartmentOverviewView, SepsisBoardView, AcknowledgeModal, CriticalAlertBanner, roleAccess, ThresholdManagementView, thresholdForm, DischargeSummaryPanel, GatewayOperations, alertQuality)
├── vite.config.js
└── README.md # Dev quick start → docs/dashboard-guide.md
@@ -623,16 +645,19 @@ scripts/
├── run-phase31-verification.sh # Phase 31 — RBAC integration tests + JWT login + audit log query
├── run-phase23-verification.sh # Phase 23 — Degraded operations visibility + gateway fleet + admin panels
├── run-phase33-verification.sh # Phase 33 — Alert quality analytics integration tests
├── run-phase34-verification.sh # Phase 34 — Explainable alerts integration tests
├── demo-network-partition.sh # Gateway network partition demo script
└── mint-gateway-jwt.sh # JWT minting helper for gateway testing
docs/
├── plans/ # Phase implementation and verification guides
├── clinical-testing-guide.md # Doctor/nurse guide — alert review & feedback sessions
│ ├── vigilcare-clinical-roadmap.md # Phases 3638 implementation order
│ └── phase-*-plan.md # Per-phase plans (incl. phase-36/37/38 simulation)
├── clinical-testing-guide.md # Doctor/nurse guide — self-service Simulation sessions AD
├── dashboard-guide.md # VigilCare Dashboard user guide (ward, patient detail, charts)
├── dashboard-gap-analysis.md # Comprehensive gap analysis — P0P5 clinical usefulness assessment
├── patient-encounter-api-lifecycle.md # Full API walkthrough: registration → active stay → discharge
├── simulator-guide.md # VigilCare.Simulator user guide
├── simulator-guide.md # VigilCare.Simulator CLI / CI user guide (clinicians use dashboard Simulation)
├── integration/
│ └── mirth-fhir-channels.md # Mirth Connect HL7v2→FHIR channel mapping (ADT A01/A03/A08, ORU R01)
├── decisions/
@@ -785,6 +810,12 @@ Without this, Prometheus may show target errors like:
For full Docker troubleshooting and recovery steps, see:
- `docs/docker-compose-usage-and-troubleshooting.md`
### Simulation mode (default off)
In-app scenario replay for clinical testing is controlled by the `Simulation` section in `appsettings.json` (or environment variables). **`Simulation:Enabled` defaults to `false`.** When enabled, the API registers session/scenario/purge endpoints and a loopback runner; clinicians use the dashboard **Simulation** page (session presets AD, individual scenarios, speed control, ward reset — see `docs/clinical-testing-guide.md`). Never enable simulation against a database with real patient data — purge and replay only touch rows marked `IsSimulated`, but the feature is intended for evaluation environments only.
Key settings: `Simulation:ScenarioDirectory` (flat folder of scenario JSON; `sessions.json` may sit in that directory or its parent), `Simulation:MaxConcurrentRuns` (default 8 — enough for Session Cs seven concurrent patients), `Simulation:LoopbackBaseUrl`, and the `simulation.runner` service account password.
### Install and Run
```bash
@@ -808,9 +839,11 @@ Health checks (anonymous, no JWT required):
- `GET /health/live` — liveness probe (always 200 if process is running)
- `GET /health/ready` — readiness probe (checks PostgreSQL, Redis, Kafka, RabbitMQ, Elasticsearch)
### Run the Simulator
### Run the Simulator (CLI / CI)
With the API running, replay a scenario from the repository root:
> **Clinicians:** prefer the dashboard **Simulation** page (Sessions / Scenarios / Reset ward). The CLI is for developers, CI, `validate` / `dry-run` / `replay-all` / `mimic-generate`, and `scripts/run-phase*-verification.sh`. See `docs/simulator-guide.md` §12.
With the API running and (for in-app use) `Simulation:Enabled=true`, replay a scenario from the repository root via CLI:
```bash
dotnet run --project VigilCare.Simulator -- replay \
@@ -820,6 +853,21 @@ dotnet run --project VigilCare.Simulator -- replay \
Other commands: `validate <file>`, `dry-run <file>`, `replay-all <directory>`. See `docs/simulator-guide.md` for the full user guide.
**MIMIC-IV real patient data (Phase 35):** generate and replay scenarios from de-identified ICU records:
```bash
# List 140 available ICU stays across 100 patients
dotnet run --project VigilCare.Simulator -- mimic-list docs/MIMIC-IV/
# Generate a 24-hour scenario from a CVICU patient
dotnet run --project VigilCare.Simulator -- mimic-generate docs/MIMIC-IV/ \
--stay-id 32604416 --max-hours 24 --validate
# Replay the generated scenario
dotnet run --project VigilCare.Simulator -- replay \
VigilCare.Simulator/Scenarios/List/mimic-s32604416.json --speed 0 --poll
```
**Ward outage reconnect (Phase 24):** scenario `ward-outage-reconnect-01.json` exercises critical hyperkalemia alerting and nurse acknowledgment during a central outage, then sync back to central when connectivity returns. Manual procedure in `docs/simulator-guide.md` §10; automated end-to-end check:
```bash
@@ -838,9 +886,11 @@ npm install
npm run dev
```
Open `http://localhost:5173` — log in with a demo account (e.g. `nurse.demo` / `DemoNurse1!`). **Virtual Ward** lists active patients sorted by NEWS2 score. Click a row for patient detail (vitals, alerts, charts, replay scrubbing, alert reasoning). Use **Alert Center** for hospital-wide triage. After reviewing alerts, rate them with the six feedback buttons and export results from **Feedback Summary** (`/feedback`).
Open `http://localhost:5173` — log in with a demo account (e.g. `nurse.demo` / `DemoNurse1!`). **Virtual Ward** lists active patients sorted by NEWS2 score. Click a row for patient detail (vitals, alerts, charts, replay scrubbing, alert reasoning). Use **Alert Center** for hospital-wide triage. After reviewing alerts, rate them with the six feedback buttons and export results from **Feedback Summary** or **Alert Quality**.
Replay a simulator scenario in another terminal to watch charts and alerts populate in real time. Run dashboard tests with `cd vigilcare-dashboard && npm test`. See `docs/dashboard-guide.md` for technical documentation and `docs/clinical-testing-guide.md` for structured clinician evaluation sessions.
With `Simulation:Enabled=true`, open **Simulation** in the sidebar: start a **Sessions** preset (AD) or an individual scenario, watch runs on the progress panel, then **Reset ward** (type `RESET`) between testers. See `docs/clinical-testing-guide.md`.
Run dashboard tests with `cd vigilcare-dashboard && npm test`. See `docs/dashboard-guide.md` for technical documentation.
### Run Tests
@@ -914,6 +964,7 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-phase31-verification.sh # RBAC integration tests + JWT login + audit log query
./scripts/run-phase23-verification.sh # Degraded operations visibility + gateway fleet + admin panels
./scripts/run-phase33-verification.sh # Alert quality analytics integration tests
./scripts/run-phase34-verification.sh # Explainable alerts integration tests
```
Phase 25 — GCS scoring (requires running API + Docker Compose; set an active encounter UUID):
@@ -980,6 +1031,12 @@ Phase 33 alert quality analytics tests only:
dotnet test --filter "FullyQualifiedName~AlertQuality"
```
Phase 34 explainable alerts tests only:
```bash
dotnet test --filter "FullyQualifiedName~ExplainableAlerts"
```
Per-phase test runners (subset of `dotnet test`):
```bash
@@ -1231,11 +1288,13 @@ Uses cursor pagination on `(recorded_at DESC, id DESC)` — offset pagination wo
|---|---|---|
| GET | `/encounters/{id}/alerts` | Paginated alert list for an encounter |
| GET | `/alerts` | Global alert list; optional `status`, `severity`, `department` filter |
| GET | `/alerts/{id}` | Alert detail |
| POST | `/alerts/{id}/acknowledge` | Acknowledge with clinician ID and optional note |
| POST | `/alerts/{id}/resolve` | Resolve (must be acknowledged first) |
| GET | `/alerts/{id}` | Alert detail (`AlertResponse` with optional `explanation`) |
| POST | `/alerts/{id}/acknowledge` | Acknowledge with clinician ID and optional note; returns `AlertResponse` |
| POST | `/alerts/{id}/resolve` | Resolve (must be acknowledged first); returns `AlertResponse` |
| POST | `/alerts/{id}/feedback` | Submit clinician feedback (one per user per alert); requires `alerts:feedback` |
**Alert response shape:** list, get, acknowledge, and resolve endpoints return `AlertResponse` — alert fields plus optional `explanation` (`scoreContributors`, `trend`, `medicationContext`, `narrativeSummary`). Omitted on legacy and threshold-only alerts.
**Alert lifecycle:**
```
@@ -1416,10 +1475,12 @@ When a correlated drug was given within the `MedicationCorrelation.CorrelationWi
### Authentication
| Method | Path | Description |
|---|---|---|
| POST | `/auth/login` | Authenticate with username/password; returns JWT bearer token |
| GET | `/auth/me` | Returns the authenticated user's profile (user ID, username, display name, role) |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/auth/login` | Anonymous | Authenticate with username/password; returns access + refresh tokens |
| POST | `/auth/refresh` | Anonymous | Exchange a valid refresh token for a new access + refresh token pair |
| POST | `/auth/logout` | JWT | Revoke the refresh token and end the session |
| GET | `/auth/me` | JWT | Returns the authenticated user's profile (user ID, username, display name, role) |
**POST `/auth/login` body:**
@@ -1428,17 +1489,42 @@ When a correlated drug was given within the `MedicationCorrelation.CorrelationWi
| `username` | string | yes | Username |
| `password` | string | yes | Password |
**Response:**
**Login response:**
| Field | Type | Description |
|---|---|---|
| `accessToken` | string | JWT bearer token |
| `expiresAt` | DateTimeOffset | Token expiration (default 8 hours) |
| `accessToken` | string | JWT bearer token (default 15 min) |
| `refreshToken` | string | Opaque refresh token (default 7 days) |
| `expiresAt` | DateTimeOffset | Access token expiration |
| `userId` | Guid | User ID |
| `username` | string | Username |
| `displayName` | string | Display name |
| `role` | string | `NURSE`, `PHYSICIAN`, `ADMIN`, `INTEGRATION` |
**POST `/auth/refresh` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `refreshToken` | string | yes | The current refresh token |
**Refresh response:**
| Field | Type | Description |
|---|---|---|
| `accessToken` | string | New JWT bearer token |
| `refreshToken` | string | New refresh token (previous one is revoked) |
| `expiresAt` | DateTimeOffset | New access token expiration |
Refresh tokens rotate on every use — each call revokes the previous refresh token and issues a new one. If the refresh token is expired, revoked, or the user account is deactivated, the endpoint returns 422 and the client must re-authenticate via login.
**POST `/auth/logout` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `refreshToken` | string | yes | The refresh token to revoke |
Returns `204 No Content`. Revokes the refresh token server-side and creates a `USER_LOGOUT` audit log entry. The access token remains valid until its natural expiration (15 min max).
**Seeded demo users:**
| Username | Password | Role |
@@ -1481,7 +1567,7 @@ All endpoints except `POST /auth/login` and `GET /fhir/R4/metadata` require auth
**GET `/audit-logs` query params:** `entityType`, `entityId`, `userId`, `action`, `from`, `to`, `page`, `pageSize`
**Audit actions:** `THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`
**Audit actions:** `THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`, `USER_LOGOUT`, `TOKEN_REFRESHED`
Each audit log entry includes `action`, `entityType`, `entityId`, `userId`, `userDisplayName`, `previousValueJson` (JSONB), `newValueJson` (JSONB), `reason`, `ipAddress`, `correlationId`, and `createdAt`.
@@ -1531,6 +1617,24 @@ Each audit log entry includes `action`, `entityType`, `entityId`, `userId`, `use
|---|---|---|
| GET | `/alerts/quality-metrics` | Per-alert-type quality metric snapshots; optional `alertType`, `from`, `to` filters |
| GET | `/alerts/quality-metrics/summary` | Aggregate alert quality rates across all alert types; optional `from`, `to` |
| GET | `/alerts/quality-metrics/feedback` | Per-alert feedback rows; optional `scenarioId`, `from`, `to`; includes `scenarioId`/`sessionId` when `Simulation:Enabled` |
### Simulation (requires `Simulation:Enabled=true`)
| Method | Path | Description |
|---|---|---|
| GET | `/simulation/config` | Feature probe — `{ enabled }` (always 200; `enabled=false` when off) |
| GET | `/simulation/scenarios` | Scenario catalogue |
| GET | `/simulation/sessions` | Session presets with resolved scenario summaries |
| POST | `/simulation/sessions/{sessionId}/start` | Start all scenarios in a preset (optional `{ speed }`); all-or-nothing capacity check |
| POST | `/simulation/runs` | Start a single scenario run |
| GET | `/simulation/runs` | Active and recent runs |
| GET | `/simulation/runs/{runId}` | One run |
| POST | `/simulation/runs/{runId}/stop` | Cancel a run |
| GET | `/simulation/data/summary` | Simulated patient / observation / alert counts |
| DELETE | `/simulation/data` | Purge simulated patients and dependents (409 if runs active) |
Requires `simulation:run` (except `GET /simulation/config`, which needs `alerts:read`). Endpoints return 404 when simulation is disabled.
### Alert Feedback
@@ -1656,6 +1760,7 @@ observationId Guid? FK → Observation (null for NEWS2, GCS, SOFA composite
alertType string e.g. CRITICAL_HEART_RATE, QSOFA_SCREEN, SOFA_SEPSIS, NEWS2_WARNING, NEWS2_EMERGENCY, GCS_CRITICAL
severity string WARNING | CRITICAL
details text required
explanation jsonb? immutable structured explanation snapshot (score contributors, trend, medication context, narrative); null on legacy/threshold-only alerts
observationCode string? observation code that triggered this alert (e.g. HEART_RATE) — enables direct lookups without LIKE pattern matching
status string open | acknowledged | resolved | escalated (default: open)
acknowledgedAt DateTimeOffset?
@@ -1846,11 +1951,24 @@ createdAt DateTimeOffset
lastLoginAt DateTimeOffset?
```
### RefreshToken
```
id Guid PK
token string required, unique (max 256) — opaque base64 token (64 random bytes)
userId Guid FK → ClinicalUser (CASCADE)
expiresAt DateTimeOffset required
createdAt DateTimeOffset
revokedAt DateTimeOffset? — set on refresh rotation or explicit logout
```
Indexes: unique `(token)`, `(user_id)`
### ClinicalAuditLog
```
id Guid PK
action string required (max 50) — THRESHOLD_CREATED | THRESHOLD_UPDATED | THRESHOLD_DELETED | ALERT_ACKNOWLEDGED | ALERT_RESOLVED | ENCOUNTER_STATUS_CHANGED | PATIENT_REGISTERED | SUPPRESSION_WINDOW_SET | USER_LOGIN | AUTHORIZATION_DENIED
action string required (max 50) — THRESHOLD_CREATED | THRESHOLD_UPDATED | THRESHOLD_DELETED | ALERT_ACKNOWLEDGED | ALERT_RESOLVED | ENCOUNTER_STATUS_CHANGED | PATIENT_REGISTERED | SUPPRESSION_WINDOW_SET | USER_LOGIN | AUTHORIZATION_DENIED | USER_LOGOUT | TOKEN_REFRESHED
entityType string required (max 100) — e.g. AlertThreshold, ClinicalAlert, Encounter, Patient, ClinicalUser
entityId Guid required
userId Guid? FK → ClinicalUser (null for system-initiated actions)
@@ -2206,7 +2324,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
## Implemented Phases
Thirty-one phases from the project roadmap are implemented and verified, including the **Site & Gateway Registry** (Phase 20), the **Ward Gateway Service** (Phase 21), the **Dashboard Gap Analysis Fixes** (Phase 22), the **Degraded Operations Visibility** (Phase 23), the **Sepsis-3 clinical refactor** (Phases 2729), the **FHIR R4 Inbound Facade** (Phase 30), **RBAC with clinical audit logging** (Phase 31), the **Alert Quality Analytics** (Phase 33), and the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry, sortable/filterable ward table). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 815, 2023, 2531, 33. Phases 1719 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
Phases **138** from the project roadmap are implemented and verified, including the **Site & Gateway Registry** (Phase 20), the **Ward Gateway Service** (Phase 21), the **Dashboard Gap Analysis Fixes** (Phase 22), the **Degraded Operations Visibility** (Phase 23), the **Sepsis-3 clinical refactor** (Phases 2729), the **FHIR R4 Inbound Facade** (Phase 30), **RBAC with clinical audit logging** (Phase 31), the **Alert Quality Analytics** (Phase 33), the **Explainable Alerts** (Phase 34), the **MIMIC-IV Replay Scenario Generator** (Phase 35), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry, sortable/filterable ward table), and **self-service clinical simulation** (Phases 3638 — in-app runner, Simulation UI, session presets, ward purge, scenario-attributed feedback). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 815, 2023, 2531, 3338. Phases 1719 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`). See also `docs/plans/vigilcare-clinical-roadmap.md`.
| Phase | Feature | Status |
|---|---|---|
@@ -2238,11 +2356,16 @@ Thirty-one phases from the project roadmap are implemented and verified, includi
| 28 | **Frontend GCS + SOFA + sepsis UI refactor** — `GcsEntryForm.vue` (bedside GCS component entry); `SofaScorePanel.vue` (organ-system breakdown with staleness indicators); `useGcs` / `useSofa` composables; `scoring` Pinia store; `ScoresPanel` updated with GCS/SOFA display; `SepsisBundlePanel` and `AlertReasoning` refactored for Sepsis-3 alert types; Vitest tests for GCS entry, SOFA panel, scores panel, alert labels; `run-phase28-verification.sh` | Done |
| 29 | **Simulator scenario expansion + clinical validation** — three new scenarios (`neurological-decline-gcs-01`, `sepsis-sofa-progression-01`, `sofa-partial-spo2-fallback-01`); existing scenarios enriched with GCS/SOFA observations; `ScenarioReplayHelper` for end-to-end test replay; `ClinicalRefactorEndToEndTests` validates qSOFA screen → SOFA labs → bundle workflow; simulator polls GCS/SOFA scores; `run-phase29-verification.sh` | Done |
| 30 | **FHIR R4 Inbound Facade** — `FhirIngestController` (`POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}`); `FhirMetadataController` (CapabilityStatement); `FhirBundleProcessor` (transaction Bundles in dependency order); `LoincCodeMapper` (19 LOINC + 3 SNOMED CT → internal codes); `FhirUnitConverter` (°F→°C); `ExternalResourceIdentifier` table + `ExternalIdentifierService` for hospital MRN/visit number ↔ internal UUID linking; `FhirApiKeyMiddleware` (`X-Api-Key` auth); `FhirExceptionFilter` (→ OperationOutcome); `PatientFhirMapper`, `EncounterFhirMapper`, `ObservationFhirMapper`, `MedicationAdministrationFhirMapper`, `FhirReferenceResolver`; idempotent patient/encounter upserts (`RegisterOrUpdateByIdentifierAsync`, `OpenOrUpdateByIdentifierAsync`); configurable identifier systems, department codes, encounter class maps (`FhirOptions`); Prometheus `fhir_ingest_total`, `fhir_mapping_errors_total`; Mirth Connect integration guide; `FhirIngestTests`; `run-phase30-verification.sh` | Done |
| 31 | **RBAC + Clinical Audit Logging** — JWT bearer authentication (`AuthService`, `AuthController`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions; `AuthorizePermission` attribute on all controller actions; `PermissionAuthorizationHandler` + `PermissionPolicyProvider` resolve `perm:*` policies; `CurrentUserService` extracts identity from JWT claims; `ClinicalUser` entity with BCrypt password hashing; `ClinicalAuditLog` append-only table with before/after JSONB, user identity, IP, and correlation ID; `AuditService` writes log entries on clinical write actions (10 audit actions); `AuditLogsController` admin-only query with filters; `FhirApiKeyOrJwtMiddleware` dual auth for FHIR routes (JWT or X-Api-Key with multi-key rotation); alert `acknowledgedBy` set from authenticated user, not request body; four seeded demo users; frontend `LoginView` + `auth` Pinia store with `localStorage` token persistence; Vue router auth guard; `RbacTests`; `run-phase31-verification.sh` | Done |
| 31 | **RBAC + Clinical Audit Logging + Token Refresh** — JWT bearer authentication (`AuthService`, `AuthController`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions; `AuthorizePermission` attribute on all controller actions; `PermissionAuthorizationHandler` + `PermissionPolicyProvider` resolve `perm:*` policies; `CurrentUserService` extracts identity from JWT claims; `ClinicalUser` entity with BCrypt password hashing; `RefreshToken` entity with DB-backed opaque token storage, rotation on use, and server-side revocation; short-lived access tokens (15 min) paired with long-lived refresh tokens (7 days); `POST /auth/refresh` and `POST /auth/logout` endpoints; `ClinicalAuditLog` append-only table with before/after JSONB, user identity, IP, and correlation ID; `AuditService` writes log entries on clinical write actions (12 audit actions including `USER_LOGOUT` and `TOKEN_REFRESHED`); `AuditLogsController` admin-only query with filters; `FhirApiKeyOrJwtMiddleware` dual auth for FHIR routes (JWT or X-Api-Key with multi-key rotation); alert `acknowledgedBy` set from authenticated user, not request body; four seeded demo users; frontend `LoginView` + `auth` Pinia store with proactive token refresh, 401 auto-retry, session expiry redirect, and logout button in header/sidebar/mobile nav; Vue router auth guard; `RbacTests`; `run-phase31-verification.sh` | Done |
| 23 | **Degraded Operations Visibility** — `GatewayStaleDetectorService` background service auto-marks gateways OFFLINE when heartbeat exceeds configurable `StaleThresholdMinutes`; `OperationsController` exposes gateway fleet listing (`GET /operations/gateways` with status/site filters), gateway detail (`GET /operations/gateways/{id}`), and site summary (`GET /operations/sites/{siteId}/summary`); `DischargeSummaryService` with `GET /encounters/{id}/discharge-summary` (info) and `GET /encounters/{id}/discharge-summary/content` (MinIO PDF download); `UsersController` (`GET /users`, `POST /users`, `PATCH /users/{id}`) for admin user account management; frontend: `GatewayOperations.vue` operations dashboard, `DegradedModeBanner.vue` warning banner, `DischargeSummaryPanel.vue` on patient detail, `ThresholdManagementView.vue` with `ThresholdFormModal.vue`, `UserManagementView.vue` with `UserFormModal.vue`, `AuditLogView.vue`, `ReconciliationView.vue`; role-aware admin sidebar navigation; `roleAccess.js` composable; `useChartTheme.js`, `useFocusTrap.js`, `useApiMode.js` composables; `CollapsibleSection.vue`, `SeverityBadge.vue` UI components; `OperationsApiTests`; `run-phase23-verification.sh` | Done |
| 33 | **Alert Quality Analytics** — `AlertFeedback` entity with per-user-per-alert constraint; `POST /alerts/{id}/feedback` server-side feedback submission with `alerts:feedback` permission (Nurse, Physician, Admin); `AlertQualityMetric` entity stores per-alert-type quality snapshots (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve); `AlertQualityAggregatorService` background service computes metrics periodically; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range + alert type filter) and `GET /alerts/quality-metrics/summary`; `AlertFeedbackConfiguration` and `AlertQualityMetricConfiguration` EF Core configs; `SubmitAlertFeedbackRequestValidator`; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges; Grafana `alert-quality-dashboard.json`; frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue` and `alertQuality` Pinia store; `AlertQualityAnalyticsTests`; `run-phase33-verification.sh` | Done |
| 34 | **Explainable Alerts** — `AlertExplanation` value object (`ScoreContributor`, `TrendContext`, `MedicationContext`, `NarrativeSummary`); JSONB `ClinicalAlert.Explanation` column (immutable at creation); contributor builders for NEWS2, SOFA, GCS; `TrendContextBuilder`; `AlertExplanationBuilder` + `ClinicalAlertFactory`; NEWS2, SOFA, GCS, and `TrendDetector` wire explanation and include `explanation` in `alert.generated` outbox; `MedicationCorrelationHelper.TryGetContextAsync()` for structured medication context; `AlertResponse` DTO + `AlertResponseMapper`; GET/list/acknowledge/resolve return `AlertResponse`; ES indexer projects `NarrativeSummary`; data lake Parquet `explanation_json`; ward gateway `LocalClinicalAlert.ExplanationJson` + sync; dashboard `AlertReasoning.vue` + `alertExplanation.js`; simulator `ExpectedOutcomeValidator` with `narrativeContains`; `ExplainableAlertsTests`; `run-phase34-verification.sh` | Done |
| 35 | **MIMIC-IV Replay Scenario Generator** — offline CLI tool in `VigilCare.Simulator/Mimic/` that reads MIMIC-IV CSV files (100 patients, 140 ICU stays, 668K chart events, 107K lab events from `docs/MIMIC-IV/`) and generates standard VigilCare scenario JSONs; `MimicCsvReader` streaming CSV parser with header-index lookup and filter predicate (memory-efficient for large files); `MimicItemMap` maps 17 chart event items (vitals, GCS, FiO₂, PaO₂, labs) and 8 lab event items to VigilCare observation codes with GCS text-to-numeric fallback dictionary, Fahrenheit-to-Celsius conversion, and blood pressure priority (non-invasive preferred over arterial); `MimicCareUnitMap` maps care units to departments and generates tags; `MimicDataLoader` streams chartevents/labevents/prescriptions filtered by stay_id + item ID set; `MimicScenarioBuilder` deduplicates BP, enforces 10-observation-per-cluster limit with priority-based splitting, computes offsetMinutes, parses medication doses; `MimicListCommand` (`mimic-list`) displays Spectre.Console table of available stays; `MimicGenerateCommand` (`mimic-generate`) produces scenario JSON with `--max-hours`, `--no-medications`, `--no-labs`, `--validate` options; generated scenarios pass `ScenarioValidator` and replay through the standard `replay` command | Done |
| 36 | **In-app simulation runner** — `Simulation:Enabled` gate; `ScenarioCatalog` + `SimulationRunner` hosted service; loopback API client; `IsSimulated` patient flag + filtered index; `simulation_runs` table; `GET/POST /api/v1/simulation/*` (config, scenarios, runs); `simulation:run` permission | Done |
| 37 | **Simulation control UI** — dashboard Simulation page with scenario catalogue, speed control, run progress panel, SIM badge / simulation mode banner | Done |
| 38 | **Self-service clinical testing sessions** — `sessions.json` presets; multi-run session start (all-or-nothing, staggered); simulated-data purge + typed reset UI; scenario attribution on alert-quality feedback/CSV; clinical testing guide rewritten for self-service | Done |
**Ward dashboard:** backend APIs (`GET /encounters` ward list with extended summary fields including SOFA/GCS/attending/admitted-at, `GET /qsofa/current`, `GET /qsofa/history`, `GET /gcs/history`, `GET /sepsis-bundles` hospital-wide list, `GET /operations/gateways` fleet management, `GET /users` user management, `GET /alerts/quality-metrics` alert quality, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `GapAnalysisFixTests`, `OperationsApiTests`, `AlertQualityAnalyticsTests`, `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table, ward sort, ward filter, department format, sepsis format, alert acknowledge, critical alert detect, handoff report, vitals form, GCS entry/history, SOFA panel/history, qSOFA history, scores panel, alert labels, PatientBanner, EncounterTimeline, medication chart markers, AcknowledgeModal, CriticalAlertBanner, DepartmentOverviewView, SepsisBoardView, VitalsEntryForm, useAlertStore, useWardStore, roleAccess, ThresholdManagementView, DischargeSummaryPanel, GatewayOperations, alertQuality).
**Ward dashboard:** backend APIs (`GET /encounters` ward list with extended summary fields including SOFA/GCS/attending/admitted-at/`isSimulated`, `GET /qsofa/current`, `GET /qsofa/history`, `GET /gcs/history`, `GET /sepsis-bundles` hospital-wide list, `GET /operations/gateways` fleet management, `GET /users` user management, `GET /alerts/quality-metrics` alert quality (+ `/feedback` with scenario attribution), `GET /alerts/{id}` with structured explanation, `/api/v1/simulation/*` when enabled, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `GapAnalysisFixTests`, `OperationsApiTests`, `AlertQualityAnalyticsTests`, `ExplainableAlertsTests`, simulation suite (`SimulationRunnerTests`, `SimulationSessionTests`, `SimulationPurgeTests`, `SessionCatalogTests`, `AlertQualityScenarioAttributionTests`), `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, AlertReasoning, charts, ward table, ward sort, ward filter, department format, sepsis format, alert acknowledge, critical alert detect, handoff report, vitals form, GCS entry/history, SOFA panel/history, qSOFA history, scores panel, alert labels, PatientBanner, EncounterTimeline, medication chart markers, AcknowledgeModal, CriticalAlertBanner, DepartmentOverviewView, SepsisBoardView, VitalsEntryForm, useAlertStore, useWardStore, roleAccess, ThresholdManagementView, DischargeSummaryPanel, GatewayOperations, alertQuality, SimulationControlView, SessionCard, ResetWardPanel, ScenarioCard, SimulationRunPanel, SimulationModeBanner, useSimulationStore).
**Enhanced Dashboard (post-Phase 22):** Major dashboard feature expansion addressing clinical workflow gaps. **Department Overview** (`/departments`) — unit-level snapshot cards showing patient count, critical/alert/bundle totals per department with acuity distribution bars; click-through to ward filtered by department. **Sepsis Bundle Board** (`/sepsis`) — real-time bundle compliance tracking with countdown timers to 1-hour deadline, urgency-sorted (overdue → at-risk → on-track), live 1-second tick updates. **Critical Alert Notifications** — `CriticalAlertBanner` surfaces new critical alerts from polling cycle with audible 880Hz two-tone alert, browser title flash, and native `Notification` API integration; mute toggle persisted in settings. **Shift Handoff Report** — `HandoffReport.vue` generates SBAR-format (Situation, Background, Assessment, Recommendation) structured reports for all ward patients, enriched with latest vitals, open alerts, pending orders, and sepsis bundle status; ward summary with department stats; print/PDF export. **Vitals Entry Form** — `VitalsEntryForm.vue` on patient detail page enables manual observation recording (7 vital parameters with AVPU dropdown) with client-side plausibility validation matching server-side ranges. **Ward Table Enhancements** — multi-column sorting (room, patient, department, NEWS2, qSOFA, sepsis, alerts) with sortable column headers, debounced patient search (name/MRN), quick-filter toggles (critical, has alerts, active sepsis), clear-all filters. **Acknowledge Modal** — role-aware acknowledgment with clinician identity pre-populated from JWT, role-specific guidance text, and acknowledgment note preview. Backend additions: `GET /sepsis-bundles` paginated hospital-wide list with `SepsisBundleSummary` (patient demographics, elements, deadlines); `WardEncounterSummary` extended with `sofaScore`, `sofaDelta`, `gcsScore`, `gcsClassification`, `lastObservationAt`, `attendingPhysician`, `admittedAt`.
@@ -2258,11 +2381,17 @@ Thirty-one phases from the project roadmap are implemented and verified, includi
**FHIR R4 integration (Phase 30):** Inbound facade accepts FHIR R4 JSON from integration engines (Mirth Connect, Rhapsody). Supports per-resource endpoints and transaction Bundles for ADT admit workflows. LOINC/SNOMED code mapping, Fahrenheit conversion, and external identifier linking enable drop-in EHR integration without changing the internal clinical pipeline.
**RBAC + audit logging (Phase 31):** JWT authentication with role-based permission gating on every endpoint. Four clinical roles with 18 granular permissions. Append-only audit logging records who did what, when, and why — with before/after state snapshots for compliance and incident review. Frontend login page with token-based session management.
**RBAC + audit logging + token refresh (Phase 31):** JWT authentication with role-based permission gating on every endpoint. Four clinical roles with 18 granular permissions. Short-lived access tokens (15 min) paired with rotating opaque refresh tokens (7 days) stored in PostgreSQL — `POST /auth/refresh` rotates tokens, `POST /auth/logout` revokes server-side. Frontend auto-refreshes before expiry, retries on 401, and redirects to login on refresh failure; logout button in header, sidebar, and mobile nav. Append-only audit logging records who did what, when, and why — with before/after state snapshots for compliance and incident review, including `USER_LOGOUT` and `TOKEN_REFRESHED` actions.
**Degraded Operations Visibility (Phase 23):** Gateway fleet operations panel with stale gateway auto-detection (`GatewayStaleDetectorService`), discharge summary API with MinIO PDF retrieval, admin panels for user management, threshold management, audit log browsing, and reconciliation viewing. Frontend adds role-aware sidebar navigation, degraded-mode banner for offline gateways, and comprehensive admin CRUD views.
**Alert Quality Analytics (Phase 33):** Server-side clinician feedback persisted as `AlertFeedback` entities (one per user per alert, six feedback types). `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, response times). REST API exposes quality metric snapshots and aggregate summaries. Grafana dashboard visualizes alert quality trends. Frontend analytics view with quality charts.
**Alert Quality Analytics (Phase 33):** Server-side clinician feedback persisted as `AlertFeedback` entities (one per user per alert, six feedback types). `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, response times). REST API exposes quality metric snapshots and aggregate summaries. Grafana dashboard visualizes alert quality trends. Frontend analytics view with quality charts. Phase 38 extends this with `GET /alerts/quality-metrics/feedback` scenario attribution (`scenarioId` / `sessionId` via `SimulationRun.EncounterId` join), scenario filter, by-scenario breakdown, and CSV columns.
**Explainable Alerts (Phase 34):** Composite alerts (NEWS2, SOFA, GCS, rapid deterioration) carry an immutable JSONB `explanation` snapshot at creation — score contributors with raw values and normal ranges, trend context (percent change, duration, direction), structured medication context, and a bedside `NarrativeSummary`. `AlertResponse` exposes explanation on GET/list/acknowledge/resolve. Downstream consumers (Elasticsearch indexer, data lake Parquet, ward gateway sync, Kafka `alert.generated`) propagate explanation without breaking legacy consumers. Dashboard `AlertReasoning.vue` renders structured reasoning. Simulator validates `narrativeContains` on key scenarios.
**MIMIC-IV Replay Scenario Generator (Phase 35):** Offline CLI tool that converts real de-identified ICU data from MIT's MIMIC-IV dataset into VigilCare scenario JSONs. `mimic-list` browses 140 ICU stays across 100 patients with demographics, care unit, LOS, and outcome. `mimic-generate` produces a scenario from a specific stay ID with options for duration capping, medication/lab exclusion, and inline validation. The streaming CSV parser handles 668K-row chartevents efficiently by filtering at the string level before allocating records. Item mappings cover 17 chart event items (vitals, GCS text-to-numeric, FiO₂, PaO₂, ICU labs) and 8 lab event items (creatinine, platelets, bilirubin, lactate, WBC, potassium, glucose, PaO₂). Blood pressure deduplication prefers non-invasive over arterial readings. The 10-observation-per-cluster limit is enforced by priority-based splitting (vitals first, labs spill to the next offset). Generated scenarios are structurally identical to hand-crafted ones and replay through the existing `replay` command (or in-app Simulation), driving NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting on real patient trajectories.
**Self-service clinical simulation (Phases 3638):** Default-off in-app runner (`Simulation:Enabled`) loads scenario JSON and `sessions.json` presets, replays via loopback as `simulation.runner`, and marks patients `IsSimulated`. Dashboard **Simulation** page offers Sessions (AD presets with all-or-nothing staggered multi-run start), Scenarios catalogue, speed control, run progress, and typed ward reset that purges only simulated data (`SIMULATION_DATA_PURGED` audit). Alert Quality feedback joins to `SimulationRun` for scenario/session attribution. Clinical testing guide rewritten so doctors and nurses complete Sessions AD with no terminal; CLI remains the developer/CI tool (`docs/simulator-guide.md` §12). Roadmap: `docs/plans/vigilcare-clinical-roadmap.md`.
**Post-phase hardening (after Phase 31):**
- **FHIR R4 read/search** — `FhirReadController` adds `GET /fhir/R4/Patient/{id}`, `GET /fhir/R4/Patient` (search by `identifier`), `GET /fhir/R4/Encounter/{id}`, `GET /fhir/R4/Encounter` (search by `patient`/`status`); new `fhir:read` permission for Admin and Integration roles; CapabilityStatement updated to advertise `read` and `searchType` interactions for Patient and Encounter
@@ -2270,8 +2399,9 @@ Thirty-one phases from the project roadmap are implemented and verified, includi
- **FHIR API key rotation** — `Fhir:ApiKeys` array alongside existing `Fhir:ApiKey` for zero-downtime key rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals` prevents timing attacks
- **Authorization failure logging** — `PermissionAuthorizationHandler` logs denied requests with structured details (username, user ID, role, required permission, endpoint); Prometheus `authorization_failures_total` counter with `permission` and `role` labels
- **JWT signing key validation** — startup guard rejects keys shorter than 256 bits (HMAC-SHA256 minimum); prevents silent misconfiguration that would weaken token verification
- **Token refresh and revocation** — `RefreshToken` entity with DB-backed opaque token storage; `POST /auth/refresh` rotates access + refresh tokens (previous refresh token revoked on each use); `POST /auth/logout` revokes refresh token server-side; access token reduced from 8 hours to 15 minutes; refresh token valid for 7 days; frontend auto-refreshes 1 minute before expiry with 401 retry fallback; logout button in header, sidebar, and mobile nav with session redirect; `USER_LOGOUT` and `TOKEN_REFRESHED` audit actions
- **Concurrency hardening** — `SepsisBundleService.TryCreateBundleAsync` wraps order + bundle creation in a single database transaction so the unique constraint rollback also reverts orphaned orders; `PatientService.OpenEncounterAsync` enforced by new partial unique index `ix_encounters_patient_active_type` on `(patient_id, encounter_type) WHERE status = 'ACTIVE'` with constraint-violation catch returning 409 Conflict; `ConcurrencyTests` validates parallel patient registration, sepsis bundle creation, observation idempotency, and encounter open race conditions
- **New Prometheus metrics** — `fhir_read_total` (resource_type, interaction, outcome), `authorization_failures_total` (permission, role)
- **New audit actions** — `THRESHOLD_DELETED`, `AUTHORIZATION_DENIED`
- **New audit actions** — `THRESHOLD_DELETED`, `AUTHORIZATION_DENIED`, `USER_LOGOUT`, `TOKEN_REFRESHED`
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.
@@ -5,6 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>VigilCare.ClinicalContracts</RootNamespace>
<Copyright>Copyright (c) 2024-2026 voltsrage. All Rights Reserved.</Copyright>
<Authors>voltsrage</Authors>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>
</Project>
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public class AlertExplanation
{
public List<ScoreContributor> ScoreContributors { get; set; } = new();
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record AlertResponse(
Guid Id,
Guid EncounterId,
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error);
public record ApiError(string Message, string Code);
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
@@ -1 +1,3 @@
namespace VigilCare.Simulation;
public record BatchIngestRequest(List<IngestObservationRequest> Observations);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record CreateMedicationAdministrationRequest(
string DrugName, decimal Dose, string DoseUnit, string Route,
DateTimeOffset? AdministeredAt, string AdministeredBy);
@@ -1 +1,3 @@
namespace VigilCare.Simulation;
public record CreateOrderRequest(string OrderType, string Description, string OrderedBy);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record EncounterResponse(
Guid Id, Guid PatientId, string EncounterType, string Status,
string Department, string AttendingPhysician, string? RoomBed,
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record GcsResponse(
int EyeScore, int VerbalScore, int MotorScore,
int TotalScore, string Classification, DateTimeOffset CalculatedAt);
@@ -1,5 +1,6 @@
namespace VigilCare.Simulation;
public record IngestObservationRequest(
string ObservationCode, decimal Value, string Unit,
string Source, DateTimeOffset RecordedAt, string? IdempotencyKey);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record SimLoginRequest(string Username, string Password);
public record SimLoginResponse(
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record News2Response(
Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree,
int RespRateScore, int Spo2Score, int SystolicBpScore,
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record OpenEncounterRequest(
string EncounterType, string Department, string AttendingPhysician,
@@ -1 +1,3 @@
namespace VigilCare.Simulation;
public record OrderResponse(Guid Id, string Description, string Status);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record PatientResponse(
Guid Id, string Mrn, string FirstName, string LastName,
DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record QsofaResponse(int ActiveCriteria);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record RecordOrderResultRequest(string? ResultSummary);
@@ -1,2 +1,4 @@
namespace VigilCare.Simulation;
public record RegisterPatientRequest(
string FirstName, string LastName, DateOnly DateOfBirth, string Gender);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record SepsisBundleElementResponse(string Status);
public record SepsisBundleResponse(
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record SofaResponse(
int TotalScore,
int RespiratoryScore, int CoagulationScore, int LiverScore,
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record SofaStalenessResponse(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
@@ -2,6 +2,8 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace VigilCare.Simulation;
public class VigilCareApiClient
{
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
@@ -38,7 +40,12 @@ public class VigilCareApiClient
public async Task<PatientResponse> RegisterPatientAsync(RegisterPatientRequest req)
{
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
response.EnsureSuccessStatusCode();
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Register patient failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
return envelope!.Data!;
}
@@ -0,0 +1,10 @@
namespace VigilCare.Simulation;
/// <summary>
/// Optional post-cluster polling hook. Console hosts display scores/alerts;
/// the API host passes null — polling is a display concern.
/// </summary>
public interface IApiPoller
{
Task PollAndDisplayAsync(Guid encounterId, string simTime);
}
@@ -0,0 +1,31 @@
namespace VigilCare.Simulation;
public interface IReplayObserver
{
void Header(string name, string? description);
void Info(string message);
void Event(string simTime, string description);
void Waiting(double deltaMinutes, int delayMs);
void Warn(string message);
void Error(string message);
void DryRun(string message);
void Completed(ReplayResult result);
/// <summary>Fired after each cluster so hosts can report progress.</summary>
void Progress(double offsetMinutes, int clusterIndex, int clusterCount);
}
public sealed class NullReplayObserver : IReplayObserver
{
public static readonly NullReplayObserver Instance = new();
public void Header(string name, string? description) { }
public void Info(string message) { }
public void Event(string simTime, string description) { }
public void Waiting(double deltaMinutes, int delayMs) { }
public void Warn(string message) { }
public void Error(string message) { }
public void DryRun(string message) { }
public void Completed(ReplayResult result) { }
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) { }
}
@@ -1,13 +1,23 @@
namespace VigilCare.Simulation;
public class ReplayEngine
{
private readonly VigilCareApiClient _client;
private readonly ApiPoller? _poller;
private readonly IApiPoller? _poller;
private readonly IReplayObserver _observer;
private readonly Func<Guid, CancellationToken, Task>? _onPatientRegistered;
private DateTimeOffset _scenarioStartTime;
public ReplayEngine(VigilCareApiClient client, ApiPoller? poller)
public ReplayEngine(
VigilCareApiClient client,
IApiPoller? poller,
IReplayObserver? observer = null,
Func<Guid, CancellationToken, Task>? onPatientRegistered = null)
{
_client = client;
_poller = poller;
_observer = observer ?? NullReplayObserver.Instance;
_onPatientRegistered = onPatientRegistered;
}
public async Task<ReplayResult> RunAsync(
@@ -18,7 +28,7 @@ public class ReplayEngine
_scenarioStartTime = startTime;
// --- Phase 1: Setup ---
SimulatorConsole.Header(scenario.Scenario.Name, scenario.Scenario.Description);
_observer.Header(scenario.Scenario.Name, scenario.Scenario.Description);
if (options.DryRun)
{
@@ -26,27 +36,27 @@ public class ReplayEngine
{
result.EncounterId = options.ExistingEncounterId.Value;
if (options.Target == ReplayTarget.Gateway)
SimulatorConsole.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
_observer.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
else
SimulatorConsole.DryRun($"Would use existing encounter {result.EncounterId}");
_observer.DryRun($"Would use existing encounter {result.EncounterId}");
}
else
{
SimulatorConsole.DryRun("Would register patient: " +
_observer.DryRun("Would register patient: " +
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
SimulatorConsole.DryRun("Would open encounter: " +
_observer.DryRun("Would open encounter: " +
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
}
}
else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue)
{
result.EncounterId = options.ExistingEncounterId.Value;
SimulatorConsole.Info($"Gateway mode — using existing encounter {result.EncounterId}");
_observer.Info($"Gateway mode — using existing encounter {result.EncounterId}");
}
else if (options.ExistingEncounterId.HasValue)
{
result.EncounterId = options.ExistingEncounterId.Value;
SimulatorConsole.Info($"Using existing encounter {result.EncounterId}");
_observer.Info($"Using existing encounter {result.EncounterId}");
}
else
{
@@ -56,6 +66,9 @@ public class ReplayEngine
DateOnly.Parse(scenario.Patient.DateOfBirth),
scenario.Patient.Gender));
if (_onPatientRegistered is not null)
await _onPatientRegistered(patient.Id, ct);
var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
scenario.Encounter.EncounterType,
DepartmentMapper.ToApiDepartment(scenario.Encounter.Department),
@@ -63,8 +76,8 @@ public class ReplayEngine
scenario.Encounter.RoomBed,
scenario.Encounter.AdmissionReason));
SimulatorConsole.Info($"Patient registered: {patient.Id} ({patient.Mrn})");
SimulatorConsole.Info($"Encounter opened: {encounter.Id} ({encounter.Status})");
_observer.Info($"Patient registered: {patient.Id} ({patient.Mrn})");
_observer.Info($"Encounter opened: {encounter.Id} ({encounter.Status})");
result.PatientId = patient.Id;
result.EncounterId = encounter.Id;
}
@@ -76,15 +89,16 @@ public class ReplayEngine
.OrderBy(g => g.Key)
.ToList();
foreach (var cluster in clusters)
for (var clusterIndex = 0; clusterIndex < clusters.Count; clusterIndex++)
{
var cluster = clusters[clusterIndex];
ct.ThrowIfCancellationRequested();
var deltaMinutes = cluster.Key - lastOffset;
if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun)
{
var delayMs = (int)(deltaMinutes * 60_000 / options.Speed);
SimulatorConsole.Wait(deltaMinutes, delayMs);
_observer.Waiting(deltaMinutes, delayMs);
await Task.Delay(delayMs, ct);
}
@@ -116,6 +130,7 @@ public class ReplayEngine
}
lastOffset = cluster.Key;
_observer.Progress(cluster.Key, clusterIndex, clusters.Count);
if (options.Poll && !options.DryRun && _poller is not null)
{
@@ -136,10 +151,10 @@ public class ReplayEngine
result.HadExpectedOutcomes = true;
result.OutcomeFailures.AddRange(failures);
foreach (var failure in failures)
SimulatorConsole.Error(failure);
_observer.Error(failure);
}
SimulatorConsole.Summary(result);
_observer.Completed(result);
return result;
}
@@ -160,7 +175,7 @@ public class ReplayEngine
var unit = evt.Data.GetProperty("unit").GetString()!;
var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual";
SimulatorConsole.Event(simTime, $"{code} {value} {unit}");
_observer.Event(simTime, $"{code} {value} {unit}");
result.ObservationsSent++;
if (!options.DryRun)
@@ -185,7 +200,7 @@ public class ReplayEngine
if (options.DryRun)
{
SimulatorConsole.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}");
_observer.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}");
return;
}
@@ -194,12 +209,12 @@ public class ReplayEngine
if (sent)
{
SimulatorConsole.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent");
_observer.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent");
result.MedicationsSent++;
}
else
{
SimulatorConsole.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)");
_observer.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)");
result.MedicationsSkipped++;
}
}
@@ -215,7 +230,7 @@ public class ReplayEngine
if (options.DryRun)
{
SimulatorConsole.DryRun($"[{simTime}] ORDER {orderType}: {description}");
_observer.DryRun($"[{simTime}] ORDER {orderType}: {description}");
return;
}
@@ -224,12 +239,12 @@ public class ReplayEngine
if (placed)
{
SimulatorConsole.Event(simTime, $"ORDER {description} placed");
_observer.Event(simTime, $"ORDER {description} placed");
result.OrdersPlaced++;
}
else
{
SimulatorConsole.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'");
_observer.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'");
}
}
@@ -242,7 +257,7 @@ public class ReplayEngine
if (options.DryRun)
{
SimulatorConsole.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}");
_observer.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}");
return;
}
@@ -251,12 +266,12 @@ public class ReplayEngine
if (ok)
{
SimulatorConsole.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted");
_observer.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted");
result.OrdersResulted++;
}
else
{
SimulatorConsole.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted");
_observer.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted");
}
}
@@ -285,7 +300,7 @@ public class ReplayEngine
if (options.DryRun)
{
SimulatorConsole.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}");
_observer.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}");
return;
}
@@ -296,8 +311,8 @@ public class ReplayEngine
var ok = await _client.TryAcknowledgeAlertAsync(
result.EncounterId, alertType, clinicianId, note, waitForAlert, ct);
if (ok)
SimulatorConsole.Event(simTime, $"ACK {alertType} by {clinicianId}");
_observer.Event(simTime, $"ACK {alertType} by {clinicianId}");
else
SimulatorConsole.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
_observer.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
}
}
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public enum ReplayTarget { Central, Gateway }
public record ReplayOptions(
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public class ReplayResult
{
public string ScenarioId { get; }
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public static class DepartmentMapper
{
private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase)
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public static class ExpectedOutcomeValidator
{
private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8);
@@ -1,5 +1,7 @@
using System.Text.Json;
namespace VigilCare.Simulation;
public record ScenarioFile(
ScenarioMeta Scenario,
ScenarioPatient Patient,
@@ -0,0 +1,59 @@
using System.Text.Json;
namespace VigilCare.Simulation;
public static class ScenarioLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static ScenarioFile Load(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Scenario file not found: {path}");
var json = File.ReadAllText(path);
var scenario = JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize: {path}");
return scenario with
{
Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList()
};
}
/// <summary>
/// Enumerates <c>*.json</c> in <paramref name="directory"/>, skips files that
/// fail to deserialize, and returns pairs sorted by <see cref="ScenarioMeta.Id"/>.
/// </summary>
public static IReadOnlyList<(ScenarioFile Scenario, string Path)> LoadAll(string directory)
{
if (!Directory.Exists(directory))
return Array.Empty<(ScenarioFile, string)>();
var results = new List<(ScenarioFile Scenario, string Path)>();
foreach (var path in Directory.EnumerateFiles(directory, "*.json")
.Where(p => !string.Equals(
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{
try
{
results.Add((Load(path), path));
}
catch
{
// Skip files that fail to deserialize — catalogue must stay resilient.
}
}
return results
.OrderBy(r => r.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public static class ScenarioValidator
{
private static readonly HashSet<string> ValidCodes = new()
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>VigilCare.Simulation</RootNamespace>
<Copyright>Copyright (c) 2024-2026 voltsrage. All Rights Reserved.</Copyright>
<Authors>voltsrage</Authors>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>
</Project>
@@ -1 +0,0 @@
public record QsofaResponse(int ActiveCriteria);
@@ -1 +0,0 @@
public record RecordOrderResultRequest(string? ResultSummary);
@@ -1,4 +1,5 @@
using System.CommandLine;
using VigilCare.Simulation;
public static class DryRunCommand
{
@@ -21,7 +22,7 @@ public static class DryRunCommand
return;
}
var engine = new ReplayEngine(client: null!, poller: null);
var engine = new ReplayEngine(client: null!, poller: null, new ConsoleReplayObserver());
await engine.RunAsync(scenario, new ReplayOptions(DryRun: true));
}, fileArg);
@@ -1,4 +1,5 @@
using System.CommandLine;
using VigilCare.Simulation;
public static class ReplayAllCommand
{
@@ -38,7 +39,7 @@ public static class ReplayAllCommand
await client.LoginAsync(username, password);
SimulatorConsole.Info("Authenticated.");
var engine = new ReplayEngine(client, poller: null);
var engine = new ReplayEngine(client, poller: null, new ConsoleReplayObserver());
var results = new List<ReplayResult>();
foreach (var file in files)
@@ -1,4 +1,5 @@
using System.CommandLine;
using VigilCare.Simulation;
public static class ReplayCommand
{
@@ -86,7 +87,7 @@ public static class ReplayCommand
}
var poller = poll ? new ApiPoller(client) : null;
var engine = new ReplayEngine(client, poller);
var engine = new ReplayEngine(client, poller, new ConsoleReplayObserver());
var options = new ReplayOptions(
speed, poll, pollInterval, DryRun: false,
Target: gateway ? ReplayTarget.Gateway : ReplayTarget.Central,
@@ -1,4 +1,5 @@
using System.CommandLine;
using VigilCare.Simulation;
public static class ValidateCommand
{
@@ -0,0 +1,37 @@
public static class MimicCareUnitMap
{
public static string ToVigilCareDepartment(string mimicCareUnit)
{
// All MIMIC ICU stays map to ICU in VigilCare
return "Icu";
}
public static string ToVigilCareEncounterType(string admissionType)
{
if (admissionType.Contains("EMER", StringComparison.OrdinalIgnoreCase))
return "Emergency";
return "Inpatient";
}
public static List<string> GetTags(string mimicCareUnit, int hospitalExpireFlag)
{
var tags = new List<string> { "mimic-iv", "real-data", "icu" };
var unit = mimicCareUnit.ToUpperInvariant();
if (unit.Contains("CARDIAC") || unit.Contains("CVICU") || unit.Contains("CCU") || unit.Contains("CORONARY"))
tags.Add("cardiac");
if (unit.Contains("NEURO"))
tags.Add("neuro");
if (unit.Contains("SURG") || unit.Contains("TSICU"))
tags.Add("surgical");
if (unit.Contains("TRAUMA"))
tags.Add("trauma");
if (unit.Contains("MICU") || unit.Contains("MEDICAL"))
tags.Add("medical");
if (hospitalExpireFlag == 1)
tags.Add("expired");
return tags;
}
}
@@ -0,0 +1,70 @@
public static class MimicCsvReader
{
public static IEnumerable<T> Read<T>(
string filePath,
Func<string[], Dictionary<string, int>, T?> parser,
Func<string[], Dictionary<string, int>, bool>? filter = null)
{
using var reader = new StreamReader(filePath);
var headerLine = reader.ReadLine();
if (headerLine is null) yield break;
var headers = BuildHeaderIndex(headerLine);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var fields = line.Split(',');
if (filter is not null && !filter(fields, headers))
continue;
var record = parser(fields, headers);
if (record is not null)
yield return record;
}
}
public static List<T> ReadAll<T>(
string filePath,
Func<string[], Dictionary<string, int>, T?> parser)
{
return Read(filePath, parser).ToList();
}
private static Dictionary<string, int> BuildHeaderIndex(string headerLine)
{
var headers = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var columns = headerLine.Split(',');
for (var i = 0; i < columns.Length; i++)
headers[columns[i].Trim()] = i;
return headers;
}
public static string Col(string[] fields, Dictionary<string, int> headers, string name)
{
return headers.TryGetValue(name, out var idx) && idx < fields.Length
? fields[idx].Trim()
: string.Empty;
}
public static int? ColInt(string[] fields, Dictionary<string, int> headers, string name)
{
var val = Col(fields, headers, name);
return int.TryParse(val, out var result) ? result : null;
}
public static decimal? ColDecimal(string[] fields, Dictionary<string, int> headers, string name)
{
var val = Col(fields, headers, name);
return decimal.TryParse(val, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var result) ? result : null;
}
public static DateTime? ColDateTime(string[] fields, Dictionary<string, int> headers, string name)
{
var val = Col(fields, headers, name);
return DateTime.TryParse(val, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var result) ? result : null;
}
}
@@ -0,0 +1,191 @@
public record MimicPatient(int SubjectId, string Gender, int AnchorAge, int AnchorYear, string? Dod);
public record MimicAdmission(
int SubjectId, int HadmId,
DateTime AdmitTime, DateTime DischTime, DateTime? DeathTime,
string AdmissionType, string? AdmissionLocation, string? DischargeLocation,
int HospitalExpireFlag);
public record MimicIcuStay(
int SubjectId, int HadmId, int StayId,
string FirstCareUnit, string LastCareUnit,
DateTime InTime, DateTime OutTime, decimal Los);
public record MimicChartEvent(
int StayId, DateTime ChartTime, int ItemId,
string? TextValue, decimal? ValueNum);
public record MimicLabEvent(
int HadmId, DateTime ChartTime, int ItemId,
decimal? ValueNum, string? ValueUom);
public record MimicPrescription(
int HadmId, DateTime StartTime,
string Drug, string? DoseValRx, string? DoseUnitRx, string? Route);
public class MimicDataLoader
{
private readonly string _dataDir;
public MimicDataLoader(string dataDir)
{
if (!Directory.Exists(dataDir))
throw new DirectoryNotFoundException($"MIMIC data directory not found: {dataDir}");
_dataDir = dataDir;
}
private string Path(string fileName) => System.IO.Path.Combine(_dataDir, fileName);
public List<MimicPatient> LoadPatients()
{
return MimicCsvReader.ReadAll(Path("patients.csv"), (f, h) =>
{
var id = MimicCsvReader.ColInt(f, h, "subject_id");
var age = MimicCsvReader.ColInt(f, h, "anchor_age");
var year = MimicCsvReader.ColInt(f, h, "anchor_year");
if (id is null || age is null || year is null) return null;
return new MimicPatient(
id.Value,
MimicCsvReader.Col(f, h, "gender"),
age.Value,
year.Value,
MimicCsvReader.Col(f, h, "dod") is { Length: > 0 } dod ? dod : null);
});
}
public List<MimicAdmission> LoadAdmissions()
{
return MimicCsvReader.ReadAll(Path("admissions.csv"), (f, h) =>
{
var subjectId = MimicCsvReader.ColInt(f, h, "subject_id");
var hadmId = MimicCsvReader.ColInt(f, h, "hadm_id");
var admitTime = MimicCsvReader.ColDateTime(f, h, "admittime");
var dischTime = MimicCsvReader.ColDateTime(f, h, "dischtime");
if (subjectId is null || hadmId is null || admitTime is null || dischTime is null)
return null;
return new MimicAdmission(
subjectId.Value, hadmId.Value,
admitTime.Value, dischTime.Value,
MimicCsvReader.ColDateTime(f, h, "deathtime"),
MimicCsvReader.Col(f, h, "admission_type"),
MimicCsvReader.Col(f, h, "admission_location") is { Length: > 0 } loc ? loc : null,
MimicCsvReader.Col(f, h, "discharge_location") is { Length: > 0 } dloc ? dloc : null,
MimicCsvReader.ColInt(f, h, "hospital_expire_flag") ?? 0);
});
}
public List<MimicIcuStay> LoadIcuStays()
{
return MimicCsvReader.ReadAll(Path("icustays.csv"), (f, h) =>
{
var subjectId = MimicCsvReader.ColInt(f, h, "subject_id");
var hadmId = MimicCsvReader.ColInt(f, h, "hadm_id");
var stayId = MimicCsvReader.ColInt(f, h, "stay_id");
var inTime = MimicCsvReader.ColDateTime(f, h, "intime");
var outTime = MimicCsvReader.ColDateTime(f, h, "outtime");
if (subjectId is null || hadmId is null || stayId is null
|| inTime is null || outTime is null)
return null;
return new MimicIcuStay(
subjectId.Value, hadmId.Value, stayId.Value,
MimicCsvReader.Col(f, h, "first_careunit"),
MimicCsvReader.Col(f, h, "last_careunit"),
inTime.Value, outTime.Value,
MimicCsvReader.ColDecimal(f, h, "los") ?? 0m);
});
}
public IEnumerable<MimicChartEvent> StreamChartEvents(int stayId)
{
var stayIdStr = stayId.ToString();
return MimicCsvReader.Read(
Path("chartevents.csv"),
parser: (f, h) =>
{
var itemId = MimicCsvReader.ColInt(f, h, "itemid");
if (itemId is null || !MimicItemMap.AllChartItemIds.Contains(itemId.Value))
return null;
var chartTime = MimicCsvReader.ColDateTime(f, h, "charttime");
if (chartTime is null) return null;
var valueNum = MimicCsvReader.ColDecimal(f, h, "valuenum");
var textValue = MimicCsvReader.Col(f, h, "value");
if (MimicItemMap.IsGcsItem(itemId.Value))
{
var gcsVal = MimicItemMap.ResolveGcsValue(itemId.Value, textValue, valueNum);
if (gcsVal is null) return null;
return new MimicChartEvent(stayId, chartTime.Value, itemId.Value, textValue, gcsVal);
}
if (valueNum is null) return null;
return new MimicChartEvent(stayId, chartTime.Value, itemId.Value, textValue, valueNum);
},
filter: (f, h) =>
{
var sid = MimicCsvReader.Col(f, h, "stay_id");
return sid == stayIdStr;
});
}
public IEnumerable<MimicLabEvent> StreamLabEvents(int hadmId, DateTime? after = null, DateTime? before = null)
{
var hadmIdStr = hadmId.ToString();
return MimicCsvReader.Read(
Path("labevents.csv"),
parser: (f, h) =>
{
var itemId = MimicCsvReader.ColInt(f, h, "itemid");
if (itemId is null || !MimicItemMap.AllLabItemIds.Contains(itemId.Value))
return null;
var chartTime = MimicCsvReader.ColDateTime(f, h, "charttime");
if (chartTime is null) return null;
if (after.HasValue && chartTime.Value < after.Value) return null;
if (before.HasValue && chartTime.Value > before.Value) return null;
var valueNum = MimicCsvReader.ColDecimal(f, h, "valuenum");
if (valueNum is null) return null;
return new MimicLabEvent(
hadmId, chartTime.Value, itemId.Value, valueNum,
MimicCsvReader.Col(f, h, "valueuom") is { Length: > 0 } uom ? uom : null);
},
filter: (f, h) =>
{
var hid = MimicCsvReader.Col(f, h, "hadm_id");
return hid == hadmIdStr;
});
}
public IEnumerable<MimicPrescription> StreamPrescriptions(
int hadmId, DateTime? after = null, DateTime? before = null)
{
var hadmIdStr = hadmId.ToString();
return MimicCsvReader.Read(
Path("prescriptions.csv"),
parser: (f, h) =>
{
var startTime = MimicCsvReader.ColDateTime(f, h, "starttime");
if (startTime is null) return null;
if (after.HasValue && startTime.Value < after.Value) return null;
if (before.HasValue && startTime.Value > before.Value) return null;
var drug = MimicCsvReader.Col(f, h, "drug");
if (string.IsNullOrWhiteSpace(drug)) return null;
return new MimicPrescription(
hadmId, startTime.Value, drug,
MimicCsvReader.Col(f, h, "dose_val_rx") is { Length: > 0 } d ? d : null,
MimicCsvReader.Col(f, h, "dose_unit_rx") is { Length: > 0 } u ? u : null,
MimicCsvReader.Col(f, h, "route") is { Length: > 0 } r ? r : null);
},
filter: (f, h) =>
{
var hid = MimicCsvReader.Col(f, h, "hadm_id");
return hid == hadmIdStr;
});
}
}
@@ -0,0 +1,137 @@
using System.CommandLine;
using System.Text.Json;
using Spectre.Console;
using VigilCare.Simulation;
public static class MimicGenerateCommand
{
public static Command Create()
{
var dataDirArg = new Argument<DirectoryInfo>("mimic-dir",
"Path to directory containing MIMIC-IV CSV files");
var stayIdOpt = new Option<int>("--stay-id", "ICU stay ID to generate scenario for")
{ IsRequired = true };
var maxHoursOpt = new Option<int?>("--max-hours", "Limit scenario duration in hours");
var noMedsOpt = new Option<bool>("--no-medications", () => false,
"Exclude medication events");
var noLabsOpt = new Option<bool>("--no-labs", () => false,
"Exclude lab observations");
var outputOpt = new Option<string?>("--output", "Output file path (default: auto-named)");
var validateOpt = new Option<bool>("--validate", () => false,
"Run scenario validation after generation");
var command = new Command("mimic-generate",
"Generate a VigilCare scenario JSON from a MIMIC-IV ICU stay")
{
dataDirArg, stayIdOpt, maxHoursOpt, noMedsOpt, noLabsOpt, outputOpt, validateOpt
};
command.SetHandler(context =>
{
var dataDir = context.ParseResult.GetValueForArgument(dataDirArg);
var stayId = context.ParseResult.GetValueForOption(stayIdOpt);
var maxHours = context.ParseResult.GetValueForOption(maxHoursOpt);
var noMeds = context.ParseResult.GetValueForOption(noMedsOpt);
var noLabs = context.ParseResult.GetValueForOption(noLabsOpt);
var outputPath = context.ParseResult.GetValueForOption(outputOpt);
var validate = context.ParseResult.GetValueForOption(validateOpt);
var loader = new MimicDataLoader(dataDir.FullName);
AnsiConsole.MarkupLine($"[bold]Loading MIMIC-IV data for stay {stayId}...[/]");
var stays = loader.LoadIcuStays();
var stay = stays.FirstOrDefault(s => s.StayId == stayId);
if (stay is null)
{
AnsiConsole.MarkupLine($"[red]ICU stay {stayId} not found.[/]");
var available = stays.Select(s => s.StayId).OrderBy(x => x).ToList();
AnsiConsole.MarkupLine($"Available stay IDs: {string.Join(", ", available.Take(20))}...");
return;
}
var admissions = loader.LoadAdmissions();
var admission = admissions.FirstOrDefault(a => a.HadmId == stay.HadmId);
if (admission is null)
{
AnsiConsole.MarkupLine($"[red]Admission {stay.HadmId} not found.[/]");
return;
}
var patients = loader.LoadPatients();
var patient = patients.FirstOrDefault(p => p.SubjectId == stay.SubjectId);
if (patient is null)
{
AnsiConsole.MarkupLine($"[red]Patient {stay.SubjectId} not found.[/]");
return;
}
AnsiConsole.MarkupLine(
$" Patient: [cyan]{patient.SubjectId}[/] ({patient.Gender}, ~{patient.AnchorAge}y)");
AnsiConsole.MarkupLine(
$" Stay: [cyan]{stay.StayId}[/] in {stay.FirstCareUnit}");
AnsiConsole.MarkupLine(
$" LOS: {stay.Los:F1} days ({stay.InTime:g} → {stay.OutTime:g})");
AnsiConsole.MarkupLine(
$" Outcome: {(admission.HospitalExpireFlag == 1 ? "[red]Expired[/]" : "Survived")}");
var options = new MimicGenerateOptions(
MaxHours: maxHours,
IncludeMedications: !noMeds,
IncludeLabs: !noLabs);
AnsiConsole.MarkupLine("\n[bold]Generating scenario...[/]");
var builder = new MimicScenarioBuilder(loader);
var (scenario, warnings) = builder.Build(stay, admission, patient, options);
foreach (var warning in warnings)
AnsiConsole.MarkupLine($" [yellow]WARNING:[/] {Markup.Escape(warning)}");
var obsCount = scenario.Events.Count(e => e.Type == "observation");
var medCount = scenario.Events.Count(e => e.Type == "medication");
AnsiConsole.MarkupLine(
$" Events: [green]{obsCount}[/] observations, [green]{medCount}[/] medications");
AnsiConsole.MarkupLine(
$" Duration: {scenario.Scenario.DurationMinutes} minutes " +
$"({scenario.Scenario.DurationMinutes / 60.0:F1} hours)");
if (validate)
{
var errors = ScenarioValidator.Validate(scenario);
if (errors.Count == 0)
{
AnsiConsole.MarkupLine(" [green]Validation: PASSED[/]");
}
else
{
AnsiConsole.MarkupLine($" [red]Validation: {errors.Count} error(s)[/]");
foreach (var err in errors)
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(err)}[/]");
}
}
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var json = JsonSerializer.Serialize(scenario, jsonOptions);
var filePath = outputPath
?? Path.Combine("Scenarios", "List", $"mimic-s{stayId}.json");
var dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(filePath, json);
AnsiConsole.MarkupLine($"\n[bold green]Scenario written to:[/] {filePath}");
AnsiConsole.MarkupLine($" Replay with: [dim]dotnet run -- replay {filePath}[/]");
});
return command;
}
}
+110
View File
@@ -0,0 +1,110 @@
public static class MimicItemMap
{
public record ChartItemMapping(
string Code, string Unit, string Source,
int Priority = 0,
bool ConvertFahrenheit = false);
public record LabItemMapping(string Code, string Unit);
private static readonly Dictionary<int, ChartItemMapping> ChartMappings = new()
{
[220045] = new("HEART_RATE", "bpm", "Device"),
[220210] = new("RESP_RATE", "/min", "Device"),
[220179] = new("SYSTOLIC_BP", "mmHg", "Device"),
[220180] = new("DIASTOLIC_BP", "mmHg", "Device"),
[220050] = new("SYSTOLIC_BP", "mmHg", "Device", Priority: 1),
[220051] = new("DIASTOLIC_BP", "mmHg", "Device", Priority: 1),
[223762] = new("TEMP_C", "°C", "Manual"),
[223761] = new("TEMP_C", "°C", "Manual", ConvertFahrenheit: true),
[220277] = new("SPO2", "%", "Device"),
[223835] = new("FIO2_PCT", "%", "Device"),
[220739] = new("GCS_EYE", "score", "Manual"),
[223900] = new("GCS_VERBAL", "score", "Manual"),
[223901] = new("GCS_MOTOR", "score", "Manual"),
[220615] = new("CREATININE_MG_DL", "mg/dL", "Lab"),
[225690] = new("BILIRUBIN_MG_DL", "mg/dL", "Lab"),
[225678] = new("PLATELET_K_UL", "k/µL", "Lab"),
[220224] = new("PAO2_MMHG", "mmHg", "Lab"),
};
private static readonly Dictionary<int, LabItemMapping> LabMappings = new()
{
[50912] = new("CREATININE_MG_DL", "mg/dL"),
[51704] = new("PLATELET_K_UL", "k/µL"),
[50885] = new("BILIRUBIN_MG_DL", "mg/dL"),
[50813] = new("LACTATE_MMOL_L", "mmol/L"),
[51301] = new("WBC_K_UL", "k/µL"),
[50971] = new("POTASSIUM_MEQ_L", "mEq/L"),
[50931] = new("GLUCOSE_MG_DL", "mg/dL"),
[50821] = new("PAO2_MMHG", "mmHg"),
};
// GCS text → numeric fallback (in case valuenum is missing)
private static readonly Dictionary<string, int> GcsEyeText = new(StringComparer.OrdinalIgnoreCase)
{
["None"] = 1, ["No Response"] = 1,
["To Pain"] = 2,
["To Speech"] = 3,
["Spontaneously"] = 4,
};
private static readonly Dictionary<string, int> GcsVerbalText = new(StringComparer.OrdinalIgnoreCase)
{
["No Response"] = 1, ["No Response-ETT"] = 1,
["Incomprehensible sounds"] = 2, ["Incomprehensible Sounds"] = 2,
["Inappropriate Words"] = 3,
["Confused"] = 4,
["Oriented"] = 5,
};
private static readonly Dictionary<string, int> GcsMotorText = new(StringComparer.OrdinalIgnoreCase)
{
["No response"] = 1, ["No Response"] = 1,
["Abnormal extension"] = 2, ["Abnormal Extension"] = 2,
["Abnormal Flexion"] = 3,
["Flex-withdraws"] = 3, ["Flex-Withdraws"] = 3,
["Localizes Pain"] = 4,
["Obeys Commands"] = 6,
};
public static readonly HashSet<int> AllChartItemIds = new(ChartMappings.Keys);
public static readonly HashSet<int> AllLabItemIds = new(LabMappings.Keys);
public static bool TryMapChartEvent(int itemId, out ChartItemMapping mapping)
=> ChartMappings.TryGetValue(itemId, out mapping!);
public static bool TryMapLabEvent(int itemId, out LabItemMapping mapping)
=> LabMappings.TryGetValue(itemId, out mapping!);
public static decimal ConvertValue(decimal rawValue, ChartItemMapping mapping)
{
if (mapping.ConvertFahrenheit)
return Math.Round((rawValue - 32m) * 5m / 9m, 1);
return rawValue;
}
public static decimal? ResolveGcsValue(int itemId, string? textValue, decimal? numericValue)
{
if (numericValue.HasValue && numericValue.Value > 0)
return numericValue.Value;
if (string.IsNullOrWhiteSpace(textValue))
return null;
var lookup = itemId switch
{
220739 => GcsEyeText,
223900 => GcsVerbalText,
223901 => GcsMotorText,
_ => null
};
if (lookup is not null && lookup.TryGetValue(textValue, out var score))
return score;
return null;
}
public static bool IsGcsItem(int itemId) => itemId is 220739 or 223900 or 223901;
}
@@ -0,0 +1,75 @@
using System.CommandLine;
using Spectre.Console;
public static class MimicListCommand
{
public static Command Create()
{
var dataDirArg = new Argument<DirectoryInfo>("mimic-dir",
"Path to directory containing MIMIC-IV CSV files");
var subjectIdOpt = new Option<int?>("--subject-id", "Filter to a specific patient");
var stayIdOpt = new Option<int?>("--stay-id", "Filter to a specific ICU stay");
var command = new Command("mimic-list",
"List available MIMIC-IV patients and ICU stays")
{
dataDirArg, subjectIdOpt, stayIdOpt
};
command.SetHandler(context =>
{
var dataDir = context.ParseResult.GetValueForArgument(dataDirArg);
var subjectId = context.ParseResult.GetValueForOption(subjectIdOpt);
var stayId = context.ParseResult.GetValueForOption(stayIdOpt);
var loader = new MimicDataLoader(dataDir.FullName);
var patients = loader.LoadPatients().ToDictionary(p => p.SubjectId);
var admissions = loader.LoadAdmissions().ToDictionary(a => a.HadmId);
var stays = loader.LoadIcuStays();
if (subjectId.HasValue)
stays = stays.Where(s => s.SubjectId == subjectId.Value).ToList();
if (stayId.HasValue)
stays = stays.Where(s => s.StayId == stayId.Value).ToList();
var table = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]MIMIC-IV ICU Stays[/]");
table.AddColumn("StayId");
table.AddColumn("SubjectId");
table.AddColumn("HadmId");
table.AddColumn("Gender");
table.AddColumn("Age");
table.AddColumn("Care Unit");
table.AddColumn("Admission");
table.AddColumn("LOS (d)");
table.AddColumn("Expired");
foreach (var stay in stays.OrderBy(s => s.SubjectId).ThenBy(s => s.InTime))
{
var pt = patients.GetValueOrDefault(stay.SubjectId);
var adm = admissions.GetValueOrDefault(stay.HadmId);
table.AddRow(
stay.StayId.ToString(),
stay.SubjectId.ToString(),
stay.HadmId.ToString(),
pt?.Gender ?? "?",
pt?.AnchorAge.ToString() ?? "?",
Markup.Escape(stay.FirstCareUnit),
adm?.AdmissionType ?? "?",
stay.Los.ToString("F1"),
adm?.HospitalExpireFlag == 1 ? "[red]Yes[/]" : "No");
}
AnsiConsole.Write(table);
var patientCount = stays.Select(s => s.SubjectId).Distinct().Count();
AnsiConsole.MarkupLine(
$"\nFound [bold]{stays.Count}[/] ICU stays across [bold]{patientCount}[/] patients.");
});
return command;
}
}
@@ -0,0 +1,323 @@
using System.Text.Json;
using VigilCare.Simulation;
public record MimicGenerateOptions(
int? MaxHours = null,
bool IncludeMedications = true,
bool IncludeLabs = true);
public class MimicScenarioBuilder
{
private readonly MimicDataLoader _loader;
private static readonly string[] ObservationPriority =
[
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP", "TEMP_C", "SPO2",
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR",
"FIO2_PCT", "PAO2_MMHG",
"CREATININE_MG_DL", "BILIRUBIN_MG_DL", "PLATELET_K_UL",
"WBC_K_UL", "POTASSIUM_MEQ_L", "LACTATE_MMOL_L", "GLUCOSE_MG_DL",
"URINE_OUTPUT_ML_H"
];
public MimicScenarioBuilder(MimicDataLoader loader)
{
_loader = loader;
}
public (ScenarioFile Scenario, List<string> Warnings) Build(
MimicIcuStay stay, MimicAdmission admission, MimicPatient patient,
MimicGenerateOptions options)
{
var warnings = new List<string>();
var scenarioStart = stay.InTime;
var scenarioEnd = stay.OutTime;
if (options.MaxHours.HasValue)
{
var maxEnd = scenarioStart.AddHours(options.MaxHours.Value);
if (maxEnd < scenarioEnd)
scenarioEnd = maxEnd;
}
var scenarioPatient = BuildPatient(patient, admission);
var encounter = BuildEncounter(stay, admission);
var events = BuildEvents(stay, admission, scenarioStart, scenarioEnd, options, warnings);
var meta = BuildMeta(stay, admission, patient, scenarioStart, scenarioEnd, events);
var scenario = new ScenarioFile(meta, scenarioPatient, encounter, events, ExpectedOutcomes: null);
return (scenario, warnings);
}
private static ScenarioPatient BuildPatient(MimicPatient patient, MimicAdmission admission)
{
var birthYear = DateTime.UtcNow.Year - patient.AnchorAge;
var dob = new DateTime(birthYear, 7, 1);
return new ScenarioPatient(
FirstName: $"MIMIC-{patient.SubjectId}",
LastName: $"S{admission.HadmId}",
DateOfBirth: dob.ToString("yyyy-MM-dd"),
Gender: patient.Gender == "F" ? "Female" : "Male");
}
private static ScenarioEncounter BuildEncounter(MimicIcuStay stay, MimicAdmission admission)
{
return new ScenarioEncounter(
Department: MimicCareUnitMap.ToVigilCareDepartment(stay.FirstCareUnit),
EncounterType: MimicCareUnitMap.ToVigilCareEncounterType(admission.AdmissionType),
AttendingPhysician: "MIMIC-Physician",
RoomBed: $"ICU-{stay.StayId % 100:D2}",
AdmissionReason: $"MIMIC-IV admission ({admission.AdmissionType}, from {admission.AdmissionLocation ?? "unknown"})");
}
private List<ScenarioEvent> BuildEvents(
MimicIcuStay stay, MimicAdmission admission,
DateTime scenarioStart, DateTime scenarioEnd,
MimicGenerateOptions options, List<string> warnings)
{
var rawObs = CollectObservations(stay, admission, scenarioStart, scenarioEnd, options);
var deduplicated = DeduplicateBloodPressure(rawObs);
var events = new List<ScenarioEvent>();
foreach (var obs in deduplicated)
{
var offsetMinutes = Math.Round((obs.ChartTime - scenarioStart).TotalMinutes);
if (offsetMinutes < 0) offsetMinutes = 0;
var data = JsonSerializer.SerializeToElement(new
{
code = obs.Code,
value = obs.Value,
unit = obs.Unit,
source = obs.Source
}, SerializerOptions);
events.Add(new ScenarioEvent(offsetMinutes, "observation", data, null));
}
if (options.IncludeMedications)
{
var meds = CollectMedications(admission, scenarioStart, scenarioEnd, warnings);
events.AddRange(meds);
}
events = events.OrderBy(e => e.OffsetMinutes).ToList();
events = EnforceClusterLimit(events, warnings);
return events;
}
private List<RawObservation> CollectObservations(
MimicIcuStay stay, MimicAdmission admission,
DateTime scenarioStart, DateTime scenarioEnd,
MimicGenerateOptions options)
{
var observations = new List<RawObservation>();
foreach (var ce in _loader.StreamChartEvents(stay.StayId))
{
if (ce.ChartTime < scenarioStart || ce.ChartTime > scenarioEnd) continue;
if (!MimicItemMap.TryMapChartEvent(ce.ItemId, out var mapping)) continue;
if (ce.ValueNum is null) continue;
var value = MimicItemMap.ConvertValue(ce.ValueNum.Value, mapping);
observations.Add(new RawObservation(
ce.ChartTime, mapping.Code, value, mapping.Unit, mapping.Source, mapping.Priority));
}
if (options.IncludeLabs)
{
var labCodes = new HashSet<(DateTime time, string code)>(
observations.Select(o => (o.ChartTime, o.Code)));
foreach (var le in _loader.StreamLabEvents(admission.HadmId, scenarioStart, scenarioEnd))
{
if (!MimicItemMap.TryMapLabEvent(le.ItemId, out var mapping)) continue;
if (le.ValueNum is null) continue;
if (labCodes.Contains((le.ChartTime, mapping.Code)))
continue;
observations.Add(new RawObservation(
le.ChartTime, mapping.Code, le.ValueNum.Value, mapping.Unit, "Lab", 0));
}
}
return observations.OrderBy(o => o.ChartTime).ToList();
}
private static List<RawObservation> DeduplicateBloodPressure(List<RawObservation> observations)
{
var bpGroups = observations
.Where(o => o.Code is "SYSTOLIC_BP" or "DIASTOLIC_BP")
.GroupBy(o => (Time: RoundToMinute(o.ChartTime), o.Code));
var removals = new HashSet<RawObservation>();
foreach (var group in bpGroups)
{
var items = group.ToList();
if (items.Count <= 1) continue;
var hasPrimary = items.Any(i => i.Priority == 0);
if (hasPrimary)
{
foreach (var fallback in items.Where(i => i.Priority > 0))
removals.Add(fallback);
}
}
return removals.Count > 0
? observations.Where(o => !removals.Contains(o)).ToList()
: observations;
}
private List<ScenarioEvent> CollectMedications(
MimicAdmission admission, DateTime scenarioStart, DateTime scenarioEnd,
List<string> warnings)
{
var events = new List<ScenarioEvent>();
var count = 0;
var skipped = 0;
foreach (var rx in _loader.StreamPrescriptions(admission.HadmId, scenarioStart, scenarioEnd))
{
var dose = ParseDose(rx.DoseValRx);
if (dose is null || string.IsNullOrWhiteSpace(rx.DoseUnitRx)
|| string.IsNullOrWhiteSpace(rx.Route))
{
skipped++;
continue;
}
var offsetMinutes = Math.Round((rx.StartTime - scenarioStart).TotalMinutes);
if (offsetMinutes < 0) offsetMinutes = 0;
var data = JsonSerializer.SerializeToElement(new
{
drugName = rx.Drug,
dose = dose.Value,
doseUnit = rx.DoseUnitRx,
route = rx.Route,
administeredBy = "MIMIC-RN"
}, SerializerOptions);
events.Add(new ScenarioEvent(offsetMinutes, "medication", data, null));
count++;
}
if (skipped > 0)
warnings.Add($"Skipped {skipped} prescriptions with missing dose/unit/route data");
return events;
}
private static List<ScenarioEvent> EnforceClusterLimit(
List<ScenarioEvent> events, List<string> warnings)
{
var result = new List<ScenarioEvent>();
var clusters = events.GroupBy(e => e.OffsetMinutes).OrderBy(g => g.Key).ToList();
var spillover = new List<(double offset, ScenarioEvent evt)>();
foreach (var cluster in clusters)
{
var obsInCluster = cluster.Where(e => e.Type == "observation").ToList();
var otherInCluster = cluster.Where(e => e.Type != "observation").ToList();
// Add any spillover from previous clusters at this offset
var spilled = spillover.Where(s => s.offset == cluster.Key).Select(s => s.evt).ToList();
spillover.RemoveAll(s => s.offset == cluster.Key);
obsInCluster.AddRange(spilled);
if (obsInCluster.Count > 10)
{
var sorted = obsInCluster
.OrderBy(e => GetObservationPriority(e))
.ToList();
var keep = sorted.Take(10).ToList();
var overflow = sorted.Skip(10).ToList();
warnings.Add(
$"Offset {cluster.Key}: split {obsInCluster.Count} observations " +
$"(moved {overflow.Count} to offset {cluster.Key + 1})");
foreach (var evt in overflow)
spillover.Add((cluster.Key + 1,
new ScenarioEvent(cluster.Key + 1, evt.Type, evt.Data, evt.Note)));
obsInCluster = keep;
}
result.AddRange(obsInCluster);
result.AddRange(otherInCluster);
}
// Handle any remaining spillover
foreach (var (offset, evt) in spillover.OrderBy(s => s.offset))
result.Add(evt);
return result.OrderBy(e => e.OffsetMinutes).ToList();
}
private static int GetObservationPriority(ScenarioEvent evt)
{
var code = evt.Data.TryGetProperty("code", out var codeProp)
? codeProp.GetString() : null;
if (code is null) return 999;
var idx = Array.IndexOf(ObservationPriority, code);
return idx >= 0 ? idx : 999;
}
private static ScenarioMeta BuildMeta(
MimicIcuStay stay, MimicAdmission admission, MimicPatient patient,
DateTime scenarioStart, DateTime scenarioEnd,
List<ScenarioEvent> events)
{
var durationMinutes = (int)(scenarioEnd - scenarioStart).TotalMinutes;
var obsCount = events.Count(e => e.Type == "observation");
var medCount = events.Count(e => e.Type == "medication");
var description =
$"Real de-identified MIMIC-IV data. " +
$"Subject {patient.SubjectId}, stay {stay.StayId}. " +
$"{(patient.Gender == "F" ? "Female" : "Male")}, age ~{patient.AnchorAge}. " +
$"ICU LOS: {stay.Los:F1} days. Care unit: {stay.FirstCareUnit}. " +
$"{obsCount} observations, {medCount} medications. " +
(admission.HospitalExpireFlag == 1
? "Patient expired during hospitalization."
: $"Discharged to {admission.DischargeLocation ?? "unknown"}.");
return new ScenarioMeta(
Id: $"mimic-s{stay.StayId}",
Name: $"MIMIC-IV — {stay.FirstCareUnit} ({patient.Gender}, ~{patient.AnchorAge}y)",
Description: description,
DurationMinutes: durationMinutes,
Tags: MimicCareUnitMap.GetTags(stay.FirstCareUnit, admission.HospitalExpireFlag));
}
private static decimal? ParseDose(string? doseValRx)
{
if (string.IsNullOrWhiteSpace(doseValRx)) return null;
var val = doseValRx.Trim();
var dashIdx = val.IndexOf('-');
if (dashIdx > 0) val = val[..dashIdx];
return decimal.TryParse(val, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var d) && d > 0
? d
: null;
}
private static DateTime RoundToMinute(DateTime dt)
=> new(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0);
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private record RawObservation(
DateTime ChartTime, string Code, decimal Value,
string Unit, string Source, int Priority);
}
@@ -0,0 +1,33 @@
using VigilCare.Simulation;
public sealed class ConsoleReplayObserver : IReplayObserver
{
public void Header(string name, string? description) =>
SimulatorConsole.Header(name, description);
public void Info(string message) =>
SimulatorConsole.Info(message);
public void Event(string simTime, string description) =>
SimulatorConsole.Event(simTime, description);
public void Waiting(double deltaMinutes, int delayMs) =>
SimulatorConsole.Wait(deltaMinutes, delayMs);
public void Warn(string message) =>
SimulatorConsole.Warn(message);
public void Error(string message) =>
SimulatorConsole.Error(message);
public void DryRun(string message) =>
SimulatorConsole.DryRun(message);
public void Completed(ReplayResult result) =>
SimulatorConsole.Summary(result);
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount)
{
// Console progress is already visible via Event/Wait; no extra output.
}
}
@@ -1,4 +1,5 @@
using Spectre.Console;
using VigilCare.Simulation;
public static class SimulatorConsole
{
+3 -1
View File
@@ -1,4 +1,6 @@
public class ApiPoller
using VigilCare.Simulation;
public class ApiPoller : IApiPoller
{
private readonly VigilCareApiClient _client;
private readonly HashSet<Guid> _seenAlertIds = new();
@@ -1,3 +1,5 @@
using VigilCare.Simulation;
public record PollResult(
News2Response? News2,
GcsResponse? Gcs,
+2
View File
@@ -6,5 +6,7 @@ rootCommand.AddCommand(ReplayCommand.Create());
rootCommand.AddCommand(ReplayAllCommand.Create());
rootCommand.AddCommand(ValidateCommand.Create());
rootCommand.AddCommand(DryRunCommand.Create());
rootCommand.AddCommand(MimicListCommand.Create());
rootCommand.AddCommand(MimicGenerateCommand.Create());
return await rootCommand.InvokeAsync(args);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,26 +0,0 @@
using System.Text.Json;
public static class ScenarioLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static ScenarioFile Load(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Scenario file not found: {path}");
var json = File.ReadAllText(path);
var scenario = JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize: {path}");
return scenario with
{
Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList()
};
}
}
@@ -0,0 +1,61 @@
{
"sessions": [
{
"id": "session-a-orientation",
"name": "Session A — Quick orientation",
"goal": "Learn the interface and rate at least 5 alerts.",
"estimatedMinutes": 25,
"defaultSpeed": 60,
"scenarios": ["stable-baseline-01", "uti-sepsis-elderly-01"]
},
{
"id": "session-b-alert-quality",
"name": "Session B — Alert quality deep dive",
"goal": "Compare alert types across contrasting scenarios.",
"estimatedMinutes": 50,
"defaultSpeed": 120,
"scenarios": [
"medication-false-alarm-01",
"uti-sepsis-elderly-01",
"respiratory-failure-asthma-01"
]
},
{
"id": "session-c-ward-round",
"name": "Session C — Ward workflow",
"goal": "Test prioritisation, search, and handoff on a full ward.",
"estimatedMinutes": 30,
"defaultSpeed": 240,
"scenarios": [
"stable-baseline-01", "uti-sepsis-elderly-01", "post-op-hemorrhage-01",
"respiratory-failure-asthma-01", "hypothermia-elderly-01",
"dka-electrolyte-01", "cardiac-arrest-post-mi-01"
]
},
{
"id": "session-d-new-features",
"name": "Session D — New features",
"goal": "Evaluate department overview, sepsis board, handoff, and vitals entry.",
"estimatedMinutes": 40,
"defaultSpeed": 120,
"scenarios": [
"uti-sepsis-elderly-01", "sepsis-sofa-progression-01",
"neurological-decline-gcs-01", "post-op-hemorrhage-01"
]
},
{
"id": "session-e-mimic-ward",
"name": "Session E — MIMIC-IV real stays",
"goal": "Exercise alert quality on de-identified MIMIC-IV ICU trajectories across care units.",
"estimatedMinutes": 45,
"defaultSpeed": 240,
"scenarios": [
"mimic-s35396193",
"mimic-s38329661",
"mimic-s30876334",
"mimic-s38017367",
"mimic-s31248398"
]
}
]
}
@@ -5,6 +5,9 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Copyright>Copyright (c) 2024-2026 voltsrage. All Rights Reserved.</Copyright>
<Authors>voltsrage</Authors>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>
<ItemGroup>
@@ -16,4 +19,8 @@
<PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj" />
</ItemGroup>
</Project>
@@ -4,12 +4,20 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StackExchange.Redis;
public class GatewayApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
public const string TestConnectionString =
"Host=localhost;Port=5437;Database=vigilcare_ward_test;Username=postgres;Password=password";
// Prefer CI/env overrides; fall back to ward-gateway compose profile ports.
public static string TestConnectionString { get; } =
Environment.GetEnvironmentVariable("ConnectionStrings__GatewayDb")
?? "Host=localhost;Port=5437;Database=vigilcare_ward_test;Username=postgres;Password=password";
// Never fall back to the API Redis__* env — that points at a different instance/DB index.
public static string RedisConnection { get; } =
Environment.GetEnvironmentVariable("Gateway__Redis__ConnectionString")
?? "localhost:6383,defaultDatabase=2,allowAdmin=true";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
@@ -19,12 +27,18 @@ public class GatewayApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:GatewayDb"] = TestConnectionString,
["Redis:ConnectionString"] = "localhost:6383,allowAdmin=true",
["RabbitMq:Host"] = "localhost",
["RabbitMq:Port"] = "5675",
["RabbitMq:Username"] = "guest",
["RabbitMq:Password"] = "guest",
["Redis:ConnectionString"] = RedisConnection,
["RabbitMq:Host"] = Environment.GetEnvironmentVariable("Gateway__RabbitMq__Host")
?? Environment.GetEnvironmentVariable("RabbitMq__Host")
?? "localhost",
// Do not fall back to RabbitMq__Port (API uses 5674); gateway broker is 5675.
["RabbitMq:Port"] = Environment.GetEnvironmentVariable("Gateway__RabbitMq__Port")
?? "5675",
["RabbitMq:Username"] = Environment.GetEnvironmentVariable("RabbitMq__Username") ?? "guest",
["RabbitMq:Password"] = Environment.GetEnvironmentVariable("RabbitMq__Password") ?? "guest",
["RabbitMq:PagingAckTimeoutMs"] = "5000",
// Neutralize appsettings.Production.json if the process env was Production.
["RabbitMq:UseSsl"] = "false",
["CentralApi:BaseUrl"] = "http://127.0.0.1:1",
["Gateway:GatewayId"] = "22222222-2222-2222-2222-222222222222",
["Gateway:SiteId"] = "11111111-1111-1111-1111-111111111111",
@@ -33,6 +47,7 @@ public class GatewayApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
["Gateway:CentralReachabilityIntervalSeconds"] = "1",
["Gateway:HeartbeatIntervalSeconds"] = "1",
["Gateway:SyncBatchSize"] = "500",
["Gateway:AutoMigrate"] = "false",
["ApiKey:Gateway"] = "dev-gateway-key-change-in-production",
["Jwt:SigningKey"] = "dev-signing-key-minimum-32-bytes-long!!",
["Jwt:Issuer"] = "vigilcare-gateway",
@@ -42,6 +57,9 @@ public class GatewayApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
builder.ConfigureServices(services =>
{
services.Configure<HostOptions>(o =>
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore);
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestingAuthHandler.SchemeName;
@@ -63,10 +81,19 @@ public class GatewayApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
await GatewayDbResetHelper.ResetAsync(migrateDb);
}
using var scope = Services.CreateScope();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
// Flush only the gateway test DB index — do not FlushAll (would wipe API test DB 1).
// Must run before Services is touched: host startup saturates the thread pool and
// causes StackExchange.Redis TimeoutException on FLUSHDB under CI load.
var dbIndex = 2;
var cfg = RedisConnection.Split(',').FirstOrDefault(p => p.StartsWith("defaultDatabase=", StringComparison.OrdinalIgnoreCase));
if (cfg is not null && int.TryParse(cfg.Split('=')[1], out var parsed))
dbIndex = parsed;
await using (var redis = await ConnectionMultiplexer.ConnectAsync(RedisConnection))
{
var server = redis.GetServer(redis.GetEndPoints().First());
await server.FlushAllDatabasesAsync();
await server.FlushDatabaseAsync(dbIndex);
}
}
protected override void ConfigureClient(HttpClient client)
@@ -24,15 +24,30 @@ public sealed class LocalEscalationWorkerService : BackgroundService
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
var o = _opts.Value;
var factory = new ConnectionFactory
while (!stoppingToken.IsCancellationRequested)
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
DispatchConsumersAsync = true,
};
try
{
await RunConsumerAsync(stoppingToken);
return;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogError(ex,
"LocalEscalationWorkerService disconnected — retrying in 5s");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
private async Task RunConsumerAsync(CancellationToken stoppingToken)
{
var o = _opts.Value;
var factory = RabbitMqConnectionFactory.Create(o, dispatchConsumersAsync: true);
using var connection = factory.CreateConnection("gateway-escalation-worker");
using var channel = connection.CreateModel();
@@ -25,15 +25,30 @@ public sealed class LocalPagingWorkerService : BackgroundService
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
var o = _opts.Value;
var factory = new ConnectionFactory
while (!stoppingToken.IsCancellationRequested)
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
DispatchConsumersAsync = true,
};
try
{
await RunConsumerAsync(stoppingToken);
return;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogError(ex,
"LocalPagingWorkerService disconnected — retrying in 5s");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
private async Task RunConsumerAsync(CancellationToken stoppingToken)
{
var o = _opts.Value;
var factory = RabbitMqConnectionFactory.Create(o, dispatchConsumersAsync: true);
using var connection = factory.CreateConnection("gateway-paging-worker");
using var channel = connection.CreateModel();
@@ -8,4 +8,11 @@ public sealed class GatewayOptions
public int CentralReachabilityIntervalSeconds { get; init; } = 30;
public int HeartbeatIntervalSeconds { get; init; } = 60;
public int SyncBatchSize { get; init; } = 500;
/// <summary>
/// When true (default), applies EF migrations at startup. The gateway is
/// single-instance by design, so startup migration is safe. Set false if
/// migrations are applied out-of-band (e.g. an EF migration bundle).
/// </summary>
public bool AutoMigrate { get; init; } = true;
}
@@ -8,4 +8,9 @@ public sealed class RabbitMqOptions
// Drives both the paging worker poll timeout and the DLQ x-message-ttl.
// In production: 300000 (5 min). In tests: 5000 (5 sec).
public int PagingAckTimeoutMs { get; init; } = 300000;
/// <summary>
/// When true, enables TLS on the AMQP connection (typical production port 5671).
/// </summary>
public bool UseSsl { get; init; } = false;
}
+25 -2
View File
@@ -1,14 +1,37 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY VigilCareClinical.sln ./
COPY VigilCare.ClinicalContracts/VigilCare.ClinicalContracts.csproj VigilCare.ClinicalContracts/
COPY VigilCare.WardGateway/VigilCare.WardGateway.csproj VigilCare.WardGateway/
RUN dotnet restore VigilCare.WardGateway/VigilCare.WardGateway.csproj
COPY VigilCare.ClinicalContracts/ VigilCare.ClinicalContracts/
COPY VigilCare.WardGateway/ VigilCare.WardGateway/
RUN dotnet restore VigilCare.WardGateway/VigilCare.WardGateway.csproj
RUN dotnet publish VigilCare.WardGateway/VigilCare.WardGateway.csproj -c Release -o /app/publish --no-restore
RUN dotnet publish VigilCare.WardGateway/VigilCare.WardGateway.csproj \
-c Release -o /app/publish --no-restore
# Scrub development secrets (Step 4) — production supplies Jwt / ApiKey via env.
RUN sed -i \
-e 's/"SigningKey": "[^"]*"/"SigningKey": ""/' \
-e 's/"Gateway": "dev-[^"]*"/"Gateway": ""/' \
/app/publish/appsettings.json
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
RUN chown -R app:app /app
USER app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "VigilCare.WardGateway.dll"]
@@ -0,0 +1,39 @@
using System.Text.Json;
/// <summary>
/// Shared read helper for converters that write DB-wire strings (e.g. "ICU")
/// but must also accept default System.Text.Json client payloads (numeric enums)
/// and PascalCase enum names.
/// </summary>
internal static class DbStringEnumJson
{
public static TEnum Read<TEnum>(ref Utf8JsonReader reader, Func<string, TEnum> fromDbString)
where TEnum : struct, Enum
{
switch (reader.TokenType)
{
case JsonTokenType.Number:
if (reader.TryGetInt32(out var numeric)
&& Enum.IsDefined(typeof(TEnum), numeric))
return (TEnum)Enum.ToObject(typeof(TEnum), numeric);
throw new JsonException($"Invalid numeric value for {typeof(TEnum).Name}.");
case JsonTokenType.String:
var raw = reader.GetString()
?? throw new JsonException($"Null string for {typeof(TEnum).Name}.");
try
{
return fromDbString(raw);
}
catch (ArgumentOutOfRangeException) when (
Enum.TryParse(raw, ignoreCase: true, out TEnum byName))
{
return byName;
}
default:
throw new JsonException(
$"Unexpected token {reader.TokenType} when parsing {typeof(TEnum).Name}.");
}
}
}
@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
public sealed class DepartmentJsonConverter : JsonConverter<Department>
{
public override Department Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> DepartmentExtensions.FromDbString(reader.GetString()!);
=> DbStringEnumJson.Read(ref reader, DepartmentExtensions.FromDbString);
public override void Write(Utf8JsonWriter writer, Department value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToDbString());
@@ -11,13 +11,7 @@ public sealed class RabbitMqHealthCheck : IHealthCheck
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
var factory = new ConnectionFactory
{
HostName = _options.Host,
Port = _options.Port,
UserName = _options.Username,
Password = _options.Password
};
var factory = RabbitMqConnectionFactory.Create(_options);
using var connection = await Task.Run(() => factory.CreateConnection(), cancellationToken);
var data = new Dictionary<string, object> { ["endpoint"] = connection.Endpoint.ToString() };
@@ -1,9 +1,12 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCare.WardGateway.Migrations;
[DbContext(typeof(GatewayDbContext))]
[Migration("20260625000000_AddLocalAlertExplanation")]
public partial class AddLocalAlertExplanation : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
@@ -0,0 +1,27 @@
using RabbitMQ.Client;
public static class RabbitMqConnectionFactory
{
public static ConnectionFactory Create(RabbitMqOptions o, bool dispatchConsumersAsync = false)
{
var factory = new ConnectionFactory
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
DispatchConsumersAsync = dispatchConsumersAsync,
};
if (o.UseSsl)
{
factory.Ssl = new SslOption
{
Enabled = true,
ServerName = o.Host,
};
}
return factory;
}
}
@@ -85,12 +85,6 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
public ConnectionFactory BuildFactory() => new()
{
HostName = _opts.Host,
Port = _opts.Port,
UserName = _opts.Username,
Password = _opts.Password,
DispatchConsumersAsync = true,
};
public ConnectionFactory BuildFactory() =>
RabbitMqConnectionFactory.Create(_opts, dispatchConsumersAsync: true);
}
+9 -5
View File
@@ -80,8 +80,10 @@ try
builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
var corsOrigins = builder.Configuration.GetSection("Dashboard:CorsOrigins").Get<string[]>()
?? ["http://localhost:5173"];
builder.Services.AddCors(o => o.AddPolicy("Dashboard", p =>
p.WithOrigins("http://localhost:5173").AllowAnyHeader().AllowAnyMethod()));
p.WithOrigins(corsOrigins).AllowAnyHeader().AllowAnyMethod()));
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -120,14 +122,16 @@ try
ResponseWriter = HealthCheckResponseWriter.WriteAsync
});
if (!app.Environment.IsEnvironment("Testing"))
{
using (var scope = app.Services.CreateScope())
// Startup migration is the proven path for this single-instance edge host.
// Gate with Gateway:AutoMigrate so production can switch to an out-of-band
// EF migration bundle later without a code change (Phase 36 Step 6).
if (!app.Environment.IsEnvironment("Testing")
&& builder.Configuration.GetValue("Gateway:AutoMigrate", true))
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
await db.Database.MigrateAsync();
}
}
app.Run();
}
@@ -18,13 +18,7 @@ public sealed class LocalPagingPublisher
{
try
{
var factory = new ConnectionFactory
{
HostName = _opts.Host,
Port = _opts.Port,
UserName = _opts.Username,
Password = _opts.Password
};
var factory = RabbitMqConnectionFactory.Create(_opts);
using var conn = factory.CreateConnection("gateway-publisher");
using var channel = conn.CreateModel();
@@ -4,6 +4,9 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Copyright>Copyright (c) 2024-2026 voltsrage. All Rights Reserved.</Copyright>
<Authors>voltsrage</Authors>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>
<ItemGroup>
@@ -12,6 +15,11 @@
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="RabbitMQ.Client" Version="6.8.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="2.3.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.27" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
@@ -0,0 +1,26 @@
{
"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": "VigilCare.WardGateway"
}
},
"Gateway": {
"AutoMigrate": true
},
"RabbitMq": {
"UseSsl": true
}
}
+7 -2
View File
@@ -10,11 +10,15 @@
"Port": 5675,
"Username": "guest",
"Password": "guest",
"PagingAckTimeoutMs": 300000
"PagingAckTimeoutMs": 300000,
"UseSsl": false
},
"CentralApi": {
"BaseUrl": "http://localhost:5270"
},
"Dashboard": {
"CorsOrigins": [ "http://localhost:5173" ]
},
"Gateway": {
"GatewayId": "22222222-2222-2222-2222-222222222222",
"SiteId": "11111111-1111-1111-1111-111111111111",
@@ -22,7 +26,8 @@
"EncounterSyncIntervalMinutes": 5,
"CentralReachabilityIntervalSeconds": 30,
"HeartbeatIntervalSeconds": 60,
"SyncBatchSize": 500
"SyncBatchSize": 500,
"AutoMigrate": true
},
"ApiKey": {
"Gateway": "dev-gateway-key-change-in-production"
+6
View File
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway", "Vi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway.Tests", "VigilCare.WardGateway.Tests\VigilCare.WardGateway.Tests.csproj", "{1C6D261E-488B-4541-8995-12C5029441A6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.Simulation.Core", "VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj", "{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -54,5 +56,9 @@ Global
{1C6D261E-488B-4541-8995-12C5029441A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.Build.0 = Release|Any CPU
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
@@ -62,7 +62,7 @@ public class AlertLifecycleTests : IAsyncLifetime
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
body!.RootElement.GetProperty("data").GetProperty("status").GetString()
.Should().Be("Acknowledged");
.Should().Be("ACKNOWLEDGED");
body.RootElement.GetProperty("data").GetProperty("acknowledgedBy").GetString()
.Should().Be("Test NURSE (NURSE)");
}
@@ -90,6 +90,6 @@ public class AlertLifecycleTests : IAsyncLifetime
resolveResp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resolveResp.Content.ReadFromJsonAsync<JsonDocument>();
body!.RootElement.GetProperty("data").GetProperty("status").GetString()
.Should().Be("Resolved");
.Should().Be("RESOLVED");
}
}
@@ -0,0 +1,165 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
[Collection("Integration")]
public class AlertQualityScenarioAttributionTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
public AlertQualityScenarioAttributionTests(ApiFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
_client.AsNurse();
}
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task AlertQuality_FiltersByScenarioId()
{
Guid matchFeedbackId;
Guid otherFeedbackId;
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var patientA = NewPatient("MRN-SCEN-A");
var patientB = NewPatient("MRN-SCEN-B");
var encounterA = NewEncounter(patientA.Id);
var encounterB = NewEncounter(patientB.Id);
var alertA = NewAlert(encounterA.Id, patientA.Id, AlertType.News2Warning);
var alertB = NewAlert(encounterB.Id, patientB.Id, AlertType.News2Emergency);
db.Patients.AddRange(patientA, patientB);
db.Encounters.AddRange(encounterA, encounterB);
db.ClinicalAlerts.AddRange(alertA, alertB);
db.SimulationRuns.Add(new SimulationRun
{
Id = Guid.NewGuid(),
ScenarioId = "uti-sepsis-elderly-01",
ScenarioName = "UTI Sepsis",
Speed = 60,
Status = SimulationRunStatus.Completed,
PatientId = patientA.Id,
EncounterId = encounterA.Id,
SessionId = "session-b-alert-quality",
StartedByUserId = "tester",
StartedAt = DateTimeOffset.UtcNow.AddHours(-1),
CompletedAt = DateTimeOffset.UtcNow,
});
db.SimulationRuns.Add(new SimulationRun
{
Id = Guid.NewGuid(),
ScenarioId = "medication-false-alarm-01",
ScenarioName = "Med False Alarm",
Speed = 60,
Status = SimulationRunStatus.Completed,
PatientId = patientB.Id,
EncounterId = encounterB.Id,
SessionId = "session-b-alert-quality",
StartedByUserId = "tester",
StartedAt = DateTimeOffset.UtcNow.AddHours(-1),
CompletedAt = DateTimeOffset.UtcNow,
});
var fbA = new AlertFeedback
{
Id = Guid.NewGuid(),
AlertId = alertA.Id,
UserId = Guid.NewGuid(),
FeedbackType = AlertFeedbackType.WouldAct,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
};
var fbB = new AlertFeedback
{
Id = Guid.NewGuid(),
AlertId = alertB.Id,
UserId = Guid.NewGuid(),
FeedbackType = AlertFeedbackType.FalsePositive,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
};
db.AlertFeedbacks.AddRange(fbA, fbB);
await db.SaveChangesAsync();
matchFeedbackId = fbA.Id;
otherFeedbackId = fbB.Id;
}
var all = await _client.GetAsync("/api/v1/alerts/quality-metrics/feedback");
all.StatusCode.Should().Be(HttpStatusCode.OK);
var allBody = await all.Content.ReadFromJsonAsync<JsonDocument>();
var allItems = allBody!.RootElement.GetProperty("data").GetProperty("items");
allItems.GetArrayLength().Should().Be(2);
var match = allItems.EnumerateArray()
.Single(e => e.GetProperty("id").GetGuid() == matchFeedbackId);
match.GetProperty("scenarioId").GetString().Should().Be("uti-sepsis-elderly-01");
match.GetProperty("sessionId").GetString().Should().Be("session-b-alert-quality");
var filtered = await _client.GetAsync(
"/api/v1/alerts/quality-metrics/feedback?scenarioId=uti-sepsis-elderly-01");
filtered.StatusCode.Should().Be(HttpStatusCode.OK);
var filteredBody = await filtered.Content.ReadFromJsonAsync<JsonDocument>();
var filteredItems = filteredBody!.RootElement.GetProperty("data").GetProperty("items");
filteredItems.GetArrayLength().Should().Be(1);
filteredItems[0].GetProperty("id").GetGuid().Should().Be(matchFeedbackId);
filteredItems[0].GetProperty("scenarioId").GetString().Should().Be("uti-sepsis-elderly-01");
// Other scenario's feedback must not leak into the filter.
filteredItems.EnumerateArray()
.Any(e => e.GetProperty("id").GetGuid() == otherFeedbackId)
.Should().BeFalse();
}
private static Patient NewPatient(string mrn) => new()
{
Id = Guid.NewGuid(),
Mrn = mrn,
FirstName = "Sim",
LastName = "Patient",
DateOfBirth = new DateOnly(1980, 1, 1),
Gender = "F",
CreatedAt = DateTimeOffset.UtcNow,
IsSimulated = true,
};
private static Encounter NewEncounter(Guid patientId) => new()
{
Id = Guid.NewGuid(),
PatientId = patientId,
EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active,
Department = Department.Icu,
AttendingPhysician = "Dr. Test",
AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
};
private static ClinicalAlert NewAlert(Guid encounterId, Guid patientId, AlertType type) => new()
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
AlertType = type,
Severity = AlertSeverity.Warning,
Details = "Test alert",
Status = AlertStatus.Acknowledged,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
AcknowledgedAt = DateTimeOffset.UtcNow.AddMinutes(-20),
AcknowledgedBy = "nurse",
};
}
@@ -44,7 +44,7 @@ public class ExplainableAlertsTests : IAsyncLifetime
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
body.Explanation.Should().NotBeNull();
body.Explanation!.ScoreContributors.Should().HaveCount(6);
body.Explanation!.ScoreContributors.Should().NotBeEmpty();
body.Explanation.ScoreContributors.Should().Contain(c => c.Parameter == "Respiratory");
}
@@ -3,6 +3,7 @@ using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
using VigilCare.Simulation;
[Collection("Integration")]
public class ClinicalRefactorEndToEndTests : IAsyncLifetime
@@ -142,6 +142,7 @@ public class EncountersListTests : IAsyncLifetime
icu.GetProperty("gcsClassification").GetString().Should().Be("MODERATE");
icu.GetProperty("attendingPhysician").GetString().Should().Be("Dr. Ward");
icu.GetProperty("lastObservationAt").GetDateTimeOffset().Should().NotBe(default);
icu.GetProperty("isSimulated").GetBoolean().Should().BeFalse();
}
[Fact]
@@ -1,13 +1,37 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using StackExchange.Redis;
public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
// Prefer CI/env overrides; fall back to local docker-compose.yml host ports.
public static string PgConnection { get; } =
Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection")
?? "Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password";
public static string RedisConnection { get; } =
Environment.GetEnvironmentVariable("Redis__ConnectionString")
?? "localhost:6382,defaultDatabase=1,allowAdmin=true";
public static string RabbitHost { get; } =
Environment.GetEnvironmentVariable("RabbitMq__Host") ?? "localhost";
public static int RabbitPort { get; } =
int.TryParse(Environment.GetEnvironmentVariable("RabbitMq__Port"), out var p) ? p : 5674;
public static string SimulationScenarioDirectory { get; } = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "Fixtures", "Scenarios"));
public TestSimulationClientFactory SimulationClientFactory =>
Services.GetRequiredService<TestSimulationClientFactory>();
// Override configuration to point at a test database — never run tests against
// the development database; a botched rollback could corrupt seed data.
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -17,26 +41,52 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] =
"Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password",
["Redis:ConnectionString"] = "localhost:6382,defaultDatabase=1,allowAdmin=true",
["RabbitMq:Host"] = "localhost",
["RabbitMq:Port"] = "5674",
["RabbitMq:Username"] = "guest",
["RabbitMq:Password"] = "guest",
["ConnectionStrings:DefaultConnection"] = PgConnection,
["Redis:ConnectionString"] = RedisConnection,
["RabbitMq:Host"] = RabbitHost,
["RabbitMq:Port"] = RabbitPort.ToString(),
["RabbitMq:Username"] = Environment.GetEnvironmentVariable("RabbitMq__Username") ?? "guest",
["RabbitMq:Password"] = Environment.GetEnvironmentVariable("RabbitMq__Password") ?? "guest",
["RabbitMq:PagingAckTimeoutMs"] = "5000",
["RabbitMq:VirtualHost"] = "vigilcare_test",
// Neutralize appsettings.Production.json if the process env was Production.
["RabbitMq:UseSsl"] = "false",
["Kafka:BootstrapServers"] =
Environment.GetEnvironmentVariable("Kafka__BootstrapServers") ?? "localhost:9092",
["Kafka:ReplicationFactor"] =
Environment.GetEnvironmentVariable("Kafka__ReplicationFactor") ?? "1",
["Kafka:SecurityProtocol"] =
Environment.GetEnvironmentVariable("Kafka__SecurityProtocol") ?? "Plaintext",
["Kafka:NotificationPublisherGroupId"] = "notification-publisher-integration-test",
["Kafka:NotificationPublisherAutoOffsetReset"] = "Latest",
["Elasticsearch:Uri"] =
Environment.GetEnvironmentVariable("Elasticsearch__Uri") ?? "http://localhost:9200",
["Minio:Endpoint"] =
Environment.GetEnvironmentVariable("Minio__Endpoint") ?? "localhost:9005",
["Minio:UseSSL"] =
Environment.GetEnvironmentVariable("Minio__UseSSL") ?? "false",
["Fhir:ApiKey"] = "dev-integration-key-change-in-production",
["ApiKey:Gateway"] = GatewayAuthHelper.DevGatewayKey,
});
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
// Win over appsettings.Testing.json catalogue path / concurrency defaults.
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Simulation:Enabled"] = "true",
["Simulation:ScenarioDirectory"] = SimulationScenarioDirectory,
["Simulation:MaxConcurrentRuns"] = "2",
["Simulation:MaxSpeed"] = "600",
["Simulation:RunHistoryLimit"] = "50",
});
});
builder.ConfigureServices(services =>
{
services.Configure<HostOptions>(o =>
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore);
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestingAuthHandler.SchemeName;
@@ -45,6 +95,15 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
.AddScheme<AuthenticationSchemeOptions, TestingAuthHandler>(
TestingAuthHandler.SchemeName, _ => { });
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ISimulationClientFactory>();
services.AddSingleton<TestSimulationClientFactory>(_ =>
new TestSimulationClientFactory(this));
services.AddSingleton<ISimulationClientFactory>(sp =>
sp.GetRequiredService<TestSimulationClientFactory>());
});
}
public async Task InitializeAsync()
@@ -52,9 +111,8 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
// Apply migrations and clean stale data before the host starts — background
// services such as ThresholdCacheLoader query the database during StartAsync,
// so the reset must happen while no hosted service holds a lock.
var connectionString = "Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connectionString)
.UseNpgsql(PgConnection)
.Options;
await using (var migrateDb = new AppDbContext(options))
{
@@ -64,18 +122,23 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
await RabbitMqTestHelper.EnsureVirtualHostAsync(new RabbitMqOptions
{
Host = "localhost",
Port = 5674,
Username = "guest",
Password = "guest",
Host = RabbitHost,
Port = RabbitPort,
Username = Environment.GetEnvironmentVariable("RabbitMq__Username") ?? "guest",
Password = Environment.GetEnvironmentVariable("RabbitMq__Password") ?? "guest",
VirtualHost = "vigilcare_test",
});
using var scope = Services.CreateScope();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
// Flush Redis before starting the host. Accessing Services boots many hosted
// services that saturate the thread pool; FLUSHDB on the shared multiplexer
// then times out waiting for a reply that already arrived (see SE.Redis
// TimeoutException: last-in/cur-in stuck, QueuedItems > 0).
await using (var redis = await ConnectionMultiplexer.ConnectAsync(RedisConnection))
{
var server = redis.GetServer(redis.GetEndPoints().First());
await server.FlushDatabaseAsync(1);
}
}
protected override void ConfigureClient(HttpClient client)
{
@@ -0,0 +1,44 @@
{
"scenario": {
"id": "minimal-sim-01",
"name": "Minimal Simulation Fixture",
"description": "Three observation clusters for Phase 36 CI tests.",
"durationMinutes": 2,
"tags": ["test", "minimal"]
},
"patient": {
"firstName": "Sim",
"lastName": "Fixture",
"dateOfBirth": "1980-01-15",
"gender": "Female"
},
"encounter": {
"department": "GeneralMedicine",
"encounterType": "Inpatient",
"attendingPhysician": "Dr. Test",
"roomBed": "T-1",
"admissionReason": "Simulation fixture"
},
"events": [
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 72, "unit": "bpm", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Manual" }
},
{
"offsetMinutes": 1,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 74, "unit": "bpm", "source": "Manual" }
},
{
"offsetMinutes": 2,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 70, "unit": "bpm", "source": "Manual" }
}
]
}
@@ -0,0 +1,28 @@
{
"sessions": [
{
"id": "session-test-one",
"name": "Test Session One",
"goal": "Single fixture run for session start tests.",
"estimatedMinutes": 2,
"defaultSpeed": 120,
"scenarios": ["minimal-sim-01"]
},
{
"id": "session-test-two",
"name": "Test Session Two",
"goal": "Two concurrent fixture runs.",
"estimatedMinutes": 5,
"defaultSpeed": 60,
"scenarios": ["minimal-sim-01", "minimal-sim-01"]
},
{
"id": "session-test-three",
"name": "Test Session Three",
"goal": "Over capacity when MaxConcurrentRuns is 2.",
"estimatedMinutes": 5,
"defaultSpeed": 60,
"scenarios": ["minimal-sim-01", "minimal-sim-01", "minimal-sim-01"]
}
]
}
@@ -280,7 +280,7 @@ public class GapAnalysisFixTests : IAsyncLifetime
bundle.GetProperty("firstName").GetString().Should().Be("List");
bundle.GetProperty("lastName").GetString().Should().Be("Test");
bundle.GetProperty("mrn").GetString().Should().Be("MRN-LIST-001");
bundle.GetProperty("department").GetString().Should().Be("Icu");
bundle.GetProperty("department").GetString().Should().Be("ICU");
bundle.GetProperty("elements").EnumerateArray().Should().HaveCount(4);
}
@@ -35,6 +35,7 @@ public static class DbResetHelper
DELETE FROM alert_thresholds;
DELETE FROM clinical_audit_logs;
DELETE FROM clinical_users;
DELETE FROM simulation_runs;
DELETE FROM patients;
");
return;

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