Compare commits
10
Commits
7bb9124230
...
2a3ef62a7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3ef62a7d | ||
|
|
9e88ff6113 | ||
|
|
131a04630d | ||
|
|
26bef39aae | ||
|
|
d48e1737b5 | ||
|
|
fdcc646fae | ||
|
|
a8964381a2 | ||
|
|
069881991a | ||
|
|
09a84f34ba | ||
|
|
df6fbed401 |
@@ -0,0 +1,22 @@
|
||||
**/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/
|
||||
VigilCare.Simulator/
|
||||
vigilcare-dashboard/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,70 @@
|
||||
# ---- 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=8080
|
||||
DASHBOARD_ORIGIN=https://vigilcare.example.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
|
||||
@@ -0,0 +1,174 @@
|
||||
# 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, 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
|
||||
container:
|
||||
image: mcr.microsoft.com/dotnet/sdk:8.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build migration bundle
|
||||
run: |
|
||||
dotnet tool install --global dotnet-ef --version 8.0.4 \
|
||||
|| dotnet tool update --global dotnet-ef --version 8.0.4
|
||||
export PATH="$PATH:/root/.dotnet/tools"
|
||||
bash ./scripts/build-api-migration-bundle.sh
|
||||
cp -f ./artifacts/migrate-api ./migrate-api
|
||||
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.
|
||||
- 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'
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://localhost:5270/health/ready >/dev/null; then
|
||||
echo "Ready check passed."
|
||||
curl -fsS http://localhost:5081/health/live >/dev/null && echo "Gateway live."
|
||||
curl -fsS http://localhost:8080/ >/dev/null && echo "Dashboard serving."
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "Ready check never passed — dumping API logs:"
|
||||
docker compose -f /opt/vigilcare/docker-compose.prod.yml --env-file /opt/vigilcare/.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
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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
|
||||
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: 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:
|
||||
# Defaults match docker-compose.yml published ports; fixtures fall back to these anyway.
|
||||
ConnectionStrings__DefaultConnection: "Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password"
|
||||
ConnectionStrings__GatewayDb: "Host=localhost;Port=5437;Database=vigilcare_ward_test;Username=postgres;Password=password"
|
||||
Redis__ConnectionString: "localhost:6382,defaultDatabase=1,allowAdmin=true"
|
||||
Gateway__Redis__ConnectionString: "localhost:6383,defaultDatabase=2,allowAdmin=true"
|
||||
RabbitMq__Host: "localhost"
|
||||
RabbitMq__Port: "5674"
|
||||
Gateway__RabbitMq__Port: "5675"
|
||||
Kafka__BootstrapServers: "localhost:9092"
|
||||
Kafka__ReplicationFactor: "1"
|
||||
Elasticsearch__Uri: "http://localhost:9200"
|
||||
Minio__Endpoint: "localhost:9005"
|
||||
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
|
||||
@@ -5,6 +5,7 @@ bin/
|
||||
obj/
|
||||
out/
|
||||
publish/
|
||||
artifacts/
|
||||
|
||||
# =========================
|
||||
# User-specific files
|
||||
|
||||
@@ -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
|
||||
@@ -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 20–23) — 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:** Thirty-three planned phases are complete through Phase 35 (plus Phases 20–23) — 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), **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), and **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). 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).
|
||||
|
||||
## 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 5–10 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), 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, 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 5–10 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
|
||||
@@ -88,10 +88,12 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
||||
- **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
|
||||
- **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`
|
||||
- **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`, `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); 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 +115,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)
|
||||
@@ -202,7 +205,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 +213,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 +234,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 +249,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 +303,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 +311,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 +330,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 +388,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 +431,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 +494,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,34 +572,44 @@ 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 17–19, 22, 23, 27–28, 31, 33 — Vue 3 ward dashboard SPA
|
||||
vigilcare-dashboard/ # Phases 17–19, 22, 23, 27–28, 31, 33–34 — 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
|
||||
│ ├── 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, 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
|
||||
@@ -623,6 +643,7 @@ 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
|
||||
|
||||
@@ -820,6 +841,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
|
||||
@@ -914,6 +950,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 +1017,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 +1274,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 +1461,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 +1475,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 +1553,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`.
|
||||
|
||||
@@ -1656,6 +1728,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 +1919,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 +2292,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 27–29), 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 8–15, 20–23, 25–31, 33. Phases 17–19 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
|
||||
Thirty-three 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 27–29), 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), 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 8–15, 20–23, 25–31, 33–35. Phases 17–19 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|---|---|---|
|
||||
@@ -2238,11 +2324,13 @@ 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 |
|
||||
|
||||
**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, `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, `GET /alerts/{id}` with structured explanation, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `GapAnalysisFixTests`, `OperationsApiTests`, `AlertQualityAnalyticsTests`, `ExplainableAlertsTests`, `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).
|
||||
|
||||
**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,20 +2346,25 @@ 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.
|
||||
|
||||
**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, driving NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting on real patient trajectories.
|
||||
|
||||
**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
|
||||
- **Alert threshold deletion** — `DELETE /alert-thresholds/{id}` with `THRESHOLD_DELETED` audit action and Redis cache invalidation
|
||||
- **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>
|
||||
|
||||
@@ -38,7 +38,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,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,136 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,322 @@
|
||||
using System.Text.Json;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -8,8 +8,15 @@ 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";
|
||||
|
||||
public static string RedisConnection { get; } =
|
||||
Environment.GetEnvironmentVariable("Gateway__Redis__ConnectionString")
|
||||
?? Environment.GetEnvironmentVariable("Redis__ConnectionString")
|
||||
?? "localhost:6383,defaultDatabase=2,allowAdmin=true";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
@@ -19,11 +26,15 @@ 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",
|
||||
["RabbitMq:Port"] = Environment.GetEnvironmentVariable("Gateway__RabbitMq__Port")
|
||||
?? Environment.GetEnvironmentVariable("RabbitMq__Port")
|
||||
?? "5675",
|
||||
["RabbitMq:Username"] = Environment.GetEnvironmentVariable("RabbitMq__Username") ?? "guest",
|
||||
["RabbitMq:Password"] = Environment.GetEnvironmentVariable("RabbitMq__Password") ?? "guest",
|
||||
["RabbitMq:PagingAckTimeoutMs"] = "5000",
|
||||
["CentralApi:BaseUrl"] = "http://127.0.0.1:1",
|
||||
["Gateway:GatewayId"] = "22222222-2222-2222-2222-222222222222",
|
||||
@@ -33,6 +44,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",
|
||||
@@ -66,7 +78,12 @@ public class GatewayApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
using var scope = Services.CreateScope();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var server = redis.GetServer(redis.GetEndPoints().First());
|
||||
await server.FlushAllDatabasesAsync();
|
||||
// Flush only the gateway test DB index — do not FlushAll (would wipe API test DB 1).
|
||||
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 server.FlushDatabaseAsync(dbIndex);
|
||||
}
|
||||
|
||||
protected override void ConfigureClient(HttpClient client)
|
||||
|
||||
@@ -25,14 +25,7 @@ public sealed class LocalEscalationWorkerService : BackgroundService
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var o = _opts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
var factory = RabbitMqConnectionFactory.Create(o, dispatchConsumersAsync: true);
|
||||
|
||||
using var connection = factory.CreateConnection("gateway-escalation-worker");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
@@ -26,14 +26,7 @@ public sealed class LocalPagingWorkerService : BackgroundService
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var o = _opts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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() };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -120,14 +120,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
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
"Port": 5675,
|
||||
"Username": "guest",
|
||||
"Password": "guest",
|
||||
"PagingAckTimeoutMs": 300000
|
||||
"PagingAckTimeoutMs": 300000,
|
||||
"UseSsl": false
|
||||
},
|
||||
"CentralApi": {
|
||||
"BaseUrl": "http://localhost:5270"
|
||||
@@ -22,7 +23,8 @@
|
||||
"EncounterSyncIntervalMinutes": 5,
|
||||
"CentralReachabilityIntervalSeconds": 30,
|
||||
"HeartbeatIntervalSeconds": 60,
|
||||
"SyncBatchSize": 500
|
||||
"SyncBatchSize": 500,
|
||||
"AutoMigrate": true
|
||||
},
|
||||
"ApiKey": {
|
||||
"Gateway": "dev-gateway-key-change-in-production"
|
||||
|
||||
@@ -8,6 +8,21 @@ 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;
|
||||
|
||||
// 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,17 +32,24 @@ 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",
|
||||
["Kafka:BootstrapServers"] =
|
||||
Environment.GetEnvironmentVariable("Kafka__BootstrapServers") ?? "localhost:9092",
|
||||
["Kafka:ReplicationFactor"] =
|
||||
Environment.GetEnvironmentVariable("Kafka__ReplicationFactor") ?? "1",
|
||||
["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",
|
||||
["Fhir:ApiKey"] = "dev-integration-key-change-in-production",
|
||||
["ApiKey:Gateway"] = GatewayAuthHelper.DevGatewayKey,
|
||||
});
|
||||
@@ -52,9 +74,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,10 +85,10 @@ 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",
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ public class EsIndexerService : BackgroundService
|
||||
// Consumers must be idempotent. See idempotency contract above.
|
||||
EnableAutoCommit = false,
|
||||
EnablePartitionEof = false
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public class GcsScoringService : BackgroundService
|
||||
GroupId = "gcs-scoring",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
@@ -18,7 +18,7 @@ public class KafkaTopicProvisioner : IHostedService
|
||||
using var admin = new AdminClientBuilder(new AdminClientConfig
|
||||
{
|
||||
BootstrapServers = _options.BootstrapServers
|
||||
}).Build();
|
||||
}.ApplySecurity(_options)).Build();
|
||||
|
||||
var topicNames = new[]
|
||||
{
|
||||
|
||||
@@ -48,7 +48,8 @@ public sealed class KafkaConsumerLagCollector : BackgroundService
|
||||
private async Task CollectGroupLagAsync(string groupId, CancellationToken ct)
|
||||
{
|
||||
var adminConfig = new AdminClientConfig
|
||||
{ BootstrapServers = _kafkaOptions.BootstrapServers };
|
||||
{ BootstrapServers = _kafkaOptions.BootstrapServers }
|
||||
.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var admin = new AdminClientBuilder(adminConfig).Build();
|
||||
|
||||
@@ -69,7 +70,7 @@ public sealed class KafkaConsumerLagCollector : BackgroundService
|
||||
{
|
||||
BootstrapServers = _kafkaOptions.BootstrapServers,
|
||||
GroupId = $"__lag-probe",
|
||||
}).Build();
|
||||
}.ApplySecurity(_kafkaOptions)).Build();
|
||||
|
||||
long totalLag = 0;
|
||||
foreach (var tpo in partitions)
|
||||
|
||||
@@ -26,7 +26,7 @@ public class News2ScoringService : BackgroundService
|
||||
GroupId = "news2-scoring",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ public sealed class NotificationPublisherService : BackgroundService
|
||||
AutoOffsetReset = Enum.Parse<AutoOffsetReset>(
|
||||
_kafkaOptions.NotificationPublisherAutoOffsetReset, ignoreCase: true),
|
||||
EnableAutoCommit = false,
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
|
||||
consumer.Subscribe(new[]
|
||||
|
||||
@@ -43,7 +43,7 @@ public class OutboxRelayService : BackgroundService
|
||||
EnableIdempotence = true,
|
||||
MessageSendMaxRetries = 3,
|
||||
RetryBackoffMs = 100
|
||||
}).Build();
|
||||
}.ApplySecurity(_options)).Build();
|
||||
|
||||
var factory = RabbitMqConnectionFactory.Create(_rabbitOpts);
|
||||
_rabbitConnection = factory.CreateConnection("outbox-relay");
|
||||
|
||||
@@ -31,7 +31,7 @@ public class SepsisEngineService : BackgroundService
|
||||
GroupId = "sepsis-engine",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
@@ -26,7 +26,7 @@ public class SofaScoringService : BackgroundService
|
||||
GroupId = "sofa-scoring",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(new[]
|
||||
|
||||
@@ -26,7 +26,7 @@ public class TrendAnalyzerService : BackgroundService
|
||||
GroupId = "trend-analyzer",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
@@ -26,7 +26,7 @@ public class WarningAlertService : BackgroundService
|
||||
GroupId = "warning-evaluator",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot bootstrap: creates the first Admin user when the ClinicalUsers
|
||||
/// table is empty. Refuses to run if any user already exists so it cannot
|
||||
/// be used as a backdoor after the system is in use.
|
||||
///
|
||||
/// Usage:
|
||||
/// dotnet VigilCareClinicalAPI.dll create-admin --username <u> --password <p> --display-name "<n>"
|
||||
/// </summary>
|
||||
public static class CreateAdminCommand
|
||||
{
|
||||
public static async Task<int> RunAsync(IServiceProvider services, string[] args)
|
||||
{
|
||||
var username = RequireArg(args, "--username");
|
||||
var password = RequireArg(args, "--password");
|
||||
var displayName = RequireArg(args, "--display-name");
|
||||
|
||||
if (username.Length > 100)
|
||||
{
|
||||
Console.Error.WriteLine("Username must be 100 characters or fewer.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (password.Length < 8)
|
||||
{
|
||||
Console.Error.WriteLine("Password must be at least 8 characters.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (displayName.Length > 200)
|
||||
{
|
||||
Console.Error.WriteLine("Display name must be 200 characters or fewer.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
if (await db.ClinicalUsers.AnyAsync())
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Refusing to create admin: ClinicalUsers already contains at least one row. " +
|
||||
"create-admin is a one-shot bootstrap and cannot be used as a backdoor.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var user = new ClinicalUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = username.Trim(),
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||
DisplayName = displayName.Trim(),
|
||||
Role = ClinicalRole.Admin,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.ClinicalUsers.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine($"Created admin user '{user.Username}' (id={user.Id}).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string RequireArg(string[] args, string name)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = args[i + 1];
|
||||
if (string.IsNullOrWhiteSpace(value) || value.StartsWith("--", StringComparison.Ordinal))
|
||||
break;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Missing required argument: {name}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot / idempotent registration of a clinical site + ward gateway with
|
||||
/// caller-supplied IDs. Production cannot rely on GatewayRegistrySeeder (demo
|
||||
/// data is gated off); the gateway authenticates using Gateway:GatewayId /
|
||||
/// Gateway:SiteId from its environment, so those GUIDs must exist in the API DB.
|
||||
///
|
||||
/// Usage:
|
||||
/// dotnet VigilCareClinicalAPI.dll register-gateway \
|
||||
/// --site-id <guid> --gateway-id <guid> \
|
||||
/// --site-code SITE-01 --site-name "General Hospital" \
|
||||
/// --gateway-code GW-ICU-1 --department ICU \
|
||||
/// [--address "123 Main St"]
|
||||
///
|
||||
/// Re-running with the same IDs is a no-op success. Conflicting codes or IDs fail.
|
||||
/// </summary>
|
||||
public static class RegisterGatewayCommand
|
||||
{
|
||||
public static async Task<int> RunAsync(IServiceProvider services, string[] args)
|
||||
{
|
||||
var siteId = RequireGuid(args, "--site-id");
|
||||
var gatewayId = RequireGuid(args, "--gateway-id");
|
||||
var siteCode = RequireArg(args, "--site-code").Trim();
|
||||
var siteName = RequireArg(args, "--site-name").Trim();
|
||||
var gatewayCode = RequireArg(args, "--gateway-code").Trim();
|
||||
var department = RequireArg(args, "--department").Trim();
|
||||
var address = GetArg(args, "--address")?.Trim();
|
||||
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var existingGateway = await db.WardGateways
|
||||
.Include(g => g.Site)
|
||||
.FirstOrDefaultAsync(g => g.Id == gatewayId);
|
||||
|
||||
if (existingGateway is not null)
|
||||
{
|
||||
if (existingGateway.SiteId != siteId
|
||||
|| !string.Equals(existingGateway.GatewayCode, gatewayCode, StringComparison.Ordinal)
|
||||
|| !string.Equals(existingGateway.Department, department, StringComparison.Ordinal))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Gateway id {gatewayId} already exists with different site/code/department. Aborting.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"Gateway '{existingGateway.GatewayCode}' (id={gatewayId}) already registered — nothing to do.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var existingSite = await db.ClinicalSites.FirstOrDefaultAsync(s => s.Id == siteId);
|
||||
if (existingSite is null)
|
||||
{
|
||||
var codeTaken = await db.ClinicalSites.AnyAsync(s => s.SiteCode == siteCode);
|
||||
if (codeTaken)
|
||||
{
|
||||
Console.Error.WriteLine($"Site code '{siteCode}' is already registered under a different id.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var site = new ClinicalSite(siteCode, siteName, address);
|
||||
db.Entry(site).Property(nameof(ClinicalSite.Id)).CurrentValue = siteId;
|
||||
db.ClinicalSites.Add(site);
|
||||
Console.WriteLine($"Created site '{siteCode}' (id={siteId}).");
|
||||
}
|
||||
else if (!string.Equals(existingSite.SiteCode, siteCode, StringComparison.Ordinal))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Site id {siteId} already exists as code '{existingSite.SiteCode}', " +
|
||||
$"not '{siteCode}'. Aborting.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var duplicateCode = await db.WardGateways.AnyAsync(g =>
|
||||
g.SiteId == siteId && g.GatewayCode == gatewayCode);
|
||||
if (duplicateCode)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Gateway code '{gatewayCode}' already exists for site {siteId} under a different id.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var gateway = new WardGateway(siteId, gatewayCode, department);
|
||||
db.Entry(gateway).Property(nameof(WardGateway.Id)).CurrentValue = gatewayId;
|
||||
db.WardGateways.Add(gateway);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine($"Registered gateway '{gatewayCode}' (id={gatewayId}) on site {siteId}.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Guid RequireGuid(string[] args, string name)
|
||||
{
|
||||
var raw = RequireArg(args, name);
|
||||
if (!Guid.TryParse(raw, out var id) || id == Guid.Empty)
|
||||
throw new ArgumentException($"{name} must be a non-empty GUID.");
|
||||
return id;
|
||||
}
|
||||
|
||||
private static string RequireArg(string[] args, string name)
|
||||
{
|
||||
var value = GetArg(args, name);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException($"Missing required argument: {name}");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string? GetArg(string[] args, string name)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = args[i + 1];
|
||||
if (string.IsNullOrWhiteSpace(value) || value.StartsWith("--", StringComparison.Ordinal))
|
||||
return null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@ public class JwtOptions
|
||||
public string Issuer { get; set; } = "VigilCareClinical";
|
||||
public string Audience { get; set; } = "VigilCareClinical.Dashboard";
|
||||
public string SigningKey { get; set; } = null!;
|
||||
public int ExpirationMinutes { get; set; } = 480;
|
||||
public int ExpirationMinutes { get; set; } = 15;
|
||||
public int RefreshTokenExpirationDays { get; set; } = 7;
|
||||
}
|
||||
@@ -11,4 +11,11 @@ public class KafkaOptions
|
||||
public int MaxPoisonRetries { get; set; } = 5;
|
||||
public string NotificationPublisherGroupId { get; set; } = "notification-publisher";
|
||||
public string NotificationPublisherAutoOffsetReset { get; set; } = "Earliest";
|
||||
|
||||
/// <summary>Plaintext for local compose; SaslSsl (etc.) for production.</summary>
|
||||
public string SecurityProtocol { get; set; } = "Plaintext";
|
||||
public string? SaslMechanism { get; set; }
|
||||
public string? SaslUsername { get; set; }
|
||||
public string? SaslPassword { get; set; }
|
||||
public string? SslCaLocation { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using Confluent.Kafka;
|
||||
|
||||
public static class KafkaSecurityExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies the configured security protocol and SASL credentials to any
|
||||
/// Confluent client config. Called by every producer, consumer, and admin
|
||||
/// client so credentials are configured in exactly one place.
|
||||
/// </summary>
|
||||
public static T ApplySecurity<T>(this T config, KafkaOptions options)
|
||||
where T : ClientConfig
|
||||
{
|
||||
if (Enum.TryParse<SecurityProtocol>(options.SecurityProtocol, true, out var protocol))
|
||||
config.SecurityProtocol = protocol;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.SaslMechanism)
|
||||
&& Enum.TryParse<SaslMechanism>(options.SaslMechanism, true, out var mechanism))
|
||||
{
|
||||
config.SaslMechanism = mechanism;
|
||||
config.SaslUsername = options.SaslUsername;
|
||||
config.SaslPassword = options.SaslPassword;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.SslCaLocation))
|
||||
config.SslCaLocation = options.SslCaLocation;
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,10 @@ 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).
|
||||
/// Local compose uses plaintext on 5674/5672 — leave false.
|
||||
/// </summary>
|
||||
public bool UseSsl { get; init; } = false;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// JWT authentication: login and current-user profile.
|
||||
/// JWT authentication: login, token refresh, logout, and current-user profile.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
@@ -24,6 +24,29 @@ public class AuthController : ControllerBase
|
||||
return Ok(ApiResponse<LoginResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>Exchange a refresh token for a new access + refresh token pair.</summary>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<RefreshResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req)
|
||||
{
|
||||
var result = await _auth.RefreshAsync(req.RefreshToken);
|
||||
return Ok(ApiResponse<RefreshResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>Revoke the refresh token and end the session.</summary>
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Logout(
|
||||
[FromBody] LogoutRequest req,
|
||||
[FromServices] ICurrentUserService currentUser)
|
||||
{
|
||||
await _auth.LogoutAsync(req.RefreshToken, currentUser.UserId!.Value);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Returns the authenticated user's profile.</summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
|
||||
@@ -36,6 +36,7 @@ public class AppDbContext : DbContext
|
||||
public DbSet<ClinicalSyncConflict> ClinicalSyncConflicts => Set<ClinicalSyncConflict>();
|
||||
public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>();
|
||||
public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -3,4 +3,12 @@ public class ElasticsearchOptions
|
||||
public const string Section = "Elasticsearch";
|
||||
public string Uri { get; set; } = null!;
|
||||
public ElasticIndexOptions Indices { get; set; } = null!;
|
||||
|
||||
// Production clusters run with xpack.security enabled. Supply either an
|
||||
// API key (preferred) or basic credentials; leave all null for the
|
||||
// security-disabled development cluster.
|
||||
public string? ApiKey { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public bool DisableCertificateValidation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RefreshToken> builder)
|
||||
{
|
||||
builder.ToTable("refresh_tokens");
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(t => t.Token).HasColumnName("token").HasMaxLength(256).IsRequired();
|
||||
builder.Property(t => t.UserId).HasColumnName("user_id").IsRequired();
|
||||
builder.Property(t => t.ExpiresAt).HasColumnName("expires_at").IsRequired();
|
||||
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(t => t.RevokedAt).HasColumnName("revoked_at");
|
||||
|
||||
builder.HasIndex(t => t.Token).IsUnique();
|
||||
builder.HasIndex(t => t.UserId);
|
||||
|
||||
builder.HasOne(t => t.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,10 @@ public static class DataSeeder
|
||||
db.Observations.AddRange(observations);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"SELECT setval('mrn_seq', COALESCE((SELECT MAX(CAST(SUBSTRING(mrn FROM 5) AS bigint)) FROM patients WHERE mrn ~ '^MRN-[0-9]+$'), 0))");
|
||||
|
||||
await CacheThresholdsAsync(redis, thresholds);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class DataLakeWriterService : BackgroundService
|
||||
GroupId = "data-lake-writer",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false,
|
||||
};
|
||||
}.ApplySecurity(_kafkaOptions);
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
|
||||
consumer.Subscribe(new[]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Restore layer — copy only project files so NuGet restore is cached
|
||||
# independently of source changes. ClinicalContracts must be present
|
||||
# because VigilCareClinicalAPI.csproj ProjectReferences it.
|
||||
COPY VigilCareClinical.sln ./
|
||||
COPY VigilCare.ClinicalContracts/VigilCare.ClinicalContracts.csproj VigilCare.ClinicalContracts/
|
||||
COPY VigilCareClinicalAPI/VigilCareClinicalAPI.csproj VigilCareClinicalAPI/
|
||||
RUN dotnet restore VigilCareClinicalAPI/VigilCareClinicalAPI.csproj
|
||||
|
||||
COPY VigilCare.ClinicalContracts/ VigilCare.ClinicalContracts/
|
||||
COPY VigilCareClinicalAPI/ VigilCareClinicalAPI/
|
||||
RUN dotnet publish VigilCareClinicalAPI/VigilCareClinicalAPI.csproj \
|
||||
-c Release -o /app/publish --no-restore
|
||||
|
||||
# Scrub development secrets from the published appsettings.json (Step 4).
|
||||
# Production supplies Jwt / PHI / API keys via environment variables only.
|
||||
RUN sed -i \
|
||||
-e 's/"SigningKey": "[^"]*"/"SigningKey": ""/' \
|
||||
-e 's/"SearchTokenKey": "[^"]*"/"SearchTokenKey": ""/' \
|
||||
-e 's/"Gateway": "dev-[^"]*"/"Gateway": ""/' \
|
||||
-e 's/"ApiKey": "dev-integration[^"]*"/"ApiKey": ""/' \
|
||||
/app/publish/appsettings.json
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# curl is required by the container healthcheck; the aspnet image does not ship it.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# The keyring directory must be owned by the runtime user — see Step 10.
|
||||
# The aspnet:8.0 image ships a non-root `app` user (uid 1654).
|
||||
RUN mkdir -p /app/data-protection-keys && 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", "VigilCareClinicalAPI.dll"]
|
||||
@@ -0,0 +1,11 @@
|
||||
public class RefreshToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Token { get; set; } = null!;
|
||||
public Guid UserId { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
public ClinicalUser User { get; set; } = null!;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ public enum AuditAction
|
||||
UserLogin,
|
||||
AuthorizationDenied,
|
||||
AlertFeedbackSubmitted,
|
||||
UserLogout,
|
||||
TokenRefreshed,
|
||||
}
|
||||
|
||||
public static class AuditActionExtensions
|
||||
@@ -30,6 +32,8 @@ public static class AuditActionExtensions
|
||||
AuditAction.UserLogin => "USER_LOGIN",
|
||||
AuditAction.AuthorizationDenied => "AUTHORIZATION_DENIED",
|
||||
AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED",
|
||||
AuditAction.UserLogout => "USER_LOGOUT",
|
||||
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
||||
};
|
||||
|
||||
@@ -47,6 +51,8 @@ public static class AuditActionExtensions
|
||||
"USER_LOGIN" => AuditAction.UserLogin,
|
||||
"AUTHORIZATION_DENIED" => AuditAction.AuthorizationDenied,
|
||||
"ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted,
|
||||
"USER_LOGOUT" => AuditAction.UserLogout,
|
||||
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class AlertSeverityJsonConverter : JsonConverter<AlertSeverity>
|
||||
{
|
||||
public override AlertSeverity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> AlertSeverityExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, AlertSeverity value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class AlertStatusJsonConverter : JsonConverter<AlertStatus>
|
||||
{
|
||||
public override AlertStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> AlertStatusExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, AlertStatus value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class AlertTypeJsonConverter : JsonConverter<AlertType>
|
||||
{
|
||||
public override AlertType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> AlertTypeExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, AlertType value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public sealed class KafkaHealthCheck : IHealthCheck
|
||||
using var admin = new AdminClientBuilder(new AdminClientConfig
|
||||
{
|
||||
BootstrapServers = _options.BootstrapServers
|
||||
}).Build();
|
||||
}.ApplySecurity(_options)).Build();
|
||||
|
||||
var metadata = await Task.Run(
|
||||
() => admin.GetMetadata(TimeSpan.FromSeconds(5)), cancellationToken);
|
||||
|
||||
@@ -44,7 +44,10 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
table: "patients");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"ALTER TABLE patients ALTER COLUMN date_of_birth TYPE date USING date_of_birth::date;");
|
||||
"""
|
||||
UPDATE patients SET date_of_birth = '1900-01-01' WHERE date_of_birth !~ '^\d{4}-\d{2}-\d{2}$';
|
||||
ALTER TABLE patients ALTER COLUMN date_of_birth TYPE date USING date_of_birth::date;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE patients SET last_name = LEFT(last_name, 100);
|
||||
UPDATE patients SET first_name = LEFT(first_name, 100);
|
||||
UPDATE patients SET emergency_contact_phone = LEFT(emergency_contact_phone, 20);
|
||||
UPDATE patients SET emergency_contact_name = LEFT(emergency_contact_name, 200);
|
||||
""");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "last_name",
|
||||
table: "patients",
|
||||
|
||||
+1923
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRefreshTokens : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "refresh_tokens",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
token = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
revoked_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_refresh_tokens", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_refresh_tokens_clinical_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "clinical_users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_refresh_tokens_token",
|
||||
table: "refresh_tokens",
|
||||
column: "token",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_refresh_tokens_user_id",
|
||||
table: "refresh_tokens",
|
||||
column: "user_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "refresh_tokens");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1377,6 +1377,48 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("token");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("refresh_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SepsisBundle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1769,6 +1811,17 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("ClinicalUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SepsisBundle", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
public record LoginResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset ExpiresAt,
|
||||
Guid UserId,
|
||||
string Username,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public record LogoutRequest(string RefreshToken);
|
||||
@@ -0,0 +1 @@
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
@@ -0,0 +1,4 @@
|
||||
public record RefreshResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset ExpiresAt);
|
||||
@@ -2,7 +2,9 @@ using RabbitMQ.Client;
|
||||
|
||||
public static class RabbitMqConnectionFactory
|
||||
{
|
||||
public static ConnectionFactory Create(RabbitMqOptions o, bool dispatchConsumersAsync = false) => new()
|
||||
public static ConnectionFactory Create(RabbitMqOptions o, bool dispatchConsumersAsync = false)
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
@@ -11,4 +13,16 @@ public static class RabbitMqConnectionFactory
|
||||
VirtualHost = o.VirtualHost,
|
||||
DispatchConsumersAsync = dispatchConsumersAsync,
|
||||
};
|
||||
|
||||
if (o.UseSsl)
|
||||
{
|
||||
factory.Ssl = new SslOption
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = o.Host,
|
||||
};
|
||||
}
|
||||
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +94,21 @@ try
|
||||
.GetSection(ElasticsearchOptions.Section)
|
||||
.Get<ElasticsearchOptions>()!;
|
||||
|
||||
builder.Services.AddSingleton(
|
||||
new ElasticsearchClient(new Uri(esOptions.Uri)));
|
||||
builder.Services.AddSingleton(_ =>
|
||||
{
|
||||
var settings = new ElasticsearchClientSettings(new Uri(esOptions.Uri));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(esOptions.ApiKey))
|
||||
settings = settings.Authentication(new Elastic.Transport.ApiKey(esOptions.ApiKey));
|
||||
else if (!string.IsNullOrWhiteSpace(esOptions.Username))
|
||||
settings = settings.Authentication(
|
||||
new Elastic.Transport.BasicAuthentication(esOptions.Username, esOptions.Password ?? ""));
|
||||
|
||||
if (esOptions.DisableCertificateValidation)
|
||||
settings = settings.ServerCertificateValidationCallback((_, _, _, _) => true);
|
||||
|
||||
return new ElasticsearchClient(settings);
|
||||
});
|
||||
|
||||
builder.Services.Configure<RabbitMqOptions>(
|
||||
builder.Configuration.GetSection(RabbitMqOptions.Section));
|
||||
@@ -258,9 +271,12 @@ try
|
||||
opts.JsonSerializerOptions.Converters.Add(new BloodTypeJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new AuditActionJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new SepsisBundleComplianceStatusJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new AlertTypeJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new AlertSeverityJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new AlertStatusJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
@@ -323,7 +339,21 @@ try
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
// One-shot CLI verbs exit before hosting. Skip demo seeding for them so
|
||||
// create-admin / register-gateway never race the demo UserSeeder.
|
||||
var isCliCommand = args.Contains("encrypt-phi")
|
||||
|| args.Contains("create-admin")
|
||||
|| args.Contains("register-gateway");
|
||||
|
||||
// Demo data — including the seeded demo users with well-known passwords —
|
||||
// must never be created in production. Seeding:EnableDemoData defaults to
|
||||
// true so local development and the existing verification scripts are
|
||||
// unaffected; appsettings.Production.json sets it to false.
|
||||
var enableDemoData = builder.Configuration.GetValue("Seeding:EnableDemoData", true)
|
||||
&& !app.Environment.IsEnvironment("Testing")
|
||||
&& !isCliCommand;
|
||||
|
||||
if (enableDemoData)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
@@ -342,11 +372,14 @@ try
|
||||
});
|
||||
}
|
||||
|
||||
if (builder.Configuration.GetValue("Swagger:Enabled", !app.Environment.IsProduction()))
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("/swagger/v1/swagger.json", "VigilCare Clinical API v1");
|
||||
});
|
||||
}
|
||||
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseMiddleware<FhirApiKeyOrJwtMiddleware>();
|
||||
@@ -369,7 +402,7 @@ try
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteAsync
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapMetrics("/metrics");
|
||||
app.MapMetrics("/metrics").AllowAnonymous();
|
||||
app.MapControllers();
|
||||
|
||||
if (args.Contains("encrypt-phi"))
|
||||
@@ -378,6 +411,34 @@ try
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Contains("create-admin"))
|
||||
{
|
||||
try
|
||||
{
|
||||
Environment.ExitCode = await CreateAdminCommand.RunAsync(app.Services, args);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Contains("register-gateway"))
|
||||
{
|
||||
try
|
||||
{
|
||||
Environment.ExitCode = await RegisterGatewayCommand.RunAsync(app.Services, args);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -40,10 +41,12 @@ public class AuthService : IAuthService
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
|
||||
var token = GenerateToken(user, expires);
|
||||
var accessToken = GenerateAccessToken(user, expires);
|
||||
var refreshToken = await CreateRefreshTokenAsync(user.Id);
|
||||
|
||||
return new LoginResponse(
|
||||
token,
|
||||
accessToken,
|
||||
refreshToken.Token,
|
||||
expires,
|
||||
user.Id,
|
||||
user.Username,
|
||||
@@ -51,7 +54,88 @@ public class AuthService : IAuthService
|
||||
user.Role.ToDbString());
|
||||
}
|
||||
|
||||
private string GenerateToken(ClinicalUser user, DateTimeOffset expires)
|
||||
public async Task<RefreshResponse> RefreshAsync(string refreshToken)
|
||||
{
|
||||
var stored = await _db.RefreshTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Token == refreshToken);
|
||||
|
||||
if (stored is null || stored.RevokedAt is not null || stored.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
throw new ValidationException("Invalid or expired refresh token.", "INVALID_REFRESH_TOKEN");
|
||||
|
||||
if (!stored.User.IsActive)
|
||||
throw new ValidationException("Account is deactivated.", "ACCOUNT_DEACTIVATED");
|
||||
|
||||
stored.RevokedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var newRefreshToken = await CreateRefreshTokenAsync(stored.UserId);
|
||||
|
||||
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
|
||||
var accessToken = GenerateAccessToken(stored.User, expires);
|
||||
|
||||
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Action = AuditAction.TokenRefreshed,
|
||||
EntityType = "ClinicalUser",
|
||||
EntityId = stored.UserId,
|
||||
UserId = stored.UserId,
|
||||
UserDisplayName = stored.User.DisplayName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new RefreshResponse(accessToken, newRefreshToken.Token, expires);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string refreshToken, Guid userId)
|
||||
{
|
||||
var stored = await _db.RefreshTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Token == refreshToken && t.UserId == userId);
|
||||
|
||||
if (stored is not null && stored.RevokedAt is null)
|
||||
{
|
||||
stored.RevokedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Action = AuditAction.UserLogout,
|
||||
EntityType = "ClinicalUser",
|
||||
EntityId = userId,
|
||||
UserId = userId,
|
||||
UserDisplayName = stored.User.DisplayName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RefreshToken> CreateRefreshTokenAsync(Guid userId)
|
||||
{
|
||||
var token = new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Token = GenerateOpaqueToken(),
|
||||
UserId = userId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(_jwt.RefreshTokenExpirationDays),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_db.RefreshTokens.Add(token);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private static string GenerateOpaqueToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private string GenerateAccessToken(ClinicalUser user, DateTimeOffset expires)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<LoginResponse> LoginAsync(LoginRequest req);
|
||||
Task<RefreshResponse> RefreshAsync(string refreshToken);
|
||||
Task LogoutAsync(string refreshToken, Guid userId);
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
<Copyright>Copyright (c) 2024-2026 voltsrage. All Rights Reserved.</Copyright>
|
||||
<Authors>voltsrage</Authors>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -34,6 +37,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{ "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" } },
|
||||
{ "Name": "Seq", "Args": { "serverUrl": "http://localhost:5341" } }
|
||||
],
|
||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
|
||||
"Properties": {
|
||||
"Application": "VigilCareClinicalAPI"
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
},
|
||||
"Kafka": {
|
||||
"ReplicationFactor": 3,
|
||||
"NumPartitions": 6,
|
||||
"SecurityProtocol": "SaslSsl"
|
||||
},
|
||||
"Minio": {
|
||||
"UseSSL": true
|
||||
},
|
||||
"RabbitMq": {
|
||||
"UseSsl": true
|
||||
},
|
||||
"PhiEncryption": {
|
||||
"LogListAccess": true
|
||||
},
|
||||
"Swagger": { "Enabled": false },
|
||||
"Seeding": { "EnableDemoData": false }
|
||||
}
|
||||
@@ -48,6 +48,8 @@
|
||||
"GcsScored": "gcs.scored"
|
||||
},
|
||||
"NumPartitions": 6,
|
||||
"ReplicationFactor": 1,
|
||||
"SecurityProtocol": "Plaintext",
|
||||
"OutboxBatchSize": 100,
|
||||
"OutboxPollIntervalMs": 1000
|
||||
},
|
||||
@@ -64,7 +66,8 @@
|
||||
"Port": 5674,
|
||||
"Username": "guest",
|
||||
"Password": "guest",
|
||||
"PagingAckTimeoutMs": 300000
|
||||
"PagingAckTimeoutMs": 300000,
|
||||
"UseSsl": false
|
||||
},
|
||||
"Minio": {
|
||||
"Endpoint": "localhost:9005",
|
||||
@@ -195,7 +198,8 @@
|
||||
"Issuer": "VigilCareClinical",
|
||||
"Audience": "VigilCareClinical.Dashboard",
|
||||
"SigningKey": "DEV-ONLY-REPLACE-WITH-256-BIT-SECRET-IN-PRODUCTION-abc123xyz",
|
||||
"ExpirationMinutes": 480
|
||||
"ExpirationMinutes": 15,
|
||||
"RefreshTokenExpirationDays": 7
|
||||
},
|
||||
"PhiEncryption": {
|
||||
"ProtectorPurpose": "VigilCare.PatientPhi.v1",
|
||||
@@ -218,5 +222,11 @@
|
||||
"AlertQuality": {
|
||||
"IntervalMinutes": 60,
|
||||
"WindowHours": 1
|
||||
},
|
||||
"Swagger": {
|
||||
"Enabled": true
|
||||
},
|
||||
"Seeding": {
|
||||
"EnableDemoData": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Production overlay. Runs ONLY the three application containers — Postgres,
|
||||
# Redis, Seq, Kafka, Elasticsearch, RabbitMQ, MinIO, Prometheus and Grafana are
|
||||
# pre-existing external services referenced through .env.
|
||||
#
|
||||
# docker compose -f docker-compose.prod.yml --env-file .env up -d
|
||||
#
|
||||
# Does not extend docker-compose.yml. Local-dev infra stays in that file;
|
||||
# this one ships only the three deployable apps.
|
||||
|
||||
name: vigilcare
|
||||
|
||||
services:
|
||||
api:
|
||||
image: ${REGISTRY}/clinical-api:${IMAGE_TAG}
|
||||
container_name: vigilcare_api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT:-5270}:8080"
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
ConnectionStrings__DefaultConnection: "${PG_CONNECTION}"
|
||||
Redis__ConnectionString: "${REDIS_CONNECTION}"
|
||||
Seq__ServerUrl: "${SEQ_URL}"
|
||||
Serilog__WriteTo__1__Args__serverUrl: "${SEQ_URL}"
|
||||
Serilog__WriteTo__1__Args__apiKey: "${SEQ_API_KEY}"
|
||||
Kafka__BootstrapServers: "${KAFKA_BOOTSTRAP}"
|
||||
Kafka__ReplicationFactor: "${KAFKA_REPLICATION_FACTOR:-3}"
|
||||
Kafka__SecurityProtocol: "${KAFKA_SECURITY_PROTOCOL:-Plaintext}"
|
||||
Kafka__SaslMechanism: "${KAFKA_SASL_MECHANISM:-}"
|
||||
Kafka__SaslUsername: "${KAFKA_SASL_USERNAME:-}"
|
||||
Kafka__SaslPassword: "${KAFKA_SASL_PASSWORD:-}"
|
||||
Elasticsearch__Uri: "${ES_URI}"
|
||||
Elasticsearch__Username: "${ES_USERNAME:-}"
|
||||
Elasticsearch__Password: "${ES_PASSWORD:-}"
|
||||
Elasticsearch__ApiKey: "${ES_API_KEY:-}"
|
||||
RabbitMq__Host: "${RABBITMQ_HOST}"
|
||||
RabbitMq__Port: "${RABBITMQ_PORT:-5672}"
|
||||
RabbitMq__Username: "${RABBITMQ_USERNAME}"
|
||||
RabbitMq__Password: "${RABBITMQ_PASSWORD}"
|
||||
RabbitMq__UseSsl: "${RABBITMQ_USE_SSL:-true}"
|
||||
Minio__Endpoint: "${MINIO_ENDPOINT}"
|
||||
Minio__AccessKey: "${MINIO_ACCESS_KEY}"
|
||||
Minio__SecretKey: "${MINIO_SECRET_KEY}"
|
||||
Minio__UseSSL: "${MINIO_USE_SSL:-true}"
|
||||
Jwt__SigningKey: "${JWT_SIGNING_KEY}"
|
||||
Jwt__Issuer: "${JWT_ISSUER:-VigilCareClinical}"
|
||||
Jwt__Audience: "${JWT_AUDIENCE:-VigilCareClinical.Dashboard}"
|
||||
PhiEncryption__SearchTokenKey: "${PHI_SEARCH_TOKEN_KEY}"
|
||||
DataProtection__KeyPath: "/app/data-protection-keys"
|
||||
ApiKey__Gateway: "${GATEWAY_API_KEY}"
|
||||
Fhir__ApiKey: "${FHIR_API_KEY}"
|
||||
Dashboard__CorsOrigins__0: "${DASHBOARD_ORIGIN}"
|
||||
Seeding__EnableDemoData: "false"
|
||||
Swagger__Enabled: "false"
|
||||
volumes:
|
||||
# CRITICAL: the Data Protection keyring encrypts patient PHI. If this
|
||||
# volume is lost, every encrypted patient record becomes unreadable.
|
||||
# With `name: vigilcare` above, Docker creates vigilcare_dp_keys.
|
||||
# Backed up by scripts/backup-dp-keys.sh — see Step 10.
|
||||
- dp_keys:/app/data-protection-keys
|
||||
networks:
|
||||
- vigilcare_prod
|
||||
- monitoring
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "50m", max-file: "5" }
|
||||
deploy:
|
||||
resources:
|
||||
limits: { memory: 2G }
|
||||
|
||||
gateway:
|
||||
image: ${REGISTRY}/ward-gateway:${IMAGE_TAG}
|
||||
container_name: vigilcare_gateway
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${GATEWAY_PORT:-5081}:8080"
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
ConnectionStrings__GatewayDb: "${GATEWAY_PG_CONNECTION}"
|
||||
Redis__ConnectionString: "${GATEWAY_REDIS_CONNECTION}"
|
||||
RabbitMq__Host: "${GATEWAY_RABBITMQ_HOST}"
|
||||
RabbitMq__Port: "${GATEWAY_RABBITMQ_PORT:-5672}"
|
||||
RabbitMq__Username: "${GATEWAY_RABBITMQ_USERNAME}"
|
||||
RabbitMq__Password: "${GATEWAY_RABBITMQ_PASSWORD}"
|
||||
RabbitMq__UseSsl: "${GATEWAY_RABBITMQ_USE_SSL:-true}"
|
||||
CentralApi__BaseUrl: "http://api:8080"
|
||||
Gateway__GatewayId: "${GATEWAY_ID}"
|
||||
Gateway__SiteId: "${GATEWAY_SITE_ID}"
|
||||
Gateway__Department: "${GATEWAY_DEPARTMENT:-ICU}"
|
||||
ApiKey__Gateway: "${GATEWAY_API_KEY}"
|
||||
Jwt__SigningKey: "${GATEWAY_JWT_SIGNING_KEY}"
|
||||
Jwt__Issuer: "${GATEWAY_JWT_ISSUER:-vigilcare-gateway}"
|
||||
Jwt__Audience: "${GATEWAY_JWT_AUDIENCE:-vigilcare-dashboard}"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- vigilcare_prod
|
||||
- monitoring
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "50m", max-file: "5" }
|
||||
|
||||
dashboard:
|
||||
image: ${REGISTRY}/dashboard:${IMAGE_TAG}
|
||||
container_name: vigilcare_dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${DASHBOARD_PORT:-8080}:80"
|
||||
networks:
|
||||
- vigilcare_prod
|
||||
- monitoring
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "20m", max-file: "3" }
|
||||
|
||||
volumes:
|
||||
dp_keys:
|
||||
# Named volume backed by a host bind is preferable if the host has a
|
||||
# backed-up filesystem path; see Step 10. Full Docker name: vigilcare_dp_keys.
|
||||
|
||||
networks:
|
||||
vigilcare_prod:
|
||||
driver: bridge
|
||||
monitoring:
|
||||
external: true
|
||||
@@ -0,0 +1,276 @@
|
||||
subject_id,hadm_id,admittime,dischtime,deathtime,admission_type,admit_provider_id,admission_location,discharge_location,insurance,language,marital_status,race,edregtime,edouttime,hospital_expire_flag
|
||||
10004235,24181354,2196-02-24 14:38:00,2196-03-04 14:02:00,,URGENT,P03YMR,TRANSFER FROM HOSPITAL,SKILLED NURSING FACILITY,Medicaid,ENGLISH,SINGLE,BLACK/CAPE VERDEAN,2196-02-24 12:15:00,2196-02-24 17:07:00,0
|
||||
10009628,25926192,2153-09-17 17:08:00,2153-09-25 13:20:00,,URGENT,P41R5N,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Medicaid,?,MARRIED,HISPANIC/LATINO - PUERTO RICAN,,,0
|
||||
10018081,23983182,2134-08-18 02:02:00,2134-08-23 19:35:00,,URGENT,P233F6,TRANSFER FROM HOSPITAL,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,WHITE,2134-08-17 16:24:00,2134-08-18 03:15:00,0
|
||||
10006053,22942076,2111-11-13 23:39:00,2111-11-15 17:20:00,2111-11-15 17:20:00,URGENT,P38TI6,TRANSFER FROM HOSPITAL,DIED,Medicaid,ENGLISH,,UNKNOWN,,,1
|
||||
10031404,21606243,2113-08-04 18:46:00,2113-08-06 20:57:00,,URGENT,P07HDB,TRANSFER FROM HOSPITAL,HOME,Other,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10005817,20626031,2132-12-12 01:43:00,2132-12-20 15:04:00,,URGENT,P41R5N,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Medicare,ENGLISH,MARRIED,WHITE,,,0
|
||||
10019385,20297618,2180-02-15 20:28:00,2180-02-25 13:45:00,,URGENT,P536JC,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10002495,24982426,2141-05-22 20:17:00,2141-05-29 17:41:00,,URGENT,P79SJ2,TRANSFER FROM HOSPITAL,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,UNKNOWN,,,0
|
||||
10038081,20755971,2115-09-27 20:40:00,2115-10-12 00:00:00,2115-10-12 22:20:00,URGENT,P48GIG,TRANSFER FROM HOSPITAL,DIED,Other,?,SINGLE,UNKNOWN,,,1
|
||||
10019917,22585261,2182-01-07 23:25:00,2182-01-10 16:52:00,,URGENT,P3529J,TRANSFER FROM HOSPITAL,HOME,Other,ENGLISH,SINGLE,OTHER,,,0
|
||||
10037861,24256866,2115-10-09 20:28:00,2115-10-18 16:50:00,,URGENT,P64TGC,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Medicare,ENGLISH,MARRIED,UNKNOWN,,,0
|
||||
10026354,24547356,2119-10-26 07:11:00,2119-11-06 12:30:00,,URGENT,P77BSD,TRANSFER FROM HOSPITAL,SKILLED NURSING FACILITY,Other,?,,WHITE,2119-10-26 06:00:00,2119-10-26 06:37:00,0
|
||||
10015860,26352758,2192-09-24 09:19:00,2192-09-28 19:52:00,,URGENT,P54RLA,TRANSFER FROM SKILLED NURSING FACILITY,SKILLED NURSING FACILITY,Other,ENGLISH,SINGLE,WHITE,2192-09-23 22:43:00,2192-09-24 09:38:00,0
|
||||
10020740,23831430,2150-03-11 15:34:00,2150-04-25 13:50:00,,URGENT,P7554I,TRANSFER FROM HOSPITAL,SKILLED NURSING FACILITY,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10012552,27089790,2140-03-22 17:18:00,2140-03-30 14:10:00,,URGENT,P47E1G,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Medicare,ENGLISH,MARRIED,UNKNOWN,,,0
|
||||
10014354,28335091,2147-04-26 16:44:00,2147-04-29 15:30:00,,URGENT,P79SJ2,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10014354,27487226,2148-06-30 01:09:00,2148-07-13 19:35:00,,URGENT,P44WVR,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2148-06-29 21:06:00,2148-06-30 02:27:00,0
|
||||
10039708,25864431,2142-03-26 06:08:00,2142-04-11 21:00:00,,URGENT,P81KFM,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2142-03-25 22:22:00,2142-03-26 08:08:00,0
|
||||
10000032,22595853,2180-05-06 22:23:00,2180-05-07 17:15:00,,URGENT,P874LG,TRANSFER FROM HOSPITAL,HOME,Other,ENGLISH,WIDOWED,WHITE,2180-05-06 19:17:00,2180-05-06 23:30:00,0
|
||||
10020786,23488445,2189-06-09 12:45:00,2189-06-13 17:20:00,,URGENT,P031HZ,TRANSFER FROM HOSPITAL,HOME,Medicare,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10027445,27488741,2145-12-08 19:47:00,2145-12-19 14:36:00,,URGENT,P38RSS,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10027445,29163082,2142-08-27 21:05:00,2142-09-05 17:33:00,,URGENT,P509SB,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10015931,22130791,2177-03-24 21:47:00,2177-03-29 14:15:00,2177-03-29 14:15:00,URGENT,P031HZ,TRANSFER FROM HOSPITAL,DIED,Medicare,ENGLISH,MARRIED,WHITE,,,1
|
||||
10036156,28019404,2157-07-01 04:52:00,2157-07-03 15:08:00,,URGENT,P94V16,TRANSFER FROM HOSPITAL,HOME,Other,ENGLISH,,UNKNOWN,,,0
|
||||
10021312,25020332,2113-08-16 00:32:00,2113-08-18 17:35:00,,URGENT,P31A9M,TRANSFER FROM HOSPITAL,HOME,Other,ENGLISH,,UNKNOWN,,,0
|
||||
10021312,28829452,2113-09-12 14:42:00,2113-09-20 18:40:00,,URGENT,P72G4H,TRANSFER FROM HOSPITAL,ACUTE HOSPITAL,Other,ENGLISH,,UNKNOWN,,,0
|
||||
10003400,23559586,2137-08-04 00:07:00,2137-09-02 17:05:00,2137-09-02 17:05:00,URGENT,P99U21,TRANSFER FROM HOSPITAL,DIED,Medicare,ENGLISH,MARRIED,BLACK/AFRICAN AMERICAN,,,1
|
||||
10003400,20214994,2137-02-24 10:00:00,2137-03-19 15:45:00,,URGENT,P60ZCO,TRANSFER FROM SKILLED NURSING FACILITY,CHRONIC/LONG TERM ACUTE CARE,Medicare,ENGLISH,MARRIED,BLACK/AFRICAN AMERICAN,,,0
|
||||
10023117,24244087,2174-06-07 23:25:00,2174-06-12 15:55:00,,URGENT,P878WT,TRANSFER FROM HOSPITAL,HOME,Medicare,ENGLISH,WIDOWED,WHITE,2174-06-07 16:24:00,2174-06-08 01:02:00,0
|
||||
10038992,22797747,2185-11-02 18:26:00,2185-11-08 16:22:00,,URGENT,P534S8,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Medicare,ENGLISH,MARRIED,UNKNOWN,,,0
|
||||
10037975,27617929,2185-01-17 19:11:00,2185-01-22 14:25:00,2185-01-22 14:25:00,URGENT,P17MK7,TRANSFER FROM HOSPITAL,DIED,Medicare,ENGLISH,MARRIED,UNKNOWN,,,1
|
||||
10035185,22580999,2120-05-12 12:53:00,2120-05-17 16:00:00,,URGENT,P41R5N,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10002930,28301173,2197-04-08 19:37:00,2197-04-15 12:01:00,,URGENT,P3763P,INTERNAL TRANSFER TO OR FROM PSYCH,HOME,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,,,0
|
||||
10002930,22733922,2198-04-22 16:17:00,2198-05-04 13:20:00,,URGENT,P3763P,INTERNAL TRANSFER TO OR FROM PSYCH,HOME,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,,,0
|
||||
10007795,28477357,2136-04-10 20:33:00,2136-05-02 16:35:00,,URGENT,P172D4,TRANSFER FROM HOSPITAL,CHRONIC/LONG TERM ACUTE CARE,Medicare,ENGLISH,SINGLE,WHITE,,,0
|
||||
10007795,25135483,2136-05-04 20:20:00,2136-05-12 17:12:00,,URGENT,P99LA7,TRANSFER FROM HOSPITAL,REHAB,Medicare,ENGLISH,SINGLE,WHITE,,,0
|
||||
10004733,27411876,2174-12-04 11:28:00,2174-12-27 14:00:00,,URGENT,P48KFD,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Medicaid,ENGLISH,SINGLE,UNKNOWN,,,0
|
||||
10021118,24490144,2161-11-15 20:10:00,2161-11-23 16:00:00,,URGENT,P96FGV,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10006580,24159665,2137-08-10 11:00:00,2137-08-15 13:25:00,,ELECTIVE,P76K54,PHYSICIAN REFERRAL,HOME,Medicaid,?,MARRIED,HISPANIC/LATINO - SALVADORAN,,,0
|
||||
10023771,20044587,2113-08-25 07:15:00,2113-08-30 14:15:00,,ELECTIVE,P47E1G,PHYSICIAN REFERRAL,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,WHITE,,,0
|
||||
10005348,29496232,2128-09-05 08:30:00,2128-09-12 16:55:00,,ELECTIVE,P786Y5,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10019172,24997044,2118-11-15 14:00:00,2118-11-21 18:31:00,,ELECTIVE,P41R5N,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,,UNABLE TO OBTAIN,,,0
|
||||
10019172,21540783,2118-10-08 14:00:00,2118-10-11 20:15:00,,ELECTIVE,P41R5N,PHYSICIAN REFERRAL,HOME,Other,ENGLISH,,UNABLE TO OBTAIN,,,0
|
||||
10021487,20429160,2117-07-16 07:15:00,2117-07-25 12:34:00,,ELECTIVE,P96Y5O,PHYSICIAN REFERRAL,HOME,Other,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10013049,22675517,2114-06-20 10:15:00,2114-06-24 16:45:00,,ELECTIVE,P41R5N,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10035631,20385771,2112-12-04 00:00:00,2112-12-27 16:24:00,,ELECTIVE,P45GUA,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10035631,24912093,2112-10-22 00:00:00,2112-10-28 12:16:00,,ELECTIVE,P45GUA,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,UNKNOWN,,,0
|
||||
10019003,26226543,2155-10-17 18:01:00,2155-11-03 18:00:00,,ELECTIVE,P7280F,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10019003,28003918,2148-12-21 07:15:00,2148-12-24 17:10:00,,ELECTIVE,P80J89,PHYSICIAN REFERRAL,HOME,Medicare,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10009035,28324362,2161-04-27 07:15:00,2161-05-01 13:45:00,,ELECTIVE,P41R5N,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10040025,25933959,2147-12-29 19:36:00,2148-01-09 17:38:00,,ELECTIVE,P43BTJ,PHYSICIAN REFERRAL,SKILLED NURSING FACILITY,Other,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10014729,23300884,2125-03-19 16:58:00,2125-03-28 13:37:00,,EW EMER.,P76K54,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE - OTHER EUROPEAN,2125-03-19 12:36:00,2125-03-19 18:45:00,0
|
||||
10026255,20437651,2200-09-17 22:53:00,2200-09-29 18:25:00,,EW EMER.,P233F6,EMERGENCY ROOM,HOME,Other,ENGLISH,MARRIED,WHITE,2200-09-17 18:38:00,2200-09-18 00:57:00,0
|
||||
10026255,22059910,2201-07-07 18:15:00,2201-07-13 23:27:00,2201-07-13 23:27:00,EW EMER.,P48KFD,EMERGENCY ROOM,DIED,Other,ENGLISH,MARRIED,WHITE,2201-07-07 12:31:00,2201-07-07 19:40:00,1
|
||||
10007058,22954658,2167-11-07 19:05:00,2167-11-11 14:23:00,,EW EMER.,P76K54,EMERGENCY ROOM,HOME,Other,ENGLISH,MARRIED,WHITE,2167-11-07 17:57:00,2167-11-07 20:22:00,0
|
||||
10018845,21101111,2184-10-08 02:28:00,2184-10-11 17:00:00,,EW EMER.,P34SFE,EMERGENCY ROOM,REHAB,Other,ENGLISH,MARRIED,WHITE,2184-10-07 22:35:00,2184-10-08 04:09:00,0
|
||||
10020640,27984218,2153-02-13 00:22:00,2153-02-20 13:52:00,,EW EMER.,P7554I,EMERGENCY ROOM,SKILLED NURSING FACILITY,Other,ENGLISH,WIDOWED,WHITE,2153-02-12 21:59:00,2153-02-13 01:38:00,0
|
||||
10018081,25973915,2134-09-06 15:57:00,2134-09-18 15:50:00,,EW EMER.,P13ZRJ,EMERGENCY ROOM,CHRONIC/LONG TERM ACUTE CARE,Medicare,ENGLISH,MARRIED,WHITE,2134-09-05 16:14:00,2134-09-06 16:49:00,0
|
||||
10018081,21027282,2133-12-18 16:58:00,2134-01-12 11:00:00,,EW EMER.,P19SWB,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,WHITE,2133-12-18 12:09:00,2133-12-18 17:10:00,0
|
||||
10021666,22756440,2172-03-12 23:47:00,2172-03-23 15:40:00,,EW EMER.,P99698,EMERGENCY ROOM,CHRONIC/LONG TERM ACUTE CARE,Medicare,ENGLISH,MARRIED,WHITE,2172-03-12 20:52:00,2172-03-13 01:46:00,0
|
||||
10015272,27993466,2137-06-12 18:36:00,2137-06-18 15:45:00,,EW EMER.,P3529J,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,ENGLISH,,WHITE,2137-06-12 16:54:00,2137-06-12 20:37:00,0
|
||||
10016810,20973395,2185-06-16 01:31:00,2185-06-21 15:55:00,,EW EMER.,P3638G,EMERGENCY ROOM,HOME,Other,ENGLISH,,UNKNOWN,2185-06-15 23:08:00,2185-06-16 02:16:00,0
|
||||
10021938,23112364,2181-10-13 01:48:00,2181-10-14 17:40:00,,EW EMER.,P3529J,EMERGENCY ROOM,HOME,Medicare,ENGLISH,SINGLE,WHITE,2181-10-12 20:17:00,2181-10-13 02:52:00,0
|
||||
10021938,27154822,2181-10-25 10:44:00,2181-10-27 15:30:00,,EW EMER.,P99BGS,EMERGENCY ROOM,HOME,Medicare,ENGLISH,SINGLE,WHITE,2181-10-25 09:23:00,2181-10-25 11:35:00,0
|
||||
10001725,25563031,2110-04-11 15:08:00,2110-04-14 15:00:00,,EW EMER.,P35SU0,PACU,HOME,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10001217,24597018,2157-11-18 22:56:00,2157-11-25 18:00:00,,EW EMER.,P4645A,EMERGENCY ROOM,HOME HEALTH CARE,Other,?,MARRIED,WHITE,2157-11-18 17:38:00,2157-11-19 01:24:00,0
|
||||
10023239,29295881,2137-06-19 17:35:00,2137-06-22 14:57:00,,EW EMER.,P44WVR,EMERGENCY ROOM,HOME,Other,ENGLISH,SINGLE,WHITE,2137-06-19 15:05:00,2137-06-19 19:09:00,0
|
||||
10023239,21759936,2140-10-03 09:06:00,2140-10-08 15:28:00,,EW EMER.,P031HZ,TRANSFER FROM HOSPITAL,HOME,Other,ENGLISH,MARRIED,WHITE,2140-10-03 06:20:00,2140-10-03 11:04:00,0
|
||||
10020944,29974575,2131-02-27 15:34:00,2131-03-13 17:01:00,,EW EMER.,P27588,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,,UNKNOWN,2131-02-27 13:16:00,2131-02-27 16:40:00,0
|
||||
10027602,28166872,2201-10-30 12:05:00,2201-11-20 14:45:00,,EW EMER.,P47SIK,EMERGENCY ROOM,REHAB,Other,ENGLISH,SINGLE,WHITE,2201-10-30 10:48:00,2201-10-30 12:25:00,0
|
||||
10032725,25177949,2143-02-17 14:20:00,2143-03-16 17:15:00,,EW EMER.,P39043,EMERGENCY ROOM,CHRONIC/LONG TERM ACUTE CARE,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2143-02-17 07:19:00,2143-02-17 15:18:00,0
|
||||
10032725,20611640,2143-03-22 04:59:00,2143-03-25 13:00:00,,EW EMER.,P7554I,EMERGENCY ROOM,HOSPICE,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2143-03-22 01:19:00,2143-03-22 06:42:00,0
|
||||
10005348,25239799,2130-10-26 17:03:00,2130-11-02 16:00:00,,EW EMER.,P38RSS,PROCEDURE SITE,SKILLED NURSING FACILITY,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10037861,24540843,2117-03-14 16:34:00,2117-03-24 00:01:00,2117-03-24 23:08:00,EW EMER.,P01LRQ,EMERGENCY ROOM,DIED,Medicare,ENGLISH,MARRIED,UNKNOWN,2117-03-14 15:19:00,2117-03-14 17:53:00,1
|
||||
10010471,29842315,2155-12-02 19:36:00,2155-12-07 15:30:00,2155-12-07 15:30:00,EW EMER.,P04X8Y,EMERGENCY ROOM,DIED,Medicare,ENGLISH,WIDOWED,WHITE,2155-12-02 16:00:00,2155-12-02 20:33:00,1
|
||||
10005866,27167814,2148-03-10 16:16:00,2148-03-21 18:30:00,,EW EMER.,P28OI9,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicaid,ENGLISH,SINGLE,PORTUGUESE,2148-03-10 04:46:00,2148-03-10 11:27:00,0
|
||||
10005866,26134779,2149-09-13 07:36:00,2149-09-19 18:00:00,,EW EMER.,P95QHH,EMERGENCY ROOM,HOME,Medicaid,ENGLISH,SINGLE,PORTUGUESE,2149-09-12 15:31:00,2149-09-13 09:02:00,0
|
||||
10005866,26158160,2146-06-06 00:50:00,2146-06-09 16:45:00,,EW EMER.,P94RT6,EMERGENCY ROOM,HOME,Medicaid,ENGLISH,SINGLE,PORTUGUESE,2146-06-05 22:26:00,2146-06-06 01:45:00,0
|
||||
10024043,24717014,2117-04-11 20:46:00,2117-04-16 18:55:00,,EW EMER.,P5487F,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2117-04-11 17:23:00,2117-04-11 22:05:00,0
|
||||
10021487,28998349,2116-12-03 00:23:00,2116-12-28 13:19:00,,EW EMER.,P61T4I,EMERGENCY ROOM,HOME,Other,ENGLISH,MARRIED,WHITE,2116-12-02 22:57:00,2116-12-03 01:02:00,0
|
||||
10021487,21928381,2117-12-03 17:07:00,2117-12-06 17:30:00,,EW EMER.,P96Y5O,PROCEDURE SITE,HOME,Other,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10021487,26321862,2117-01-28 00:19:00,2117-02-05 15:40:00,,EW EMER.,P82B0E,EMERGENCY ROOM,HOME,Other,ENGLISH,MARRIED,WHITE,2117-01-27 18:19:00,2117-01-28 01:23:00,0
|
||||
10021487,27112038,2117-10-25 22:22:00,2117-10-29 14:40:00,,EW EMER.,P233F6,EMERGENCY ROOM,HOME,Other,ENGLISH,DIVORCED,WHITE,2117-10-25 15:49:00,2117-10-25 22:53:00,0
|
||||
10015860,24698912,2192-05-12 07:42:00,2192-05-27 18:50:00,,EW EMER.,P03YMR,PHYSICIAN REFERRAL,SKILLED NURSING FACILITY,Other,ENGLISH,SINGLE,WHITE,2192-05-11 16:26:00,2192-05-12 09:31:00,0
|
||||
10015860,28236161,2187-09-15 18:49:00,2187-09-19 14:50:00,,EW EMER.,P62A9I,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2187-09-15 14:14:00,2187-09-15 20:49:00,0
|
||||
10015860,20790339,2189-05-23 02:14:00,2189-05-25 14:45:00,,EW EMER.,P00P3O,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2189-05-22 23:18:00,2189-05-23 03:38:00,0
|
||||
10015860,25085565,2186-09-15 16:12:00,2186-09-29 18:05:00,,EW EMER.,P56SP9,EMERGENCY ROOM,REHAB,Other,ENGLISH,SINGLE,WHITE,2186-09-15 12:56:00,2186-09-15 17:15:00,0
|
||||
10015860,20854119,2188-08-06 00:49:00,2188-08-12 17:49:00,,EW EMER.,P29CGZ,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2188-08-05 20:36:00,2188-08-06 03:24:00,0
|
||||
10018423,29366372,2167-05-03 21:24:00,2167-05-11 12:57:00,,EW EMER.,P204LI,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,2167-05-03 15:18:00,2167-05-03 22:50:00,0
|
||||
10007928,20338077,2129-04-05 22:56:00,2129-04-11 17:25:00,,EW EMER.,P825SO,EMERGENCY ROOM,HOME,Other,ENGLISH,DIVORCED,WHITE,2129-04-05 21:18:00,2129-04-06 00:25:00,0
|
||||
10014078,25809882,2166-08-21 23:09:00,2166-08-26 14:48:00,,EW EMER.,P64JK6,EMERGENCY ROOM,HOME HEALTH CARE,Medicaid,ENGLISH,,UNABLE TO OBTAIN,2166-08-21 21:39:00,2166-08-22 00:36:00,0
|
||||
10020740,25826145,2150-06-03 20:12:00,2150-06-07 15:05:00,,EW EMER.,P80QIV,PACU,SKILLED NURSING FACILITY,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10008454,20291550,2110-11-30 06:31:00,2110-12-10 15:53:00,,EW EMER.,P77BSD,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2110-11-30 04:45:00,2110-11-30 08:03:00,0
|
||||
10022281,29642388,2125-06-17 04:11:00,2125-06-19 15:25:00,,EW EMER.,P76K54,EMERGENCY ROOM,HOME,Other,ENGLISH,MARRIED,OTHER,2125-06-16 20:32:00,2125-06-17 05:14:00,0
|
||||
10014354,24357615,2150-05-09 16:09:00,2150-05-10 15:59:00,,EW EMER.,P15PLY,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2150-05-08 22:30:00,2150-05-09 19:09:00,0
|
||||
10014354,22741225,2146-10-08 23:47:00,2146-10-12 18:20:00,,EW EMER.,P5487F,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,2146-10-08 21:27:00,2146-10-09 01:08:00,0
|
||||
10014354,26013492,2147-11-14 22:12:00,2147-11-16 15:00:00,,EW EMER.,P424IO,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2147-11-14 18:22:00,2147-11-14 23:09:00,0
|
||||
10014354,29757856,2150-04-10 02:40:00,2150-04-15 18:00:00,,EW EMER.,P08904,WALK-IN/SELF REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2150-04-09 19:50:00,2150-04-10 03:46:00,0
|
||||
10038933,25129047,2148-09-10 12:09:00,2148-09-23 12:18:00,,EW EMER.,P47KFL,EMERGENCY ROOM,REHAB,Other,ENGLISH,SINGLE,WHITE,2148-09-10 09:23:00,2148-09-10 13:19:00,0
|
||||
10016150,29374560,2142-05-10 15:05:00,2142-05-15 19:49:00,,EW EMER.,P1037P,PROCEDURE SITE,REHAB,Medicare,ENGLISH,SINGLE,WHITE,,,0
|
||||
10022041,28909879,2187-05-18 17:08:00,2187-05-23 17:20:00,,EW EMER.,P09IS0,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,ENGLISH,MARRIED,OTHER,2187-05-18 14:59:00,2187-05-18 18:39:00,0
|
||||
10039708,20572787,2138-10-30 23:30:00,2138-11-06 23:30:00,,EW EMER.,P39SWK,EMERGENCY ROOM,AGAINST ADVICE,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2138-10-30 17:27:00,2138-10-31 00:19:00,0
|
||||
10039708,28258130,2140-01-23 16:19:00,2140-02-26 18:15:00,,EW EMER.,P44WVR,EMERGENCY ROOM,CHRONIC/LONG TERM ACUTE CARE,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2140-01-23 12:35:00,2140-01-23 18:08:00,0
|
||||
10000032,22841357,2180-06-26 18:27:00,2180-06-27 18:49:00,,EW EMER.,P09Q6Y,EMERGENCY ROOM,HOME,Medicaid,ENGLISH,WIDOWED,WHITE,2180-06-26 15:54:00,2180-06-26 21:31:00,0
|
||||
10000032,25742920,2180-08-05 23:44:00,2180-08-07 17:50:00,,EW EMER.,P60CC5,EMERGENCY ROOM,HOSPICE,Medicaid,ENGLISH,WIDOWED,WHITE,2180-08-05 20:58:00,2180-08-06 01:44:00,0
|
||||
10000032,29079034,2180-07-23 12:35:00,2180-07-25 17:55:00,,EW EMER.,P30KEH,EMERGENCY ROOM,HOME,Medicaid,ENGLISH,WIDOWED,WHITE,2180-07-23 05:54:00,2180-07-23 14:00:00,0
|
||||
10026406,25260176,2129-01-03 15:55:00,2129-01-05 14:10:00,,EW EMER.,P51MA2,EMERGENCY ROOM,AGAINST ADVICE,Other,ENGLISH,DIVORCED,WHITE,2129-01-02 23:41:00,2129-01-03 18:33:00,0
|
||||
10037928,22326517,2177-12-21 20:47:00,2177-12-23 17:45:00,,EW EMER.,P51MA2,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2177-12-21 16:49:00,2177-12-21 22:26:00,0
|
||||
10037928,22490490,2177-07-14 16:55:00,2177-07-24 13:33:00,,EW EMER.,P44WVR,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2177-07-14 14:52:00,2177-07-14 20:38:00,0
|
||||
10037928,20192635,2177-09-04 12:05:00,2177-09-07 16:10:00,,EW EMER.,P072C5,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2177-09-04 06:29:00,2177-09-04 14:44:00,0
|
||||
10037928,29802992,2179-07-25 00:06:00,2179-07-28 15:54:00,,EW EMER.,P450WM,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2179-07-24 18:21:00,2179-07-25 01:17:00,0
|
||||
10037928,23721604,2179-03-27 18:27:00,2179-04-04 19:40:00,,EW EMER.,P80GYA,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2179-03-27 14:15:00,2179-03-27 19:48:00,0
|
||||
10037928,24225421,2178-09-28 23:05:00,2178-10-02 17:13:00,,EW EMER.,P30AMB,EMERGENCY ROOM,HOME,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2178-09-28 20:29:00,2178-09-29 00:43:00,0
|
||||
10037928,24656677,2178-12-21 05:30:00,2178-12-26 18:35:00,,EW EMER.,P30SZF,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2178-12-21 03:17:00,2178-12-21 08:27:00,0
|
||||
10029484,20764029,2160-11-08 04:16:00,2160-11-11 11:40:00,,EW EMER.,P38TI6,EMERGENCY ROOM,HOME,Other,ENGLISH,SINGLE,WHITE,2160-11-07 19:15:00,2160-11-08 05:23:00,0
|
||||
10027445,26275841,2142-07-31 00:32:00,2142-08-09 17:30:00,,EW EMER.,P3529J,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,WIDOWED,WHITE,2142-07-30 23:33:00,2142-07-31 01:41:00,0
|
||||
10012853,22539296,2176-06-06 18:09:00,2176-06-08 18:30:00,,EW EMER.,P172D4,EMERGENCY ROOM,HOME,Medicare,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,2176-06-06 13:33:00,2176-06-06 20:18:00,0
|
||||
10012853,26369609,2175-04-05 15:36:00,2175-04-10 16:55:00,,EW EMER.,P623U2,EMERGENCY ROOM,HOME,Other,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,2175-04-05 06:22:00,2175-04-05 17:10:00,0
|
||||
10012853,27882036,2176-11-25 21:28:00,2176-12-03 15:24:00,,EW EMER.,P450WM,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,2176-11-25 18:34:00,2176-11-25 23:51:00,0
|
||||
10019003,29279905,2153-03-27 23:25:00,2153-04-07 16:20:00,,EW EMER.,P60F78,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,WIDOWED,WHITE,2153-03-27 21:28:00,2153-03-28 02:21:00,0
|
||||
10019003,21457723,2155-07-10 17:48:00,2155-07-18 16:59:00,,EW EMER.,P44WVR,TRANSFER FROM HOSPITAL,HOME,Other,ENGLISH,WIDOWED,WHITE,2155-07-10 12:46:00,2155-07-10 19:03:00,0
|
||||
10019003,27525946,2153-04-12 19:07:00,2153-04-20 17:09:00,,EW EMER.,P56B4N,EMERGENCY ROOM,HOME,Other,ENGLISH,WIDOWED,WHITE,2153-04-12 13:03:00,2153-04-12 21:40:00,0
|
||||
10019003,26703331,2155-06-10 23:09:00,2155-06-15 16:30:00,,EW EMER.,P203BW,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,WIDOWED,WHITE,2155-06-10 20:37:00,2155-06-11 01:53:00,0
|
||||
10019777,27738145,2187-02-10 18:57:00,2187-02-27 13:22:00,,EW EMER.,P08BV8,EMERGENCY ROOM,HOSPICE,Other,ENGLISH,DIVORCED,WHITE,2187-02-10 15:11:00,2187-02-10 20:34:00,0
|
||||
10018501,28479513,2141-07-30 22:34:00,2141-08-05 18:06:00,,EW EMER.,P99698,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,,WHITE,2141-07-30 18:53:00,2141-07-31 00:01:00,0
|
||||
10004422,21255400,2111-01-15 14:55:00,2111-01-25 15:00:00,,EW EMER.,P1037P,PROCEDURE SITE,HOME HEALTH CARE,Medicare,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10003400,27296885,2136-12-31 21:40:00,2137-01-03 17:05:00,,EW EMER.,P14622,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,BLACK/AFRICAN AMERICAN,2136-12-31 13:41:00,2137-01-01 00:35:00,0
|
||||
10003400,29483621,2136-11-04 20:43:00,2136-11-12 17:40:00,,EW EMER.,P37QNF,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,BLACK/AFRICAN AMERICAN,2136-11-04 16:08:00,2136-11-04 22:12:00,0
|
||||
10003400,22390287,2137-02-07 19:42:00,2137-02-18 18:30:00,,EW EMER.,P42DF5,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,BLACK/AFRICAN AMERICAN,2137-02-07 13:06:00,2137-02-07 21:44:00,0
|
||||
10003400,26090619,2134-06-06 02:25:00,2134-06-07 15:05:00,,EW EMER.,P77SO2,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,ENGLISH,MARRIED,BLACK/AFRICAN AMERICAN,2134-06-05 21:42:00,2134-06-06 03:44:00,0
|
||||
10003400,26467376,2136-12-09 14:44:00,2136-12-15 16:00:00,,EW EMER.,P14622,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,BLACK/AFRICAN AMERICAN,2136-12-09 13:16:00,2136-12-09 18:45:00,0
|
||||
10023117,28872262,2171-11-07 21:37:00,2171-11-22 15:30:00,,EW EMER.,P89ZCW,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,ENGLISH,WIDOWED,WHITE,2171-11-07 17:40:00,2171-11-07 22:50:00,0
|
||||
10023117,29858644,2173-04-16 22:15:00,2173-04-20 16:40:00,,EW EMER.,P23URY,EMERGENCY ROOM,HOME,Medicare,ENGLISH,WIDOWED,WHITE,2173-04-16 17:00:00,2173-04-16 23:18:00,0
|
||||
10017492,27417763,2116-06-26 18:25:00,2116-07-05 08:05:00,2116-07-05 08:05:00,EW EMER.,P3417E,EMERGENCY ROOM,DIED,Medicaid,?,SINGLE,PATIENT DECLINED TO ANSWER,2116-06-26 14:29:00,2116-06-26 21:46:00,1
|
||||
10017492,27672872,2114-03-19 20:05:00,2114-04-02 18:30:00,,EW EMER.,P68RKH,EMERGENCY ROOM,HOME HEALTH CARE,Other,?,SINGLE,PATIENT DECLINED TO ANSWER,2114-03-19 15:57:00,2114-03-19 21:38:00,0
|
||||
10038999,29026789,2132-05-17 23:32:00,2132-05-23 13:01:00,,EW EMER.,P64JOR,TRANSFER FROM HOSPITAL,REHAB,Medicare,ENGLISH,SINGLE,WHITE,2132-05-17 19:56:00,2132-05-18 01:36:00,0
|
||||
10040025,25172300,2145-07-03 23:46:00,2145-07-05 13:57:00,,EW EMER.,P73Y1N,EMERGENCY ROOM,HOME,Other,ENGLISH,DIVORCED,WHITE,2145-07-03 20:02:00,2145-07-04 10:58:00,0
|
||||
10040025,25384176,2145-10-19 23:48:00,2145-10-24 14:20:00,,EW EMER.,P1904G,EMERGENCY ROOM,HOME,Other,ENGLISH,DIVORCED,WHITE,2145-10-19 19:23:00,2145-10-20 00:54:00,0
|
||||
10040025,27876215,2147-11-09 08:02:00,2147-11-14 18:53:00,,EW EMER.,P059D0,EMERGENCY ROOM,SKILLED NURSING FACILITY,Other,ENGLISH,DIVORCED,WHITE,2147-11-09 04:17:00,2147-11-09 10:56:00,0
|
||||
10040025,27553957,2145-07-24 19:00:00,2145-07-31 14:12:00,,EW EMER.,P38RSS,EMERGENCY ROOM,HOME,Other,ENGLISH,DIVORCED,WHITE,2145-07-24 11:30:00,2145-07-24 20:32:00,0
|
||||
10009049,22995465,2174-05-26 08:21:00,2174-05-31 14:15:00,,EW EMER.,P3529J,EMERGENCY ROOM,HOME,Other,ENGLISH,MARRIED,WHITE,2174-05-26 04:20:00,2174-05-26 09:18:00,0
|
||||
10016742,29281842,2178-07-03 21:13:00,2178-07-08 20:20:00,,EW EMER.,P031HZ,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicaid,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2178-07-03 17:39:00,2178-07-03 22:45:00,0
|
||||
10004457,28723315,2141-08-12 16:02:00,2141-08-13 17:47:00,,EW EMER.,P46WR5,EMERGENCY ROOM,HOME,Medicare,ENGLISH,SINGLE,WHITE,2141-08-12 12:08:00,2141-08-12 17:20:00,0
|
||||
10029291,22205327,2123-02-20 01:59:00,2123-03-10 15:30:00,,EW EMER.,P44UQI,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,ENGLISH,DIVORCED,WHITE,2123-02-19 23:57:00,2123-02-20 04:13:00,0
|
||||
10004720,22081550,2186-11-12 18:01:00,2186-11-17 18:30:00,2186-11-17 18:30:00,EW EMER.,P3529J,INFORMATION NOT AVAILABLE,DIED,Medicare,ENGLISH,SINGLE,WHITE,2186-11-12 16:09:00,2186-11-12 19:55:00,1
|
||||
10019568,28710730,2120-01-30 21:07:00,2120-02-02 15:40:00,,EW EMER.,P80LEI,EMERGENCY ROOM,HOME,Medicare,ENGLISH,SINGLE,WHITE,2120-01-30 19:44:00,2120-01-30 22:51:00,0
|
||||
10002930,25922998,2198-04-17 19:38:00,2198-04-22 16:02:00,,EW EMER.,P30KEH,EMERGENCY ROOM,PSYCH FACILITY,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2198-04-17 11:42:00,2198-04-17 21:24:00,0
|
||||
10002930,25696644,2196-04-14 12:25:00,2196-04-17 15:28:00,,EW EMER.,P43A1R,EMERGENCY ROOM,PSYCH FACILITY,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2196-04-14 05:55:00,2196-04-14 13:40:00,0
|
||||
10007795,22051341,2136-09-22 20:51:00,2136-09-24 14:20:00,,EW EMER.,P99LA7,EMERGENCY ROOM,HOME,Medicare,ENGLISH,SINGLE,WHITE,2136-09-22 17:07:00,2136-09-22 22:30:00,0
|
||||
10007795,20285402,2136-08-04 22:16:00,2136-08-11 19:20:00,,EW EMER.,P74QAJ,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,ENGLISH,SINGLE,WHITE,2136-08-04 16:26:00,2136-08-05 00:17:00,0
|
||||
10010867,22429197,2147-12-30 08:40:00,2148-01-11 17:55:00,,EW EMER.,P3417E,EMERGENCY ROOM,REHAB,Other,ENGLISH,SINGLE,WHITE - BRAZILIAN,2147-12-30 06:45:00,2147-12-30 09:33:00,0
|
||||
10025463,24470193,2137-10-08 21:20:00,2137-10-09 15:30:00,2137-10-09 15:30:00,EW EMER.,P01LRQ,EMERGENCY ROOM,DIED,Other,ENGLISH,MARRIED,WHITE,2137-10-08 18:16:00,2137-10-08 20:44:00,1
|
||||
10002428,28662225,2156-04-12 14:16:00,2156-04-29 16:26:00,,EW EMER.,P64TOH,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,WIDOWED,WHITE,2156-04-12 09:56:00,2156-04-12 17:11:00,0
|
||||
10002428,20321825,2156-04-30 20:35:00,2156-05-03 16:36:00,,EW EMER.,P825SO,EMERGENCY ROOM,CHRONIC/LONG TERM ACUTE CARE,Medicare,ENGLISH,WIDOWED,WHITE,2156-04-30 18:30:00,2156-04-30 21:53:00,0
|
||||
10002428,23473524,2156-05-11 14:49:00,2156-05-22 14:16:00,,EW EMER.,P3529J,EMERGENCY ROOM,CHRONIC/LONG TERM ACUTE CARE,Medicare,ENGLISH,WIDOWED,WHITE,2156-05-11 11:29:00,2156-05-11 16:53:00,0
|
||||
10007818,22987108,2146-06-10 16:37:00,2146-07-12 00:00:00,2146-07-12 20:50:00,DIRECT EMER.,P48GIG,PHYSICIAN REFERRAL,DIED,Medicare,ENGLISH,MARRIED,WHITE,,,1
|
||||
10004235,22187210,2196-06-20 21:11:00,2196-06-22 13:30:00,,DIRECT EMER.,P98TAU,PHYSICIAN REFERRAL,HOME HEALTH CARE,Medicaid,ENGLISH,SINGLE,BLACK/CAPE VERDEAN,,,0
|
||||
10001217,27703517,2157-12-18 16:58:00,2157-12-24 14:55:00,,DIRECT EMER.,P99698,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,?,MARRIED,WHITE,,,0
|
||||
10021487,27660781,2117-03-03 15:59:00,2117-03-27 16:40:00,,DIRECT EMER.,P82B0E,CLINIC REFERRAL,HOME,Other,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10015860,28613200,2188-03-29 14:14:00,2188-04-02 16:30:00,,DIRECT EMER.,P62A9I,CLINIC REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10031757,28477280,2137-10-12 22:43:00,2137-10-24 17:30:00,,DIRECT EMER.,P94RT6,CLINIC REFERRAL,HOSPICE,Other,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10014354,27562275,2148-07-18 02:31:00,2148-07-20 17:47:00,,DIRECT EMER.,P90LIK,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2148-07-17 20:36:00,2148-07-18 04:46:00,0
|
||||
10035631,27496788,2113-08-26 17:07:00,2113-08-29 15:18:00,,DIRECT EMER.,P45GUA,CLINIC REFERRAL,HOME,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10035631,29462354,2112-09-17 19:13:00,2112-10-17 01:41:00,,DIRECT EMER.,P45GUA,PHYSICIAN REFERRAL,HOME,Other,ENGLISH,MARRIED,UNKNOWN,2112-09-17 18:46:00,2112-09-17 19:50:00,0
|
||||
10035631,22732862,2112-11-10 15:55:00,2112-11-20 16:20:00,,DIRECT EMER.,P11CH5,CLINIC REFERRAL,HOME,Other,ENGLISH,MARRIED,UNKNOWN,,,0
|
||||
10035631,21599196,2116-02-13 11:17:00,2116-02-15 17:09:00,,DIRECT EMER.,P15Q8N,PHYSICIAN REFERRAL,HOME,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10019003,26529390,2155-05-17 00:02:00,2155-05-19 18:27:00,,DIRECT EMER.,P73PF1,PHYSICIAN REFERRAL,HOME,Other,ENGLISH,WIDOWED,WHITE,2155-05-17 21:03:00,2155-05-18 03:03:00,0
|
||||
10023117,28887654,2174-12-16 13:25:00,2174-12-20 10:27:00,,DIRECT EMER.,P94CCF,PHYSICIAN REFERRAL,HOME,Medicare,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10002930,23688993,2193-08-05 11:45:00,2193-08-11 09:25:00,,DIRECT EMER.,P356OC,PHYSICIAN REFERRAL,HOME,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2193-08-05 05:04:00,2193-08-05 12:00:00,0
|
||||
10025463,27327816,2136-10-31 07:15:00,2136-11-02 16:50:00,,DIRECT EMER.,P38W5P,PHYSICIAN REFERRAL,HOME,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10023239,28984460,2140-10-22 22:04:00,2140-10-23 17:01:00,,EU OBSERVATION,P76VMW,PHYSICIAN REFERRAL,,Other,ENGLISH,MARRIED,WHITE,2140-10-22 18:05:00,2140-10-23 17:01:00,0
|
||||
10024043,25561728,2117-02-03 20:10:00,2117-02-04 11:53:00,,EU OBSERVATION,P19QFH,EMERGENCY ROOM,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2117-02-03 17:13:00,2117-02-04 11:53:00,0
|
||||
10015860,27670224,2187-12-14 15:48:00,2187-12-16 14:38:00,,EU OBSERVATION,P25XDB,EMERGENCY ROOM,,Other,ENGLISH,SINGLE,WHITE,2187-12-14 12:49:00,2187-12-14 17:48:00,0
|
||||
10015860,22607171,2190-05-15 08:16:00,2190-05-16 15:44:00,,EU OBSERVATION,P73Y1N,EMERGENCY ROOM,,Other,ENGLISH,SINGLE,WHITE,2190-05-14 17:11:00,2190-05-15 10:00:00,0
|
||||
10015860,22416954,2193-05-03 22:45:00,2193-05-05 02:13:00,,EU OBSERVATION,P21E4S,TRANSFER FROM HOSPITAL,,Other,ENGLISH,SINGLE,WHITE,2193-05-03 19:12:00,2193-05-05 02:13:00,0
|
||||
10020740,23143086,2151-01-15 15:25:00,2151-01-16 02:38:00,,EU OBSERVATION,P607HY,EMERGENCY ROOM,,Other,ENGLISH,SINGLE,WHITE,2151-01-15 11:47:00,2151-01-16 02:38:00,0
|
||||
10020740,23199774,2150-09-15 14:09:00,2150-09-15 17:09:00,,EU OBSERVATION,P30KYN,EMERGENCY ROOM,,Other,ENGLISH,SINGLE,WHITE,2150-09-15 09:31:00,2150-09-15 17:09:00,0
|
||||
10014354,24096336,2149-06-19 22:54:00,2149-06-20 13:48:00,,EU OBSERVATION,P83K3D,PHYSICIAN REFERRAL,,Other,ENGLISH,SINGLE,WHITE,2149-06-19 17:01:00,2149-06-20 13:48:00,0
|
||||
10014354,26722126,2146-11-09 01:53:00,2146-11-09 13:13:00,,EU OBSERVATION,P187JB,EMERGENCY ROOM,,Other,ENGLISH,MARRIED,WHITE,2146-11-08 21:24:00,2146-11-09 13:13:00,0
|
||||
10014354,20900955,2149-03-04 23:14:00,2149-03-05 14:59:00,,EU OBSERVATION,P63AD6,WALK-IN/SELF REFERRAL,,Other,ENGLISH,SINGLE,WHITE,2149-03-04 20:24:00,2149-03-05 14:59:00,0
|
||||
10014354,22502504,2147-09-12 05:06:00,2147-09-12 19:00:00,,EU OBSERVATION,P04X8Y,EMERGENCY ROOM,,Other,ENGLISH,SINGLE,WHITE,2147-09-12 02:01:00,2147-09-12 06:37:00,0
|
||||
10014354,26173805,2149-09-17 22:54:00,2149-09-18 10:45:00,,EU OBSERVATION,P57NY9,WALK-IN/SELF REFERRAL,,Other,ENGLISH,SINGLE,WHITE,2149-09-17 09:08:00,2149-09-17 23:55:00,0
|
||||
10014354,27494880,2147-06-04 00:42:00,2147-06-04 15:00:00,,EU OBSERVATION,P79SJ2,EMERGENCY ROOM,,Other,ENGLISH,SINGLE,WHITE,2147-06-03 22:39:00,2147-06-04 09:54:00,0
|
||||
10039708,27504040,2142-07-06 09:08:00,2142-07-07 16:40:00,,EU OBSERVATION,P94RT6,PHYSICIAN REFERRAL,,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2142-07-06 07:21:00,2142-07-06 08:27:00,0
|
||||
10026406,25166559,2133-03-01 19:30:00,2133-03-04 17:05:00,,EU OBSERVATION,P42K8Q,WALK-IN/SELF REFERRAL,,Other,ENGLISH,DIVORCED,WHITE,2133-03-01 16:42:00,2133-03-04 17:05:00,0
|
||||
10037928,22228639,2183-08-04 04:04:00,2183-08-04 16:07:00,,EU OBSERVATION,P850UN,PHYSICIAN REFERRAL,,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2183-08-03 19:27:00,2183-08-04 16:07:00,0
|
||||
10037928,28543425,2175-10-26 01:31:00,2175-10-26 12:48:00,,EU OBSERVATION,P40IUG,EMERGENCY ROOM,,Medicare,?,SINGLE,HISPANIC OR LATINO,2175-10-25 19:55:00,2175-10-26 12:48:00,0
|
||||
10012853,22896692,2176-08-11 15:17:00,2176-08-11 17:35:00,,EU OBSERVATION,P76VMW,EMERGENCY ROOM,,Medicare,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,2176-08-11 08:36:00,2176-08-11 17:35:00,0
|
||||
10039997,21390688,2135-11-07 02:42:00,2135-11-07 06:27:00,,EU OBSERVATION,P03N1K,EMERGENCY ROOM,,Medicare,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,2135-11-06 21:44:00,2135-11-07 06:27:00,0
|
||||
10020306,26332470,2133-05-05 05:43:00,2133-05-05 15:37:00,,EU OBSERVATION,P45LON,CLINIC REFERRAL,,Medicare,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,2133-05-04 20:52:00,2133-05-05 15:37:00,0
|
||||
10002930,25282382,2197-04-17 02:01:00,2197-04-17 09:48:00,,EU OBSERVATION,P33NK4,EMERGENCY ROOM,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2197-04-16 22:57:00,2197-04-17 09:48:00,0
|
||||
10002930,28697806,2200-06-05 05:43:00,2200-06-05 10:26:00,,EU OBSERVATION,P68U3G,PHYSICIAN REFERRAL,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2200-06-04 16:38:00,2200-06-05 10:26:00,0
|
||||
10002930,20846853,2201-02-12 16:58:00,2201-02-13 11:11:00,,EU OBSERVATION,P2358X,PHYSICIAN REFERRAL,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2201-02-12 15:11:00,2201-02-13 11:11:00,0
|
||||
10002930,22380825,2193-08-05 06:18:00,2193-08-05 11:44:00,,EU OBSERVATION,P38XXV,EMERGENCY ROOM,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2193-08-05 05:04:00,2193-08-05 12:00:00,0
|
||||
10002930,23720373,2199-02-17 21:45:00,2199-02-19 13:38:00,,EU OBSERVATION,P59HPG,EMERGENCY ROOM,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2199-02-17 14:32:00,2199-02-19 13:38:00,0
|
||||
10002930,20282368,2201-03-23 19:15:00,2201-03-26 14:24:00,,EU OBSERVATION,P850UN,PHYSICIAN REFERRAL,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2201-03-23 12:04:00,2201-03-26 14:24:00,0
|
||||
10002930,28477649,2197-04-07 06:56:00,2197-04-08 19:37:00,,EU OBSERVATION,P11RCJ,EMERGENCY ROOM,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2197-04-07 03:20:00,2197-04-08 20:14:00,0
|
||||
10002428,28676446,2157-07-16 04:09:00,2157-07-18 16:49:00,,EU OBSERVATION,P08S8F,EMERGENCY ROOM,,Medicare,ENGLISH,WIDOWED,WHITE,2157-07-16 01:50:00,2157-07-16 09:51:00,0
|
||||
10002428,25797028,2155-07-14 19:15:00,2155-07-15 18:37:00,,EU OBSERVATION,P64TOH,EMERGENCY ROOM,,Medicare,ENGLISH,WIDOWED,WHITE,2155-07-14 16:58:00,2155-07-14 20:04:00,0
|
||||
10002428,26549334,2160-07-15 23:37:00,2160-07-16 18:49:00,,EU OBSERVATION,P607HY,EMERGENCY ROOM,,Medicare,ENGLISH,WIDOWED,WHITE,2160-07-15 17:34:00,2160-07-16 18:49:00,0
|
||||
10018328,26706939,2154-02-05 21:58:00,2154-02-09 15:15:00,,OBSERVATION ADMIT,P233F6,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,DIVORCED,WHITE,2154-02-05 17:09:00,2154-02-05 22:54:00,0
|
||||
10005817,28661809,2135-01-03 21:54:00,2135-01-19 18:36:00,2135-01-19 18:36:00,OBSERVATION ADMIT,P7554I,TRANSFER FROM HOSPITAL,DIED,Medicare,ENGLISH,MARRIED,WHITE,,,1
|
||||
10019385,20611796,2180-03-04 01:16:00,2180-03-06 18:32:00,,OBSERVATION ADMIT,P95P90,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,MARRIED,WHITE,2180-03-03 20:26:00,2180-03-04 02:24:00,0
|
||||
10020187,24104168,2169-01-15 04:04:00,2169-01-24 17:20:00,,OBSERVATION ADMIT,P898NM,EMERGENCY ROOM,HOME HEALTH CARE,Other,?,MARRIED,HISPANIC/LATINO - SALVADORAN,2169-01-14 23:26:00,2169-01-15 04:56:00,0
|
||||
10010471,21322534,2155-05-08 17:05:00,2155-05-10 18:55:00,,OBSERVATION ADMIT,P89T1L,EMERGENCY ROOM,HOME,Medicare,ENGLISH,WIDOWED,WHITE,2155-05-08 13:48:00,2155-05-08 18:16:00,0
|
||||
10005866,22589518,2149-02-11 21:49:00,2149-02-14 16:45:00,,OBSERVATION ADMIT,P874LG,EMERGENCY ROOM,HOME,Medicaid,ENGLISH,SINGLE,PORTUGUESE,2149-02-11 08:52:00,2149-02-11 23:09:00,0
|
||||
10005866,21636229,2149-09-20 14:30:00,2149-09-26 15:05:00,,OBSERVATION ADMIT,P96Y5O,EMERGENCY ROOM,HOME,Medicaid,ENGLISH,SINGLE,PORTUGUESE,2149-09-20 05:50:00,2149-09-20 15:53:00,0
|
||||
10005866,23514107,2149-06-20 19:27:00,2149-06-25 15:55:00,,OBSERVATION ADMIT,P28OI9,EMERGENCY ROOM,HOME,Medicaid,ENGLISH,SINGLE,PORTUGUESE,2149-06-20 10:20:00,2149-06-20 20:59:00,0
|
||||
10005866,20364112,2149-10-01 18:56:00,2149-10-25 18:50:00,,OBSERVATION ADMIT,P94RT6,PHYSICIAN REFERRAL,SKILLED NURSING FACILITY,Medicaid,ENGLISH,SINGLE,PORTUGUESE,2149-10-01 02:07:00,2149-10-01 15:59:00,0
|
||||
10015860,25103777,2192-07-31 16:05:00,2192-08-06 16:37:00,,OBSERVATION ADMIT,P104SU,TRANSFER FROM SKILLED NURSING FACILITY,SKILLED NURSING FACILITY,Other,ENGLISH,SINGLE,WHITE,2192-07-31 11:00:00,2192-07-31 17:10:00,0
|
||||
10015860,22413744,2191-01-15 01:55:00,2191-01-30 17:09:00,,OBSERVATION ADMIT,P172D4,EMERGENCY ROOM,SKILLED NURSING FACILITY,Other,ENGLISH,SINGLE,WHITE,2191-01-14 21:18:00,2191-01-15 03:20:00,0
|
||||
10015860,28196804,2193-11-23 19:15:00,2193-11-27 21:58:00,,OBSERVATION ADMIT,P54UVQ,WALK-IN/SELF REFERRAL,SKILLED NURSING FACILITY,Other,ENGLISH,SINGLE,WHITE,2193-11-23 12:38:00,2193-11-23 20:59:00,0
|
||||
10014354,22508257,2148-05-10 23:29:00,2148-05-20 14:30:00,,OBSERVATION ADMIT,P57B0F,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2148-05-10 20:25:00,2148-05-11 01:22:00,0
|
||||
10014354,29600294,2148-08-14 22:57:00,2148-08-18 21:12:00,,OBSERVATION ADMIT,P723L9,PHYSICIAN REFERRAL,AGAINST ADVICE,Other,ENGLISH,SINGLE,WHITE,2148-08-14 16:32:00,2148-08-15 00:27:00,0
|
||||
10014354,26228185,2150-04-30 20:19:00,2150-05-07 14:10:00,,OBSERVATION ADMIT,P17YHV,TRANSFER FROM HOSPITAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2150-04-30 14:26:00,2150-04-30 21:54:00,0
|
||||
10014354,24980601,2150-02-04 20:12:00,2150-02-08 14:10:00,,OBSERVATION ADMIT,P42VJP,PHYSICIAN REFERRAL,AGAINST ADVICE,Other,ENGLISH,SINGLE,WHITE,2150-02-04 14:50:00,2150-02-04 22:44:00,0
|
||||
10014354,26486158,2148-08-22 15:18:00,2148-09-08 12:00:00,,OBSERVATION ADMIT,P17YHV,CLINIC REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10014354,29780751,2147-11-26 00:39:00,2147-11-30 16:54:00,,OBSERVATION ADMIT,P21X87,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,2147-11-25 19:39:00,2147-11-26 01:30:00,0
|
||||
10014354,23132022,2148-06-24 15:22:00,2148-06-28 13:54:00,,OBSERVATION ADMIT,P259GB,PHYSICIAN REFERRAL,HOME,Other,ENGLISH,SINGLE,WHITE,2148-06-24 09:22:00,2148-06-24 16:12:00,0
|
||||
10005909,20199380,2144-10-28 23:20:00,2144-11-02 15:23:00,,OBSERVATION ADMIT,P43BTJ,EMERGENCY ROOM,HOME,Other,ENGLISH,MARRIED,WHITE,2144-10-28 18:29:00,2144-10-29 00:10:00,0
|
||||
10039708,26793610,2140-09-25 04:17:00,2140-09-26 17:40:00,,OBSERVATION ADMIT,P91LM4,EMERGENCY ROOM,HOME,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2140-09-24 21:17:00,2140-09-25 05:57:00,0
|
||||
10039708,29488258,2144-01-19 12:07:00,2144-01-21 21:20:00,,OBSERVATION ADMIT,P8785Z,WALK-IN/SELF REFERRAL,HOME,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2144-01-18 11:53:00,2144-01-19 13:59:00,0
|
||||
10039708,24928679,2143-09-19 18:36:00,2143-09-22 23:00:00,,OBSERVATION ADMIT,P8785Z,CLINIC REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2143-09-19 10:57:00,2143-09-19 20:57:00,0
|
||||
10039708,23819016,2140-06-18 00:22:00,2140-06-22 17:40:00,,OBSERVATION ADMIT,P48KFD,EMERGENCY ROOM,HOME,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2140-06-17 17:47:00,2140-06-18 01:41:00,0
|
||||
10039708,20093566,2143-09-26 18:24:00,2143-09-30 20:00:00,,OBSERVATION ADMIT,P04ZFH,CLINIC REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2143-09-26 08:16:00,2143-09-26 19:52:00,0
|
||||
10037928,24885579,2182-04-29 04:29:00,2182-05-03 17:20:00,,OBSERVATION ADMIT,P3417E,WALK-IN/SELF REFERRAL,HOME,Medicare,?,WIDOWED,HISPANIC/LATINO - CUBAN,2182-04-28 17:25:00,2182-04-29 07:49:00,0
|
||||
10035631,21476294,2115-11-08 13:54:00,2115-12-08 17:31:00,,OBSERVATION ADMIT,P45GUA,EMERGENCY ROOM,HOME,Other,ENGLISH,MARRIED,WHITE,2115-11-08 12:02:00,2115-11-08 15:43:00,0
|
||||
10035631,29276678,2116-02-27 20:55:00,2116-03-12 07:45:00,2116-03-12 07:45:00,OBSERVATION ADMIT,P17YHV,EMERGENCY ROOM,DIED,Other,ENGLISH,MARRIED,WHITE,2116-02-27 15:33:00,2116-02-27 22:03:00,1
|
||||
10015931,24420677,2176-12-16 23:31:00,2176-12-31 17:35:00,,OBSERVATION ADMIT,P49NLZ,EMERGENCY ROOM,HOME HEALTH CARE,Medicare,ENGLISH,MARRIED,WHITE,2176-12-16 18:24:00,2176-12-17 01:07:00,0
|
||||
10015931,28157142,2176-11-14 18:02:00,2176-11-27 13:30:00,,OBSERVATION ADMIT,P20N5X,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,WHITE,2176-11-14 02:27:00,2176-11-14 19:51:00,0
|
||||
10019003,25508812,2155-05-22 21:46:00,2155-05-30 03:30:00,,OBSERVATION ADMIT,P88A32,PHYSICIAN REFERRAL,HOME,Other,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10039997,22484749,2137-03-15 22:08:00,2137-03-18 16:34:00,,OBSERVATION ADMIT,P70N8P,PHYSICIAN REFERRAL,REHAB,Medicare,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,2137-03-15 15:27:00,2137-03-16 00:11:00,0
|
||||
10023117,21607814,2175-07-06 15:57:00,2175-07-20 00:00:00,2175-07-20 22:50:00,OBSERVATION ADMIT,P49NLZ,EMERGENCY ROOM,DIED,Medicare,ENGLISH,WIDOWED,WHITE,2175-07-06 14:02:00,2175-07-06 17:41:00,1
|
||||
10023117,21133938,2175-03-20 23:29:00,2175-03-29 16:00:00,,OBSERVATION ADMIT,P66Z67,TRANSFER FROM HOSPITAL,HOME,Medicare,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10020306,23052851,2135-01-15 20:55:00,2135-02-07 17:50:00,,OBSERVATION ADMIT,P54UVQ,PHYSICIAN REFERRAL,SKILLED NURSING FACILITY,Other,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,2135-01-15 16:12:00,2135-01-15 22:38:00,0
|
||||
10038999,27189241,2131-05-22 21:49:00,2131-06-04 13:43:00,,OBSERVATION ADMIT,P44UQI,EMERGENCY ROOM,HOME,Medicare,ENGLISH,SINGLE,WHITE,2131-05-22 20:33:00,2131-05-22 21:28:00,0
|
||||
10040025,21791856,2147-06-16 22:00:00,2147-06-22 16:15:00,,OBSERVATION ADMIT,P7634X,EMERGENCY ROOM,HOME HEALTH CARE,Other,ENGLISH,DIVORCED,WHITE,2147-06-16 18:58:00,2147-06-16 23:06:00,0
|
||||
10040025,22251969,2147-08-03 02:58:00,2147-08-06 16:50:00,,OBSERVATION ADMIT,P85BWS,EMERGENCY ROOM,HOME,Other,ENGLISH,DIVORCED,WHITE,2147-08-02 18:14:00,2147-08-03 02:41:00,0
|
||||
10040025,27259207,2147-12-04 20:48:00,2147-12-18 16:43:00,,OBSERVATION ADMIT,P43BTJ,EMERGENCY ROOM,SKILLED NURSING FACILITY,Other,ENGLISH,DIVORCED,WHITE,2147-12-04 13:11:00,2147-12-05 02:28:00,0
|
||||
10040025,27996267,2148-01-23 12:18:00,2148-02-04 20:51:00,,OBSERVATION ADMIT,P10WWR,TRANSFER FROM SKILLED NURSING FACILITY,HOSPICE,Other,ENGLISH,DIVORCED,WHITE,2148-01-22 14:47:00,2148-01-23 09:42:00,0
|
||||
10016742,28506150,2178-07-13 05:40:00,2178-07-16 02:00:00,,OBSERVATION ADMIT,P031HZ,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicaid,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2178-07-13 04:37:00,2178-07-13 08:16:00,0
|
||||
10016742,27568122,2178-07-22 07:19:00,2178-07-25 16:30:00,,OBSERVATION ADMIT,P513EK,EMERGENCY ROOM,CHRONIC/LONG TERM ACUTE CARE,Medicaid,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2178-07-22 05:41:00,2178-07-22 08:19:00,0
|
||||
10010867,22196214,2148-03-07 23:30:00,2148-03-13 15:50:00,,OBSERVATION ADMIT,P233F6,EMERGENCY ROOM,REHAB,Other,ENGLISH,DIVORCED,WHITE - BRAZILIAN,2148-03-07 18:21:00,2148-03-08 00:50:00,0
|
||||
10010867,22950920,2148-01-25 22:58:00,2148-01-30 11:23:00,,OBSERVATION ADMIT,P39FGY,EMERGENCY ROOM,REHAB,Other,ENGLISH,DIVORCED,WHITE - BRAZILIAN,2148-01-25 18:46:00,2148-01-26 01:27:00,0
|
||||
10002428,28295257,2160-04-14 12:30:00,2160-04-18 16:00:00,,OBSERVATION ADMIT,P18I28,EMERGENCY ROOM,SKILLED NURSING FACILITY,Medicare,ENGLISH,WIDOWED,WHITE,2160-04-14 09:01:00,2160-04-14 14:28:00,0
|
||||
10005348,29176490,2129-05-22 16:00:00,2129-05-23 11:30:00,,DIRECT OBSERVATION,P132L1,PHYSICIAN REFERRAL,,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10020740,29820177,2150-07-09 22:09:00,2150-07-12 18:00:00,,DIRECT OBSERVATION,P1942H,CLINIC REFERRAL,,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10039708,22999601,2142-05-15 17:14:00,2142-05-15 18:21:00,,DIRECT OBSERVATION,P42K8Q,PHYSICIAN REFERRAL,,Other,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,2142-05-15 10:47:00,2142-05-15 18:21:00,0
|
||||
10012853,20457729,2177-11-03 09:30:00,2177-11-04 15:06:00,,DIRECT OBSERVATION,P05BRR,PHYSICIAN REFERRAL,,Medicare,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,,,0
|
||||
10012853,26739864,2177-02-11 07:15:00,2177-02-12 16:20:00,,DIRECT OBSERVATION,P05BRR,PHYSICIAN REFERRAL,,Medicare,ENGLISH,WIDOWED,BLACK/AFRICAN AMERICAN,,,0
|
||||
10004457,25559382,2148-09-14 14:19:00,2148-09-15 12:45:00,,DIRECT OBSERVATION,P466EI,PHYSICIAN REFERRAL,,Medicare,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10007795,27962747,2136-07-24 17:15:00,2136-07-27 14:27:00,,DIRECT OBSERVATION,P99LA7,PHYSICIAN REFERRAL,,Medicare,ENGLISH,SINGLE,WHITE,,,0
|
||||
10035631,29654498,2113-07-17 17:15:00,2113-07-18 14:55:00,,AMBULATORY OBSERVATION,P48P6U,PACU,,Other,ENGLISH,MARRIED,WHITE,,,0
|
||||
10020306,28778757,2129-10-29 08:00:00,2129-10-30 13:20:00,,AMBULATORY OBSERVATION,P75JMU,PACU,,Medicare,ENGLISH,SINGLE,BLACK/AFRICAN AMERICAN,,,0
|
||||
10040025,27125816,2143-03-18 12:34:00,2143-03-19 12:00:00,,AMBULATORY OBSERVATION,P623U2,PROCEDURE SITE,,Other,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10004457,21039249,2140-09-17 13:41:00,2140-09-18 12:40:00,,AMBULATORY OBSERVATION,P99YFT,PROCEDURE SITE,,Medicare,ENGLISH,SINGLE,WHITE,,,0
|
||||
10004457,21216581,2143-03-09 11:10:00,2143-03-10 11:35:00,,AMBULATORY OBSERVATION,P89ZCW,PROCEDURE SITE,,Medicare,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10011398,27505812,2146-12-15 07:15:00,2146-12-19 13:37:00,,SURGICAL SAME DAY ADMISSION,P47E1G,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,HISPANIC OR LATINO,,,0
|
||||
10014729,28889419,2125-02-27 07:15:00,2125-03-06 14:25:00,,SURGICAL SAME DAY ADMISSION,P17BJ5,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE - OTHER EUROPEAN,,,0
|
||||
10004235,25970245,2196-06-14 08:30:00,2196-06-19 14:54:00,,SURGICAL SAME DAY ADMISSION,P96Y5O,PHYSICIAN REFERRAL,HOME HEALTH CARE,Medicaid,ENGLISH,SINGLE,BLACK/CAPE VERDEAN,,,0
|
||||
10039831,26924951,2115-12-28 07:15:00,2116-01-02 14:34:00,,SURGICAL SAME DAY ADMISSION,P82B0E,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,MARRIED,UNABLE TO OBTAIN,,,0
|
||||
10018328,23786647,2154-04-24 03:15:00,2154-05-03 14:00:00,,SURGICAL SAME DAY ADMISSION,P898NM,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,DIVORCED,WHITE,,,0
|
||||
10018081,28861356,2134-08-02 07:15:00,2134-08-13 17:34:00,,SURGICAL SAME DAY ADMISSION,P3417E,PHYSICIAN REFERRAL,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,WHITE,,,0
|
||||
10027602,21477991,2201-12-11 12:00:00,2201-12-17 13:45:00,,SURGICAL SAME DAY ADMISSION,P898NM,PHYSICIAN REFERRAL,REHAB,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10020187,26842957,2170-02-24 00:00:00,2170-02-25 15:00:00,,SURGICAL SAME DAY ADMISSION,P898NM,PHYSICIAN REFERRAL,HOME,Other,?,MARRIED,HISPANIC/LATINO - SALVADORAN,,,0
|
||||
10003046,26048429,2154-01-02 07:15:00,2154-01-09 11:53:00,,SURGICAL SAME DAY ADMISSION,P2720E,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10022017,22342963,2189-09-10 00:00:00,2189-09-16 15:00:00,,SURGICAL SAME DAY ADMISSION,P47E1G,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10025612,23403708,2125-09-25 07:15:00,2125-10-03 12:24:00,,SURGICAL SAME DAY ADMISSION,P786Y5,PHYSICIAN REFERRAL,HOME HEALTH CARE,Medicare,ENGLISH,MARRIED,WHITE,,,0
|
||||
10039997,24294180,2134-09-07 12:00:00,2134-09-11 13:10:00,,SURGICAL SAME DAY ADMISSION,P47SIK,PHYSICIAN REFERRAL,HOME,Medicare,ENGLISH,MARRIED,BLACK/AFRICAN AMERICAN,,,0
|
||||
10023117,29839885,2170-10-08 07:15:00,2170-10-09 16:30:00,,SURGICAL SAME DAY ADMISSION,P89ZCW,PHYSICIAN REFERRAL,HOME HEALTH CARE,Medicare,ENGLISH,WIDOWED,WHITE,,,0
|
||||
10038992,24745425,2187-07-29 01:05:00,2187-08-03 17:02:00,,SURGICAL SAME DAY ADMISSION,P41R5N,PHYSICIAN REFERRAL,SKILLED NURSING FACILITY,Medicare,ENGLISH,MARRIED,WHITE,,,0
|
||||
10008287,22168393,2145-09-28 01:17:00,2145-10-02 13:35:00,,SURGICAL SAME DAY ADMISSION,P898NM,PHYSICIAN REFERRAL,HOME HEALTH CARE,Other,ENGLISH,SINGLE,WHITE,,,0
|
||||
10022880,27708593,2177-03-12 07:15:00,2177-03-19 14:25:00,,SURGICAL SAME DAY ADMISSION,P99698,PHYSICIAN REFERRAL,HOME,Medicare,ENGLISH,MARRIED,WHITE,,,0
|
||||
10004457,23251352,2141-12-17 11:00:00,2141-12-21 15:56:00,,SURGICAL SAME DAY ADMISSION,P41R5N,PHYSICIAN REFERRAL,REHAB,Medicare,ENGLISH,SINGLE,OTHER,,,0
|
||||
10004457,28108313,2147-12-19 00:00:00,2147-12-21 16:10:00,,SURGICAL SAME DAY ADMISSION,P10WWR,PHYSICIAN REFERRAL,SKILLED NURSING FACILITY,Medicare,ENGLISH,DIVORCED,WHITE,,,0
|
||||
|
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
+35836
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
subject_id,hadm_id,stay_id,first_careunit,last_careunit,intime,outtime,los
|
||||
10018328,23786647,31269608,Neuro Stepdown,Neuro Stepdown,2154-04-24 23:03:44,2154-05-02 15:55:21,7.7025115740740739
|
||||
10020187,24104168,37509585,Neuro Surgical Intensive Care Unit (Neuro SICU),Neuro Stepdown,2169-01-15 04:56:00,2169-01-20 15:47:50,5.4526620370370367
|
||||
10020187,26842957,32554129,Neuro Intermediate,Neuro Intermediate,2170-02-24 18:18:46,2170-02-25 15:15:26,0.87268518518518523
|
||||
10012853,27882036,31338022,Trauma SICU (TSICU),Trauma SICU (TSICU),2176-11-26 02:34:49,2176-11-29 20:58:54,3.766724537037037
|
||||
10020740,25826145,32145159,Trauma SICU (TSICU),Trauma SICU (TSICU),2150-06-03 20:12:32,2150-06-04 21:05:58,1.0371064814814817
|
||||
10039708,23819016,38559363,Trauma SICU (TSICU),Trauma SICU (TSICU),2140-06-18 01:41:00,2140-06-19 21:47:16,1.8376851851851852
|
||||
10020306,23052851,38540883,Trauma SICU (TSICU),Trauma SICU (TSICU),2135-01-21 17:01:57,2135-01-24 21:47:45,3.1984722222222222
|
||||
10019568,28710730,30876334,Trauma SICU (TSICU),Trauma SICU (TSICU),2120-01-30 22:51:00,2120-01-31 18:25:12,0.81541666666666668
|
||||
10018081,28861356,38333427,Trauma SICU (TSICU),Trauma SICU (TSICU),2134-08-05 14:53:33,2134-08-07 17:32:43,2.1105324074074074
|
||||
10018081,21027282,37293400,Trauma SICU (TSICU),Trauma SICU (TSICU),2133-12-18 17:10:00,2134-01-01 14:44:53,13.899224537037037
|
||||
10010867,22429197,39880770,Trauma SICU (TSICU),Trauma SICU (TSICU),2147-12-30 09:33:00,2148-01-08 18:14:21,9.3620486111111116
|
||||
10021487,28998349,38197705,Trauma SICU (TSICU),Trauma SICU (TSICU),2116-12-03 01:02:00,2116-12-18 17:34:03,15.688923611111109
|
||||
10003046,26048429,35514836,Trauma SICU (TSICU),Trauma SICU (TSICU),2154-01-02 15:57:15,2154-01-04 15:19:56,1.9740856481481481
|
||||
10017492,27417763,36035031,Trauma SICU (TSICU),Trauma SICU (TSICU),2116-06-27 17:35:34,2116-06-27 20:26:18,0.1185648148148148
|
||||
10017492,27417763,39543480,Trauma SICU (TSICU),Trauma SICU (TSICU),2116-06-26 20:35:09,2116-06-27 15:44:27,0.79812499999999986
|
||||
10018501,28479513,35446858,Trauma SICU (TSICU),Trauma SICU (TSICU),2141-07-31 00:01:00,2141-08-01 22:36:21,1.9412152777777776
|
||||
10008454,20291550,31959184,Trauma SICU (TSICU),Trauma SICU (TSICU),2110-11-30 17:11:36,2110-12-05 16:48:24,4.983888888888889
|
||||
10026354,24547356,36091287,Trauma SICU (TSICU),Trauma SICU (TSICU),2119-10-26 08:33:32,2119-10-27 17:50:50,1.387013888888889
|
||||
10029291,22205327,36059427,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2123-02-20 04:13:00,2123-02-26 12:03:56,6.3270370370370363
|
||||
10027445,29163082,36084484,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2142-08-30 13:20:56,2142-08-31 18:19:36,1.2074074074074075
|
||||
10010471,29842315,32119961,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2155-12-02 20:33:00,2155-12-07 18:19:18,4.9071527777777781
|
||||
10023117,21607814,30955999,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2175-07-06 17:41:00,2175-07-19 22:50:08,13.214675925925926
|
||||
10002495,24982426,36753294,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2141-05-22 20:18:01,2141-05-27 22:24:02,5.0875115740740737
|
||||
10031404,21606243,35544374,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2113-08-04 18:47:42,2113-08-05 23:45:02,1.2064814814814815
|
||||
10017492,27417763,36871784,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2116-07-05 06:50:16,2116-07-05 13:06:59,0.26160879629629624
|
||||
10026255,22059910,31248398,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2201-07-07 19:40:00,2201-07-08 15:43:15,0.83559027777777783
|
||||
10015931,28157142,39544395,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2176-11-19 14:24:52,2176-11-20 19:18:13,1.2037152777777778
|
||||
10038999,27189241,39711498,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2131-05-22 21:50:33,2131-05-31 17:55:04,8.8364699074074071
|
||||
10029291,22205327,35146796,Coronary Care Unit (CCU),Coronary Care Unit (CCU),2123-02-26 12:12:32,2123-03-04 23:36:14,6.4747916666666674
|
||||
10023117,21133938,38554095,Medical Intensive Care Unit (MICU),Coronary Care Unit (CCU),2175-03-21 03:20:53,2175-03-27 17:52:59,6.605625
|
||||
10023117,28872262,30057454,Cardiac Vascular Intensive Care Unit (CVICU),Coronary Care Unit (CCU),2171-11-14 10:06:41,2171-11-18 20:49:43,4.4465509259259264
|
||||
10014354,27487226,38017367,Neuro Surgical Intensive Care Unit (Neuro SICU),Coronary Care Unit (CCU),2148-07-07 15:48:09,2148-07-10 18:25:51,3.1095138888888889
|
||||
10003400,23559586,38383343,Coronary Care Unit (CCU),Medical Intensive Care Unit (MICU),2137-08-17 17:36:37,2137-09-02 19:17:11,16.069837962962961
|
||||
10004235,24181354,34100191,Coronary Care Unit (CCU),Medical Intensive Care Unit (MICU),2196-02-24 17:07:00,2196-02-29 15:58:02,4.9521064814814819
|
||||
10002930,25696644,37049133,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2196-04-14 13:40:00,2196-04-15 16:54:44,1.1352314814814815
|
||||
10002428,20321825,34807493,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2156-04-30 21:53:00,2156-05-02 22:27:20,2.0238425925925925
|
||||
10007928,20338077,35128235,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2129-04-06 00:25:00,2129-04-08 21:02:55,2.8596643518518516
|
||||
10020944,29974575,30757476,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2131-02-27 16:40:00,2131-03-08 18:30:38,9.0768287037037041
|
||||
10002428,28662225,38875437,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2156-04-19 18:11:19,2156-04-26 18:58:41,7.0328935185185193
|
||||
10026406,25260176,30864406,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2129-01-05 02:37:19,2129-01-05 14:11:03,0.48175925925925928
|
||||
10020740,23831430,35889503,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2150-03-25 18:22:56,2150-03-28 22:20:47,3.1651736111111113
|
||||
10036156,28019404,38587181,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2157-07-01 04:52:37,2157-07-02 14:18:55,1.3932638888888889
|
||||
10006053,22942076,34617352,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2111-11-14 00:19:12,2111-11-15 18:21:10,1.7513657407407408
|
||||
10006053,22942076,32895909,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2111-11-13 23:40:00,2111-11-14 00:14:10,0.02372685185185185
|
||||
10000032,29079034,39553978,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2180-07-23 14:00:00,2180-07-23 23:50:47,0.4102662037037037
|
||||
10019777,27738145,34578020,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2187-02-10 20:34:00,2187-02-17 23:38:57,7.1284375
|
||||
10021312,28829452,37507305,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2113-09-14 10:19:17,2113-09-15 12:43:00,1.0998032407407408
|
||||
10007818,22987108,32359580,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2146-06-22 11:46:29,2146-07-13 00:27:47,20.528680555555557
|
||||
10020740,23831430,31077365,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2150-03-30 07:57:10,2150-04-04 10:58:43,5.1260763888888894
|
||||
10021938,23112364,39492446,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2181-10-13 02:52:00,2181-10-14 18:03:28,1.6329629629629629
|
||||
10016742,28506150,32314488,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2178-07-13 08:16:00,2178-07-16 14:41:31,3.2677199074074075
|
||||
10019917,22585261,34324099,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2182-01-07 23:26:32,2182-01-09 07:09:31,1.3215162037037038
|
||||
10037975,27617929,39061571,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2185-01-17 19:12:12,2185-01-22 16:16:52,4.8782407407407407
|
||||
10002428,28662225,33987268,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2156-04-12 16:24:18,2156-04-17 15:57:08,4.9811342592592593
|
||||
10015931,24420677,38137964,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2176-12-22 16:24:33,2176-12-23 20:14:53,1.1599537037037038
|
||||
10021938,27154822,33083787,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2181-10-25 11:35:00,2181-10-26 20:53:57,1.3881597222222224
|
||||
10027445,26275841,34499716,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2142-07-31 01:41:00,2142-08-03 21:05:58,3.8090046296296296
|
||||
10015860,25085565,32496174,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2186-09-15 17:15:00,2186-09-16 11:17:40,0.75185185185185188
|
||||
10016742,27568122,30425410,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2178-07-22 08:19:00,2178-07-25 16:42:43,3.3498032407407403
|
||||
10029484,20764029,35396193,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2160-11-08 05:23:00,2160-11-08 21:04:55,0.65410879629629626
|
||||
10038081,20755971,38430513,Medical Intensive Care Unit (MICU),Medical Intensive Care Unit (MICU),2115-10-09 10:15:25,2115-10-13 03:01:17,3.6985185185185188
|
||||
10004720,22081550,35009126,Surgical Intensive Care Unit (SICU),Medical Intensive Care Unit (MICU),2186-11-12 19:55:00,2186-11-17 21:15:55,5.05619212962963
|
||||
10002428,23473524,35479615,Surgical Intensive Care Unit (SICU),Medical Intensive Care Unit (MICU),2156-05-11 14:49:34,2156-05-22 14:16:46,10.977222222222222
|
||||
10005866,20364112,34170353,Trauma SICU (TSICU),Surgical Intensive Care Unit (SICU),2149-10-02 12:48:08,2149-10-04 17:48:36,2.2086574074074075
|
||||
10026255,22059910,38229329,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2201-07-10 10:10:47,2201-07-11 13:48:02,1.1508680555555555
|
||||
10008287,22168393,33348260,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2145-09-28 20:59:43,2145-09-30 00:34:15,1.1489814814814816
|
||||
10001217,24597018,37067082,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2157-11-20 19:18:02,2157-11-21 22:08:00,1.1180324074074075
|
||||
10022041,28909879,30913302,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2187-05-18 18:39:00,2187-05-20 16:04:02,1.8923842592592592
|
||||
10016810,20973395,35436337,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2185-06-16 02:16:00,2185-06-18 14:00:02,2.488912037037037
|
||||
10024043,24717014,32374504,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2117-04-11 22:05:00,2117-04-14 14:36:11,2.6883217592592596
|
||||
10018845,21101111,36427705,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2184-10-08 04:09:00,2184-10-09 15:55:53,1.4908912037037034
|
||||
10014078,25809882,38907302,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2166-08-22 00:36:00,2166-08-24 13:12:44,2.5255092592592594
|
||||
10016150,29374560,33652203,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2142-05-10 16:39:15,2142-05-10 20:23:49,0.15594907407407407
|
||||
10021487,27660781,35065627,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2117-03-07 23:06:21,2117-03-09 18:01:57,1.7886111111111109
|
||||
10015860,24698912,36734659,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2192-05-12 09:31:00,2192-05-13 00:55:45,0.6421875
|
||||
10027602,21477991,32453351,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2201-12-11 20:11:52,2201-12-13 18:29:00,1.9285648148148147
|
||||
10039831,26924951,39142259,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2115-12-28 19:36:43,2115-12-30 16:31:48,1.8715856481481483
|
||||
10025463,24470193,38275267,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2137-10-09 02:51:25,2137-10-09 17:32:37,0.61194444444444451
|
||||
10039997,24294180,36893762,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2134-09-07 18:03:58,2134-09-08 22:11:05,1.1716087962962962
|
||||
10016742,29281842,37057036,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2178-07-03 22:45:00,2178-07-08 20:34:44,4.9095370370370377
|
||||
10039708,24928679,37323533,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2143-09-19 19:40:55,2143-09-20 16:29:51,0.86731481481481476
|
||||
10025612,23403708,32587226,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2125-09-25 13:23:24,2125-09-30 18:54:33,5.2299652777777776
|
||||
10031757,28477280,33244906,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2137-10-15 17:29:21,2137-10-17 22:16:51,2.1996527777777777
|
||||
10001217,27703517,34592300,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2157-12-19 15:42:24,2157-12-20 14:27:41,0.94811342592592585
|
||||
10040025,27996267,36107367,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2148-01-24 04:50:17,2148-01-30 17:45:09,6.5381018518518523
|
||||
10038933,25129047,32166508,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2148-09-10 13:19:00,2148-09-15 21:50:29,5.35519675925926
|
||||
10014354,22741225,37200209,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2146-10-09 01:08:00,2146-10-09 21:20:50,0.84224537037037039
|
||||
10021666,22756440,35475449,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2172-03-13 01:46:00,2172-03-13 16:33:34,0.6163657407407408
|
||||
10022880,27708593,39623478,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2177-03-15 05:49:26,2177-03-15 22:47:09,0.70674768518518516
|
||||
10027602,28166872,32391858,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2201-10-30 12:25:00,2201-11-12 18:37:10,13.258449074074074
|
||||
10031757,28477280,30458995,Surgical Intensive Care Unit (SICU),Surgical Intensive Care Unit (SICU),2137-10-12 22:44:57,2137-10-14 17:08:34,1.766400462962963
|
||||
10019172,24997044,32283063,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2118-11-16 09:38:22,2118-11-19 20:34:51,3.455891203703704
|
||||
10035185,22580999,39084876,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2120-05-13 14:27:34,2120-05-14 16:28:21,1.0838773148148149
|
||||
10013049,22675517,35679826,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2114-06-20 09:32:58,2114-06-21 13:47:44,1.1769212962962963
|
||||
10022017,22342963,39497668,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2189-09-10 10:05:24,2189-09-14 21:27:28,4.4736574074074076
|
||||
10009049,22995465,35636875,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2174-05-26 09:18:00,2174-05-27 14:31:12,1.2175
|
||||
10004457,23251352,31494479,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2141-12-17 10:24:25,2141-12-18 14:16:17,1.1610185185185184
|
||||
10038992,24745425,37127068,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2187-07-29 09:41:11,2187-07-30 13:42:51,1.1678240740740742
|
||||
10004422,21255400,32155744,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2111-01-17 09:44:50,2111-01-23 18:18:46,6.3568981481481472
|
||||
10007058,22954658,32506122,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2167-11-07 20:22:00,2167-11-09 20:55:04,2.0229629629629629
|
||||
10006580,24159665,38329661,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2137-08-10 16:23:10,2137-08-11 13:56:56,0.89844907407407415
|
||||
10005909,20199380,36496303,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2144-10-29 23:09:03,2144-11-02 15:24:29,3.6773842592592594
|
||||
10011398,27505812,37648963,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2146-12-15 09:54:58,2146-12-16 10:53:06,1.0403703703703704
|
||||
10018423,29366372,30665396,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2167-05-05 12:54:06,2167-05-06 18:40:10,1.240324074074074
|
||||
10009035,28324362,38507547,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2161-04-27 10:38:12,2161-04-28 15:06:17,1.1861689814814815
|
||||
10023771,20044587,33177122,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2113-08-25 09:32:41,2113-08-27 16:27:53,2.2883333333333331
|
||||
10022281,29642388,30585761,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2125-06-17 04:12:54,2125-06-18 14:55:55,1.4465393518518521
|
||||
10005348,25239799,34629895,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2130-10-27 12:06:00,2130-10-29 12:05:02,1.9993287037037037
|
||||
10012552,27089790,33383124,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2140-03-25 14:37:26,2140-03-28 18:25:54,3.1586574074074072
|
||||
10019385,20297618,39268883,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2180-02-21 08:34:06,2180-02-22 16:05:14,1.3132870370370371
|
||||
10014729,28889419,33558396,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2125-02-27 10:03:08,2125-03-01 21:21:37,2.4711689814814815
|
||||
10009628,25926192,35258379,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2153-09-19 09:54:49,2153-09-21 16:39:06,2.2807523148148148
|
||||
10005817,20626031,32604416,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2132-12-15 09:29:01,2132-12-17 18:06:07,2.3590972222222222
|
||||
10021118,24490144,36558922,Cardiac Vascular Intensive Care Unit (CVICU),Cardiac Vascular Intensive Care Unit (CVICU),2161-11-19 10:04:04,2161-11-20 21:45:42,1.4872453703703703
|
||||
10037861,24540843,34531557,Neuro Surgical Intensive Care Unit (Neuro SICU),Neuro Surgical Intensive Care Unit (Neuro SICU),2117-03-14 16:34:58,2117-03-25 02:35:08,10.416782407407407
|
||||
10005817,28661809,31316840,Medical Intensive Care Unit (MICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2135-01-03 21:55:32,2135-01-19 21:16:23,15.972812499999998
|
||||
10015272,27993466,37267577,Cardiac Vascular Intensive Care Unit (CVICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2137-06-12 18:37:22,2137-06-14 20:25:41,2.0752199074074071
|
||||
10019003,29279905,34107647,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2153-03-28 02:21:00,2153-03-31 16:59:04,3.6097685185185182
|
||||
10037928,22490490,31552399,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2177-07-14 20:38:00,2177-07-15 16:08:36,0.81291666666666662
|
||||
10039708,28258130,33281088,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2140-01-23 18:08:00,2140-02-08 22:28:20,16.180787037037035
|
||||
10002930,25922998,35629889,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2198-04-17 21:24:00,2198-04-18 13:41:43,0.67896990740740737
|
||||
10014354,27487226,34600477,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2148-06-30 02:27:00,2148-07-01 20:58:50,1.7721064814814815
|
||||
10019003,21457723,35727289,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2155-07-10 17:48:57,2155-07-13 15:04:42,2.8859375000000003
|
||||
10014354,29600294,39864867,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2148-08-16 08:57:26,2148-08-17 14:45:17,1.2415625
|
||||
10004733,27411876,39635619,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2174-12-04 11:28:24,2174-12-12 20:03:01,8.3573726851851848
|
||||
10007795,28477357,31921355,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2136-04-22 18:01:13,2136-04-23 19:13:58,1.0505208333333333
|
||||
10020640,27984218,30849778,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2153-02-13 01:38:00,2153-02-15 15:00:57,2.5576041666666667
|
||||
10020740,23831430,35044342,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2150-03-19 04:41:33,2150-03-23 20:49:33,4.6722222222222225
|
||||
10023239,29295881,33846653,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2137-06-19 19:09:00,2137-06-22 14:57:32,2.8253703703703703
|
||||
10023239,21759936,35024147,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2140-10-03 09:07:56,2140-10-05 19:31:27,2.4329976851851853
|
||||
10001725,25563031,31205490,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2110-04-11 15:52:22,2110-04-12 23:59:56,1.3385879629629629
|
||||
10003400,23559586,34577403,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2137-08-10 19:54:51,2137-08-13 17:54:54,2.9167013888888889
|
||||
10019003,27525946,35214014,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2153-04-13 19:45:30,2153-04-16 21:15:15,3.0623263888888892
|
||||
10035631,29276678,30932571,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2116-02-28 18:43:20,2116-03-10 06:35:04,10.494259259259259
|
||||
10003400,20214994,32128372,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2137-02-25 23:37:19,2137-03-10 21:29:36,12.91130787037037
|
||||
10020786,23488445,33683112,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2189-06-09 12:46:30,2189-06-10 22:58:09,1.4247569444444446
|
||||
10020740,23831430,35026312,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2150-03-11 15:34:56,2150-03-19 02:17:47,7.4464236111111113
|
||||
10032725,20611640,30101877,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2143-03-22 06:42:00,2143-03-25 15:05:33,3.3496875
|
||||
10037928,24656677,39804682,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2178-12-21 06:05:18,2178-12-22 02:16:08,0.8408564814814814
|
||||
10015931,22130791,37093652,Medical/Surgical Intensive Care Unit (MICU/SICU),Medical/Surgical Intensive Care Unit (MICU/SICU),2177-03-24 21:48:07,2177-03-29 18:03:36,4.8440856481481482
|
||||
|
+107728
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
subject_id,gender,anchor_age,anchor_year,anchor_year_group,dod
|
||||
10014729,F,21,2125,2011 - 2013,
|
||||
10003400,F,72,2134,2011 - 2013,2137-09-02
|
||||
10002428,F,80,2155,2011 - 2013,
|
||||
10032725,F,38,2143,2011 - 2013,2143-03-30
|
||||
10027445,F,48,2142,2011 - 2013,2146-02-09
|
||||
10037928,F,78,2175,2011 - 2013,
|
||||
10001725,F,46,2110,2011 - 2013,
|
||||
10040025,F,64,2143,2011 - 2013,2148-02-07
|
||||
10008454,F,26,2110,2011 - 2013,
|
||||
10020640,F,91,2153,2011 - 2013,2154-02-04
|
||||
10014078,F,60,2166,2011 - 2013,
|
||||
10007795,F,53,2136,2011 - 2013,
|
||||
10001217,F,55,2157,2011 - 2013,
|
||||
10019172,F,63,2118,2011 - 2013,
|
||||
10039831,F,57,2115,2011 - 2013,
|
||||
10007928,F,59,2129,2011 - 2013,
|
||||
10019003,F,65,2148,2011 - 2013,2155-12-03
|
||||
10002930,F,48,2193,2011 - 2013,2201-12-24
|
||||
10010471,F,89,2155,2014 - 2016,2155-12-07
|
||||
10027602,F,71,2201,2014 - 2016,
|
||||
10031757,F,67,2137,2014 - 2016,2137-10-31
|
||||
10016742,F,58,2178,2014 - 2016,
|
||||
10039997,F,67,2134,2014 - 2016,
|
||||
10012853,F,91,2175,2014 - 2016,
|
||||
10000032,F,52,2180,2014 - 2016,2180-09-09
|
||||
10031404,F,82,2113,2014 - 2016,
|
||||
10029291,F,50,2123,2014 - 2016,
|
||||
10005909,F,40,2144,2014 - 2016,
|
||||
10039708,F,46,2138,2014 - 2016,
|
||||
10019568,F,59,2120,2014 - 2016,
|
||||
10020187,F,63,2169,2014 - 2016,
|
||||
10016810,F,66,2185,2014 - 2016,
|
||||
10038081,F,63,2115,2014 - 2016,2115-10-12
|
||||
10021312,F,55,2113,2014 - 2016,
|
||||
10006580,F,63,2137,2014 - 2016,
|
||||
10010867,F,28,2147,2014 - 2016,
|
||||
10020786,F,86,2189,2014 - 2016,
|
||||
10020306,F,74,2129,2014 - 2016,
|
||||
10023239,F,29,2137,2014 - 2016,
|
||||
10018328,F,83,2154,2014 - 2016,
|
||||
10008287,F,43,2145,2014 - 2016,
|
||||
10036156,F,88,2157,2014 - 2016,
|
||||
10015272,F,78,2137,2014 - 2016,
|
||||
10022281,M,84,2125,2011 - 2013,
|
||||
10035631,M,63,2112,2011 - 2013,2116-03-12
|
||||
10024043,M,67,2117,2011 - 2013,2117-06-26
|
||||
10025612,M,82,2125,2011 - 2013,
|
||||
10003046,M,64,2154,2011 - 2013,
|
||||
10021666,M,87,2172,2011 - 2013,2172-04-19
|
||||
10018423,M,37,2162,2011 - 2013,
|
||||
10025463,M,66,2136,2011 - 2013,2137-10-09
|
||||
10017492,M,84,2114,2011 - 2013,2116-07-05
|
||||
10004422,M,78,2111,2011 - 2013,
|
||||
10011398,M,67,2146,2011 - 2013,
|
||||
10021938,M,65,2181,2011 - 2013,2182-10-16
|
||||
10009628,M,58,2153,2011 - 2013,
|
||||
10004457,M,65,2140,2011 - 2013,
|
||||
10015860,M,53,2186,2011 - 2013,
|
||||
10021487,M,43,2116,2011 - 2013,
|
||||
10013049,M,52,2114,2011 - 2013,
|
||||
10026255,M,66,2200,2011 - 2013,2201-07-13
|
||||
10023771,M,70,2113,2011 - 2013,
|
||||
10023117,M,53,2170,2011 - 2013,2175-07-20
|
||||
10022041,M,64,2187,2011 - 2013,
|
||||
10005348,M,76,2128,2011 - 2013,
|
||||
10009035,M,28,2161,2011 - 2013,
|
||||
10018081,M,79,2133,2011 - 2013,2134-10-28
|
||||
10038933,M,34,2148,2011 - 2013,
|
||||
10037975,M,60,2185,2014 - 2016,2185-01-22
|
||||
10035185,M,70,2120,2014 - 2016,
|
||||
10014354,M,60,2146,2014 - 2016,
|
||||
10022880,M,66,2177,2014 - 2016,
|
||||
10004720,M,61,2183,2014 - 2016,2186-11-17
|
||||
10019385,M,44,2180,2014 - 2016,
|
||||
10029484,M,64,2160,2014 - 2016,
|
||||
10037861,M,77,2115,2014 - 2016,2117-03-24
|
||||
10026406,M,45,2129,2014 - 2016,
|
||||
10007818,M,69,2146,2014 - 2016,2146-07-12
|
||||
10020740,M,56,2150,2014 - 2016,
|
||||
10038999,M,45,2131,2014 - 2016,
|
||||
10004235,M,47,2196,2014 - 2016,
|
||||
10022017,M,59,2189,2014 - 2016,
|
||||
10002495,M,81,2141,2014 - 2016,
|
||||
10018845,M,91,2184,2014 - 2016,2184-11-22
|
||||
10020944,M,72,2131,2014 - 2016,2131-04-28
|
||||
10016150,M,69,2142,2014 - 2016,
|
||||
10005866,M,57,2146,2014 - 2016,2149-11-21
|
||||
10026354,M,34,2119,2014 - 2016,
|
||||
10019917,M,44,2182,2014 - 2016,
|
||||
10019777,M,51,2187,2014 - 2016,2187-04-29
|
||||
10005817,M,66,2132,2014 - 2016,2135-01-19
|
||||
10009049,M,56,2174,2014 - 2016,
|
||||
10015931,M,87,2176,2014 - 2016,2177-03-29
|
||||
10006053,M,52,2111,2014 - 2016,2111-11-15
|
||||
10012552,M,78,2140,2014 - 2016,
|
||||
10004733,M,51,2174,2014 - 2016,
|
||||
10021118,M,62,2161,2014 - 2016,
|
||||
10018501,M,83,2141,2014 - 2016,
|
||||
10007058,M,48,2167,2014 - 2016,
|
||||
10038992,M,70,2185,2014 - 2016,
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -243,27 +243,55 @@ Grafana dashboard JSON template for alert quality overview.
|
||||
| Clinical Usage | 10 |
|
||||
| Composite | 9.0 |
|
||||
|
||||
> **Status:** Complete — see [phase-34-plan.md](plans/phase-34-plan.md) for full implementation detail, tests, and file inventory.
|
||||
|
||||
Architecture: Each alert carries a structured explanation payload alongside the existing `Details` string. A new `AlertExplanation` value object captures score contributors, trend context, and medication context at alert creation time. Scoring consumers (NEWS2, SOFA, GCS) emit contributor breakdowns. The `TrendDetector` attaches trend summaries. `MedicationCorrelationHelper` attaches recent medication context. All explanation data is serialized as JSONB on the `ClinicalAlert` row so the explanation is immutable — it reflects the state at alert time, not query time.
|
||||
|
||||
> **Prerequisite:** Phase 33 for feedback loop. No hard technical dependency but sequencing allows feedback data to inform which explanations clinicians value.
|
||||
|
||||
### What exists
|
||||
|
||||
After Phase 33:
|
||||
After Phase 34 (implemented):
|
||||
|
||||
- `ClinicalAlert.Details` is a free-text string, sometimes containing score values
|
||||
- `TrendDetector` returns `TrendOutcome` enum but no narrative description
|
||||
- `MedicationCorrelationHelper.TryAnnotateDetailsAsync()` appends drug info to the details string
|
||||
- NEWS2, SOFA, GCS scoring returns aggregate scores but not per-component breakdowns to the alert layer
|
||||
- No structured explanation model
|
||||
- `AlertExplanation` value object with `ScoreContributor`, `TrendContext`, `MedicationContext`, and `NarrativeSummary`
|
||||
- `ClinicalAlert.Explanation` — nullable JSONB column, immutable at alert creation
|
||||
- NEWS2, SOFA, GCS, and Trend detectors assemble explanation at alert creation; `alert.generated` outbox/Kafka payloads include `explanation`
|
||||
- `MedicationCorrelationHelper.TryGetContextAsync()` — structured medication context (replaces string-append to `Details`)
|
||||
- Scoring results carry contributors: `News2Result`, `SofaScoringResult`, `GcsResult`, `TrendResult`
|
||||
- `AlertResponse` DTO with `Explanation`; GET/list/acknowledge/resolve endpoints return it
|
||||
- Dashboard `AlertReasoning.vue` and alert surfaces consume structured explanation; legacy alerts fall back to `Details`
|
||||
- Simulator `AlertResponse.Explanation` + `ExpectedOutcomeValidator` with `narrativeContains` on key scenarios
|
||||
- Elasticsearch indexes `NarrativeSummary`; data lake Parquet includes `explanation_json`; ward gateway sync forwards explanation
|
||||
- `ExplainableAlertsTests` (10 tests) + `scripts/run-phase34-verification.sh`
|
||||
- `Details` string unchanged for backward compatibility with threshold-only and legacy alerts
|
||||
|
||||
### What needs to be built
|
||||
|
||||
Six steps, in order.
|
||||
Nothing — Phase 34 is complete. **Next:** [Phase 35 — Alert Lifecycle Analytics](#phase-35--alert-lifecycle-analytics).
|
||||
|
||||
### Verification Checklist (Phase 34)
|
||||
|
||||
- [x] NEWS2 alert includes per-component score contributors in explanation
|
||||
- [x] SOFA alert includes per-organ-system contributors
|
||||
- [x] GCS alert includes Eye/Verbal/Motor breakdown
|
||||
- [x] Trend-triggered alerts include trend context with percent change and duration
|
||||
- [x] Medication-correlated alerts include drug context
|
||||
- [x] Narrative summary is human-readable and accurate
|
||||
- [x] Existing alerts with null explanation still serialize correctly
|
||||
- [x] Alert GET endpoints return explanation object
|
||||
- [x] No change to scoring algorithm outputs (same scores, same thresholds)
|
||||
- [x] Migration applies cleanly on existing data
|
||||
- [x] Dashboard and simulator consume explanation
|
||||
- [x] Downstream consumers (ES, data lake, gateway sync) include explanation fields
|
||||
|
||||
---
|
||||
|
||||
#### Step 1 — Explanation Value Objects
|
||||
#### Implementation reference (Steps 1–7)
|
||||
|
||||
The step-by-step design below is retained for interview prep and onboarding. All steps are implemented; see [phase-34-plan.md](plans/phase-34-plan.md) for code paths and tests.
|
||||
|
||||
<details>
|
||||
<summary>Original step-by-step design (Steps 1–6)</summary>
|
||||
|
||||
**`Domains/ValueObjects/AlertExplanation.cs`** (NEW):
|
||||
|
||||
@@ -380,20 +408,7 @@ Extend alert GET endpoints to include the `Explanation` object. The explanation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Verification Checklist (Phase 34)
|
||||
|
||||
- [ ] NEWS2 alert includes per-component score contributors in explanation
|
||||
- [ ] SOFA alert includes per-organ-system contributors
|
||||
- [ ] GCS alert includes Eye/Verbal/Motor breakdown
|
||||
- [ ] Trend-triggered alerts include trend context with percent change and duration
|
||||
- [ ] Medication-correlated alerts include drug context
|
||||
- [ ] Narrative summary is human-readable and accurate
|
||||
- [ ] Existing alerts with null explanation still serialize correctly
|
||||
- [ ] Alert GET endpoints return explanation object
|
||||
- [ ] No change to scoring algorithm outputs (same scores, same thresholds)
|
||||
- [ ] Migration applies cleanly on existing data
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -415,6 +430,8 @@ After Phase 34:
|
||||
|
||||
- `ClinicalAlert` has `Status` transitions: Open → Acknowledged → Resolved, Open → Escalated → Acknowledged → Resolved
|
||||
- `AcknowledgedAt`, `ResolvedAt` timestamps exist on the entity
|
||||
- `ClinicalAlert.Explanation` JSONB with immutable score contributors, trend, and medication context at alert time
|
||||
- `AlertResponse` exposes explanation to dashboard and simulator; `Details` retained for legacy consumers
|
||||
- `ClinicalAuditLog` captures some transitions but is not structured for time-series analytics
|
||||
- No dedicated lifecycle event log
|
||||
- No computed lifecycle metrics (median ack time, escalation rate)
|
||||
@@ -673,7 +690,7 @@ Authorized for `Admin` role only. PUT upserts a rule and invalidates the Redis c
|
||||
|
||||
Architecture: A correlation engine groups alerts that fire within a configurable time window for the same patient into a single clinical narrative. Correlated alerts are linked by a shared `CorrelationGroupId`. The first alert in a group becomes the primary; subsequent alerts within the window attach as secondary. The group carries a composite explanation built from Phase 34 individual explanations. Clinicians see one bundled notification with the full picture instead of multiple independent alerts.
|
||||
|
||||
> **Prerequisite:** Phase 34 (Explainable Alerts) for structured explanations to compose into bundles.
|
||||
> **Prerequisite:** Phase 34 (Explainable Alerts) **complete** — structured explanations compose into bundle narratives.
|
||||
|
||||
### What exists
|
||||
|
||||
@@ -681,9 +698,9 @@ After Phase 36:
|
||||
|
||||
- Alerts fire independently per scoring consumer and trend detector
|
||||
- No correlation between simultaneous alerts for the same patient
|
||||
- Phase 34 `AlertExplanation` provides structured per-alert context
|
||||
- Phase 34 `AlertExplanation` **shipped** — per-alert `NarrativeSummary`, score contributors, trend, and medication context available on `AlertResponse`
|
||||
- `AlertSuppressionService` prevents duplicate alert types but not cross-type bundling
|
||||
- Medication correlation annotates individual alerts but does not group them
|
||||
- Medication correlation attaches structured context to individual alerts but does not group them
|
||||
|
||||
### What needs to be built
|
||||
|
||||
@@ -953,7 +970,7 @@ After Phase 38:
|
||||
- qSOFA scoring implementation in dedicated service
|
||||
- GCS scoring implementation in dedicated service
|
||||
- Each has different input shapes, output shapes, and integration points
|
||||
- Phase 34 added contributor extraction but each scoring system returns it differently
|
||||
- Phase 34 added contributor extraction but each scoring system returns it differently (**implemented** — unify via `ScoreResult.Contributors` in Phase 39)
|
||||
|
||||
### What needs to be built
|
||||
|
||||
|
||||
@@ -565,26 +565,9 @@ Any compromised container on the Docker network can read/write/delete clinical d
|
||||
|
||||
---
|
||||
|
||||
## P3 — No token refresh or revocation mechanism
|
||||
## ~~P3 — No token refresh or revocation mechanism~~ DONE
|
||||
|
||||
### Problem
|
||||
|
||||
JWT tokens are issued with a configurable expiration but there is no refresh token flow and no token revocation/blacklist. A compromised token remains valid until natural expiration. There is no `POST /auth/refresh` or `POST /auth/revoke` endpoint.
|
||||
|
||||
### Why fix
|
||||
|
||||
Clinical sessions may last entire shifts (8-12 hours). Short token lifetimes require frequent re-authentication, disrupting clinical workflows. Long lifetimes without revocation mean a stolen token grants extended access. Compromised accounts cannot be locked out until the token expires.
|
||||
|
||||
### How to fix
|
||||
|
||||
1. Add refresh token support: issue a long-lived opaque refresh token stored in the database alongside the access token.
|
||||
2. Add `POST /api/v1/auth/refresh` — validate refresh token, issue new access token.
|
||||
3. Add `POST /api/v1/auth/revoke` — invalidate refresh token and optionally blacklist the access token (via Redis TTL set matching remaining token lifetime).
|
||||
4. Add `LastLoginAt` update on token refresh (already exists on `ClinicalUser`).
|
||||
|
||||
**Files:** `AuthController.cs`, `AuthService.cs`, `ClinicalUser.cs` (add `RefreshToken`, `RefreshTokenExpiresAt`), migration.
|
||||
|
||||
**Dependency:** None.
|
||||
Implemented: `RefreshToken` entity with DB-backed storage, `POST /api/v1/auth/refresh` (rotate refresh token + issue new access token), `POST /api/v1/auth/logout` (revoke refresh token server-side). Access token reduced to 15 min, refresh token 7 days. Frontend auto-refreshes before expiry, retries on 401, and redirects to login on refresh failure. Logout button in header, sidebar, and mobile nav. Audit logged as `USER_LOGOUT` and `TOKEN_REFRESHED`.
|
||||
|
||||
---
|
||||
|
||||
@@ -743,7 +726,7 @@ Different hospitals and clinical settings have different protocols. CMS Sepsis S
|
||||
| 19 | JWT key not validated on startup | P3 | D | Open |
|
||||
| 20 | No authorization failure audit | P3 | D | Open |
|
||||
| 21 | Elasticsearch security disabled | P3 | D | Open |
|
||||
| 22 | No token refresh/revocation | P3 | D | Open |
|
||||
| 22 | ~~No token refresh/revocation~~ | P3 | D | **Done** |
|
||||
| 23 | No request timing metrics | P5 | E | Open |
|
||||
| 24 | Background service error metrics | P5 | E | Open |
|
||||
| 25 | Thin concurrent/resilience tests | P5 | E | Open |
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,295 @@
|
||||
# VigilCare Dashboard — Charts & Visualizations Guide
|
||||
|
||||
This document describes every chart in the VigilCare Clinical dashboard: what it shows, how to read it, and what the colors and thresholds mean.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Vital Signs Trends (5 Charts)](#1-vital-signs-trends)
|
||||
2. [NEWS2 History](#2-news2-history-chart)
|
||||
3. [GCS History](#3-gcs-history-chart)
|
||||
4. [qSOFA Screen History](#4-qsofa-screen-history-chart)
|
||||
5. [SOFA History](#5-sofa-history-chart)
|
||||
6. [Alert Quality](#6-alert-quality-chart)
|
||||
7. [Department Acuity Bar](#7-department-acuity-bar)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vital Signs Trends
|
||||
|
||||
**Location:** Patient Detail view — rendered as a 2×3 grid of line charts
|
||||
**Component:** `TrendsGrid.vue` → `VitalTrendChart.vue` → `VitalChart.vue`
|
||||
|
||||
### Overview
|
||||
|
||||
Five individual line charts, one per vital sign, showing how each measurement has changed over time. Each chart plots recorded observations on the X-axis (time) and the vital value on the Y-axis.
|
||||
|
||||
### Individual Charts
|
||||
|
||||
| Vital Sign | Y-Axis Range | Line Color | Unit |
|
||||
|---|---|---|---|
|
||||
| Heart Rate | 30 – 180 | Red (#ef4444) | bpm |
|
||||
| Respiratory Rate | 0 – 40 | Blue (#3b82f6) | breaths/min |
|
||||
| Systolic Blood Pressure | 60 – 250 | Purple (#8b5cf6) | mmHg |
|
||||
| SpO₂ (Oxygen Saturation) | 80 – 100 | Cyan (#06b6d4) | % |
|
||||
| Temperature | 34 – 42 | Amber (#f59e0b) | °C |
|
||||
|
||||
### How to Read
|
||||
|
||||
- **Each dot** on the line represents a single recorded observation at a specific time.
|
||||
- **The line** connecting the dots shows the trend direction — rising, falling, or stable.
|
||||
- **Y-axis** is fixed to a clinically relevant range so that values near the edges indicate an abnormal reading.
|
||||
- **X-axis** labels show timestamps of when each observation was recorded.
|
||||
|
||||
### Medication Markers
|
||||
|
||||
Dashed purple vertical lines may appear on any vital chart. These mark the time a medication was administered.
|
||||
|
||||
- **Hover over** a purple line to see the drug name, dose, route, and timestamp.
|
||||
- Medication markers help correlate treatment events with changes in vital signs (e.g., did a dose of antihypertensive cause blood pressure to drop?).
|
||||
- The tooltip activates within a 15-minute window of the marker.
|
||||
|
||||
---
|
||||
|
||||
## 2. NEWS2 History Chart
|
||||
|
||||
**Location:** Patient Detail view
|
||||
**Component:** `News2History.vue`
|
||||
|
||||
### What It Shows
|
||||
|
||||
The **National Early Warning Score 2 (NEWS2)** over time. NEWS2 is an aggregate score (0–20) calculated from six physiological parameters: respiratory rate, oxygen saturation, systolic blood pressure, pulse rate, consciousness level, and temperature. A higher score indicates greater clinical deterioration.
|
||||
|
||||
### How to Read
|
||||
|
||||
- **Blue line** traces the NEWS2 score at each assessment point.
|
||||
- **Y-axis:** 0 to 20 (total NEWS2 range).
|
||||
- **X-axis:** Time of each assessment.
|
||||
- **Each dot** (radius 4px) is one assessment — hover for the exact score.
|
||||
|
||||
### Background Color Bands (Severity Zones)
|
||||
|
||||
The chart background is color-filled to show the severity of the score:
|
||||
|
||||
| Background Color | Score Range | Meaning |
|
||||
|---|---|---|
|
||||
| **Green** | 0 – 4 | **Low risk** — routine monitoring is appropriate |
|
||||
| **Amber** | 5 – 6 | **Medium risk** — increased frequency of monitoring; urgent clinical review needed |
|
||||
| **Red** | 7 – 20 | **High risk** — emergency assessment required; continuous monitoring |
|
||||
|
||||
**Key insight:** If the line enters the amber or red zone, the patient's condition is deteriorating and requires escalated care. A line that stays in or returns to the green zone suggests clinical stability or improvement.
|
||||
|
||||
---
|
||||
|
||||
## 3. GCS History Chart
|
||||
|
||||
**Location:** Patient Detail view
|
||||
**Component:** `GcsHistory.vue`
|
||||
|
||||
### What It Shows
|
||||
|
||||
The **Glasgow Coma Scale (GCS)** over time. GCS measures a patient's level of consciousness across three components: Eye opening (1–4), Verbal response (1–5), and Motor response (1–6). The total ranges from 3 (deep unresponsiveness) to 15 (fully alert).
|
||||
|
||||
### How to Read
|
||||
|
||||
This chart displays **four lines**:
|
||||
|
||||
| Line | Color | Style | Range |
|
||||
|---|---|---|---|
|
||||
| **GCS Total** | Dark accent | Solid, thick (2px) | 3 – 15 |
|
||||
| Eye component | Blue (#3b82f6) | Dashed | 1 – 4 |
|
||||
| Verbal component | Purple (#8b5cf6) | Dashed | 1 – 5 |
|
||||
| Motor component | Cyan (#06b6d4) | Dashed | 1 – 6 |
|
||||
|
||||
- **Focus on the solid total line** for an overall consciousness assessment.
|
||||
- **Use the dashed component lines** to identify which specific domain is changing (e.g., verbal response dropping while motor stays stable may indicate different clinical concerns than all three declining together).
|
||||
|
||||
### Point Colors on the Total Line (Severity)
|
||||
|
||||
The dots on the GCS total line change color to indicate severity:
|
||||
|
||||
| Point & Fill Color | Score Range | Severity |
|
||||
|---|---|---|
|
||||
| **Green** (#22c55e) | 13 – 15 | **Mild** — patient is alert and oriented |
|
||||
| **Amber** (#f59e0b) | 9 – 12 | **Moderate** — impaired consciousness |
|
||||
| **Red** (#dc2626) | 3 – 8 | **Severe** — patient may need intubation/airway protection |
|
||||
|
||||
**Key insight:** A downward trend (especially into the red zone) signals worsening neurological status. The tooltip footer displays the severity label for each point.
|
||||
|
||||
---
|
||||
|
||||
## 4. qSOFA Screen History Chart
|
||||
|
||||
**Location:** Patient Detail view
|
||||
**Component:** `QsofaHistory.vue`
|
||||
|
||||
### What It Shows
|
||||
|
||||
The **quick Sequential Organ Failure Assessment (qSOFA)** screen results over time. qSOFA is a bedside screening tool for sepsis risk using three criteria:
|
||||
|
||||
1. **Respiratory rate ≥ 22 breaths/min**
|
||||
2. **Systolic blood pressure ≤ 100 mmHg**
|
||||
3. **Altered mentation** (GCS < 15)
|
||||
|
||||
Each criterion is scored as either present (1) or absent (0), giving a total of 0–3 active criteria.
|
||||
|
||||
### How to Read
|
||||
|
||||
This is a **dual-axis chart**:
|
||||
|
||||
**Left Y-axis — Active Criteria Count (0–3):**
|
||||
- The solid stepped line shows how many qSOFA criteria are met at each assessment.
|
||||
- Points are color-coded by risk level.
|
||||
|
||||
**Right Y-axis — Individual Criteria (normalized 0–1):**
|
||||
- Three dashed lines show whether each individual criterion is present (1) or absent (0):
|
||||
- **Respiratory rate:** Blue (#3b82f6) dashed
|
||||
- **Systolic BP:** Purple (#8b5cf6) dashed
|
||||
- **Altered mentation:** Pink (#ec4899) dashed
|
||||
|
||||
### Point Colors & Shapes
|
||||
|
||||
| Color | Count | Meaning |
|
||||
|---|---|---|
|
||||
| **Green** (#22c55e) | 0 criteria | **Screen negative** — low sepsis risk |
|
||||
| **Amber** (#f59e0b) | 1 criterion | **Screen negative** — one criterion present, monitor closely |
|
||||
| **Red** (#dc2626) | ≥ 2 criteria | **Screen positive** — suspected sepsis; escalate care |
|
||||
|
||||
- **Circle points:** Standard assessment.
|
||||
- **Star points (larger, radius 7):** An alert was fired at this assessment time.
|
||||
|
||||
**Key insight:** A score of ≥ 2 is the critical threshold — this is a **positive qSOFA screen** suggesting possible sepsis. The tooltip shows whether the screen was positive/negative and whether an alert fired.
|
||||
|
||||
---
|
||||
|
||||
## 5. SOFA History Chart
|
||||
|
||||
**Location:** Patient Detail view
|
||||
**Component:** `SofaHistory.vue`
|
||||
|
||||
### What It Shows
|
||||
|
||||
The **Sequential Organ Failure Assessment (SOFA)** score over time. SOFA provides a more detailed assessment of organ dysfunction than qSOFA, scoring six organ systems on a 0–4 scale each, for a total range of 0–24.
|
||||
|
||||
### How to Read
|
||||
|
||||
This chart has **two visual layers**:
|
||||
|
||||
#### Layer 1: Stacked Area (Organ System Contributions)
|
||||
|
||||
Six semi-transparent colored areas stacked on top of each other, showing how much each organ system contributes to the total SOFA score:
|
||||
|
||||
| Organ System | Color |
|
||||
|---|---|
|
||||
| Respiratory | Blue (#3b82f6) |
|
||||
| Coagulation | Purple (#8b5cf6) |
|
||||
| Liver | Amber (#f59e0b) |
|
||||
| Cardiovascular | Red (#ef4444) |
|
||||
| CNS (Central Nervous System) | Pink (#ec4899) |
|
||||
| Renal | Cyan (#06b6d4) |
|
||||
|
||||
The **height of each colored band** represents that organ system's individual score (0–4). The **total stack height** equals the total SOFA score.
|
||||
|
||||
#### Layer 2: Total SOFA Line (Overlaid)
|
||||
|
||||
A solid line with color-coded dots sits on top of the stacked areas:
|
||||
|
||||
| Point Color | Score Range | Risk Level |
|
||||
|---|---|---|
|
||||
| **Green** (#22c55e) | 0 – 5 | **Low** — minimal organ dysfunction |
|
||||
| **Amber** (#f59e0b) | 6 – 9 | **Moderate** — significant organ dysfunction |
|
||||
| **Red** (#dc2626) | ≥ 10 | **High** — severe organ dysfunction; high mortality risk |
|
||||
|
||||
**Key insight:** This chart answers two questions at once: (1) How severe is overall organ dysfunction? (read the total line) and (2) Which organ systems are driving the score? (read the colored stacked bands). For example, if the respiratory (blue) band is growing while others stay flat, the lungs are the primary concern.
|
||||
|
||||
---
|
||||
|
||||
## 6. Alert Quality Chart
|
||||
|
||||
**Location:** Alert Quality Analytics view
|
||||
**Component:** `AlertQualityChart.vue`
|
||||
|
||||
### What It Shows
|
||||
|
||||
Quality and performance metrics for the clinical alerting system over time. This chart evaluates how useful and accurate the alerts are. It can display one of four metrics at a time (controlled by the view):
|
||||
|
||||
| Metric | What It Measures |
|
||||
|---|---|
|
||||
| **Useful Rate %** | Percentage of alerts that clinicians rated as clinically useful |
|
||||
| **False Positive Rate %** | Percentage of alerts that turned out to be false alarms |
|
||||
| **Acknowledgement Rate %** | Percentage of alerts that were acknowledged by staff |
|
||||
| **Would Act Rate %** | Percentage of alerts where a clinician said they would take clinical action |
|
||||
|
||||
### How to Read
|
||||
|
||||
- **Blue line** traces the metric value over time.
|
||||
- **Light blue fill** below the line provides visual weight.
|
||||
- **Y-axis:** 0% to 100%.
|
||||
- **X-axis:** Alert types or time windows (sorted by alert window start time).
|
||||
- **Each dot** is one data point — hover for the exact percentage.
|
||||
|
||||
**Key insight:**
|
||||
- **Useful Rate / Would Act Rate:** Higher is better — clinicians find the alerts actionable.
|
||||
- **False Positive Rate:** Lower is better — high values indicate alert fatigue risk.
|
||||
- **Acknowledgement Rate:** Higher is better — low values may indicate staff are ignoring alerts.
|
||||
|
||||
This chart is used by system administrators and quality improvement teams to tune alerting thresholds and reduce alert fatigue.
|
||||
|
||||
---
|
||||
|
||||
## 7. Department Acuity Bar
|
||||
|
||||
**Location:** Department Overview view (inside each Department Card)
|
||||
**Component:** `AcuityBar.vue`
|
||||
|
||||
### What It Shows
|
||||
|
||||
A horizontal stacked bar showing the **distribution of patient acuity levels** within a department at a glance.
|
||||
|
||||
### How to Read
|
||||
|
||||
The bar is divided into up to three colored segments, each proportional to the number of patients at that acuity level:
|
||||
|
||||
| Segment Color | Acuity Level | Meaning |
|
||||
|---|---|---|
|
||||
| **Green** (#22c55e) | Low | Stable patients requiring routine care |
|
||||
| **Amber** (#f59e0b) | Medium | Patients requiring increased monitoring or clinical attention |
|
||||
| **Red** (#dc2626) | High | Critical patients requiring urgent or intensive care |
|
||||
| **Gray** (#d1d5db) | Empty | No patients (shown when department has capacity) |
|
||||
|
||||
- **Segment width** is proportional to the percentage of patients at each level.
|
||||
- **Count labels** below the bar show the exact number of patients in each category.
|
||||
- **Hover** over a segment to see the exact count.
|
||||
|
||||
**Key insight:** A bar that is mostly red indicates a department under heavy clinical pressure. A bar that shifts from green to amber/red over time suggests a department's workload is escalating.
|
||||
|
||||
---
|
||||
|
||||
## Universal Visual Conventions
|
||||
|
||||
### Color Coding (Traffic Light System)
|
||||
|
||||
Across all clinical charts, the dashboard uses a consistent traffic-light color scheme:
|
||||
|
||||
| Color | Hex | Meaning |
|
||||
|---|---|---|
|
||||
| **Green** | #22c55e | Safe / Low risk / Normal |
|
||||
| **Amber** | #f59e0b | Warning / Medium risk / Monitor |
|
||||
| **Red** | #dc2626 or #ef4444 | Critical / High risk / Escalate |
|
||||
|
||||
### Dark Mode
|
||||
|
||||
All charts support dark mode. Grid lines, text, and backgrounds automatically adjust:
|
||||
- **Light mode:** Dark text (#6b7280), subtle gridlines
|
||||
- **Dark mode:** Light text (#9ca3af), subdued gridlines
|
||||
|
||||
### Interactivity
|
||||
|
||||
- **Hover tooltips** are available on all charts — hover over any data point for exact values.
|
||||
- **Legends** appear at the bottom of multi-dataset charts (GCS, qSOFA, SOFA).
|
||||
- **Medication markers** (purple dashed lines) can appear on any vital signs chart.
|
||||
|
||||
### Responsiveness
|
||||
|
||||
All charts maintain a 16:9 aspect ratio and have a minimum height of 220px for readability on smaller screens. Animations are disabled when the user has the "prefers reduced motion" accessibility setting enabled.
|
||||
@@ -0,0 +1,268 @@
|
||||
# Guide 16: PHI Encryption with Data Protection API
|
||||
|
||||
## What is PHI and Why Encrypt It?
|
||||
|
||||
**PHI** stands for **Protected Health Information** — any data that can identify a patient and relates to their health. This includes names, dates of birth, medical record numbers, allergies, and emergency contact details. Healthcare regulations (HIPAA in the US, GDPR in the EU) require that PHI be **encrypted at rest** — meaning even if someone steals the database files or a backup, they can't read the patient data without the encryption key.
|
||||
|
||||
**Encryption at rest** protects against scenarios like:
|
||||
- A database backup is accidentally uploaded to a public S3 bucket
|
||||
- A disgruntled employee copies the database files
|
||||
- The database server's hard drive is stolen or improperly disposed of
|
||||
|
||||
Without encryption, the database stores plaintext: `first_name = "Sarah"`. With encryption: `first_name = "CfDJ8Nrq7...long encrypted string..."`. The application decrypts transparently when reading, and encrypts transparently when writing.
|
||||
|
||||
## What is the Data Protection API?
|
||||
|
||||
**.NET's Data Protection API (DPAPI)** is a built-in framework for encrypting and decrypting data. It manages encryption keys, handles key rotation, and provides a simple `Protect()`/`Unprotect()` interface. You don't need to pick cipher algorithms or manage IVs manually — DPAPI handles the cryptographic details.
|
||||
|
||||
Key concepts:
|
||||
- **`IDataProtectionProvider`**: The factory that creates protectors. Registered with DI at startup.
|
||||
- **`IDataProtector`**: An instance tied to a specific **purpose string**. A protector created with purpose `"VigilCare.PatientPhi.v1"` can only decrypt data that was encrypted with the same purpose. This prevents accidentally decrypting data meant for a different part of the application.
|
||||
- **Key ring**: DPAPI stores encryption keys on disk (configurable path). Keys are automatically rotated and expired on a schedule. Old keys are kept so previously encrypted data can still be decrypted.
|
||||
|
||||
---
|
||||
|
||||
## How PHI Encryption Works in This Project
|
||||
|
||||
```
|
||||
Application reads patient.FirstName
|
||||
│
|
||||
▼
|
||||
EF Core Value Converter
|
||||
│ calls crypto.Decrypt(ciphertext)
|
||||
▼
|
||||
PhiEncryptionService.Decrypt()
|
||||
│ calls _protector.Unprotect(ciphertext)
|
||||
▼
|
||||
Returns "Sarah" to the application
|
||||
```
|
||||
|
||||
```
|
||||
Application writes patient.FirstName = "Sarah"
|
||||
│
|
||||
▼
|
||||
EF Core Value Converter
|
||||
│ calls crypto.Encrypt("Sarah")
|
||||
▼
|
||||
PhiEncryptionService.Encrypt()
|
||||
│ calls _protector.Protect("Sarah")
|
||||
▼
|
||||
Stores "CfDJ8Nrq7..." in PostgreSQL
|
||||
```
|
||||
|
||||
The application code never sees ciphertext — it works with plaintext strings as usual. The encryption and decryption happen inside EF Core value converters (see Guide 4), invisible to the rest of the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### PhiEncryptionOptions
|
||||
|
||||
```csharp
|
||||
public class PhiEncryptionOptions
|
||||
{
|
||||
public const string Section = "PhiEncryption";
|
||||
public string ProtectorPurpose { get; set; } = "VigilCare.PatientPhi.v1";
|
||||
public string SearchTokenKey { get; set; } = null!;
|
||||
public bool LogListAccess { get; set; } = true;
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| `ProtectorPurpose` | The purpose string for the data protector. Changing this creates a new encryption scope — old data can't be decrypted with a new purpose without migration. |
|
||||
| `SearchTokenKey` | HMAC key for generating searchable name tokens (explained below). Separate from the encryption key. |
|
||||
| `LogListAccess` | Whether to log PHI access for list/search operations (compliance auditing). |
|
||||
|
||||
### appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"PhiEncryption": {
|
||||
"ProtectorPurpose": "VigilCare.PatientPhi.v1",
|
||||
"SearchTokenKey": "DEV-ONLY-HMAC-KEY-REPLACE-IN-PRODUCTION-32bytes!!",
|
||||
"LogListAccess": true
|
||||
},
|
||||
"DataProtection": {
|
||||
"KeyPath": "./data-protection-keys"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Registration in Program.cs
|
||||
|
||||
```csharp
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(
|
||||
builder.Configuration["DataProtection:KeyPath"]
|
||||
?? "./data-protection-keys"))
|
||||
.SetApplicationName("VigilCareClinical");
|
||||
```
|
||||
|
||||
`PersistKeysToFileSystem` stores the encryption keys in a local directory. In production, you'd use `PersistKeysToAzureBlobStorage()` or `PersistKeysToStackExchangeRedis()` so keys survive container restarts. `SetApplicationName` ensures all instances of the application share the same key ring.
|
||||
|
||||
---
|
||||
|
||||
## The PhiEncryptionService
|
||||
|
||||
```csharp
|
||||
public class PhiEncryptionService : IPhiEncryptionService
|
||||
{
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly byte[] _searchKey;
|
||||
|
||||
public PhiEncryptionService(
|
||||
IDataProtectionProvider provider,
|
||||
IOptions<PhiEncryptionOptions> options)
|
||||
{
|
||||
var opts = options.Value;
|
||||
_protector = provider.CreateProtector(opts.ProtectorPurpose);
|
||||
_searchKey = /* derived from SearchTokenKey */;
|
||||
}
|
||||
|
||||
public string Encrypt(string plaintext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plaintext)) return plaintext;
|
||||
return _protector.Protect(plaintext);
|
||||
}
|
||||
|
||||
public string Decrypt(string ciphertext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ciphertext)) return ciphertext;
|
||||
if (!IsEncrypted(ciphertext)) return ciphertext; // plaintext rows still readable
|
||||
return _protector.Unprotect(ciphertext);
|
||||
}
|
||||
|
||||
public bool IsEncrypted(string value) =>
|
||||
value.StartsWith("CfDJ8", StringComparison.Ordinal) || value.Length > 50;
|
||||
}
|
||||
```
|
||||
|
||||
**The `IsEncrypted` check**: During the migration from plaintext to encrypted data, some rows may still contain plaintext. The `Decrypt` method checks if the value looks encrypted (DPAPI-encrypted values start with `"CfDJ8"` and are much longer than typical names). If not, it returns the value as-is. This lets the application work correctly with a partially-migrated database.
|
||||
|
||||
### How EF Core Uses the Service
|
||||
|
||||
The `PatientPhiConverterConfigurator` (from Guide 4) wires the service into EF Core value converters:
|
||||
|
||||
```csharp
|
||||
entity.Property(p => p.FirstName)
|
||||
.HasConversion(
|
||||
v => crypto.Encrypt(v), // called on every INSERT/UPDATE
|
||||
v => crypto.Decrypt(v)); // called on every SELECT
|
||||
|
||||
entity.Property(p => p.DateOfBirth)
|
||||
.HasConversion(
|
||||
v => crypto.Encrypt(v.ToString("yyyy-MM-dd")),
|
||||
v => DateOnly.Parse(crypto.Decrypt(v)));
|
||||
```
|
||||
|
||||
Encrypted columns: `first_name`, `last_name`, `date_of_birth`, `allergies`, `emergency_contact_name`, `emergency_contact_phone`.
|
||||
|
||||
---
|
||||
|
||||
## The Search Problem: HMAC Name Tokens
|
||||
|
||||
**The problem**: If names are encrypted, you can't search for patients by name. SQL `WHERE first_name LIKE '%Sarah%'` doesn't work on ciphertext because each encryption of "Sarah" produces a different ciphertext (DPAPI uses random IVs).
|
||||
|
||||
**The solution**: Store a deterministic, one-way hash of the name in a separate column (`name_search_token`). To search, hash the query the same way and compare hashes.
|
||||
|
||||
```csharp
|
||||
public string ComputeNameSearchToken(string firstName, string lastName)
|
||||
{
|
||||
var normalized = $"{firstName.Trim().ToLowerInvariant()}|{lastName.Trim().ToLowerInvariant()}";
|
||||
using var hmac = new HMACSHA256(_searchKey);
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(normalized));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
```
|
||||
|
||||
**What is HMAC?** HMAC (Hash-based Message Authentication Code) is a keyed hash function. Unlike plain SHA-256 (where anyone can compute the same hash), HMAC requires a secret key. Without the key, an attacker who has the hash can't reverse it to find the name, and can't compute hashes for other names to test against.
|
||||
|
||||
**Why not just use SHA-256?** Plain SHA-256 is vulnerable to rainbow table attacks — an attacker precomputes hashes for common names ("Sarah Smith" → hash, "John Doe" → hash) and compares them against the stored hashes. HMAC with a secret key makes this impossible because the attacker would need the key to compute valid hashes.
|
||||
|
||||
The search flow:
|
||||
1. User searches for "Sarah Smith"
|
||||
2. Application computes `HMACSHA256("sarah|smith")` → `"a1b2c3d4..."`
|
||||
3. SQL query: `WHERE name_search_token = 'a1b2c3d4...'`
|
||||
4. Matching rows are returned, and EF Core's value converter decrypts the actual names
|
||||
|
||||
---
|
||||
|
||||
## PHI Access Logging
|
||||
|
||||
Every access to patient data is logged for compliance auditing:
|
||||
|
||||
```csharp
|
||||
public class PhiAccessLogService : IPhiAccessLogService
|
||||
{
|
||||
public async Task LogViewAsync(Guid patientId, string resourcePath) =>
|
||||
await WriteAsync(PhiAccessType.View, patientId, resourcePath);
|
||||
|
||||
public async Task LogListAsync(string resourcePath, int resultCount,
|
||||
string? searchQuery = null)
|
||||
{
|
||||
var accessType = string.IsNullOrWhiteSpace(searchQuery)
|
||||
? PhiAccessType.List
|
||||
: PhiAccessType.Search;
|
||||
await WriteAsync(accessType, null, resourcePath, resultCount, searchQuery);
|
||||
}
|
||||
|
||||
private async Task WriteAsync(PhiAccessType accessType, Guid? patientId, ...)
|
||||
{
|
||||
_db.PhiAccessLogs.Add(new PhiAccessLog
|
||||
{
|
||||
AccessType = accessType,
|
||||
PatientId = patientId,
|
||||
UserId = _currentUser.UserId!.Value,
|
||||
UserDisplayName = _currentUser.DisplayName,
|
||||
ResourcePath = resourcePath,
|
||||
SearchQueryHash = searchQuery is null ? null : HashQuery(searchQuery),
|
||||
IpAddress = _currentUser.IpAddress,
|
||||
CorrelationId = /* from middleware */,
|
||||
AccessedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_metrics.PhiAccessLogsTotal.WithLabels(accessType.ToDbString()).Inc();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The log records who accessed what, when, from where, and what they searched for. Search queries are hashed (not stored in plaintext) to avoid storing potentially sensitive search terms.
|
||||
|
||||
---
|
||||
|
||||
## One-Time Migration: EncryptPhiCommand
|
||||
|
||||
For existing databases with plaintext patient data, a CLI command encrypts all rows in place:
|
||||
|
||||
```csharp
|
||||
public static class EncryptPhiCommand
|
||||
{
|
||||
public static async Task RunAsync(IServiceProvider services)
|
||||
{
|
||||
var patients = await db.Patients.ToListAsync();
|
||||
foreach (var p in patients)
|
||||
{
|
||||
p.NameSearchToken = crypto.ComputeNameSearchToken(p.FirstName, p.LastName);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Console.WriteLine($"Encrypted {patients.Count} patient records.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run via: `dotnet run -- encrypt-phi`
|
||||
|
||||
The EF Core value converters handle the actual encryption — loading each patient triggers `Decrypt` (which passes plaintext through via `IsEncrypted` check), and saving triggers `Encrypt` (which encrypts the now-plaintext values). The command also computes `NameSearchToken` for every patient to enable encrypted name search.
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- **Encryption at rest protects against data breaches** — even if the database is stolen, patient data is unreadable without the encryption keys
|
||||
- **EF Core value converters make encryption transparent** — application code works with plaintext; encryption/decryption happens automatically on every read and write
|
||||
- **DPAPI handles key management** — key generation, rotation, and storage are built-in. You don't manage cryptographic primitives directly.
|
||||
- **Searchable encryption uses HMAC tokens** — deterministic, keyed hashes enable exact-match search on encrypted columns without decrypting every row
|
||||
- **PHI access logging creates an audit trail** — every view, search, and modification of patient data is recorded with user identity, timestamp, and IP address
|
||||
- **The purpose string isolates encryption scopes** — data encrypted with `"VigilCare.PatientPhi.v1"` can only be decrypted with the same purpose, preventing cross-contamination between different parts of the application
|
||||
@@ -0,0 +1,201 @@
|
||||
# Guide 17: Password Hashing with BCrypt
|
||||
|
||||
## What is Password Hashing?
|
||||
|
||||
When a user creates an account with the password `"DemoNurse1!"`, the application must store something that lets it verify the password later — but it should **never store the password itself**. If the database is compromised, plaintext passwords would be immediately usable by the attacker.
|
||||
|
||||
**Hashing** converts a password into a fixed-length string of random-looking characters using a one-way mathematical function. "One-way" means you can compute the hash from the password, but you can't compute the password from the hash:
|
||||
|
||||
```
|
||||
"DemoNurse1!" → hash() → "$2a$12$xK7W3M...long hash string..."
|
||||
```
|
||||
|
||||
When the user logs in, you hash the submitted password and compare it to the stored hash. If they match, the password is correct — without ever storing the actual password.
|
||||
|
||||
## Why BCrypt Specifically?
|
||||
|
||||
Not all hash functions are created equal. General-purpose hash functions like SHA-256 are designed to be **fast** — billions of hashes per second on modern hardware. That's a problem for passwords: an attacker who steals the hashed passwords can try billions of guesses per second.
|
||||
|
||||
**BCrypt** is specifically designed for password hashing with two key properties:
|
||||
|
||||
1. **It's intentionally slow**: BCrypt has a configurable "cost factor" (also called "work factor") that controls how many iterations the algorithm performs. Cost factor 12 (the default) means 2^12 = 4,096 iterations, making each hash take ~250ms. Fast enough that a single login is imperceptible, but an attacker trying 1 million passwords would need ~70 hours.
|
||||
|
||||
2. **It includes a built-in salt**: A **salt** is a random value mixed into the hash. Without a salt, two users with the same password would have the same hash — an attacker could build a precomputed table (a "rainbow table") of common passwords and their hashes, then look up matches instantly. BCrypt generates a random salt for each password and embeds it in the output, so identical passwords produce different hashes.
|
||||
|
||||
A BCrypt hash looks like this:
|
||||
|
||||
```
|
||||
$2a$12$xK7W3MqQ5Z6Y8B9A0C1D2EfGhIjKlMnOpQrStUvWxYz0123456789Ab
|
||||
│ │ │ │
|
||||
│ │ │ └── The hash itself
|
||||
│ │ └── The salt (22 chars)
|
||||
│ └── Cost factor (12 = 2^12 iterations)
|
||||
└── Algorithm version
|
||||
```
|
||||
|
||||
The salt and cost factor are stored right in the hash string, so you don't need a separate column for them.
|
||||
|
||||
---
|
||||
|
||||
## How BCrypt is Used in This Project
|
||||
|
||||
### NuGet Package
|
||||
|
||||
```xml
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
```
|
||||
|
||||
`BCrypt.Net-Next` is a .NET implementation of the BCrypt algorithm. It provides two key methods: `HashPassword()` and `Verify()`.
|
||||
|
||||
### Hashing on Account Creation
|
||||
|
||||
When a new user is created, the plaintext password is hashed before storage:
|
||||
|
||||
```csharp
|
||||
public async Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req)
|
||||
{
|
||||
// Validate the password meets minimum requirements
|
||||
ValidatePassword(req.Password);
|
||||
|
||||
var user = new ClinicalUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = req.Username.Trim(),
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password), // hash here
|
||||
DisplayName = req.DisplayName.Trim(),
|
||||
Role = role,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_db.ClinicalUsers.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
return Map(user);
|
||||
}
|
||||
```
|
||||
|
||||
`HashPassword(req.Password)` generates a random salt, applies BCrypt with the default cost factor (12), and returns the full hash string. Each call with the same password produces a different hash (because the salt is random).
|
||||
|
||||
### Password Validation Rules
|
||||
|
||||
```csharp
|
||||
private static void ValidatePassword(string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
throw new ValidationException("Password is required.", "PASSWORD_REQUIRED");
|
||||
if (password.Length < 8)
|
||||
throw new ValidationException(
|
||||
"Password must be at least 8 characters.", "PASSWORD_TOO_SHORT");
|
||||
}
|
||||
```
|
||||
|
||||
The minimum length of 8 characters is a baseline. In production, you'd typically also require uppercase, lowercase, digits, and special characters — but the BCrypt hash itself doesn't care about password complexity.
|
||||
|
||||
### Verification on Login
|
||||
|
||||
When a user logs in, the submitted password is verified against the stored hash:
|
||||
|
||||
```csharp
|
||||
public async Task<LoginResponse> LoginAsync(LoginRequest req)
|
||||
{
|
||||
var user = await _db.ClinicalUsers
|
||||
.FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive);
|
||||
|
||||
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
throw new ValidationException(
|
||||
"Invalid username or password.", "INVALID_CREDENTIALS");
|
||||
|
||||
// ... generate JWT token
|
||||
}
|
||||
```
|
||||
|
||||
**How does `Verify` work?**
|
||||
1. Extract the salt and cost factor from the stored hash string
|
||||
2. Hash the submitted password using the same salt and cost factor
|
||||
3. Compare the result to the stored hash
|
||||
4. If they match, the password is correct
|
||||
|
||||
**Security note**: The error message says "Invalid username or password" — it does not distinguish between "user not found" and "wrong password." This prevents an attacker from enumerating valid usernames by observing different error messages.
|
||||
|
||||
### Seed Data (Development Only)
|
||||
|
||||
The user seeder creates demo accounts with hashed passwords:
|
||||
|
||||
```csharp
|
||||
new ClinicalUser
|
||||
{
|
||||
Username = "nurse.demo",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
|
||||
DisplayName = "Demo Nurse",
|
||||
Role = ClinicalRole.Nurse,
|
||||
},
|
||||
new ClinicalUser
|
||||
{
|
||||
Username = "physician.demo",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
|
||||
DisplayName = "Dr. Demo Physician",
|
||||
Role = ClinicalRole.Physician,
|
||||
},
|
||||
new ClinicalUser
|
||||
{
|
||||
Username = "admin.demo",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
|
||||
DisplayName = "Demo Admin",
|
||||
Role = ClinicalRole.Admin,
|
||||
},
|
||||
```
|
||||
|
||||
Even in development, passwords are never stored in plaintext in the database. The passwords themselves (`"DemoNurse1!"` etc.) appear in the seeder code, but they're only used during the initial seeding and don't persist as plaintext anywhere.
|
||||
|
||||
### Database Schema
|
||||
|
||||
```csharp
|
||||
builder.Property(u => u.PasswordHash).HasColumnName("password_hash")
|
||||
.HasMaxLength(500).IsRequired();
|
||||
```
|
||||
|
||||
`HasMaxLength(500)` accommodates the BCrypt hash string (typically ~60 characters) with room for future algorithm changes that might produce longer hashes.
|
||||
|
||||
---
|
||||
|
||||
## Cost Factor Considerations
|
||||
|
||||
The default cost factor of 12 is a good balance for 2024-era hardware:
|
||||
|
||||
| Cost Factor | Iterations | Approximate Time | Use Case |
|
||||
|-------------|-----------|------------------|----------|
|
||||
| 10 | 1,024 | ~65ms | Minimum for production |
|
||||
| 11 | 2,048 | ~130ms | Reasonable for high-traffic APIs |
|
||||
| **12** | **4,096** | **~250ms** | **Default — good balance** |
|
||||
| 13 | 8,192 | ~500ms | More security, but login feels slower |
|
||||
| 14 | 16,384 | ~1s | High-security environments |
|
||||
|
||||
The cost factor should be increased over time as hardware gets faster. What takes 250ms today might take 25ms in 10 years. The industry recommendation: choose the highest cost factor that keeps login time under ~500ms for your hardware.
|
||||
|
||||
You can customize the cost factor:
|
||||
|
||||
```csharp
|
||||
BCrypt.Net.BCrypt.HashPassword(password, workFactor: 13);
|
||||
```
|
||||
|
||||
Existing hashes with a lower cost factor continue to verify correctly — BCrypt reads the cost factor from the hash string.
|
||||
|
||||
---
|
||||
|
||||
## What BCrypt Does NOT Protect Against
|
||||
|
||||
- **Weak passwords**: BCrypt slows down brute-force attacks, but "password123" will still be cracked quickly. Enforce password complexity rules at the application level.
|
||||
- **Phishing**: If a user gives their password to an attacker directly, hashing doesn't help.
|
||||
- **Memory dumps**: While the application is running, the plaintext password exists briefly in memory (during the Verify call). In extremely sensitive environments, you'd use secure memory handling.
|
||||
- **Credential stuffing**: If a user reuses their password from another breached site, BCrypt can't help. Multi-factor authentication (MFA) addresses this.
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- **Never store plaintext passwords** — always hash them before writing to the database. There is no valid reason to store or log a user's actual password.
|
||||
- **BCrypt is purposefully slow** — the cost factor makes brute-force attacks impractical while keeping legitimate logins fast
|
||||
- **Each hash includes its own salt** — even identical passwords produce different hashes, defeating rainbow table attacks
|
||||
- **The cost factor is embedded in the hash** — you can increase the cost factor for new passwords without invalidating existing ones
|
||||
- **Give generic error messages** — "Invalid username or password" prevents username enumeration. Never reveal whether the username or the password was wrong.
|
||||
- **BCrypt handles the hard parts** — salt generation, iteration count, and comparison are all managed by the library. You call `HashPassword()` and `Verify()` — nothing else.
|
||||
@@ -0,0 +1,376 @@
|
||||
# Guide 18: REST API Design & Conventions
|
||||
|
||||
## What is a REST API?
|
||||
|
||||
A **REST API** (Representational State Transfer) is the most common way to build web APIs. It uses standard HTTP methods and URLs to perform operations on resources. If you've ever used a URL like `GET /api/patients/123`, you've interacted with a REST API.
|
||||
|
||||
Key principles:
|
||||
- **Resources** are the "nouns" — patients, encounters, observations, alerts. Each resource has a URL (called an endpoint).
|
||||
- **HTTP methods** are the "verbs" — GET (read), POST (create), PUT (replace), PATCH (partial update), DELETE (remove).
|
||||
- **Status codes** tell the caller what happened — 200 (success), 201 (created), 400 (bad request), 404 (not found), 500 (server error).
|
||||
- **Stateless** — each request contains all the information the server needs. The server doesn't remember previous requests (that's what JWT tokens are for — they carry identity in every request).
|
||||
|
||||
---
|
||||
|
||||
## URL Conventions
|
||||
|
||||
### Resource Naming
|
||||
|
||||
URLs use lowercase, plural nouns with hyphens between words:
|
||||
|
||||
```
|
||||
/api/v1/encounters — collection of encounters
|
||||
/api/v1/encounters/{encounterId} — one encounter
|
||||
/api/v1/encounters/{encounterId}/observations — observations within an encounter
|
||||
/api/v1/alert-thresholds — hyphenated multi-word resource
|
||||
/api/v1/sepsis-bundles/{bundleId}/elements — nested sub-resource
|
||||
```
|
||||
|
||||
**Why plural?** `GET /api/v1/encounters` returns a list, and `GET /api/v1/encounters/123` returns one item from that list. Using the plural form for both keeps URLs consistent.
|
||||
|
||||
**Why `v1`?** Version prefixing lets you introduce breaking changes in a `v2` without breaking existing clients.
|
||||
|
||||
### Controller Declaration
|
||||
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class EncountersController : ControllerBase
|
||||
```
|
||||
|
||||
- **`[ApiController]`** enables automatic model validation, `[FromBody]` inference, and `ProblemDetails` error responses
|
||||
- **`[Route("api/v1/encounters")]`** sets the base URL for all actions in this controller
|
||||
- **`[Produces("application/json")]`** declares that all responses are JSON
|
||||
- **`[Authorize]`** requires authentication for all actions (overridable per-action)
|
||||
|
||||
---
|
||||
|
||||
## The Response Envelope
|
||||
|
||||
Every API response uses a consistent wrapper called `ApiResponse<T>`:
|
||||
|
||||
```csharp
|
||||
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error)
|
||||
{
|
||||
public static ApiResponse<T> Ok(T data) =>
|
||||
new(true, 200, data, null);
|
||||
|
||||
public static ApiResponse<T> Created(T data) =>
|
||||
new(true, 201, data, null);
|
||||
|
||||
public static ApiResponse<T> Fail(int statusCode, string message, string code) =>
|
||||
new(false, statusCode, default, new ApiError(message, code));
|
||||
}
|
||||
|
||||
public record ApiError(string Message, string Code);
|
||||
```
|
||||
|
||||
**Why an envelope?** Without a wrapper, successful and error responses have completely different shapes, making it harder for clients to parse. With the envelope, every response has the same top-level structure:
|
||||
|
||||
```json
|
||||
// Success
|
||||
{
|
||||
"success": true,
|
||||
"statusCode": 200,
|
||||
"data": { "id": "...", "status": "Active", ... },
|
||||
"error": null
|
||||
}
|
||||
|
||||
// Error
|
||||
{
|
||||
"success": false,
|
||||
"statusCode": 400,
|
||||
"data": null,
|
||||
"error": {
|
||||
"message": "Invalid status filter.",
|
||||
"code": "INVALID_STATUS"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `code` field (like `"INVALID_STATUS"`) is a machine-readable error identifier. The `message` is human-readable. Clients can switch on the code without parsing the message string, which may change or be localized.
|
||||
|
||||
---
|
||||
|
||||
## HTTP Methods and Status Codes
|
||||
|
||||
### GET — Read (never modifies data)
|
||||
|
||||
```csharp
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? department,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await _encounters.ListAsync(parsedStatus, parsedDepartment, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
page = result.Page,
|
||||
pageSize = result.PageSize,
|
||||
totalCount = result.TotalCount,
|
||||
totalPages = result.TotalPages
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
- Returns **200 OK** on success
|
||||
- Filter parameters go in query strings (`?status=ACTIVE&department=ICU`)
|
||||
- Pagination parameters are also query strings (`?page=1&pageSize=20`)
|
||||
|
||||
### POST — Create a new resource
|
||||
|
||||
```csharp
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
|
||||
public async Task<IActionResult> Create([FromBody] AlertThresholdRequest req)
|
||||
{
|
||||
var threshold = await _thresholds.CreateAsync(req);
|
||||
return StatusCode(201, ApiResponse<AlertThreshold>.Created(threshold));
|
||||
}
|
||||
```
|
||||
|
||||
- Request body is JSON (`[FromBody]`)
|
||||
- Returns **201 Created** on success (not 200 — 201 is semantically correct for creation)
|
||||
- Returns **409 Conflict** if the resource already exists
|
||||
|
||||
### POST for Ingest (Batch Operations)
|
||||
|
||||
```csharp
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.ObservationsIngest)]
|
||||
public async Task<IActionResult> Ingest(Guid encounterId, [FromBody] BatchIngestRequest req)
|
||||
{
|
||||
if (req.Observations.Count == 0)
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"At least one observation is required.", "EMPTY_BATCH"));
|
||||
|
||||
if (req.Observations.Count > 10)
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"Batch size cannot exceed 10 observations.", "BATCH_TOO_LARGE"));
|
||||
|
||||
// Process each observation...
|
||||
|
||||
return StatusCode(201, ApiResponse<object>.Created(results));
|
||||
}
|
||||
```
|
||||
|
||||
Batch endpoints accept arrays but enforce limits to prevent abuse or accidental huge payloads.
|
||||
|
||||
### PATCH — Partial update
|
||||
|
||||
```csharp
|
||||
[HttpPatch("{alertId:guid}/acknowledge")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)]
|
||||
public async Task<IActionResult> Acknowledge(Guid alertId, ...)
|
||||
```
|
||||
|
||||
- PATCH means "modify part of the resource" (only the fields you send are changed)
|
||||
- Returns **200 OK** with the updated resource
|
||||
- Returns **404 Not Found** if the resource doesn't exist
|
||||
|
||||
### PUT — Full replace
|
||||
|
||||
```csharp
|
||||
[HttpPut("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] AlertThresholdRequest req)
|
||||
```
|
||||
|
||||
- PUT means "replace the entire resource with this new version"
|
||||
- Returns **200 OK** with the updated resource
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
The `ExceptionHandlerMiddleware` (from Guide 3) catches all exceptions and converts them to consistent API responses. The controller code throws domain exceptions, and the middleware translates them:
|
||||
|
||||
| Exception | HTTP Status | When Used |
|
||||
|-----------|-------------|-----------|
|
||||
| `NotFoundException` | 404 Not Found | Resource doesn't exist |
|
||||
| `BadRequestException` | 400 Bad Request | Invalid input that FluentValidation didn't catch |
|
||||
| `ValidationException` | 422 Unprocessable Entity | Business rule violation |
|
||||
| `ConflictException` | 409 Conflict | Duplicate resource (e.g., duplicate MRN) |
|
||||
| `Exception` (unhandled) | 500 Internal Server Error | Unexpected bugs |
|
||||
|
||||
Client errors (4xx) are logged as **Warning** — they're expected. Server errors (5xx) are logged as **Error** with the full stack trace.
|
||||
|
||||
The 500 response never leaks exception details to the client:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"statusCode": 500,
|
||||
"data": null,
|
||||
"error": {
|
||||
"message": "An unexpected error occurred",
|
||||
"code": "INTERNAL_ERROR"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pagination: Offset-Based vs Cursor-Based
|
||||
|
||||
This project uses two pagination strategies depending on the use case.
|
||||
|
||||
### Offset-Based (Page Number)
|
||||
|
||||
For encounter lists where the client needs "page 3 of 10":
|
||||
|
||||
```
|
||||
GET /api/v1/encounters?page=2&pageSize=20
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"page": 2,
|
||||
"pageSize": 20,
|
||||
"totalCount": 157,
|
||||
"totalPages": 8
|
||||
}
|
||||
```
|
||||
|
||||
**How it works**: `OFFSET (page - 1) * pageSize LIMIT pageSize`. Simple but has a known limitation — if data is inserted between page requests, items can be duplicated or skipped.
|
||||
|
||||
### Cursor-Based (Keyset Pagination)
|
||||
|
||||
For observation history where data is frequently appended:
|
||||
|
||||
```
|
||||
GET /api/v1/encounters/{id}/observations?limit=20
|
||||
GET /api/v1/encounters/{id}/observations?limit=20&cursor=eyJ0Ijoi...
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"nextCursor": "eyJ0IjoiMjAyNi0wNi0yNFQxNDoyMzowMFoiLCJpIjoiYWJjLTEyMyJ9",
|
||||
"hasMore": true
|
||||
}
|
||||
```
|
||||
|
||||
**What is a cursor?** An opaque token that encodes the position of the last item. The server decodes it to construct a `WHERE` clause that fetches the next batch:
|
||||
|
||||
```csharp
|
||||
public async Task<CursorPage<Observation>> GetHistoryAsync(
|
||||
Guid encounterId, string? code,
|
||||
DateTimeOffset? from, DateTimeOffset? to,
|
||||
int limit, string? cursorToken)
|
||||
{
|
||||
var cursor = ObservationCursor.Decode(cursorToken);
|
||||
|
||||
var query = _db.Observations
|
||||
.Where(o => o.EncounterId == encounterId);
|
||||
|
||||
if (cursor is not null)
|
||||
{
|
||||
// Keyset condition: ORDER BY recorded_at DESC, id DESC
|
||||
// "Give me rows AFTER this position"
|
||||
query = query.Where(o =>
|
||||
o.RecordedAt < cursor.RecordedAt ||
|
||||
(o.RecordedAt == cursor.RecordedAt && o.Id.CompareTo(cursor.Id) < 0));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(o => o.RecordedAt)
|
||||
.ThenByDescending(o => o.Id)
|
||||
.Take(limit + 1) // fetch one extra to detect "has more"
|
||||
.ToListAsync();
|
||||
|
||||
var hasMore = items.Count > limit;
|
||||
if (hasMore) items.RemoveAt(limit);
|
||||
|
||||
var nextCursor = hasMore
|
||||
? new ObservationCursor(items[^1].RecordedAt, items[^1].Id).Encode()
|
||||
: null;
|
||||
|
||||
return new CursorPage<Observation>(items, nextCursor, hasMore);
|
||||
}
|
||||
```
|
||||
|
||||
**Why cursor-based for observations?** Observations are append-only and ordered by timestamp. Offset pagination (`OFFSET 100`) gets slower as the offset grows (PostgreSQL must scan and skip 100 rows). Keyset pagination (`WHERE recorded_at < '2026-06-24T14:23:00Z'`) uses the index directly and performs consistently regardless of how deep you paginate.
|
||||
|
||||
**The `Take(limit + 1)` trick**: Fetch one more item than requested. If you get `limit + 1` items, there's a next page — remove the extra item and return `hasMore: true`. If you get `limit` or fewer, there's no next page.
|
||||
|
||||
---
|
||||
|
||||
## Idempotency
|
||||
|
||||
**What is idempotency?** An operation is idempotent if performing it multiple times produces the same result as performing it once. `GET` is naturally idempotent (reading doesn't change anything). `POST` is not (creating a resource twice creates two resources).
|
||||
|
||||
For observation ingest, the API supports an optional `Idempotency-Key` header:
|
||||
|
||||
```
|
||||
POST /api/v1/encounters/{id}/observations
|
||||
Idempotency-Key: device-123-reading-456
|
||||
|
||||
{ "observationCode": "HEART_RATE", "value": 82, ... }
|
||||
```
|
||||
|
||||
If the same idempotency key is sent twice, the second request returns the original observation instead of creating a duplicate. This is essential for device integrations where network retries are common — a device might send the same reading twice if it doesn't receive an acknowledgment.
|
||||
|
||||
The implementation uses a partial unique index in PostgreSQL (see Guide 4) — only non-null idempotency keys are checked for uniqueness.
|
||||
|
||||
---
|
||||
|
||||
## Route Parameter Constraints
|
||||
|
||||
```csharp
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
```
|
||||
|
||||
The `:guid` constraint means ASP.NET Core only matches this route if the `{id}` segment is a valid GUID. A request to `/api/v1/encounters/not-a-guid` returns 404 instead of reaching the controller and failing during GUID parsing.
|
||||
|
||||
---
|
||||
|
||||
## Swagger / OpenAPI Documentation
|
||||
|
||||
The API is self-documenting via Swagger:
|
||||
|
||||
```csharp
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("/swagger/v1/swagger.json", "VigilCare Clinical API v1");
|
||||
});
|
||||
```
|
||||
|
||||
Available at `http://localhost:5270/swagger/ui` during development. XML documentation comments on controller actions (the `<summary>` blocks) appear in the Swagger UI, making it easy for frontend developers to understand each endpoint without reading the C# code.
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Returns cursor-paginated observation history for an encounter.
|
||||
/// </summary>
|
||||
/// <param name="encounterId">Encounter id.</param>
|
||||
/// <param name="code">Optional observation code filter.</param>
|
||||
/// <param name="limit">Maximum items per page.</param>
|
||||
/// <param name="cursor">Opaque cursor from a previous page.</param>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> History(...)
|
||||
```
|
||||
|
||||
`[ProducesResponseType]` tells Swagger which response shapes are possible, generating accurate API documentation.
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- **Consistent envelope** (`ApiResponse<T>`) makes every response predictable — clients always know where to find the data, error message, and status code
|
||||
- **Machine-readable error codes** (like `"INVALID_STATUS"`) let clients handle errors programmatically without parsing human-readable messages
|
||||
- **Use the right HTTP method** — GET reads, POST creates, PATCH updates partially, PUT replaces fully. This isn't just convention; proxies, caches, and browsers treat these differently.
|
||||
- **Use the right status code** — 201 for creation, 409 for conflicts, 422 for validation failures. Don't use 200 for everything.
|
||||
- **Cursor pagination for append-only data** — performs consistently regardless of dataset size, handles concurrent inserts correctly
|
||||
- **Offset pagination for browsable lists** — simpler for UI that needs "page X of Y" navigation
|
||||
- **Idempotency keys prevent duplicate resources** — essential for unreliable networks where requests may be retried
|
||||
- **Swagger documents the API automatically** — XML comments on controllers become interactive API documentation
|
||||
@@ -0,0 +1,295 @@
|
||||
# Guide 19: FHIR R4 Integration Facade
|
||||
|
||||
## What is FHIR?
|
||||
|
||||
**FHIR** (Fast Healthcare Interoperability Resources, pronounced "fire") is a standard for exchanging healthcare data between systems. If your hospital has an EHR (Electronic Health Record) like Epic or Cerner, a lab system, a pharmacy system, and a monitoring platform, they all need to share patient data. FHIR defines a common language for this — standard data formats (called **resources**) and standard ways to send them (RESTful HTTP endpoints).
|
||||
|
||||
Key concepts:
|
||||
|
||||
- **Resource**: A structured JSON object representing a healthcare concept. Examples: `Patient`, `Encounter`, `Observation`, `MedicationAdministration`. Each resource type has a defined set of fields and data types.
|
||||
- **FHIR R4**: The fourth major release of the FHIR standard (Release 4). R4 is the current normative version used by most healthcare systems.
|
||||
- **Identifier**: A system+value pair that identifies a resource in an external system. For example, `system: "http://hospital.example/mrn"`, `value: "MRN-001"`. A patient might have different identifiers in different systems — FHIR uses these to match records across systems.
|
||||
- **Coding**: A code from a standard terminology. `system: "http://loinc.org"`, `code: "8867-4"` means "Heart rate" in the LOINC vocabulary. `system: "http://snomed.info/sct"`, `code: "364075005"` also means "Heart rate" in SNOMED CT.
|
||||
- **Bundle**: A collection of resources sent together. A "transaction Bundle" is like a database transaction — all entries succeed or all fail.
|
||||
- **CapabilityStatement**: A resource that describes what a FHIR server can do — which resource types it supports and which operations (create, read, search).
|
||||
- **OperationOutcome**: A FHIR-standard error response. Instead of returning `{"error": "..."}`, FHIR servers return a structured `OperationOutcome` resource with severity, issue type, and diagnostic details.
|
||||
|
||||
**What is a "facade"?** This project doesn't implement a full FHIR server. It implements a **facade** — a thin translation layer that accepts FHIR-formatted requests, maps them to the internal data model, and returns FHIR-formatted responses. The internal database schema is not FHIR-native; the facade translates between the two worlds.
|
||||
|
||||
---
|
||||
|
||||
## Why FHIR in This Project?
|
||||
|
||||
Hospital integration engines (like Mirth Connect or Rhapsody) speak FHIR. They send ADT (Admit/Discharge/Transfer) messages with Patient and Encounter resources, vital signs as Observation resources, and medication records as MedicationAdministration resources. By implementing FHIR endpoints, VigilCareClinical can receive data from any FHIR-capable system without custom integration code for each one.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Hospital Systems VigilCareClinical
|
||||
┌──────────────┐ ┌──────────────────────────────┐
|
||||
│ EHR (Epic) │ │ FHIR Ingest Controller │
|
||||
│ │ POST /fhir/R4/ │ │ │
|
||||
│ Mirth Connect│ Patient │ ▼ │
|
||||
│ (integration │ ────────────────► │ PatientFhirMapper │
|
||||
│ engine) │ │ │ FHIR Patient → internal │
|
||||
│ │ POST /fhir/R4/ │ ▼ │
|
||||
│ Lab System │ Observation │ PatientService │
|
||||
│ │ ────────────────► │ │ save to PostgreSQL │
|
||||
│ Pharmacy │ │ ▼ │
|
||||
│ │ POST /fhir/R4/ │ FhirMapper (reverse) │
|
||||
│ │ Bundle │ │ internal → FHIR response│
|
||||
│ │ ────────────────► │ ▼ │
|
||||
│ │ │ Return FHIR JSON │
|
||||
└──────────────┘ └──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Resources and Interactions
|
||||
|
||||
The `CapabilityStatement` (available at `GET /fhir/R4/metadata`, no auth required) declares:
|
||||
|
||||
| Resource | Create | Read | Search |
|
||||
|----------|--------|------|--------|
|
||||
| Patient | ✓ | ✓ | ✓ (by identifier) |
|
||||
| Encounter | ✓ | ✓ | ✓ (by patient, status) |
|
||||
| Observation | ✓ | | |
|
||||
| MedicationAdministration | ✓ | | |
|
||||
| Bundle (transaction) | ✓ | | |
|
||||
|
||||
All FHIR endpoints use `application/fhir+json` as the content type (not `application/json`), following the FHIR specification.
|
||||
|
||||
---
|
||||
|
||||
## LOINC Code Mapping
|
||||
|
||||
FHIR Observations use standardized codes (LOINC, SNOMED CT) to identify what's being measured. The internal data model uses simpler codes like `"HEART_RATE"`. The `LoincCodeMapper` translates between them:
|
||||
|
||||
```csharp
|
||||
public static class LoincCodeMapper
|
||||
{
|
||||
private static readonly Dictionary<string, LoincMapping> _map = new()
|
||||
{
|
||||
["8867-4"] = new("HEART_RATE", "/min"),
|
||||
["8310-5"] = new("TEMP_C", "Cel", AllowFahrenheit: true),
|
||||
["2823-3"] = new("POTASSIUM_MEQ_L", "mmol/L"),
|
||||
["2708-6"] = new("SPO2", "%"),
|
||||
["9279-1"] = new("RESP_RATE", "/min"),
|
||||
["6690-2"] = new("WBC_K_UL", "10*3/uL"),
|
||||
["8480-6"] = new("SYSTOLIC_BP", "mm[Hg]"),
|
||||
["8462-4"] = new("DIASTOLIC_BP", "mm[Hg]"),
|
||||
["2524-7"] = new("LACTATE_MMOL_L", "mmol/L"),
|
||||
["777-3"] = new("PLATELET_K_UL", "10*3/uL"),
|
||||
["1975-2"] = new("BILIRUBIN_MG_DL", "mg/dL"),
|
||||
["2160-0"] = new("CREATININE_MG_DL", "mg/dL"),
|
||||
["2703-7"] = new("PAO2_MMHG", "mm[Hg]"),
|
||||
["80288-7"] = new("GCS_EYE", "{score}"),
|
||||
["80289-5"] = new("GCS_VERBAL", "{score}"),
|
||||
["80290-3"] = new("GCS_MOTOR", "{score}"),
|
||||
// ... 19 mappings total
|
||||
};
|
||||
|
||||
// SNOMED CT fallbacks for systems that don't use LOINC
|
||||
private static readonly Dictionary<string, LoincMapping> _snomedMap = new()
|
||||
{
|
||||
["364075005"] = new("HEART_RATE", "/min"),
|
||||
["431314004"] = new("SPO2", "%"),
|
||||
["86290005"] = new("RESP_RATE", "/min"),
|
||||
};
|
||||
|
||||
public static bool TryMap(string system, string code, out LoincMapping mapping)
|
||||
{
|
||||
if (system.Contains("loinc") && _map.TryGetValue(code, out mapping!))
|
||||
return true;
|
||||
if (system.Contains("snomed") && _snomedMap.TryGetValue(code, out mapping!))
|
||||
return true;
|
||||
mapping = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a FHIR Observation arrives with code `system: "http://loinc.org"`, `code: "8867-4"`, the mapper translates it to internal code `"HEART_RATE"` with expected unit `"/min"`.
|
||||
|
||||
### Unit Conversion
|
||||
|
||||
Some hospitals send temperatures in Fahrenheit. The `FhirUnitConverter` handles this:
|
||||
|
||||
```csharp
|
||||
public static (decimal Value, string Unit) Normalize(
|
||||
string internalCode, decimal value, string? fhirUnit, bool allowFahrenheit)
|
||||
{
|
||||
if (internalCode == "TEMP_C" && allowFahrenheit &&
|
||||
(fhirUnit == "[degF]" || fhirUnit == "degF"))
|
||||
{
|
||||
var celsius = (value - 32m) * 5m / 9m;
|
||||
return (Math.Round(celsius, 2), "Cel");
|
||||
}
|
||||
return (value, fhirUnit ?? "1");
|
||||
}
|
||||
```
|
||||
|
||||
The internal system always stores temperature in Celsius. If a FHIR Observation arrives in Fahrenheit, it's converted transparently.
|
||||
|
||||
---
|
||||
|
||||
## The Observation Mapper
|
||||
|
||||
The `ObservationFhirMapper` handles the most complex mapping because FHIR Observations can have multiple formats:
|
||||
|
||||
```csharp
|
||||
public async Task<IReadOnlyList<MappedObservation>> ToIngestRequestsAsync(
|
||||
Hl7.Fhir.Model.Observation fhir)
|
||||
{
|
||||
var encounterId = await _refs.ResolveEncounterReferenceAsync(fhir.Encounter);
|
||||
var source = MapSource(fhir); // vital-signs → Device, laboratory → Lab
|
||||
var recordedAt = fhir.Effective.ToUtcDateTimeOffset() ?? DateTimeOffset.UtcNow;
|
||||
var idempotencyKey = fhir.Identifier?.FirstOrDefault()?.Value ?? fhir.Id;
|
||||
|
||||
// FHIR Observations can be single-value or multi-component
|
||||
if (fhir.Component?.Count > 0)
|
||||
{
|
||||
// Multi-component (e.g., blood pressure with systolic + diastolic)
|
||||
foreach (var component in fhir.Component)
|
||||
results.AddRange(MapSingleCoding(component.Code, component.Value, ...));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single value (e.g., heart rate)
|
||||
results.AddRange(MapSingleCoding(fhir.Code, fhir.Value, ...));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A FHIR blood pressure Observation has two **components** (systolic and diastolic). The mapper produces two internal observations from one FHIR resource.
|
||||
|
||||
---
|
||||
|
||||
## Transaction Bundle Processing
|
||||
|
||||
A transaction Bundle groups multiple resources into one atomic operation — like a database transaction. The `FhirBundleProcessor` handles this:
|
||||
|
||||
```csharp
|
||||
public async Task<Bundle> ProcessTransactionAsync(Bundle transaction)
|
||||
{
|
||||
var response = new Bundle { Type = Bundle.BundleType.TransactionResponse };
|
||||
|
||||
// Sort entries: Patient first, then Encounter, then everything else
|
||||
var entries = transaction.Entry
|
||||
.OrderBy(e => Priority(e.Resource))
|
||||
.ToList();
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var location = resource switch
|
||||
{
|
||||
Patient p => await ProcessPatientAsync(p),
|
||||
Encounter e => await ProcessEncounterAsync(e),
|
||||
Observation o => await ProcessObservationAsync(o),
|
||||
MedicationAdministration m => await ProcessMedAsync(m),
|
||||
_ => throw new FhirMappingException("Unsupported resource type")
|
||||
};
|
||||
|
||||
response.Entry.Add(new Bundle.EntryComponent
|
||||
{
|
||||
Response = new Bundle.ResponseComponent
|
||||
{ Status = "201 Created", Location = location }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await tx.RollbackAsync(); // all-or-nothing
|
||||
response.Entry.Add(/* error entry */);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
**Why sort by priority?** An Encounter references a Patient, and an Observation references an Encounter. If the Bundle contains all three, the Patient must be created first (so the Encounter can reference it), then the Encounter (so the Observation can reference it). The `Priority()` function ensures this ordering.
|
||||
|
||||
**All-or-nothing**: If any entry fails, the entire transaction rolls back — no partially-created data.
|
||||
|
||||
---
|
||||
|
||||
## FHIR Error Responses: OperationOutcome
|
||||
|
||||
FHIR has its own error format. Instead of the project's standard `ApiResponse<T>` envelope, FHIR endpoints return `OperationOutcome` resources:
|
||||
|
||||
```csharp
|
||||
public static class FhirOperationOutcomeBuilder
|
||||
{
|
||||
public static OperationOutcome FromException(Exception ex) => ex switch
|
||||
{
|
||||
FhirMappingException fme => Create(fme.HttpStatus, fme.FhirIssueCode, fme.Message),
|
||||
NotFoundException => Create(404, "not-found", ex.Message),
|
||||
ValidationException => Create(422, "invalid", ex.Message),
|
||||
ConflictException => Create(409, "conflict", ex.Message),
|
||||
_ => Create(500, "exception", "An unexpected error occurred.")
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The `FhirExceptionFilter` catches exceptions on `/fhir/*` paths and converts them to `OperationOutcome` responses with `Content-Type: application/fhir+json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{
|
||||
"severity": "warning",
|
||||
"code": "not-found",
|
||||
"diagnostics": "Patient not found."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## External Identifier Resolution
|
||||
|
||||
When a FHIR Encounter references a Patient as `"subject": {"reference": "Patient/MRN-001"}`, the system needs to find the internal UUID for that patient. The `ExternalResourceIdentifier` table maps between external identifiers (hospital MRNs, visit numbers) and internal UUIDs:
|
||||
|
||||
| resource_type | system | value | internal_id |
|
||||
|--------------|--------|-------|-------------|
|
||||
| Patient | `http://hospital.example/mrn` | MRN-001 | `3fa85f64-...` |
|
||||
| Encounter | `http://hospital.example/visit` | VISIT-100 | `7e4b2a1f-...` |
|
||||
|
||||
This mapping is created when a resource is first ingested and used for all subsequent references. It's what makes the FHIR endpoints **idempotent** — sending the same Patient twice (same identifier) updates the existing record instead of creating a duplicate.
|
||||
|
||||
---
|
||||
|
||||
## Authentication for FHIR Endpoints
|
||||
|
||||
FHIR endpoints accept two authentication methods (see Guide 15):
|
||||
|
||||
1. **JWT token** — for admin users accessing via the dashboard or Swagger
|
||||
2. **API key** — for integration engines like Mirth Connect
|
||||
|
||||
```
|
||||
POST /fhir/R4/Observation
|
||||
X-Api-Key: dev-integration-key-change-in-production
|
||||
Content-Type: application/fhir+json
|
||||
```
|
||||
|
||||
The FHIR API key creates an identity with the `Integration` role, which has permissions for `fhir:ingest`, `fhir:read`, `patients:write`, `encounters:write`, `observations:ingest`, and `medications:write`.
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- **FHIR is the healthcare data standard** — it defines how to represent patients, encounters, observations, and medications as JSON resources with standard coding systems
|
||||
- **A facade translates, not stores** — the internal database uses its own schema; the FHIR layer maps between FHIR resources and internal entities
|
||||
- **LOINC and SNOMED CT codes are translated to internal codes** — `"8867-4"` (LOINC) becomes `"HEART_RATE"` internally; SNOMED fallbacks handle systems that don't use LOINC
|
||||
- **Transaction Bundles are atomic** — Patient → Encounter → Observation ordering is enforced, and any failure rolls back the entire bundle
|
||||
- **OperationOutcome is the FHIR error format** — FHIR endpoints return structured error resources instead of the project's standard API envelope
|
||||
- **External identifiers enable idempotency** — the same resource sent twice (same identifier) updates rather than duplicates
|
||||
- **Unit conversion happens transparently** — Fahrenheit temperatures are converted to Celsius during mapping so the internal system works with a single unit
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user