Add deployment
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -15,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
@@ -326,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>();
|
||||
@@ -345,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>();
|
||||
@@ -372,7 +402,7 @@ try
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteAsync
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapMetrics("/metrics");
|
||||
app.MapMetrics("/metrics").AllowAnonymous();
|
||||
app.MapControllers();
|
||||
|
||||
if (args.Contains("encrypt-phi"))
|
||||
@@ -381,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();
|
||||
|
||||
}
|
||||
|
||||
@@ -37,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",
|
||||
@@ -219,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
|
||||
@@ -31,7 +31,14 @@ Configuration lives in `appsettings.json` under `PhiEncryption` and `DataProtect
|
||||
| Environment | Data Protection key ring | Search HMAC key (`SearchTokenKey`) |
|
||||
|---|---|---|
|
||||
| Development | `./data-protection-keys/` on disk (`DataProtection:KeyPath`) | `appsettings.json` (dev placeholder only) |
|
||||
| Production | Azure Key Vault XML blob or AWS KMS-backed store | Key Vault / Secrets Manager secret — **not** appsettings |
|
||||
| Production | Docker named volume `vigilcare_dp_keys` mounted at `/app/data-protection-keys` (`docker-compose.prod.yml` uses `name: vigilcare`) | Host `.env` → `PHI_SEARCH_TOKEN_KEY` / secrets manager — **not** appsettings |
|
||||
|
||||
**Production custody rules (Phase 36 Step 10):**
|
||||
|
||||
1. The `dp_keys` volume is mandatory. Losing it makes every encrypted patient column permanently unreadable — a database backup alone cannot recover PHI.
|
||||
2. Back up the keyring **separately** from PostgreSQL, daily, and replicate off-host.
|
||||
3. Single API instance only with filesystem keys. Scaling past one replica requires moving to `PersistKeysToDbContext<AppDbContext>()` (or a shared store) so all instances share the ring.
|
||||
4. Keys on the volume are not encrypted at rest; ensure the host filesystem / volume store is encrypted, or add `ProtectKeysWithCertificate()` later.
|
||||
|
||||
---
|
||||
|
||||
@@ -122,6 +129,77 @@ Prometheus metric: `phi_access_logs_total{access_type="VIEW|LIST|SEARCH|CREATE|U
|
||||
|
||||
Rotating one key without the other does not require touching the other, but both rotations need a full patient re-save.
|
||||
|
||||
**`SearchTokenKey` is effectively permanent in production.** Rotating it invalidates every stored `name_search_token` and breaks patient name search until a full `encrypt-phi` re-tokenization pass completes. Prefer treating it like a root secret: generate once, store in the secrets system, never rotate casually.
|
||||
|
||||
---
|
||||
|
||||
## Production keyring backup
|
||||
|
||||
On the deploy host (after the API has started at least once and written keys into the volume):
|
||||
|
||||
```bash
|
||||
# From a checkout that includes scripts/, or copy the script to /opt/vigilcare/scripts/
|
||||
./scripts/backup-dp-keys.sh
|
||||
```
|
||||
|
||||
- Volume: `vigilcare_dp_keys` (from compose `name: vigilcare` + volume `dp_keys`)
|
||||
- Default destination: `/var/backups/vigilcare/dp-keys/dp-keys-<UTC>.tar.gz` (mode `0600`)
|
||||
- Retention: 30 days inside that directory
|
||||
- Override destination for off-host sync: `BACKUP_DIR=/mnt/offsite/vigilcare/dp-keys ./scripts/backup-dp-keys.sh`
|
||||
|
||||
Suggested cron (daily 02:15 UTC):
|
||||
|
||||
```
|
||||
15 2 * * * /opt/vigilcare/scripts/backup-dp-keys.sh >> /var/log/vigilcare-dp-backup.log 2>&1
|
||||
```
|
||||
|
||||
Replicate `/var/backups/vigilcare/dp-keys/` (or `BACKUP_DIR`) to a second site. A backup that only lives on the same disk as the volume is not a disaster-recovery backup.
|
||||
|
||||
---
|
||||
|
||||
## Keyring restore
|
||||
|
||||
Use when the volume is empty/corrupt, the host was rebuilt, or PHI decrypt fails after a redeploy.
|
||||
|
||||
```bash
|
||||
./scripts/restore-dp-keys.sh /var/backups/vigilcare/dp-keys/dp-keys-YYYYMMDDThhmmssZ.tar.gz
|
||||
```
|
||||
|
||||
The script stops the `api` service (if compose is present), extracts the archive into `vigilcare_dp_keys`, then prints the bring-up steps:
|
||||
|
||||
```bash
|
||||
docker compose -f /opt/vigilcare/docker-compose.prod.yml --env-file /opt/vigilcare/.env up -d api
|
||||
curl -fsS http://localhost:5270/health/ready
|
||||
# Then fetch a known patient and confirm firstName/lastName decrypt to plaintext.
|
||||
```
|
||||
|
||||
**Do not** invent a new empty keyring and restart the API against an existing encrypted database — that permanently orphans ciphertext.
|
||||
|
||||
---
|
||||
|
||||
## Test-restore cadence
|
||||
|
||||
A backup that has never been restored is not a backup. Cadence:
|
||||
|
||||
| Cadence | Action |
|
||||
|---|---|
|
||||
| After first production deploy | Take an immediate backup; restore into a **throwaway** Docker volume on a non-prod host (or a second named volume); start an API against a DB snapshot and decrypt one patient |
|
||||
| Quarterly | Repeat the throwaway restore drill; record date, archive name, operator, and pass/fail in the ops log |
|
||||
| Before any host migration / disk replacement | Fresh backup, then restore drill on the target host before cutting traffic |
|
||||
|
||||
Throwaway restore sketch (does not touch production volume):
|
||||
|
||||
```bash
|
||||
docker volume create vigilcare_dp_keys_drill
|
||||
docker run --rm \
|
||||
-v vigilcare_dp_keys_drill:/keys \
|
||||
-v /var/backups/vigilcare/dp-keys:/backup:ro \
|
||||
alpine tar xzf /backup/dp-keys-<stamp>.tar.gz -C /keys
|
||||
# Point a staging API at DataProtection__KeyPath=/app/data-protection-keys
|
||||
# with -v vigilcare_dp_keys_drill:/app/data-protection-keys and a DB clone.
|
||||
docker volume rm vigilcare_dp_keys_drill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHI access log retention (HIPAA)
|
||||
@@ -156,10 +234,11 @@ These paths are **not** covered by column encryption in PostgreSQL:
|
||||
### `CryptographicException` / cannot decrypt patient
|
||||
|
||||
- Key ring missing or wrong `DataProtection:KeyPath`
|
||||
- App deployed to new host without copying `data-protection-keys/`
|
||||
- App deployed to new host without restoring `vigilcare_dp_keys` (or copying `data-protection-keys/`)
|
||||
- Production compose ran without the `dp_keys` volume — container regenerated a new empty ring
|
||||
- `ProtectorPurpose` changed without re-running `encrypt-phi`
|
||||
|
||||
**Fix:** Restore key ring from backup. Do not delete old keys until all data is re-encrypted.
|
||||
**Fix:** Restore key ring from backup (`scripts/restore-dp-keys.sh`). Do not delete old keys until all data is re-encrypted.
|
||||
|
||||
### Name search returns no results
|
||||
|
||||
@@ -194,5 +273,9 @@ Migration `WidenPhiEncryptedColumns` widens `first_name`, `last_name`, and emerg
|
||||
| `VigilCareClinicalAPI/Commands/EncryptPhiCommand.cs` | Bulk re-save CLI |
|
||||
| `VigilCareClinicalAPI/BackgroundServices/PatientPhiMigrationService.cs` | Startup token backfill |
|
||||
| `scripts/encrypt-existing-patient-phi.sh` | Wrapper for encrypt CLI |
|
||||
| `scripts/backup-dp-keys.sh` | Daily backup of `vigilcare_dp_keys` |
|
||||
| `scripts/restore-dp-keys.sh` | Restore keyring archive into the Docker volume |
|
||||
| `scripts/run-phase32-verification.sh` | End-to-end verification |
|
||||
| `docker-compose.prod.yml` | Mounts `dp_keys` → `/app/data-protection-keys` |
|
||||
| `docs/plans/phase-32-plan.md` | Implementation plan and design rationale |
|
||||
| `docs/plans/vigilcare-clinical-deployment-plan.md` | Phase 36 deployment (Step 10) |
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Production observability wiring (Phase 36 Step 9)
|
||||
#
|
||||
# Local-dev Prometheus/Grafana/Seq remain in docker-compose.yml + infra/.
|
||||
# Production uses *existing external* instances — do not start those services
|
||||
# from docker-compose.prod.yml.
|
||||
|
||||
## Prometheus
|
||||
|
||||
1. Keep [infra/prometheus/prometheus.yml](../infra/prometheus/prometheus.yml) as the
|
||||
local-dev scrape of `host.docker.internal:5270`. Do not add production jobs there.
|
||||
2. On the external Prometheus host, merge
|
||||
[infra/prometheus/production/scrape-vigilcare.yml](../infra/prometheus/production/scrape-vigilcare.yml)
|
||||
into `scrape_configs`.
|
||||
3. Load
|
||||
[infra/prometheus/production/alert-rules.yml](../infra/prometheus/production/alert-rules.yml)
|
||||
via `rule_files`.
|
||||
4. Optionally enable the commented blackbox probe for `/health/ready` (same scrape file).
|
||||
|
||||
### `/metrics` access
|
||||
|
||||
The Clinical API uses a global `FallbackPolicy` that requires an authenticated user.
|
||||
`/metrics` is explicitly `.AllowAnonymous()` so Prometheus can scrape without a JWT.
|
||||
**Restrict at the network layer** — firewall so only the Prometheus host (and
|
||||
operators) can reach `API_PORT` /metrics. Metric labels include department and
|
||||
gateway identifiers.
|
||||
|
||||
## Grafana
|
||||
|
||||
Prefer file provisioning over UI import (UI imports are lost on Grafana redeploy):
|
||||
|
||||
1. Copy datasources:
|
||||
[infra/grafana/production/datasources.yml](../infra/grafana/production/datasources.yml)
|
||||
— keep `uid: prometheus` so existing dashboards keep working.
|
||||
2. Copy dashboards from [infra/grafana/dashboards/](../infra/grafana/dashboards/) to the
|
||||
path referenced by
|
||||
[infra/grafana/production/dashboards-provider.yml](../infra/grafana/production/dashboards-provider.yml).
|
||||
3. Confirm panels render against the external Prometheus (job labels
|
||||
`vigilcare_api_prod` / `environment=production`).
|
||||
|
||||
Minimum production alerts (also in Prometheus rules): API down, outbox backlog,
|
||||
Kafka consumer lag, ready probe failing, gateway offline, unacked CRITICAL alerts.
|
||||
|
||||
## Seq
|
||||
|
||||
1. Point production at Seq via env (compose already sets these):
|
||||
- `Seq__ServerUrl` / `Serilog__WriteTo__1__Args__serverUrl` → `SEQ_URL`
|
||||
- `Serilog__WriteTo__1__Args__apiKey` → `SEQ_API_KEY` (ingest-only key)
|
||||
2. `appsettings.Production.json` sets
|
||||
`Microsoft.EntityFrameworkCore.Database.Command` to **Warning** so SQL with
|
||||
patient identifiers is not shipped to Seq.
|
||||
3. Serilog `Properties:Application` is set to `VigilCareClinicalAPI` /
|
||||
`VigilCare.WardGateway` for filtering.
|
||||
4. In Seq, create a signal (or shared dashboard) approximately:
|
||||
|
||||
```
|
||||
Application = 'VigilCareClinicalAPI' and @Level in ['Error', 'Fatal']
|
||||
```
|
||||
|
||||
Optionally a second signal for the ward gateway with
|
||||
`Application = 'VigilCare.WardGateway'`.
|
||||
@@ -0,0 +1,656 @@
|
||||
# VigilCare Clinical — Physician Overview
|
||||
|
||||
**An introductory guide for clinicians evaluating the platform**
|
||||
|
||||
This document explains what VigilCare does, why it exists, and how it supports bedside and ward-level decision-making. It is written for physicians, advanced practice providers, and nurses. No software engineering background is required.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [Clinical Workflow](#2-clinical-workflow)
|
||||
3. [System Data Flow](#3-system-data-flow)
|
||||
4. [Dashboard Walkthrough](#4-dashboard-walkthrough)
|
||||
5. [Clinical Scenarios](#5-clinical-scenarios)
|
||||
6. [Architecture Overview (High Level)](#6-architecture-overview-high-level)
|
||||
7. [Safety and Reliability](#7-safety-and-reliability)
|
||||
8. [End-to-End Journey](#8-end-to-end-journey)
|
||||
9. [Quick Reference](#9-quick-reference)
|
||||
10. [Important Caveats for Evaluators](#10-important-caveats-for-evaluators)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### The problem
|
||||
|
||||
Hospitalized patients can deteriorate between scheduled assessments. Early warning signs are often scattered across monitors, the electronic health record (EHR), lab systems, and paper notes. Clinicians must mentally assemble heart rate, blood pressure, respiratory rate, oxygen saturation, temperature, consciousness, and labs into a coherent risk picture — often under time pressure, across many patients, and with incomplete context about recent medications or prior scores.
|
||||
|
||||
Missed or delayed recognition of deterioration and sepsis remains a major patient-safety challenge. At the same time, poorly tuned alarms create alert fatigue: too many low-value warnings train staff to ignore the ones that matter.
|
||||
|
||||
### What VigilCare is
|
||||
|
||||
**VigilCare Clinical** is a **clinical decision support (CDS)** and **continuous ward monitoring** platform. It watches vital signs and laboratory-style observations for admitted patients, applies established clinical scoring systems in near real time, and surfaces **prioritized, explainable alerts** on a ward dashboard so the right clinician can act sooner.
|
||||
|
||||
It is **not** a full EHR. It does not replace charting, computerized order entry, pharmacy, or billing. It sits beside those systems as a focused early-warning and sepsis-pathway layer.
|
||||
|
||||
### Who it is for
|
||||
|
||||
| Role | How they use VigilCare |
|
||||
|------|------------------------|
|
||||
| **Bedside nurse** | See ward acuity at a glance, enter vitals, review why an alert fired, acknowledge, track sepsis bundle timing |
|
||||
| **Physician / APP** | Triage high-NEWS2 and sepsis cases, review score trends and organ dysfunction, prioritize rounds |
|
||||
| **Charge nurse / supervisor** | Department overview, open alerts, overdue sepsis bundles, handoff reports |
|
||||
| **Clinical leadership / quality** | Alert-quality feedback, audit trail, threshold oversight |
|
||||
|
||||
Primary care settings in scope: **inpatient wards, ICU-style continuous monitoring, emergency and specialty units** (medicine, surgery, cardiology, pediatrics as configured).
|
||||
|
||||
### Primary benefits
|
||||
|
||||
**For clinicians**
|
||||
|
||||
- **Acuity-sorted ward board** — patients ranked by NEWS2 so the sickest rise to the top
|
||||
- **Standardized scores** — NEWS2, GCS, qSOFA (screening), and SOFA (organ dysfunction) computed consistently from incoming data
|
||||
- **Explainable alerts** — plain-language “why this fired,” including contributing vitals and recent relevant medications
|
||||
- **Sepsis pathway discipline** — qSOFA screens for possible infection-related risk; SOFA delta confirms organ dysfunction and starts hour-1 bundle tracking
|
||||
- **Trend awareness** — rapid vital-sign change can alert even before a value crosses a classic “critical” line
|
||||
- **Fewer repeated nuisance warnings** — after a clinician acknowledges a suppressible warning, similar low-severity repeats can be quieted for a configurable window (critical alerts are never quieted this way)
|
||||
|
||||
**For patients**
|
||||
|
||||
- Earlier visibility of deterioration trajectories
|
||||
- Structured tracking of time-critical sepsis interventions
|
||||
- Continuity of critical bedside alerts even if the hospital’s central network link is temporarily unavailable (ward gateway design)
|
||||
|
||||
**Bottom line:** VigilCare aims to reduce cognitive load, shorten time-to-recognition, and support — never replace — clinical judgment.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clinical Workflow
|
||||
|
||||
VigilCare fits into the work you already do. It does not invent a new model of care; it accelerates recognition and organizes what you already interpret.
|
||||
|
||||
### From admission to action
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Patient admitted<br/>encounter opened] --> B[Vitals & labs<br/>arrive continuously]
|
||||
B --> C[Scores update<br/>NEWS2 · GCS · qSOFA · SOFA]
|
||||
C --> D[Alerts appear<br/>on ward board]
|
||||
D --> E[Clinician reviews<br/>and acts]
|
||||
E --> F[Acknowledge / escalate<br/>bundle / reassess]
|
||||
```
|
||||
|
||||
| Step | What happens clinically | What VigilCare does |
|
||||
|------|-------------------------|---------------------|
|
||||
| **1. Admission** | Patient registered; inpatient (or ED) encounter opened; room/bed assigned | Creates the encounter context that all observations and alerts attach to |
|
||||
| **2. Monitoring** | Bedside monitors, nurses, and labs generate measurements | Ingests observations (directly, via FHIR/integration engine, or ward gateway) |
|
||||
| **3. Scoring** | You would normally recalculate NEWS2 / think through sepsis criteria | Continuously recomputes NEWS2, GCS, qSOFA, SOFA, and rate-of-change trends |
|
||||
| **4. Alerting** | Someone notices a concerning pattern — or misses it | Raises threshold, composite-score, trend, or sepsis-pathway alerts with severity |
|
||||
| **5. Triage** | Charge nurse or physician prioritizes who to see next | Virtual Ward sorts by NEWS2; Critical / Alerts / Active Sepsis filters |
|
||||
| **6. Assessment** | Bedside review of vitals, meds, trajectory | Patient Detail: trends, score history, alert reasoning, orders, bundle status |
|
||||
| **7. Response** | Orders, fluids, antibiotics, escalation of care | Acknowledge/resolve workflow; hour-1 sepsis bundle checklist; optional handoff report |
|
||||
| **8. Continuity** | Night shift inherits the picture | Audit trail of acknowledgments; SBAR-style handoff; escalation if no ack in time |
|
||||
|
||||
### How sepsis is handled (Sepsis-3 aligned)
|
||||
|
||||
VigilCare follows a two-step clinical logic that matches modern sepsis practice:
|
||||
|
||||
1. **qSOFA ≥ 2** → **screening alert** (“consider sepsis workup / order SOFA labs”). This is **not** a sepsis diagnosis and does **not** start the treatment bundle.
|
||||
2. **SOFA rise of ≥ 2 from the patient’s baseline** → **`SOFA_SEPSIS` critical alert** and automatic **hour-1 bundle tracking** (blood cultures, lactate, broad-spectrum antibiotics, IV crystalloid 30 mL/kg).
|
||||
|
||||
This separation reduces over-calling sepsis from bedside screens alone while still accelerating recognition when organ dysfunction is confirmed.
|
||||
|
||||
### What you do vs. what the system does
|
||||
|
||||
| Clinician owns | System supports |
|
||||
|----------------|-----------------|
|
||||
| Diagnosis and differential | Pattern detection and score calculation |
|
||||
| Ordering and treatment decisions | Suggested bundle elements and countdown visibility |
|
||||
| Whether an alert is clinically meaningful | Explainable reasoning + feedback ratings |
|
||||
| Escalation of care (RRT, ICU) | Escalation status if an alert sits unacknowledged |
|
||||
| Final accountability for the patient | Audit trail of who saw what and when |
|
||||
|
||||
---
|
||||
|
||||
## 3. System Data Flow
|
||||
|
||||
Think of VigilCare as a clinical assembly line: raw measurements enter, are checked for quality, scored with standard tools, then presented as prioritized work for the care team.
|
||||
|
||||
### Overview
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph sources [Data sources]
|
||||
EHR[EHR / integration engine<br/>HL7 → FHIR]
|
||||
MON[Bedside monitors<br/>& devices]
|
||||
LAB[Lab systems]
|
||||
NURSE[Manual vitals entry<br/>on dashboard]
|
||||
GW[Ward gateway<br/>local buffer]
|
||||
end
|
||||
|
||||
subgraph ingest [Ingestion & quality]
|
||||
API[Clinical ingest<br/>REST or FHIR]
|
||||
VAL[Validation<br/>plausible ranges, duplicates]
|
||||
NORM[Normalization<br/>units & codes mapped]
|
||||
end
|
||||
|
||||
subgraph score [Clinical intelligence]
|
||||
TH[Instant thresholds<br/>critical / warning]
|
||||
SC[Scoring engines<br/>NEWS2 · GCS · qSOFA · SOFA]
|
||||
TR[Trend / rate-of-change]
|
||||
end
|
||||
|
||||
subgraph alert [Alert management]
|
||||
GEN[Alert generation<br/>+ plain-language explanation]
|
||||
PRI[Prioritization<br/>Critical vs Warning]
|
||||
SUP[Suppression<br/>after ack of warnings]
|
||||
ESC[Escalation<br/>if unacknowledged]
|
||||
end
|
||||
|
||||
subgraph ui [Clinician surface]
|
||||
DASH[Dashboard<br/>ward · patient · alerts · sepsis]
|
||||
ACK[Acknowledge / resolve<br/>feedback]
|
||||
end
|
||||
|
||||
EHR --> API
|
||||
MON --> API
|
||||
LAB --> API
|
||||
NURSE --> API
|
||||
GW --> API
|
||||
API --> VAL --> NORM
|
||||
NORM --> TH
|
||||
NORM --> SC
|
||||
NORM --> TR
|
||||
TH --> GEN
|
||||
SC --> GEN
|
||||
TR --> GEN
|
||||
GEN --> PRI --> SUP
|
||||
PRI --> ESC
|
||||
PRI --> DASH
|
||||
ESC --> DASH
|
||||
DASH --> ACK
|
||||
ACK --> SUP
|
||||
```
|
||||
|
||||
### 3.1 Data ingestion
|
||||
|
||||
| Source | Plain-language role |
|
||||
|--------|---------------------|
|
||||
| **EHR / integration engine** | Hospital systems can send patients, encounters, observations, and medication administrations using a standard health-data format (FHIR R4), often via an interface engine such as Mirth Connect |
|
||||
| **Bedside monitors & devices** | Continuous or intermittent vitals stream into the same clinical ingest path |
|
||||
| **Laboratory systems** | Results (lactate, creatinine, platelets, bilirubin, blood gas, etc.) feed organ-dysfunction scoring |
|
||||
| **Dashboard vitals entry** | Nurses can record a vitals set at the bedside when devices are not auto-feeding |
|
||||
| **Ward gateway** | A local ward server can keep accepting data and raising critical alerts if the link to the central hospital systems is down, then sync when connectivity returns |
|
||||
|
||||
Each measurement is stored against an **encounter** (one hospital stay/episode), not floating free — the same way you think about “this admission’s vitals.”
|
||||
|
||||
### 3.2 Validation and normalization
|
||||
|
||||
Before scoring:
|
||||
|
||||
- Values are checked for **clinical plausibility** (e.g., absurd heart rates rejected)
|
||||
- **Duplicate retries** from devices are ignored safely (idempotent ingest)
|
||||
- Codes and units are **mapped to a common clinical vocabulary** (e.g., LOINC → internal observation codes; °F → °C when needed)
|
||||
|
||||
This step exists so scoring engines see comparable, trustworthy inputs.
|
||||
|
||||
### 3.3 Clinical rules and scoring
|
||||
|
||||
| Tool | Clinical question it answers | What triggers attention |
|
||||
|------|------------------------------|-------------------------|
|
||||
| **Instant thresholds** | Is this single value dangerous right now? | Critical or warning bands for HR, SpO₂, BP, temperature, K⁺, lactate, glucose, etc. |
|
||||
| **NEWS2** | How high is general deterioration risk? | 5–6 (or any single parameter = 3) → warning; ≥ 7 → emergency |
|
||||
| **GCS** | What is consciousness level? | 9–12 warning; ≤ 8 critical; also feeds NEWS2 and qSOFA/SOFA |
|
||||
| **qSOFA** | Should I screen for possible sepsis-related risk at the bedside? | ≥ 2 of: RR ≥ 22, SBP ≤ 100, altered mentation |
|
||||
| **SOFA** | Is there new organ dysfunction vs this patient’s baseline? | Delta +1 warning; delta ≥ 2 → sepsis pathway + hour-1 bundle |
|
||||
| **Trend detection** | Is something getting worse *fast*, even if still “in range”? | Rate-of-change thresholds over ~30 minutes (e.g., rising HR/RR, falling SpO₂/SBP) |
|
||||
|
||||
**NEWS2 parameters (7):** respiratory rate, SpO₂, systolic BP, heart rate, consciousness (GCS preferred, else AVPU), temperature, supplemental oxygen.
|
||||
|
||||
**SOFA organs (6):** respiratory, coagulation, liver, cardiovascular, CNS, renal. Labs older than 24 hours are treated cautiously (stale contribution dropped); 12–24 hour labs are flagged as aging.
|
||||
|
||||
### 3.4 Trend analysis and alert generation
|
||||
|
||||
Alerts are created in layers:
|
||||
|
||||
1. **Immediate critical values** — evaluated as soon as the observation arrives (patient-safety priority)
|
||||
2. **Composite scores** — NEWS2, GCS, qSOFA, SOFA recalculated as new pieces of the puzzle arrive
|
||||
3. **Trends** — rapid deterioration even without crossing a classic critical number
|
||||
|
||||
Composite alerts carry an **immutable explanation**: which parameters contributed, trend context when relevant, recent medication context when relevant, and a short bedside narrative.
|
||||
|
||||
### 3.5 Prioritization, suppression, and escalation
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[New alert] --> B{Severity?}
|
||||
B -->|Critical| C[Always visible<br/>never suppressible]
|
||||
B -->|Warning| D[Visible; may be<br/>suppressible after ack]
|
||||
C --> E[Clinician inbox / ward]
|
||||
D --> E
|
||||
E --> F{Acknowledged<br/>within ~5 min?}
|
||||
F -->|Yes| G[Status: Acknowledged<br/>optional note]
|
||||
F -->|No| H[Status: Escalated<br/>backup notified]
|
||||
G --> I{Suppressible<br/>warning type?}
|
||||
I -->|Yes| J[Quiet similar repeats<br/>for configured window]
|
||||
I -->|No| K[Continue monitoring]
|
||||
G --> L[Later: Resolve<br/>when clinically closed]
|
||||
```
|
||||
|
||||
| Mechanism | Clinical intent |
|
||||
|-----------|-----------------|
|
||||
| **Critical vs Warning** | Separate “interrupt now” from “review soon” |
|
||||
| **Deduplication** | Avoid stacking identical open screens (e.g., one open qSOFA screen) |
|
||||
| **Suppression window** | After acknowledging a *warning*, reduce repeat noise of the same type for a period (default on the order of tens of minutes; configurable) |
|
||||
| **Never suppress** | Critical vitals/labs, NEWS2 emergency, GCS ≤ 8, SOFA sepsis, rapid deterioration |
|
||||
| **Escalation** | If still open after the acknowledgment timeout (~5 minutes), mark escalated for backup response |
|
||||
| **Medication annotation** | If a recent drug (e.g., beta-blocker) may explain bradycardia or hypotension, the alert **still fires** but shows that context so you can interpret it |
|
||||
|
||||
### 3.6 Dashboard presentation
|
||||
|
||||
The dashboard is the clinician’s workplace surface: ward list, patient deep-dive, hospital-wide alert inbox, sepsis bundle board, and department overview. Details are in [§4](#4-dashboard-walkthrough).
|
||||
|
||||
### 3.7 Clinician acknowledgement and feedback loop
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Alert presented] --> B[Review reasoning]
|
||||
B --> C[Clinical action<br/>outside or alongside system]
|
||||
C --> D[Acknowledge]
|
||||
D --> E[Optional: rate alert quality]
|
||||
E --> F[Quality analytics]
|
||||
F --> G[Threshold / rule tuning<br/>over time]
|
||||
D --> H[Resolve when done]
|
||||
```
|
||||
|
||||
After reviewing an alert, clinicians can rate it (useful, would act, too early, too late, false positive, missing context). Aggregated ratings feed an **alert quality** view so the platform can be tuned with clinician input rather than only engineering assumptions.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dashboard Walkthrough
|
||||
|
||||
### 4.1 Virtual Ward
|
||||
|
||||
**Purpose:** Answer “Who on this floor needs me first?”
|
||||
|
||||
| You see | How to interpret | Actions |
|
||||
|---------|------------------|---------|
|
||||
| Patient list sorted by **NEWS2** (highest first) | Acuity order for rounds / safety huddles | Open a patient row for detail |
|
||||
| NEWS2, qSOFA, sepsis, open-alert columns | Multi-signal glance without opening the chart | Sort by any column; search name/MRN |
|
||||
| Filters: Critical (NEWS2 ≥ 7), Has Alerts, Active Sepsis, department | Narrow to the unstable subset | Toggle filters for charge-nurse triage |
|
||||
| **Handoff report** | Structured SBAR-style summary of visible patients | Generate for shift change; print/export as allowed |
|
||||
|
||||
### 4.2 Department Overview
|
||||
|
||||
**Purpose:** Unit- and hospital-level situational awareness.
|
||||
|
||||
| You see | How to interpret | Actions |
|
||||
|---------|------------------|---------|
|
||||
| Counts of active patients, NEWS2 ≥ 7, open alerts, active sepsis bundles | Load and acuity of the service | Drill toward ward or sepsis board |
|
||||
| Per-department acuity bars | Which units are under pressure | Prioritize staffing / senior review |
|
||||
|
||||
### 4.3 Patient Detail
|
||||
|
||||
**Purpose:** Full clinical review for one encounter.
|
||||
|
||||
| Section | Information | Clinical use |
|
||||
|---------|-------------|--------------|
|
||||
| **Patient banner** | Demographics, allergies, emergency contact, blood type | Safety context before acting |
|
||||
| **Scores** | Current NEWS2 risk, SOFA with organ breakdown, GCS components | Snapshot of acuity and organ failure |
|
||||
| **Latest vitals** | Most recent measurements | Confirm what the scores are based on |
|
||||
| **Vitals entry** | Manual recording of a vitals set | Bedside documentation into the monitoring stream |
|
||||
| **Active alerts** | Open warnings/criticals for this patient | Click a row for explanation |
|
||||
| **Alert reasoning** | Plain-language narrative, score contributors, med context | Decide if this is pathology, pharmacology, or artifact |
|
||||
| **Orders** | Labs, antibiotics, fluids, etc. tracked in-system | See what has been ordered/resulted in this pathway |
|
||||
| **Sepsis bundle** | Four hour-1 elements + compliance clock | Drive timely sepsis interventions |
|
||||
| **Vital charts** | HR, RR, SBP, SpO₂, temperature with **medication markers** | Correlate spikes/dips with drug administration |
|
||||
| **Score histories** | NEWS2, SOFA, GCS, qSOFA over time | Trajectory — improving vs worsening |
|
||||
| **Encounter timeline** | Chronology of status changes, observations, alerts | Reconstruct the story of the stay |
|
||||
| **Discharge summary panel** | Available when discharging | Close the episode with structured summary support |
|
||||
|
||||
### 4.4 Alert Center
|
||||
|
||||
**Purpose:** Hospital-wide inbox independent of any one patient.
|
||||
|
||||
| Tab | Meaning |
|
||||
|-----|---------|
|
||||
| **Open** | Needs attention |
|
||||
| **Acknowledged** | Seen; clinical response may still be in progress |
|
||||
| **Resolved** | Closed |
|
||||
| **Escalated** | Missed acknowledgment window — backup path |
|
||||
|
||||
**Actions:** Acknowledge (with role-aware note), then Resolve. Critical alerts may also trigger an audible tone / browser notification on the dashboard.
|
||||
|
||||
### 4.5 Sepsis Bundle Board
|
||||
|
||||
**Purpose:** “Which sepsis clocks are ticking?”
|
||||
|
||||
Bundles are sorted by urgency: **overdue → at risk (< 15 minutes remaining) → on track**. Each shows the four hour-1 elements and countdown from recognition.
|
||||
|
||||
**Clinical note:** Bundle elements in VigilCare are **decision-support tracking** linked to in-system orders. In a live hospital deployment they would need to be connected to real CPOE/pharmacy workflows; they do not by themselves dispense medication.
|
||||
|
||||
### 4.6 Alert Quality / Feedback
|
||||
|
||||
**Purpose:** Continuous improvement of alerting.
|
||||
|
||||
Clinicians rate alerts; leadership reviews false-positive rates, acknowledgment patterns, and usefulness by alert type.
|
||||
|
||||
### 4.7 Administrative / operations views (awareness)
|
||||
|
||||
| View | Why clinicians should care |
|
||||
|------|----------------------------|
|
||||
| **Threshold management** | Local policy for warning/critical bands (admin-governed) |
|
||||
| **Audit log** | Who acknowledged what, when — governance and M&M support |
|
||||
| **Reconciliation / data quality** | Flags gaps such as disconnected-monitor style issues |
|
||||
| **Gateway operations** | Whether a ward is online, degraded, or buffering during outages |
|
||||
|
||||
---
|
||||
|
||||
## 5. Clinical Scenarios
|
||||
|
||||
These scenarios mirror the kinds of trajectories the platform is designed to detect (including built-in simulator cases used for clinician evaluation).
|
||||
|
||||
### Scenario A — Elderly UTI progressing to sepsis
|
||||
|
||||
**Story:** An 82-year-old woman is admitted with urinary tract infection. Over hours, respiratory rate rises, systolic BP drifts down, and mentation becomes cloudy.
|
||||
|
||||
| Phase | Data / scores | VigilCare assistance |
|
||||
|-------|---------------|----------------------|
|
||||
| Early | Mild vital changes | Ward list may still look relatively calm |
|
||||
| Screen | RR ≥ 22, SBP ≤ 100, GCS < 15 → **qSOFA ≥ 2** | `QSOFA_SCREEN` warning: consider SOFA labs / sepsis workup |
|
||||
| Confirmation | Rising creatinine, falling platelets, worsening GCS → **SOFA delta ≥ 2** | `SOFA_SEPSIS` critical alert + hour-1 bundle with countdown |
|
||||
| Response | Cultures, lactate, antibiotics, fluids ordered/resulted | Bundle board shows compliance vs deadline |
|
||||
| Continuity | Night team takes over | Handoff report + open alerts + audit of who acknowledged |
|
||||
|
||||
**Decision support value:** Separates “screen positive” from “organ dysfunction confirmed,” and makes the hour-1 clock visible.
|
||||
|
||||
### Scenario B — Post-operative occult hemorrhage
|
||||
|
||||
**Story:** After surgery, heart rate climbs and blood pressure/SpO₂ trend downward while the patient still “looks okay” between checks.
|
||||
|
||||
| Signal | VigilCare role |
|
||||
|--------|----------------|
|
||||
| Individual vitals entering warning/critical bands | Immediate threshold alerts |
|
||||
| Steep slope of HR up / SBP down | `RAPID_DETERIORATION` even before classic critical cutoffs |
|
||||
| Rising NEWS2 | Patient climbs the ward sort order |
|
||||
| Charts + med markers | Distinguish bleeding physiology from recent analgesic/sedative effects |
|
||||
|
||||
**Decision support value:** Trajectory and composite risk, not only single snapshot values.
|
||||
|
||||
### Scenario C — Neurological decline
|
||||
|
||||
**Story:** Traumatic brain injury or post-neurosurgical patient with serial neuro checks.
|
||||
|
||||
| Signal | VigilCare role |
|
||||
|--------|----------------|
|
||||
| Falling eye/verbal/motor scores | GCS warning → critical as total drops |
|
||||
| GCS history chart | Makes the drop from 14 → 10 over hours unmistakable |
|
||||
| Downstream effects | NEWS2 consciousness, qSOFA mentation, SOFA CNS update |
|
||||
|
||||
**Decision support value:** Turns intermittent GCS documentation into a visible neurological trajectory.
|
||||
|
||||
### Scenario D — Medication-related “false alarm” context
|
||||
|
||||
**Story:** A patient receives metoprolol; heart rate and blood pressure fall into warning ranges.
|
||||
|
||||
| What happens | Clinical nuance |
|
||||
|--------------|-----------------|
|
||||
| Warning / NEWS2 alerts still fire | Safety: do not hide potentially real deterioration |
|
||||
| Alert reasoning shows recent beta-blocker | Supports interpretation: drug effect vs shock |
|
||||
| Clinician may ack + rate as “false positive” or “missing context” if appropriate | Feeds alert-quality improvement |
|
||||
| Suppressible warnings can quiet repeats after ack | Reduces fatigue while criticals remain live |
|
||||
|
||||
**Decision support value:** Context without silencing the alarm prematurely.
|
||||
|
||||
### Scenario E — Ward isolation during network failure
|
||||
|
||||
**Story:** Central hospital uplink fails (e.g., severe weather). Bedside monitoring must continue.
|
||||
|
||||
| What continues locally | What syncs later |
|
||||
|------------------------|------------------|
|
||||
| Observation buffering on the ward gateway | Backlog of vitals/labs to central |
|
||||
| Critical/warning alerting at the ward | Acknowledgments without duplicate pages |
|
||||
| Ops visibility of degraded/offline gateways | Full audit trail after reconnection |
|
||||
|
||||
**Decision support value:** Monitoring continuity is treated as a clinical safety requirement, not only an IT concern.
|
||||
|
||||
---
|
||||
|
||||
## 6. Architecture Overview (High Level)
|
||||
|
||||
You do not need to know how the software is built to use it. This section only explains the major “rooms” of the hospital metaphor — enough to trust reliability and boundaries.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph clinicians [Clinicians]
|
||||
UI[Ward dashboard<br/>browser application]
|
||||
end
|
||||
|
||||
subgraph brain [Clinical brain]
|
||||
API[Central clinical service<br/>stores data · runs rules · serves the dashboard]
|
||||
end
|
||||
|
||||
subgraph edge [Ward edge]
|
||||
WG[Ward gateway<br/>keeps working if the network drops]
|
||||
end
|
||||
|
||||
subgraph memory [Clinical memory & messaging]
|
||||
DB[(Durable patient record)]
|
||||
CACHE[(Fast short-term state<br/>for scoring windows)]
|
||||
BUS[[Event stream<br/>many listeners in parallel]]
|
||||
PAGE[[Notification / escalation path]]
|
||||
end
|
||||
|
||||
UI <--> API
|
||||
WG <--> API
|
||||
EHR[Hospital EHR / labs / devices] --> API
|
||||
EHR --> WG
|
||||
API --> DB
|
||||
API --> CACHE
|
||||
API --> BUS
|
||||
BUS --> API
|
||||
API --> PAGE
|
||||
```
|
||||
|
||||
| Component | Everyday analogy | Clinical contribution |
|
||||
|-----------|------------------|------------------------|
|
||||
| **Dashboard** | The ward whiteboard + patient chart view | Where you see acuity, alerts, trends, and act |
|
||||
| **Central clinical service** | The always-on clinical calculator and filing clerk | Validates data, stores the record of truth, computes scores, creates alerts |
|
||||
| **Ward gateway** | A backup charge desk that keeps working in a blackout | Local continuity during network partition |
|
||||
| **Durable database** | The permanent paper chart (electronic) | Source of truth for encounters, observations, alerts, audit |
|
||||
| **Short-term state store** | Scratch pad for “vitals in the last few hours” | Assembles NEWS2/qSOFA/SOFA windows quickly |
|
||||
| **Event stream** | Overhead announcement that many teams hear at once | Lets scoring, search, and archiving proceed independently without blocking ingest |
|
||||
| **Notification path** | Paging hierarchy | Escalates if nobody acknowledges in time |
|
||||
| **FHIR / integration facade** | Hospital interpreter between vendor systems | Lets EHR ecosystems send/receive standard clinical resources |
|
||||
|
||||
**Design principle that matters clinically:** critical single-value breaches are handled with highest urgency on ingest; richer composite scoring runs immediately afterward in parallel so one heart-rate reading can update NEWS2, trends, and sepsis screens together.
|
||||
|
||||
---
|
||||
|
||||
## 7. Safety and Reliability
|
||||
|
||||
### Minimizing false positives (without hiding true danger)
|
||||
|
||||
| Safeguard | Effect |
|
||||
|-----------|--------|
|
||||
| Separate **warning** vs **critical** severity | Reduces “everything is red” |
|
||||
| **qSOFA ≠ sepsis diagnosis** | Screening does not auto-start bundles |
|
||||
| **SOFA baseline + delta** | Alerts on *new* organ dysfunction, not chronic abnormality alone |
|
||||
| **Medication context** | Helps interpret pharmacology-related vital changes |
|
||||
| **Suppression after clinician ack** | Quiets repeat *warnings* only; criticals always break through |
|
||||
| **Deduplication** | One open screen/alert of a type instead of a stack of clones |
|
||||
| **Clinician feedback ratings** | Measures real false-positive burden for tuning |
|
||||
|
||||
### Preventing missed deterioration
|
||||
|
||||
| Safeguard | Effect |
|
||||
|-----------|--------|
|
||||
| Continuous multi-score surveillance | NEWS2 + thresholds + trends + sepsis pathway |
|
||||
| **Rapid deterioration** alerts | Catches velocity before absolute critical values |
|
||||
| Ward list sorted by NEWS2 | Systematic prioritization |
|
||||
| **Escalation** on unacknowledged alerts | Backup if the first clinician is occupied |
|
||||
| **Lab staleness handling** in SOFA | Avoids false reassurance from ancient labs |
|
||||
| **Ward gateway continuity** | Critical monitoring during uplink loss |
|
||||
| Data-quality / reconciliation views | Surface gaps in monitoring feed |
|
||||
|
||||
### Auditability
|
||||
|
||||
Clinical write actions — including alert acknowledgment/resolution, threshold changes, and encounter transitions — are recorded in an **append-only audit log** with who, what, when, and relevant before/after context. This supports governance, quality review, and understanding of the care timeline.
|
||||
|
||||
### Clinician oversight — not replacement of judgment
|
||||
|
||||
VigilCare is explicitly positioned as **decision support**:
|
||||
|
||||
- Alerts explain themselves; they do not issue diagnoses
|
||||
- Bundle tracking does not replace the clinician’s order decisions or live pharmacy systems
|
||||
- Acknowledge / resolve / feedback keep a human in the loop
|
||||
- Role-based access limits who can change thresholds or view administrative audit tools
|
||||
|
||||
**The clinician remains accountable for assessment and treatment.** The system’s job is to make deterioration harder to miss and easier to explain.
|
||||
|
||||
---
|
||||
|
||||
## 8. End-to-End Journey
|
||||
|
||||
A single concrete walkthrough — **Mrs. Chen, 78, admitted with suspected pneumonia** — from first data point to clinician action.
|
||||
|
||||
### Step 1 — Encounter opens
|
||||
|
||||
Mrs. Chen is registered with an MRN. An **active inpatient encounter** is opened on the medical ward (room, attending, admission reason recorded). VigilCare now has a container for all observations and alerts for this stay.
|
||||
|
||||
### Step 2 — Data begins to flow
|
||||
|
||||
Over the next hours, measurements arrive from mixed sources:
|
||||
|
||||
- Bedside monitor / nurse vitals: HR, RR, BP, SpO₂, temperature, oxygen use
|
||||
- Consciousness: GCS components (or AVPU)
|
||||
- Labs: CBC, chemistries, lactate as ordered
|
||||
- Medications: e.g., antibiotics, antipyretics as administered
|
||||
|
||||
Each value is validated, stored against her encounter, and passed to scoring.
|
||||
|
||||
### Step 3 — Early composite picture
|
||||
|
||||
When enough NEWS2 parameters are present, a **NEWS2 score** appears on the Virtual Ward. Mrs. Chen may sit mid-list initially (e.g., NEWS2 3–4).
|
||||
|
||||
GCS is tracked; it also informs consciousness for NEWS2 and mentation for qSOFA/SOFA.
|
||||
|
||||
### Step 4 — Bedside sepsis screen turns positive
|
||||
|
||||
RR rises to 24, SBP falls to 98, GCS drops to 14.
|
||||
|
||||
**qSOFA criteria = 3.** VigilCare raises a **`QSOFA_SCREEN` (warning)** with an explanation: which criteria are active and that SOFA labs / sepsis evaluation should be considered. The ward list and Alert Center show the new open alert. **No sepsis bundle starts yet.**
|
||||
|
||||
### Step 5 — Organ dysfunction confirmed
|
||||
|
||||
Labs return: creatinine up, platelets down; cardiovascular and respiratory SOFA components worsen. Compared with Mrs. Chen’s **SOFA baseline** (established once enough organ data existed), the **delta is ≥ 2**.
|
||||
|
||||
VigilCare raises **`SOFA_SEPSIS` (critical)** and creates an **hour-1 bundle** with four tracked elements and a one-hour deadline. The Sepsis Bundle Board now shows her countdown. A critical banner/notification can draw immediate attention.
|
||||
|
||||
### Step 6 — Concurrent safety nets
|
||||
|
||||
Meanwhile:
|
||||
|
||||
- If SpO₂ briefly hits a critical band, a **critical threshold alert** fires immediately
|
||||
- If HR climbs steeply over 30 minutes, **rapid deterioration** may fire even before a classic critical HR
|
||||
- NEWS2 may cross ≥ 7 → **`NEWS2_EMERGENCY`**, moving her to the top of the ward sort
|
||||
|
||||
### Step 7 — Clinician review
|
||||
|
||||
The charge nurse opens **Patient Detail**:
|
||||
|
||||
- Banner confirms allergies
|
||||
- Scores show NEWS2 high, SOFA elevated with organ breakdown, GCS trend down
|
||||
- Charts show the vital trajectory; medication markers show recent administrations
|
||||
- Alert reasoning narrates why `SOFA_SEPSIS` fired
|
||||
- Bundle panel lists cultures, lactate, antibiotics, fluids
|
||||
|
||||
### Step 8 — Action and acknowledgment
|
||||
|
||||
The physician reviews, initiates clinical orders in the hospital workflow, and in VigilCare **acknowledges** the critical alert with a note. Acknowledgment is audit-logged. Escalation timer stops for that alert.
|
||||
|
||||
As bundle-linked elements are completed/resulted in-system, the bundle moves toward **COMPLIANT** (within the hour) or **NON_COMPLIANT** (past deadline) — making timeliness visible for the team and for quality review.
|
||||
|
||||
### Step 9 — Feedback and handoff
|
||||
|
||||
The clinician optionally rates the alert (“useful / would act”). At shift change, a **handoff report** summarizes Mrs. Chen among other high-acuity patients. The night team inherits open items, score trends, and the audit trail of daytime acknowledgments.
|
||||
|
||||
### Journey map
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Src as Monitors / EHR / Labs
|
||||
participant VC as VigilCare
|
||||
participant RN as Nurse
|
||||
participant MD as Physician
|
||||
|
||||
Src->>VC: Vitals & labs for Mrs. Chen
|
||||
VC->>VC: Validate · store · score
|
||||
VC->>RN: Ward list updates NEWS2
|
||||
Note over VC: qSOFA ≥ 2
|
||||
VC->>RN: QSOFA_SCREEN warning
|
||||
RN->>MD: Sepsis workup / SOFA labs
|
||||
Src->>VC: Organ dysfunction labs
|
||||
Note over VC: SOFA delta ≥ 2
|
||||
VC->>MD: SOFA_SEPSIS + hour-1 bundle
|
||||
MD->>VC: Acknowledge alert
|
||||
MD->>VC: Bundle elements completed
|
||||
VC->>RN: Compliance status visible
|
||||
RN->>MD: Handoff report at shift change
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Quick Reference
|
||||
|
||||
### Alert types you will commonly see
|
||||
|
||||
| Alert family | Typical meaning | Usual severity |
|
||||
|--------------|-----------------|----------------|
|
||||
| `CRITICAL_*` / `WARNING_*` | Single vital or lab outside configured bands | Critical / Warning |
|
||||
| `NEWS2_WARNING` / `NEWS2_EMERGENCY` | Early warning score medium / high | Warning / Critical |
|
||||
| `GCS_WARNING` / `GCS_CRITICAL` | Moderate / severe consciousness impairment | Warning / Critical |
|
||||
| `QSOFA_SCREEN` | Bedside sepsis *screen* positive | Warning |
|
||||
| `SOFA_WARNING` / `SOFA_SEPSIS` | Organ dysfunction rising / sepsis pathway | Warning / Critical |
|
||||
| `RAPID_DETERIORATION` | Fast adverse vital trend | Critical |
|
||||
|
||||
### Scoring cheat sheet
|
||||
|
||||
| Score | Range / rule | Action cue |
|
||||
|-------|--------------|------------|
|
||||
| NEWS2 0–4 | Low | Routine monitoring |
|
||||
| NEWS2 5–6 or any param = 3 | Medium | Urgent review |
|
||||
| NEWS2 ≥ 7 | High | Emergency response pathway |
|
||||
| qSOFA ≥ 2 | Screen positive | Consider infection + SOFA labs |
|
||||
| SOFA Δ ≥ 2 | Organ dysfunction rise | Sepsis recognition + hour-1 bundle |
|
||||
| GCS ≤ 8 | Severe | Airway/neuro emergency posture |
|
||||
|
||||
---
|
||||
|
||||
## 10. Important Caveats for Evaluators
|
||||
|
||||
Please keep these boundaries in mind when judging fitness for clinical use:
|
||||
|
||||
1. **Decision support, not an EHR** — charting, billing, and full order workflows remain in hospital systems of record.
|
||||
2. **Evaluation / prototype posture** — the platform is used for clinician feedback studies and technical demonstration; local regulatory clearance, validation studies, and hospital integration work are required before production patient-care claims.
|
||||
3. **Bundle orders are tracked in VigilCare** — they are not automatically a live link to your pharmacy robot unless integrated.
|
||||
4. **Escalation is a workflow signal** — the product models acknowledgment timeouts and escalated status; connecting to a specific enterprise paging vendor is an integration step.
|
||||
5. **MEWS is not currently implemented** — NEWS2 is the general early-warning engine in use.
|
||||
6. **SIRS-based sepsis logic was removed** in favor of **Sepsis-3** (qSOFA screen + SOFA delta).
|
||||
|
||||
---
|
||||
|
||||
## Related clinician documents
|
||||
|
||||
| Document | Audience |
|
||||
|----------|----------|
|
||||
| [Clinical Testing Guide](clinical-testing-guide.md) | Hands-on alert review sessions for doctors and nurses |
|
||||
| [Dashboard Guide](dashboard-guide.md) | Screen-by-screen dashboard reference |
|
||||
| [Dashboard Gap Analysis](dashboard-gap-analysis.md) | Known clinical UX gaps and completed improvements |
|
||||
| [Partner Brief](VigilCare-Partner-Brief.md) | Resilience / ward continuity narrative |
|
||||
|
||||
---
|
||||
|
||||
*VigilCare Clinical — Physician Overview. Intended as an introductory briefing for clinicians evaluating the platform for workflow fit, patient safety value, and decision-support quality.*
|
||||
@@ -0,0 +1,17 @@
|
||||
# Dashboard provider for the *external* production Grafana.
|
||||
# Point options.path at a host directory that contains copies of:
|
||||
# infra/grafana/dashboards/vigilcare.json
|
||||
# infra/grafana/dashboards/alert-quality-dashboard.json
|
||||
#
|
||||
# Example:
|
||||
# cp infra/grafana/dashboards/*.json /var/lib/grafana/dashboards/vigilcare/
|
||||
# # and mount/provision this file so options.path matches that directory.
|
||||
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: VigilCare
|
||||
type: file
|
||||
folder: VigilCare
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards/vigilcare
|
||||
updateIntervalSeconds: 30
|
||||
@@ -0,0 +1,17 @@
|
||||
# Grafana datasource provisioning for the *external* production Grafana.
|
||||
# Copy to the Grafana host (e.g. /etc/grafana/provisioning/datasources/) and
|
||||
# point url at the production Prometheus. Keep uid: prometheus so the
|
||||
# dashboards under infra/grafana/dashboards/ resolve without editing each panel.
|
||||
#
|
||||
# Local-dev provisioning stays in infra/grafana/provisioning/datasources/prometheus.yml
|
||||
# (url: http://prometheus:9090).
|
||||
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
url: http://prometheus.internal:9090
|
||||
isDefault: true
|
||||
access: proxy
|
||||
editable: false
|
||||
@@ -0,0 +1,71 @@
|
||||
# Production alert rules for VigilCare Clinical.
|
||||
# Load via Prometheus rule_files / alerting config on the external Prometheus host.
|
||||
# Metric names match VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs.
|
||||
|
||||
groups:
|
||||
- name: vigilcare_production
|
||||
rules:
|
||||
- alert: VigilCareApiDown
|
||||
expr: up{job="vigilcare_api_prod"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
service: clinical-api
|
||||
annotations:
|
||||
summary: "VigilCare Clinical API is down"
|
||||
description: "Prometheus cannot scrape vigilcare_api_prod for more than 2 minutes."
|
||||
|
||||
- alert: VigilCareOutboxBacklog
|
||||
expr: outbox_pending_events{job="vigilcare_api_prod"} > 1000
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
service: clinical-api
|
||||
annotations:
|
||||
summary: "Outbox backlog is growing"
|
||||
description: "outbox_pending_events is {{ $value }} (threshold 1000). Alert delivery to Kafka consumers is delayed."
|
||||
|
||||
- alert: VigilCareKafkaConsumerLag
|
||||
expr: kafka_consumer_lag{job="vigilcare_api_prod"} > 10000
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
service: clinical-api
|
||||
annotations:
|
||||
summary: "Kafka consumer lag high for {{ $labels.consumer_group }}"
|
||||
description: "consumer_group={{ $labels.consumer_group }} lag={{ $value }} (threshold 10000)."
|
||||
|
||||
# Prefer the blackbox probe job if configured; otherwise treat scrape failure
|
||||
# of the API as covering liveness. This rule fires when the ready probe exists
|
||||
# and reports failure.
|
||||
- alert: VigilCareReadyCheckFailing
|
||||
expr: probe_success{job="vigilcare_api_ready_probe"} == 0
|
||||
for: 3m
|
||||
labels:
|
||||
severity: critical
|
||||
service: clinical-api
|
||||
annotations:
|
||||
summary: "API /health/ready is failing"
|
||||
description: "Blackbox probe of /health/ready has been failing for 3 minutes."
|
||||
|
||||
# GatewayStaleDetectorService marks stale gateways OFFLINE; the collector
|
||||
# exposes them as ward_gateways_offline_gauge (site_code label).
|
||||
- alert: VigilCareGatewayStale
|
||||
expr: sum(ward_gateways_offline_gauge{job="vigilcare_api_prod"}) > 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
service: clinical-api
|
||||
annotations:
|
||||
summary: "One or more ward gateways are offline/degraded"
|
||||
description: "{{ $value }} gateway(s) offline. Stale threshold is GatewayMonitoring:StaleThresholdMinutes (default 10)."
|
||||
|
||||
- alert: VigilCareCriticalAlertsUnacked
|
||||
expr: alerts_unacknowledged_gauge{job="vigilcare_api_prod"} > 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
service: clinical-api
|
||||
annotations:
|
||||
summary: "Unacknowledged CRITICAL alerts"
|
||||
description: "{{ $value }} CRITICAL alert(s) older than 5 minutes with no acknowledgment."
|
||||
@@ -0,0 +1,45 @@
|
||||
# Fragment for the *external* production Prometheus.
|
||||
# Do NOT merge into infra/prometheus/prometheus.yml — that file is the local-dev stack
|
||||
# (scrapes host.docker.internal:5270). Copy or include this under scrape_configs:
|
||||
# on the production Prometheus host.
|
||||
#
|
||||
# - job_name: vigilcare_api_prod
|
||||
# ...
|
||||
#
|
||||
# Restrict network access so only Prometheus can reach :5270/metrics —
|
||||
# the endpoint is AllowAnonymous by design (ASP.NET FallbackPolicy would
|
||||
# otherwise return 401). See docs/ops/production-observability.md.
|
||||
|
||||
- job_name: vigilcare_api_prod
|
||||
scrape_interval: 15s
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["vigilcare-host.internal:5270"]
|
||||
labels:
|
||||
environment: production
|
||||
service: clinical-api
|
||||
|
||||
- job_name: vigilcare_gateway_prod
|
||||
scrape_interval: 30s
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["vigilcare-host.internal:5081"]
|
||||
labels:
|
||||
environment: production
|
||||
service: ward-gateway
|
||||
|
||||
# Optional: probe /health/ready via blackbox_exporter (http_2xx module).
|
||||
# Requires a blackbox_exporter job already defined on the Prometheus host.
|
||||
# - job_name: vigilcare_api_ready_probe
|
||||
# metrics_path: /probe
|
||||
# params:
|
||||
# module: [http_2xx]
|
||||
# static_configs:
|
||||
# - targets: ["http://vigilcare-host.internal:5270/health/ready"]
|
||||
# relabel_configs:
|
||||
# - source_labels: [__address__]
|
||||
# target_label: __param_target
|
||||
# - source_labels: [__param_target]
|
||||
# target_label: instance
|
||||
# - target_label: __address__
|
||||
# replacement: blackbox-exporter:9115
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backs up the ASP.NET Data Protection keyring. Without this keyring, PHI
|
||||
# encrypted by Phase 32 cannot be decrypted — a database backup alone is not
|
||||
# sufficient to restore the system. See docs/ops/phi-encryption-runbook.md.
|
||||
#
|
||||
# Volume name matches docker-compose.prod.yml `name: vigilcare` → vigilcare_dp_keys.
|
||||
# Do not derive the name from the host directory; /opt/vigilcare would otherwise
|
||||
# produce a different volume than a checkout named VigilCareClinical.
|
||||
#
|
||||
# Usage (on the production host):
|
||||
# ./scripts/backup-dp-keys.sh
|
||||
# BACKUP_DIR=/mnt/offsite/vigilcare/dp-keys ./scripts/backup-dp-keys.sh
|
||||
#
|
||||
# Cron example (daily 02:15 UTC):
|
||||
# 15 2 * * * /opt/vigilcare/scripts/backup-dp-keys.sh >> /var/log/vigilcare-dp-backup.log 2>&1
|
||||
set -euo pipefail
|
||||
|
||||
VOLUME_NAME="${VOLUME_NAME:-vigilcare_dp_keys}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/var/backups/vigilcare/dp-keys}"
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
ARCHIVE="${BACKUP_DIR}/dp-keys-${STAMP}.tar.gz"
|
||||
|
||||
if ! docker volume inspect "${VOLUME_NAME}" >/dev/null 2>&1; then
|
||||
echo "ERROR: Docker volume '${VOLUME_NAME}' not found." >&2
|
||||
echo "Confirm docker-compose.prod.yml uses 'name: vigilcare' and the API has started once." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
chmod 700 "${BACKUP_DIR}"
|
||||
|
||||
echo "Backing up volume ${VOLUME_NAME} → ${ARCHIVE}"
|
||||
docker run --rm \
|
||||
-v "${VOLUME_NAME}:/keys:ro" \
|
||||
-v "${BACKUP_DIR}:/backup" \
|
||||
alpine tar czf "/backup/dp-keys-${STAMP}.tar.gz" -C /keys .
|
||||
|
||||
chmod 600 "${ARCHIVE}"
|
||||
|
||||
# Refuse empty archives (volume had no key XML yet).
|
||||
if ! tar tzf "${ARCHIVE}" | grep -q '\.xml$'; then
|
||||
echo "ERROR: archive contains no *.xml key files — refusing to keep empty backup." >&2
|
||||
rm -f "${ARCHIVE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Keyring backed up to ${ARCHIVE}"
|
||||
|
||||
# Retain 30 days.
|
||||
find "${BACKUP_DIR}" -name 'dp-keys-*.tar.gz' -mtime +30 -delete
|
||||
echo "Retention: removed archives older than 30 days under ${BACKUP_DIR}"
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds a self-contained EF Core migration bundle for VigilCareClinicalAPI.
|
||||
# CD (Phase 36 Step 12) runs the same command; this script is for local dry-runs
|
||||
# and for operators who apply schema changes before rolling the API image.
|
||||
#
|
||||
# Usage (from repo root or anywhere):
|
||||
# ./scripts/build-api-migration-bundle.sh
|
||||
# RID=win-x64 ./scripts/build-api-migration-bundle.sh # local Windows test
|
||||
#
|
||||
# Apply against a database (DDL-privileged connection string):
|
||||
# ./artifacts/migrate-api --connection "$PG_CONNECTION_DDL"
|
||||
#
|
||||
# Zero-downtime discipline: every migration must be backwards-compatible with
|
||||
# the previously deployed API image (expand-then-contract). The bundle runs
|
||||
# while old containers are still serving traffic.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT_DIR="${OUT_DIR:-${ROOT_DIR}/artifacts}"
|
||||
RID="${RID:-linux-x64}"
|
||||
OUTPUT="${OUT_DIR}/migrate-api"
|
||||
|
||||
# Git Bash on Windows often lacks the host PATH; prefer known install locations.
|
||||
if ! command -v dotnet >/dev/null 2>&1; then
|
||||
for candidate in \
|
||||
"/c/Program Files/dotnet/dotnet" \
|
||||
"/mnt/c/Program Files/dotnet/dotnet" \
|
||||
"${HOME}/.dotnet/dotnet"
|
||||
do
|
||||
if [[ -x "${candidate}" ]]; then
|
||||
PATH="$(dirname "${candidate}"):${PATH}"
|
||||
export PATH
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if ! command -v dotnet >/dev/null 2>&1; then
|
||||
echo "dotnet SDK is required (not found on PATH)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${OUT_DIR}"
|
||||
|
||||
# Prefer a globally installed dotnet-ef; install 8.0.4 when nothing is present.
|
||||
# A newer global tool is fine — do not downgrade an existing install.
|
||||
if ! dotnet ef --version >/dev/null 2>&1; then
|
||||
echo "Installing dotnet-ef 8.0.4..."
|
||||
dotnet tool install --global dotnet-ef --version 8.0.4
|
||||
export PATH="${PATH}:${HOME}/.dotnet/tools:${USERPROFILE:-}/.dotnet/tools"
|
||||
fi
|
||||
|
||||
echo "Building API migration bundle → ${OUTPUT} (RID=${RID})"
|
||||
dotnet ef migrations bundle \
|
||||
--project "${ROOT_DIR}/VigilCareClinicalAPI" \
|
||||
--startup-project "${ROOT_DIR}/VigilCareClinicalAPI" \
|
||||
--configuration Release \
|
||||
--self-contained -r "${RID}" \
|
||||
--output "${OUTPUT}" \
|
||||
--force
|
||||
|
||||
echo "Bundle ready: ${OUTPUT}"
|
||||
echo "Apply with: ${OUTPUT} --connection \"\$PG_CONNECTION_DDL\""
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restores the ASP.NET Data Protection keyring into the production Docker volume.
|
||||
# REQUIRED after volume loss or host migration — without the matching keyring,
|
||||
# every encrypted patient PHI column is permanently unreadable.
|
||||
#
|
||||
# See docs/ops/phi-encryption-runbook.md (Keyring restore).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/restore-dp-keys.sh /var/backups/vigilcare/dp-keys/dp-keys-20260804T021500Z.tar.gz
|
||||
#
|
||||
# Stops the API container, replaces volume contents, then leaves the operator
|
||||
# to bring the stack back up (so schema/image tags stay under compose control).
|
||||
set -euo pipefail
|
||||
|
||||
ARCHIVE="${1:-}"
|
||||
VOLUME_NAME="${VOLUME_NAME:-vigilcare_dp_keys}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-/opt/vigilcare/docker-compose.prod.yml}"
|
||||
COMPOSE_DIR="$(dirname "${COMPOSE_FILE}")"
|
||||
API_SERVICE="${API_SERVICE:-api}"
|
||||
|
||||
if [[ -z "${ARCHIVE}" || ! -f "${ARCHIVE}" ]]; then
|
||||
echo "Usage: $0 <path-to-dp-keys-YYYYMMDDThhmmssZ.tar.gz>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! tar tzf "${ARCHIVE}" | grep -q '\.xml$'; then
|
||||
echo "ERROR: ${ARCHIVE} does not contain Data Protection *.xml key files." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping API service (${API_SERVICE}) so the volume is unused..."
|
||||
if [[ -f "${COMPOSE_FILE}" ]]; then
|
||||
docker compose -f "${COMPOSE_FILE}" --env-file "${COMPOSE_DIR}/.env" stop "${API_SERVICE}" || true
|
||||
else
|
||||
echo "WARN: ${COMPOSE_FILE} not found — ensure no container has ${VOLUME_NAME} mounted." >&2
|
||||
fi
|
||||
|
||||
if ! docker volume inspect "${VOLUME_NAME}" >/dev/null 2>&1; then
|
||||
echo "Creating volume ${VOLUME_NAME}..."
|
||||
docker volume create "${VOLUME_NAME}" >/dev/null
|
||||
fi
|
||||
|
||||
echo "Restoring ${ARCHIVE} → volume ${VOLUME_NAME}"
|
||||
# Clear existing keys then extract. Use a throwaway alpine container.
|
||||
docker run --rm \
|
||||
-v "${VOLUME_NAME}:/keys" \
|
||||
-v "$(cd "$(dirname "${ARCHIVE}")" && pwd):/backup:ro" \
|
||||
alpine sh -c "rm -rf /keys/* /keys/.[!.]* 2>/dev/null; tar xzf /backup/$(basename "${ARCHIVE}") -C /keys"
|
||||
|
||||
echo "Restored key files:"
|
||||
docker run --rm -v "${VOLUME_NAME}:/keys:ro" alpine ls -la /keys
|
||||
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " 1. docker compose -f ${COMPOSE_FILE} --env-file ${COMPOSE_DIR}/.env up -d ${API_SERVICE}"
|
||||
echo " 2. curl -fsS http://localhost:5270/health/ready"
|
||||
echo " 3. Fetch a known patient and confirm firstName/lastName decrypt"
|
||||
echo " 4. Record this restore in the ops log (date, archive name, operator)"
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -0,0 +1,22 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.local
|
||||
|
||||
README.md
|
||||
coverage/
|
||||
TestResults/
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
# Vite substitutes import.meta.env.VITE_* at build time — these must be
|
||||
# build arguments, not runtime environment variables.
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_SHOW_OPS=false
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_SHOW_OPS=$VITE_SHOW_OPS
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD wget -qO- http://localhost/ >/dev/null || exit 1
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Vue Router uses history mode — every unmatched path must fall through
|
||||
# to index.html or a hard refresh on /alerts returns 404.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Hashed assets are immutable; index.html must never be cached or clients
|
||||
# keep loading the previous release's asset manifest.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import HandoffReport from '@/components/ward/HandoffReport.vue'
|
||||
|
||||
const mockReport = {
|
||||
generatedAt: '2026-06-23T08:00:00Z',
|
||||
generatedBy: 'Test Nurse',
|
||||
summary: {
|
||||
department: 'ICU',
|
||||
patientCount: 1,
|
||||
criticalCount: 1,
|
||||
alertCount: 2,
|
||||
activeBundles: 1,
|
||||
},
|
||||
patients: [
|
||||
{
|
||||
encounterId: 'enc-1',
|
||||
name: 'Jane Doe',
|
||||
mrn: 'MRN001',
|
||||
room: 'ICU-3',
|
||||
department: 'ICU',
|
||||
attending: 'Dr Smith',
|
||||
news2: 8,
|
||||
sofa: 6,
|
||||
sofaDelta: 2,
|
||||
gcs: 14,
|
||||
qsofa: 2,
|
||||
vitalsSummary: 'HR 110 bpm',
|
||||
alertsSummary: 'NEWS2 Emergency',
|
||||
pendingOrders: 'Blood cultures',
|
||||
sbar: {
|
||||
situation: 'Sepsis workup',
|
||||
background: 'Allergies: Penicillin',
|
||||
assessment: 'NEWS2 8',
|
||||
recommendation: 'Blood cultures',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
vi.mock('@/composables/handoffReport', async importOriginal => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
buildHandoffReport: vi.fn(() => Promise.resolve(mockReport)),
|
||||
}
|
||||
})
|
||||
|
||||
describe('HandoffReport', () => {
|
||||
it('rendersWardSummaryAndPatientRows', async () => {
|
||||
const wrapper = mount(HandoffReport, {
|
||||
props: {
|
||||
encounters: [{ encounterId: 'enc-1' }],
|
||||
department: 'ICU',
|
||||
generatedBy: 'Test Nurse',
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('Ward Summary'))
|
||||
expect(document.body.textContent).toContain('Jane Doe')
|
||||
expect(document.body.textContent).toContain('SBAR')
|
||||
expect(document.body.textContent).toContain('Sepsis workup')
|
||||
wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user