fix: No token refresh or revocation mechanism~

This commit is contained in:
voltsrage
2026-06-25 14:14:20 +08:00
parent a8964381a2
commit fdcc646fae
26 changed files with 3453 additions and 55 deletions
+67 -24
View File
@@ -2,11 +2,11 @@
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation. A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
**Implementation status:** Thirty-three planned phases are complete through Phase 35 (plus Phases 2023) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, **Role-Based Access Control (RBAC) with clinical audit logging**, the **Dashboard Gap Analysis Fixes** (SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers on vital charts), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry form, sortable/filterable ward table), **Degraded Operations Visibility** (gateway fleet operations panel, stale gateway auto-detection, discharge summary API, admin panels for user/threshold/audit/reconciliation management, degraded-mode banner), **Alert Quality Analytics** (server-side clinician feedback with `AlertFeedback` entity, `AlertQualityAggregatorService` background metrics, quality metrics API, Grafana alert quality dashboard), **Explainable Alerts** (immutable JSONB `explanation` on composite alerts with score contributors, trend context, structured medication context, and bedside `NarrativeSummary`; `AlertResponse` DTO on GET/list/acknowledge/resolve; dashboard `AlertReasoning.vue`; ES indexer and data lake propagation; ward gateway sync), and **MIMIC-IV Replay Scenario Generator** (offline CLI tool converting real de-identified ICU data from MIT PhysioNet into VigilCare scenario JSONs; streaming CSV parser for 668K-row chartevents; 17 chart + 8 lab item ID mappings to VigilCare observation codes; GCS text-to-numeric conversion; Fahrenheit-to-Celsius; blood pressure deduplication preferring non-invasive over arterial; 10-observation cluster limit enforcement; `mimic-list` and `mimic-generate` CLI commands with Spectre.Console output; 100 patients / 140 ICU stays available for replay through NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting). Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses). **Implementation status:** Thirty-three planned phases are complete through Phase 35 (plus Phases 2023) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, **Role-Based Access Control (RBAC) with clinical audit logging**, the **Dashboard Gap Analysis Fixes** (SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers on vital charts), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry form, sortable/filterable ward table), **Degraded Operations Visibility** (gateway fleet operations panel, stale gateway auto-detection, discharge summary API, admin panels for user/threshold/audit/reconciliation management, degraded-mode banner), **Alert Quality Analytics** (server-side clinician feedback with `AlertFeedback` entity, `AlertQualityAggregatorService` background metrics, quality metrics API, Grafana alert quality dashboard), **Explainable Alerts** (immutable JSONB `explanation` on composite alerts with score contributors, trend context, structured medication context, and bedside `NarrativeSummary`; `AlertResponse` DTO on GET/list/acknowledge/resolve; dashboard `AlertReasoning.vue`; ES indexer and data lake propagation; ward gateway sync), and **MIMIC-IV Replay Scenario Generator** (offline CLI tool converting real de-identified ICU data from MIT PhysioNet into VigilCare scenario JSONs; streaming CSV parser for 668K-row chartevents; 17 chart + 8 lab item ID mappings to VigilCare observation codes; GCS text-to-numeric conversion; Fahrenheit-to-Celsius; blood pressure deduplication preferring non-invasive over arterial; 10-observation cluster limit enforcement; `mimic-list` and `mimic-generate` CLI commands with Spectre.Console output; 100 patients / 140 ICU stays available for replay through NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting). Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, **token refresh and revocation** (short-lived access tokens with rotating refresh tokens, server-side logout, proactive frontend refresh), and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
## Domain Model — How It Maps to a Real Clinical System ## Domain Model — How It Maps to a Real Clinical System
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter — either directly via the REST API, through the FHIR R4 facade that maps HL7 FHIR resources from integration engines (Mirth Connect, Rhapsody), or via ward gateway edge nodes that buffer observations locally during connectivity loss and sync to the central API when the link recovers. The FHIR facade also exposes read and search interactions so EHR systems can query patient and encounter data back in standard FHIR R4 format. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, qSOFA) aggregate multiple vitals and labs into acuity scores. The sepsis pathway follows Sepsis-3 consensus: qSOFA ≥ 2 creates a bedside screening alert recommending SOFA labs; when SOFA delta ≥ 2 from baseline confirms organ dysfunction, a `SOFA_SEPSIS` alert triggers the treatment bundle. Clinicians authenticate via JWT, and role-based access control (RBAC) gates every endpoint by clinical role (Nurse, Physician, Admin, Integration). Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All clinical write actions — patient registration, alert acknowledgment, threshold changes, encounter transitions — are recorded in an append-only audit log with user identity, IP address, correlation ID, and before/after state. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently. In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter — either directly via the REST API, through the FHIR R4 facade that maps HL7 FHIR resources from integration engines (Mirth Connect, Rhapsody), or via ward gateway edge nodes that buffer observations locally during connectivity loss and sync to the central API when the link recovers. The FHIR facade also exposes read and search interactions so EHR systems can query patient and encounter data back in standard FHIR R4 format. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, qSOFA) aggregate multiple vitals and labs into acuity scores. The sepsis pathway follows Sepsis-3 consensus: qSOFA ≥ 2 creates a bedside screening alert recommending SOFA labs; when SOFA delta ≥ 2 from baseline confirms organ dysfunction, a `SOFA_SEPSIS` alert triggers the treatment bundle. Clinicians authenticate via JWT with short-lived access tokens (15 min) and rotating refresh tokens (7 days), and role-based access control (RBAC) gates every endpoint by clinical role (Nurse, Physician, Admin, Integration). Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All clinical write actions — patient registration, alert acknowledgment, threshold changes, encounter transitions — are recorded in an append-only audit log with user identity, IP address, correlation ID, and before/after state. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently.
``` ```
Patient ─────────────────────────── one patient = one MRN, many lifetime encounters Patient ─────────────────────────── one patient = one MRN, many lifetime encounters
@@ -75,8 +75,8 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (multi-column sortable, patient search, quick-filters for critical/alerts/sepsis), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel, patient banner with demographics/allergies/emergency contact, encounter timeline, vitals entry form for manual observation recording, discharge summary panel), alert center (global acknowledge/resolve with role-aware modal and acknowledgment note preview), department overview (unit-level snapshot cards with acuity bars, patient/alert/bundle counts per department), sepsis bundle board (real-time countdown timers, on-track/at-risk/overdue urgency sorting), critical alert banner with browser notifications and audible tone, shift handoff report generator (SBAR format with ward summary, exportable via print/PDF), vital sign trend charts with medication administration markers and local replay scrubbing, NEWS2 history chart, SOFA history chart with organ-system breakdown, GCS history chart with component tracking, qSOFA evaluation history, structured alert reasoning (`AlertReasoning.vue` — score contributors, trend context, medication context, narrative summary from `explanation`), clinician feedback on every alert, admin panels (threshold management, user management, audit log viewer, reconciliation viewer), gateway operations dashboard with degraded-mode banner, and alert quality analytics with quality charts; role-aware sidebar navigation; polls API every 510 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md` - **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (multi-column sortable, patient search, quick-filters for critical/alerts/sepsis), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel, patient banner with demographics/allergies/emergency contact, encounter timeline, vitals entry form for manual observation recording, discharge summary panel), alert center (global acknowledge/resolve with role-aware modal and acknowledgment note preview), department overview (unit-level snapshot cards with acuity bars, patient/alert/bundle counts per department), sepsis bundle board (real-time countdown timers, on-track/at-risk/overdue urgency sorting), critical alert banner with browser notifications and audible tone, shift handoff report generator (SBAR format with ward summary, exportable via print/PDF), vital sign trend charts with medication administration markers and local replay scrubbing, NEWS2 history chart, SOFA history chart with organ-system breakdown, GCS history chart with component tracking, qSOFA evaluation history, structured alert reasoning (`AlertReasoning.vue` — score contributors, trend context, medication context, narrative summary from `explanation`), clinician feedback on every alert, admin panels (threshold management, user management, audit log viewer, reconciliation viewer), gateway operations dashboard with degraded-mode banner, and alert quality analytics with quality charts; role-aware sidebar navigation; polls API every 510 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyOrJwtMiddleware` authenticates via JWT bearer or `X-Api-Key` header (supports multiple keys via `Fhir:ApiKeys` array for zero-downtime rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals`); `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md` - **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyOrJwtMiddleware` authenticates via JWT bearer or `X-Api-Key` header (supports multiple keys via `Fhir:ApiKeys` array for zero-downtime rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals`); `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md`
- **FHIR R4 Read/Search** — `GET /fhir/R4/Patient/{id}` reads a Patient by internal ID; `GET /fhir/R4/Patient` searches by `identifier` (system|value) or lists all patients; `GET /fhir/R4/Encounter/{id}` reads an Encounter by internal ID; `GET /fhir/R4/Encounter` searches by `patient` (UUID) and/or `status` (`in-progress`, `finished`, `cancelled`); all return FHIR R4 JSON (`application/fhir+json`); search endpoints return `Bundle.type=searchset`; requires `fhir:read` permission (Admin and Integration roles); internal resources mapped back to FHIR via `PatientFhirMapper.ToFhirResponse` / `EncounterFhirMapper.ToFhirResponse` with hospital identifier resolution; Prometheus `fhir_read_total` counter with `resource_type`, `interaction`, `outcome` labels - **FHIR R4 Read/Search** — `GET /fhir/R4/Patient/{id}` reads a Patient by internal ID; `GET /fhir/R4/Patient` searches by `identifier` (system|value) or lists all patients; `GET /fhir/R4/Encounter/{id}` reads an Encounter by internal ID; `GET /fhir/R4/Encounter` searches by `patient` (UUID) and/or `status` (`in-progress`, `finished`, `cancelled`); all return FHIR R4 JSON (`application/fhir+json`); search endpoints return `Bundle.type=searchset`; requires `fhir:read` permission (Admin and Integration roles); internal resources mapped back to FHIR via `PatientFhirMapper.ToFhirResponse` / `EncounterFhirMapper.ToFhirResponse` with hospital identifier resolution; Prometheus `fhir_read_total` counter with `resource_type`, `interaction`, `outcome` labels
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions (`patients:read`, `alerts:acknowledge`, `alerts:feedback`, `thresholds:write`, `fhir:ingest`, `fhir:read`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap` and logs authorization failures with structured details (user, role, permission, endpoint) plus `authorization_failures_total` Prometheus counter; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `fhir:read`, `audit:read`, and `users:admin`; integration accounts get FHIR ingest and read access; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; startup validates JWT signing key is at least 256 bits (HMAC-SHA256 minimum); four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`); frontend login page with `localStorage` token persistence and automatic `Authorization: Bearer` header injection - **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions (`patients:read`, `alerts:acknowledge`, `alerts:feedback`, `thresholds:write`, `fhir:ingest`, `fhir:read`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap` and logs authorization failures with structured details (user, role, permission, endpoint) plus `authorization_failures_total` Prometheus counter; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `fhir:read`, `audit:read`, and `users:admin`; integration accounts get FHIR ingest and read access; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; startup validates JWT signing key is at least 256 bits (HMAC-SHA256 minimum); **token refresh and revocation** — short-lived access tokens (15 min) paired with rotating opaque refresh tokens (7 days) stored in the `refresh_tokens` table; `POST /auth/refresh` exchanges a valid refresh token for a new access + refresh token pair (rotation on every use revokes the previous token); `POST /auth/logout` revokes the refresh token server-side with `USER_LOGOUT` audit log; frontend auto-refreshes 1 minute before expiry, retries on 401, and redirects to login when the refresh token is exhausted; logout button in header, sidebar, and mobile nav; four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`)
- **Clinical Audit Logging** — append-only `clinical_audit_logs` table records clinical write actions with user identity, entity type/ID, before/after state (JSONB), reason, IP address, and correlation ID; ten audit actions (`THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`); `AuditService` writes log entries inline with domain operations; `GET /audit-logs` admin-only query endpoint with filters by entity type, entity ID, user ID, action, and time range; indexed on entity type, entity ID, user ID, and timestamp - **Clinical Audit Logging** — append-only `clinical_audit_logs` table records clinical write actions with user identity, entity type/ID, before/after state (JSONB), reason, IP address, and correlation ID; twelve audit actions (`THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`, `USER_LOGOUT`, `TOKEN_REFRESHED`); `AuditService` writes log entries inline with domain operations; `GET /audit-logs` admin-only query endpoint with filters by entity type, entity ID, user ID, action, and time range; indexed on entity type, entity ID, user ID, and timestamp
- **Site & Gateway Registry** — `ClinicalSite` and `WardGateway` domain entities model ward edge nodes that buffer clinical data during connectivity loss; `POST /sites` creates clinical sites; `POST /sites/{siteId}/gateways` registers gateways under a site; `PATCH /gateways/{gatewayId}/heartbeat` (gateway API key auth) updates status (`ONLINE`, `DEGRADED`, `OFFLINE`) and reported buffer depth; `GET /sites/{siteId}/gateways` lists gateways with optional `?department=` filter; dual authentication — JWT + RBAC (`users:admin`) for admin CRUD, `GatewayApiKeyAuthenticationHandler` (`X-Api-Key` + `X-Gateway-Id`) for gateway heartbeat and future sync upload; constant-time key comparison via `CryptographicOperations.FixedTimeEquals`; `VigilCare.ClinicalContracts` shared class library with sync DTOs (`ClinicalSyncBatchRequest`, `SyncedObservation`, `SyncedAlertEvent`, `GatewayHeartbeatRequest`) consumed by both central API and ward gateway projects; Prometheus `ward_gateways_offline_gauge` and `ward_gateway_buffer_depth` via `WardGatewayMetricsCollector` (60s periodic); `GatewayRegistrySeeder` provides demo site and gateway for Docker Compose and tests; FluentValidation on all request DTOs; `GatewayRegistryTests` and `ClinicalContractsTests` integration tests - **Site & Gateway Registry** — `ClinicalSite` and `WardGateway` domain entities model ward edge nodes that buffer clinical data during connectivity loss; `POST /sites` creates clinical sites; `POST /sites/{siteId}/gateways` registers gateways under a site; `PATCH /gateways/{gatewayId}/heartbeat` (gateway API key auth) updates status (`ONLINE`, `DEGRADED`, `OFFLINE`) and reported buffer depth; `GET /sites/{siteId}/gateways` lists gateways with optional `?department=` filter; dual authentication — JWT + RBAC (`users:admin`) for admin CRUD, `GatewayApiKeyAuthenticationHandler` (`X-Api-Key` + `X-Gateway-Id`) for gateway heartbeat and future sync upload; constant-time key comparison via `CryptographicOperations.FixedTimeEquals`; `VigilCare.ClinicalContracts` shared class library with sync DTOs (`ClinicalSyncBatchRequest`, `SyncedObservation`, `SyncedAlertEvent`, `GatewayHeartbeatRequest`) consumed by both central API and ward gateway projects; Prometheus `ward_gateways_offline_gauge` and `ward_gateway_buffer_depth` via `WardGatewayMetricsCollector` (60s periodic); `GatewayRegistrySeeder` provides demo site and gateway for Docker Compose and tests; FluentValidation on all request DTOs; `GatewayRegistryTests` and `ClinicalContractsTests` integration tests
- **Ward Gateway Service** — `VigilCare.WardGateway` (`http://localhost:5081`) is a standalone ASP.NET Core 8 deployable with its own PostgreSQL, Redis, and RabbitMQ; ingests observations locally via `POST /encounters/:id/observations` with plausibility validation, Redis-cached threshold evaluation, and synchronous critical alert creation; `LocalWarningEvaluator` creates warning-range alerts; `BufferedSyncWriter` writes all clinical events to `buffered_sync_items` for central upload; `EncounterReplicaSyncService` pulls patient/encounter data from central API; `ThresholdCacheLoader` fetches thresholds from central into local Redis; `CentralReachabilityService` tracks central API connectivity; `GatewayHeartbeatService` reports status and buffer depth; `SyncUploaderService` batches and uploads buffered items when online; local RabbitMQ paging and escalation queues; `GET /encounters` ward list and `GET /encounters/:id` detail; `GET /health/live` and `GET /health/ready` (Redis, RabbitMQ, encounter replica readiness); Docker Compose `ward-gateway` profile - **Ward Gateway Service** — `VigilCare.WardGateway` (`http://localhost:5081`) is a standalone ASP.NET Core 8 deployable with its own PostgreSQL, Redis, and RabbitMQ; ingests observations locally via `POST /encounters/:id/observations` with plausibility validation, Redis-cached threshold evaluation, and synchronous critical alert creation; `LocalWarningEvaluator` creates warning-range alerts; `BufferedSyncWriter` writes all clinical events to `buffered_sync_items` for central upload; `EncounterReplicaSyncService` pulls patient/encounter data from central API; `ThresholdCacheLoader` fetches thresholds from central into local Redis; `CentralReachabilityService` tracks central API connectivity; `GatewayHeartbeatService` reports status and buffer depth; `SyncUploaderService` batches and uploads buffered items when online; local RabbitMQ paging and escalation queues; `GET /encounters` ward list and `GET /encounters/:id` detail; `GET /health/live` and `GET /health/ready` (Redis, RabbitMQ, encounter replica readiness); Docker Compose `ward-gateway` profile
- **Health Check Endpoints** — `GET /health/live` (liveness — always returns 200 if the process is running) and `GET /health/ready` (readiness — checks PostgreSQL, Redis, Kafka, RabbitMQ, and Elasticsearch connectivity); both return structured JSON with per-check status and duration; anonymous access; suitable for Kubernetes probes and load balancer health checks - **Health Check Endpoints** — `GET /health/live` (liveness — always returns 200 if the process is running) and `GET /health/ready` (readiness — checks PostgreSQL, Redis, Kafka, RabbitMQ, and Elasticsearch connectivity); both return structured JSON with per-check status and duration; anonymous access; suitable for Kubernetes probes and load balancer health checks
@@ -115,6 +115,7 @@ HTTP request
→ Controllers (REST API + FHIR R4 ingest + Site/Gateway registry) → Controllers (REST API + FHIR R4 ingest + Site/Gateway registry)
→ Services → Services
├── CurrentUserService (authenticated user identity from JWT claims) ├── CurrentUserService (authenticated user identity from JWT claims)
├── AuthService (login, refresh token rotation, logout with revocation)
├── AuditService (append-only clinical_audit_logs on write actions) ├── AuditService (append-only clinical_audit_logs on write actions)
├── PostgreSQL (EF Core — writes, keyed reads) ├── PostgreSQL (EF Core — writes, keyed reads)
├── Redis (threshold cache, qSOFA state, NEWS2 parameter state, GCS state, SOFA lab cache, trend history, alert suppression keys) ├── Redis (threshold cache, qSOFA state, NEWS2 parameter state, GCS state, SOFA lab cache, trend history, alert suppression keys)
@@ -204,7 +205,7 @@ VigilCareClinicalAPI/
├── Program.cs # Service registration, middleware, seed on startup ├── Program.cs # Service registration, middleware, seed on startup
├── appsettings.json # Connection strings, Kafka, Elasticsearch, RabbitMQ, MinIO, Serilog, ReconciliationJobs ├── appsettings.json # Connection strings, Kafka, Elasticsearch, RabbitMQ, MinIO, Serilog, ReconciliationJobs
├── Controllers/ ├── Controllers/
│ ├── AuthController.cs # JWT login + authenticated user profile (GET /auth/me) │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
│ ├── AuditLogsController.cs # Clinical audit log query (Admin only) │ ├── AuditLogsController.cs # Clinical audit log query (Admin only)
│ ├── PatientsController.cs # Patient CRUD, search by name/MRN │ ├── PatientsController.cs # Patient CRUD, search by name/MRN
│ ├── EncountersController.cs # Encounter list (ward summary), get, status PATCH, timeline │ ├── EncountersController.cs # Encounter list (ward summary), get, status PATCH, timeline
@@ -248,6 +249,7 @@ VigilCareClinicalAPI/
│ │ ├── ClinicalSite.cs # Hospital site with site code, name, address │ │ ├── ClinicalSite.cs # Hospital site with site code, name, address
│ │ ├── WardGateway.cs # Ward edge node with status, buffer depth, heartbeat, sync timestamps │ │ ├── WardGateway.cs # Ward edge node with status, buffer depth, heartbeat, sync timestamps
│ │ ├── ClinicalUser.cs # Username, BCrypt password hash, display name, role, active flag │ │ ├── ClinicalUser.cs # Username, BCrypt password hash, display name, role, active flag
│ │ ├── RefreshToken.cs # Opaque refresh token with user FK, expiry, revocation timestamp
│ │ ├── ClinicalAuditLog.cs # Append-only audit trail: action, entity, user, before/after JSONB, IP, correlation ID │ │ ├── ClinicalAuditLog.cs # Append-only audit trail: action, entity, user, before/after JSONB, IP, correlation ID
│ │ ├── AlertFeedback.cs # Clinician feedback per alert (one per user per alert) │ │ ├── AlertFeedback.cs # Clinician feedback per alert (one per user per alert)
│ │ └── AlertQualityMetric.cs # Per-alert-type quality metric snapshots (acknowledgement/false-positive/useful rates) │ │ └── AlertQualityMetric.cs # Per-alert-type quality metric snapshots (acknowledgement/false-positive/useful rates)
@@ -256,7 +258,7 @@ VigilCareClinicalAPI/
│ └── Enums/ │ └── Enums/
│ ├── EncounterStatus.cs # Scheduled, Active, Discharged, Cancelled │ ├── EncounterStatus.cs # Scheduled, Active, Discharged, Cancelled
│ ├── ClinicalRole.cs # Nurse, Physician, Admin, Integration │ ├── ClinicalRole.cs # Nurse, Physician, Admin, Integration
│ ├── AuditAction.cs # ThresholdCreated/Updated/Deleted, AlertAcknowledged/Resolved, EncounterStatusChanged, PatientRegistered, SuppressionWindowSet, UserLogin, AuthorizationDenied │ ├── AuditAction.cs # ThresholdCreated/Updated/Deleted, AlertAcknowledged/Resolved, EncounterStatusChanged, PatientRegistered, SuppressionWindowSet, UserLogin, AuthorizationDenied, UserLogout, TokenRefreshed
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency │ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
│ ├── AlertSeverity.cs # Warning, Critical │ ├── AlertSeverity.cs # Warning, Critical
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated │ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
@@ -301,7 +303,7 @@ VigilCareClinicalAPI/
│ └── GatewayApiKeyAuthenticationHandler.cs # X-Api-Key + X-Gateway-Id auth for gateway heartbeat/sync routes │ └── GatewayApiKeyAuthenticationHandler.cs # X-Api-Key + X-Gateway-Id auth for gateway heartbeat/sync routes
├── Services/ ├── Services/
│ ├── Interfaces/ # IPatientService, IEncounterService, IAuditService, IAuthService, ICurrentUserService, ISiteService, IGatewayRegistryService, … │ ├── Interfaces/ # IPatientService, IEncounterService, IAuditService, IAuthService, ICurrentUserService, ISiteService, IGatewayRegistryService, …
│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, login audit log │ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh token rotation, logout revocation, audit logging
│ ├── AuditService.cs # Append-only clinical audit log writer (user, entity, before/after, IP, correlation ID) │ ├── AuditService.cs # Append-only clinical audit log writer (user, entity, before/after, IP, correlation ID)
│ ├── CurrentUserService.cs # Extracts authenticated user identity from JWT claims (HttpContext) │ ├── CurrentUserService.cs # Extracts authenticated user identity from JWT claims (HttpContext)
│ ├── PatientService.cs │ ├── PatientService.cs
@@ -386,7 +388,7 @@ VigilCareClinicalAPI/
│ ├── MedicationCorrelationOptions.cs # Drug-vital mappings + correlation window │ ├── MedicationCorrelationOptions.cs # Drug-vital mappings + correlation window
│ ├── PatientOptions.cs # MRN prefix + digit count for sequence-based generation │ ├── PatientOptions.cs # MRN prefix + digit count for sequence-based generation
│ ├── FhirOptions.cs # API key (single + rotation array), identifier systems, department/class maps, defaults │ ├── FhirOptions.cs # API key (single + rotation array), identifier systems, department/class maps, defaults
│ ├── JwtOptions.cs # Issuer, audience, signing key, expiration (default 8 hours) │ ├── JwtOptions.cs # Issuer, audience, signing key, access token expiration (15 min), refresh token expiration (7 days)
│ ├── DashboardOptions.cs # CORS origins for ward dashboard frontend │ ├── DashboardOptions.cs # CORS origins for ward dashboard frontend
│ ├── GatewayMonitoringOptions.cs # Stale gateway detection interval and threshold │ ├── GatewayMonitoringOptions.cs # Stale gateway detection interval and threshold
│ └── AlertQualityOptions.cs # Alert quality aggregation interval │ └── AlertQualityOptions.cs # Alert quality aggregation interval
@@ -429,7 +431,7 @@ VigilCareClinicalAPI/
│ └── Sepsis/QsofaResult.cs, QsofaOutcome.cs # qSOFA detector result and screening outcome enum │ └── Sepsis/QsofaResult.cs, QsofaOutcome.cs # qSOFA detector result and screening outcome enum
├── Data/ ├── Data/
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints │ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
│ ├── Configurations/ # IEntityTypeConfiguration per entity; ClinicalUserConfiguration, ClinicalAuditLogConfiguration, ClinicalSiteConfiguration, WardGatewayConfiguration, QsofaEvaluationConfiguration, AlertFeedbackConfiguration, AlertQualityMetricConfiguration; ElasticsearchOptions, ElasticIndexOptions │ ├── Configurations/ # IEntityTypeConfiguration per entity; ClinicalUserConfiguration, RefreshTokenConfiguration, ClinicalAuditLogConfiguration, ClinicalSiteConfiguration, WardGatewayConfiguration, QsofaEvaluationConfiguration, AlertFeedbackConfiguration, AlertQualityMetricConfiguration; ElasticsearchOptions, ElasticIndexOptions
│ └── Seed/ │ └── Seed/
│ ├── DataSeeder.cs # Seeds patients, encounters, thresholds, observations │ ├── DataSeeder.cs # Seeds patients, encounters, thresholds, observations
│ ├── GatewayRegistrySeeder.cs # Seeds demo site (SITE-DEMO) and gateway (GW-ICU-3B) with fixed GUIDs │ ├── GatewayRegistrySeeder.cs # Seeds demo site (SITE-DEMO) and gateway (GW-ICU-3B) with fixed GUIDs
@@ -592,14 +594,14 @@ VigilCare.Simulator/ # Phase 16, 29, 34, 35 — con
vigilcare-dashboard/ # Phases 1719, 22, 23, 2728, 31, 3334 — Vue 3 ward dashboard SPA vigilcare-dashboard/ # Phases 1719, 22, 23, 2728, 31, 3334 — Vue 3 ward dashboard SPA
├── src/ ├── src/
│ ├── api/ # HTTP client (auto Bearer header), encounters, clinical (GCS, SOFA, qSOFA history), alerts, analytics, sepsis, thresholds, users, audit, reconciliation, operations, alertQuality, normalize │ ├── api/ # HTTP client (auto Bearer header, 401 auto-refresh), encounters, clinical (GCS, SOFA, qSOFA history), alerts, analytics, sepsis, thresholds, users, audit, reconciliation, operations, alertQuality, normalize
│ ├── components/ │ ├── components/
│ │ ├── admin/ # ThresholdFormModal, UserFormModal (admin CRUD modals) │ │ ├── admin/ # ThresholdFormModal, UserFormModal (admin CRUD modals)
│ │ ├── alerts/ # AlertCard, AlertReasoning (structured explanation), AcknowledgeModal (role-aware), CriticalAlertBanner (browser notifications + audible tone) │ │ ├── alerts/ # AlertCard, AlertReasoning (structured explanation), AcknowledgeModal (role-aware), CriticalAlertBanner (browser notifications + audible tone)
│ │ ├── charts/ # SofaHistory, GcsHistory, QsofaHistory, VitalChart with medication markers, AlertQualityChart │ │ ├── charts/ # SofaHistory, GcsHistory, QsofaHistory, VitalChart with medication markers, AlertQualityChart
│ │ ├── departments/ # DepartmentCard, AcuityBar (unit-level snapshot) │ │ ├── departments/ # DepartmentCard, AcuityBar (unit-level snapshot)
│ │ ├── feedback/ # FeedbackButtons, FeedbackSummary │ │ ├── feedback/ # FeedbackButtons, FeedbackSummary
│ │ ├── layout/ # AppShell, AppHeader, AppSidebar (role-aware admin section) │ │ ├── layout/ # AppShell, AppHeader (user + logout), AppSidebar (user + logout + role-aware admin), MobileNav (logout)
│ │ ├── patient/ # GcsEntryForm, SofaScorePanel, PatientBanner, EncounterTimeline, VitalsEntryForm, VitalsPanel, AlertsList, DischargeSummaryPanel │ │ ├── patient/ # GcsEntryForm, SofaScorePanel, PatientBanner, EncounterTimeline, VitalsEntryForm, VitalsPanel, AlertsList, DischargeSummaryPanel
│ │ ├── replay/ # ReplayControls │ │ ├── replay/ # ReplayControls
│ │ ├── sepsis/ # SepsisBundleTable, SepsisBundleRow, SepsisBundleCard (countdown timer) │ │ ├── sepsis/ # SepsisBundleTable, SepsisBundleRow, SepsisBundleCard (countdown timer)
@@ -607,7 +609,7 @@ vigilcare-dashboard/ # Phases 1719, 22, 23, 27
│ │ └── ui/ # Button, Card, Badge, Skeleton, EmptyState, Modal, CollapsibleSection, SeverityBadge, DegradedModeBanner │ │ └── ui/ # Button, Card, Badge, Skeleton, EmptyState, Modal, CollapsibleSection, SeverityBadge, DegradedModeBanner
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, useApiMode, useChartTheme, useFocusTrap, chartFormat, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, alertExplanation, criticalAlertDetect, useAlertNotification, useCriticalAlertPolling, handoffReport, vitalsForm, roleAccess, auditFormat, reconciliationFormat, thresholdForm, userForm │ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, useApiMode, useChartTheme, useFocusTrap, chartFormat, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, alertExplanation, criticalAlertDetect, useAlertNotification, useCriticalAlertPolling, handoffReport, vitalsForm, roleAccess, auditFormat, reconciliationFormat, thresholdForm, userForm
│ ├── plugins/ # medicationMarkerPlugin (Chart.js plugin for medication administration markers on vital charts) │ ├── plugins/ # medicationMarkerPlugin (Chart.js plugin for medication administration markers on vital charts)
│ ├── stores/ # Pinia — ward (sort + filter + search), alerts (banner + polling), settings (sort prefs + sound mute), feedback, scoring, auth, departments, sepsis, operationsStore, alertQuality │ ├── stores/ # Pinia — ward (sort + filter + search), alerts (banner + polling), settings (sort prefs + sound mute), feedback, scoring, auth (login + refresh token rotation + logout + expiry redirect), departments, sepsis, operationsStore, alertQuality
│ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary, DepartmentOverviewView, SepsisBoardView, ThresholdManagementView, UserManagementView, AuditLogView, ReconciliationView, GatewayOperations, AlertQualityAnalytics │ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary, DepartmentOverviewView, SepsisBoardView, ThresholdManagementView, UserManagementView, AuditLogView, ReconciliationView, GatewayOperations, AlertQualityAnalytics
│ └── __tests__/ # Vitest — tests (store, feedback, replay, charts, alerts, ward, GCS, SOFA, qSOFA, PatientBanner, EncounterTimeline, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, HandoffReport, handoffReport, VitalsEntryForm, vitalsForm, useAlertStore, useWardStore, DepartmentOverviewView, SepsisBoardView, AcknowledgeModal, CriticalAlertBanner, roleAccess, ThresholdManagementView, thresholdForm, DischargeSummaryPanel, GatewayOperations, alertQuality) │ └── __tests__/ # Vitest — tests (store, feedback, replay, charts, alerts, ward, GCS, SOFA, qSOFA, PatientBanner, EncounterTimeline, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, HandoffReport, handoffReport, VitalsEntryForm, vitalsForm, useAlertStore, useWardStore, DepartmentOverviewView, SepsisBoardView, AcknowledgeModal, CriticalAlertBanner, roleAccess, ThresholdManagementView, thresholdForm, DischargeSummaryPanel, GatewayOperations, alertQuality)
├── vite.config.js ├── vite.config.js
@@ -1459,10 +1461,12 @@ When a correlated drug was given within the `MedicationCorrelation.CorrelationWi
### Authentication ### Authentication
| Method | Path | Description | | Method | Path | Auth | Description |
|---|---|---| |---|---|---|---|
| POST | `/auth/login` | Authenticate with username/password; returns JWT bearer token | | POST | `/auth/login` | Anonymous | Authenticate with username/password; returns access + refresh tokens |
| GET | `/auth/me` | Returns the authenticated user's profile (user ID, username, display name, role) | | POST | `/auth/refresh` | Anonymous | Exchange a valid refresh token for a new access + refresh token pair |
| POST | `/auth/logout` | JWT | Revoke the refresh token and end the session |
| GET | `/auth/me` | JWT | Returns the authenticated user's profile (user ID, username, display name, role) |
**POST `/auth/login` body:** **POST `/auth/login` body:**
@@ -1471,17 +1475,42 @@ When a correlated drug was given within the `MedicationCorrelation.CorrelationWi
| `username` | string | yes | Username | | `username` | string | yes | Username |
| `password` | string | yes | Password | | `password` | string | yes | Password |
**Response:** **Login response:**
| Field | Type | Description | | Field | Type | Description |
|---|---|---| |---|---|---|
| `accessToken` | string | JWT bearer token | | `accessToken` | string | JWT bearer token (default 15 min) |
| `expiresAt` | DateTimeOffset | Token expiration (default 8 hours) | | `refreshToken` | string | Opaque refresh token (default 7 days) |
| `expiresAt` | DateTimeOffset | Access token expiration |
| `userId` | Guid | User ID | | `userId` | Guid | User ID |
| `username` | string | Username | | `username` | string | Username |
| `displayName` | string | Display name | | `displayName` | string | Display name |
| `role` | string | `NURSE`, `PHYSICIAN`, `ADMIN`, `INTEGRATION` | | `role` | string | `NURSE`, `PHYSICIAN`, `ADMIN`, `INTEGRATION` |
**POST `/auth/refresh` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `refreshToken` | string | yes | The current refresh token |
**Refresh response:**
| Field | Type | Description |
|---|---|---|
| `accessToken` | string | New JWT bearer token |
| `refreshToken` | string | New refresh token (previous one is revoked) |
| `expiresAt` | DateTimeOffset | New access token expiration |
Refresh tokens rotate on every use — each call revokes the previous refresh token and issues a new one. If the refresh token is expired, revoked, or the user account is deactivated, the endpoint returns 422 and the client must re-authenticate via login.
**POST `/auth/logout` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `refreshToken` | string | yes | The refresh token to revoke |
Returns `204 No Content`. Revokes the refresh token server-side and creates a `USER_LOGOUT` audit log entry. The access token remains valid until its natural expiration (15 min max).
**Seeded demo users:** **Seeded demo users:**
| Username | Password | Role | | Username | Password | Role |
@@ -1524,7 +1553,7 @@ All endpoints except `POST /auth/login` and `GET /fhir/R4/metadata` require auth
**GET `/audit-logs` query params:** `entityType`, `entityId`, `userId`, `action`, `from`, `to`, `page`, `pageSize` **GET `/audit-logs` query params:** `entityType`, `entityId`, `userId`, `action`, `from`, `to`, `page`, `pageSize`
**Audit actions:** `THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED` **Audit actions:** `THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`, `USER_LOGOUT`, `TOKEN_REFRESHED`
Each audit log entry includes `action`, `entityType`, `entityId`, `userId`, `userDisplayName`, `previousValueJson` (JSONB), `newValueJson` (JSONB), `reason`, `ipAddress`, `correlationId`, and `createdAt`. Each audit log entry includes `action`, `entityType`, `entityId`, `userId`, `userDisplayName`, `previousValueJson` (JSONB), `newValueJson` (JSONB), `reason`, `ipAddress`, `correlationId`, and `createdAt`.
@@ -1890,11 +1919,24 @@ createdAt DateTimeOffset
lastLoginAt DateTimeOffset? lastLoginAt DateTimeOffset?
``` ```
### RefreshToken
```
id Guid PK
token string required, unique (max 256) — opaque base64 token (64 random bytes)
userId Guid FK → ClinicalUser (CASCADE)
expiresAt DateTimeOffset required
createdAt DateTimeOffset
revokedAt DateTimeOffset? — set on refresh rotation or explicit logout
```
Indexes: unique `(token)`, `(user_id)`
### ClinicalAuditLog ### ClinicalAuditLog
``` ```
id Guid PK id Guid PK
action string required (max 50) — THRESHOLD_CREATED | THRESHOLD_UPDATED | THRESHOLD_DELETED | ALERT_ACKNOWLEDGED | ALERT_RESOLVED | ENCOUNTER_STATUS_CHANGED | PATIENT_REGISTERED | SUPPRESSION_WINDOW_SET | USER_LOGIN | AUTHORIZATION_DENIED action string required (max 50) — THRESHOLD_CREATED | THRESHOLD_UPDATED | THRESHOLD_DELETED | ALERT_ACKNOWLEDGED | ALERT_RESOLVED | ENCOUNTER_STATUS_CHANGED | PATIENT_REGISTERED | SUPPRESSION_WINDOW_SET | USER_LOGIN | AUTHORIZATION_DENIED | USER_LOGOUT | TOKEN_REFRESHED
entityType string required (max 100) — e.g. AlertThreshold, ClinicalAlert, Encounter, Patient, ClinicalUser entityType string required (max 100) — e.g. AlertThreshold, ClinicalAlert, Encounter, Patient, ClinicalUser
entityId Guid required entityId Guid required
userId Guid? FK → ClinicalUser (null for system-initiated actions) userId Guid? FK → ClinicalUser (null for system-initiated actions)
@@ -2282,7 +2324,7 @@ Thirty-three phases from the project roadmap are implemented and verified, inclu
| 28 | **Frontend GCS + SOFA + sepsis UI refactor** — `GcsEntryForm.vue` (bedside GCS component entry); `SofaScorePanel.vue` (organ-system breakdown with staleness indicators); `useGcs` / `useSofa` composables; `scoring` Pinia store; `ScoresPanel` updated with GCS/SOFA display; `SepsisBundlePanel` and `AlertReasoning` refactored for Sepsis-3 alert types; Vitest tests for GCS entry, SOFA panel, scores panel, alert labels; `run-phase28-verification.sh` | Done | | 28 | **Frontend GCS + SOFA + sepsis UI refactor** — `GcsEntryForm.vue` (bedside GCS component entry); `SofaScorePanel.vue` (organ-system breakdown with staleness indicators); `useGcs` / `useSofa` composables; `scoring` Pinia store; `ScoresPanel` updated with GCS/SOFA display; `SepsisBundlePanel` and `AlertReasoning` refactored for Sepsis-3 alert types; Vitest tests for GCS entry, SOFA panel, scores panel, alert labels; `run-phase28-verification.sh` | Done |
| 29 | **Simulator scenario expansion + clinical validation** — three new scenarios (`neurological-decline-gcs-01`, `sepsis-sofa-progression-01`, `sofa-partial-spo2-fallback-01`); existing scenarios enriched with GCS/SOFA observations; `ScenarioReplayHelper` for end-to-end test replay; `ClinicalRefactorEndToEndTests` validates qSOFA screen → SOFA labs → bundle workflow; simulator polls GCS/SOFA scores; `run-phase29-verification.sh` | Done | | 29 | **Simulator scenario expansion + clinical validation** — three new scenarios (`neurological-decline-gcs-01`, `sepsis-sofa-progression-01`, `sofa-partial-spo2-fallback-01`); existing scenarios enriched with GCS/SOFA observations; `ScenarioReplayHelper` for end-to-end test replay; `ClinicalRefactorEndToEndTests` validates qSOFA screen → SOFA labs → bundle workflow; simulator polls GCS/SOFA scores; `run-phase29-verification.sh` | Done |
| 30 | **FHIR R4 Inbound Facade** — `FhirIngestController` (`POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}`); `FhirMetadataController` (CapabilityStatement); `FhirBundleProcessor` (transaction Bundles in dependency order); `LoincCodeMapper` (19 LOINC + 3 SNOMED CT → internal codes); `FhirUnitConverter` (°F→°C); `ExternalResourceIdentifier` table + `ExternalIdentifierService` for hospital MRN/visit number ↔ internal UUID linking; `FhirApiKeyMiddleware` (`X-Api-Key` auth); `FhirExceptionFilter` (→ OperationOutcome); `PatientFhirMapper`, `EncounterFhirMapper`, `ObservationFhirMapper`, `MedicationAdministrationFhirMapper`, `FhirReferenceResolver`; idempotent patient/encounter upserts (`RegisterOrUpdateByIdentifierAsync`, `OpenOrUpdateByIdentifierAsync`); configurable identifier systems, department codes, encounter class maps (`FhirOptions`); Prometheus `fhir_ingest_total`, `fhir_mapping_errors_total`; Mirth Connect integration guide; `FhirIngestTests`; `run-phase30-verification.sh` | Done | | 30 | **FHIR R4 Inbound Facade** — `FhirIngestController` (`POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}`); `FhirMetadataController` (CapabilityStatement); `FhirBundleProcessor` (transaction Bundles in dependency order); `LoincCodeMapper` (19 LOINC + 3 SNOMED CT → internal codes); `FhirUnitConverter` (°F→°C); `ExternalResourceIdentifier` table + `ExternalIdentifierService` for hospital MRN/visit number ↔ internal UUID linking; `FhirApiKeyMiddleware` (`X-Api-Key` auth); `FhirExceptionFilter` (→ OperationOutcome); `PatientFhirMapper`, `EncounterFhirMapper`, `ObservationFhirMapper`, `MedicationAdministrationFhirMapper`, `FhirReferenceResolver`; idempotent patient/encounter upserts (`RegisterOrUpdateByIdentifierAsync`, `OpenOrUpdateByIdentifierAsync`); configurable identifier systems, department codes, encounter class maps (`FhirOptions`); Prometheus `fhir_ingest_total`, `fhir_mapping_errors_total`; Mirth Connect integration guide; `FhirIngestTests`; `run-phase30-verification.sh` | Done |
| 31 | **RBAC + Clinical Audit Logging** — JWT bearer authentication (`AuthService`, `AuthController`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions; `AuthorizePermission` attribute on all controller actions; `PermissionAuthorizationHandler` + `PermissionPolicyProvider` resolve `perm:*` policies; `CurrentUserService` extracts identity from JWT claims; `ClinicalUser` entity with BCrypt password hashing; `ClinicalAuditLog` append-only table with before/after JSONB, user identity, IP, and correlation ID; `AuditService` writes log entries on clinical write actions (10 audit actions); `AuditLogsController` admin-only query with filters; `FhirApiKeyOrJwtMiddleware` dual auth for FHIR routes (JWT or X-Api-Key with multi-key rotation); alert `acknowledgedBy` set from authenticated user, not request body; four seeded demo users; frontend `LoginView` + `auth` Pinia store with `localStorage` token persistence; Vue router auth guard; `RbacTests`; `run-phase31-verification.sh` | Done | | 31 | **RBAC + Clinical Audit Logging + Token Refresh** — JWT bearer authentication (`AuthService`, `AuthController`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions; `AuthorizePermission` attribute on all controller actions; `PermissionAuthorizationHandler` + `PermissionPolicyProvider` resolve `perm:*` policies; `CurrentUserService` extracts identity from JWT claims; `ClinicalUser` entity with BCrypt password hashing; `RefreshToken` entity with DB-backed opaque token storage, rotation on use, and server-side revocation; short-lived access tokens (15 min) paired with long-lived refresh tokens (7 days); `POST /auth/refresh` and `POST /auth/logout` endpoints; `ClinicalAuditLog` append-only table with before/after JSONB, user identity, IP, and correlation ID; `AuditService` writes log entries on clinical write actions (12 audit actions including `USER_LOGOUT` and `TOKEN_REFRESHED`); `AuditLogsController` admin-only query with filters; `FhirApiKeyOrJwtMiddleware` dual auth for FHIR routes (JWT or X-Api-Key with multi-key rotation); alert `acknowledgedBy` set from authenticated user, not request body; four seeded demo users; frontend `LoginView` + `auth` Pinia store with proactive token refresh, 401 auto-retry, session expiry redirect, and logout button in header/sidebar/mobile nav; Vue router auth guard; `RbacTests`; `run-phase31-verification.sh` | Done |
| 23 | **Degraded Operations Visibility** — `GatewayStaleDetectorService` background service auto-marks gateways OFFLINE when heartbeat exceeds configurable `StaleThresholdMinutes`; `OperationsController` exposes gateway fleet listing (`GET /operations/gateways` with status/site filters), gateway detail (`GET /operations/gateways/{id}`), and site summary (`GET /operations/sites/{siteId}/summary`); `DischargeSummaryService` with `GET /encounters/{id}/discharge-summary` (info) and `GET /encounters/{id}/discharge-summary/content` (MinIO PDF download); `UsersController` (`GET /users`, `POST /users`, `PATCH /users/{id}`) for admin user account management; frontend: `GatewayOperations.vue` operations dashboard, `DegradedModeBanner.vue` warning banner, `DischargeSummaryPanel.vue` on patient detail, `ThresholdManagementView.vue` with `ThresholdFormModal.vue`, `UserManagementView.vue` with `UserFormModal.vue`, `AuditLogView.vue`, `ReconciliationView.vue`; role-aware admin sidebar navigation; `roleAccess.js` composable; `useChartTheme.js`, `useFocusTrap.js`, `useApiMode.js` composables; `CollapsibleSection.vue`, `SeverityBadge.vue` UI components; `OperationsApiTests`; `run-phase23-verification.sh` | Done | | 23 | **Degraded Operations Visibility** — `GatewayStaleDetectorService` background service auto-marks gateways OFFLINE when heartbeat exceeds configurable `StaleThresholdMinutes`; `OperationsController` exposes gateway fleet listing (`GET /operations/gateways` with status/site filters), gateway detail (`GET /operations/gateways/{id}`), and site summary (`GET /operations/sites/{siteId}/summary`); `DischargeSummaryService` with `GET /encounters/{id}/discharge-summary` (info) and `GET /encounters/{id}/discharge-summary/content` (MinIO PDF download); `UsersController` (`GET /users`, `POST /users`, `PATCH /users/{id}`) for admin user account management; frontend: `GatewayOperations.vue` operations dashboard, `DegradedModeBanner.vue` warning banner, `DischargeSummaryPanel.vue` on patient detail, `ThresholdManagementView.vue` with `ThresholdFormModal.vue`, `UserManagementView.vue` with `UserFormModal.vue`, `AuditLogView.vue`, `ReconciliationView.vue`; role-aware admin sidebar navigation; `roleAccess.js` composable; `useChartTheme.js`, `useFocusTrap.js`, `useApiMode.js` composables; `CollapsibleSection.vue`, `SeverityBadge.vue` UI components; `OperationsApiTests`; `run-phase23-verification.sh` | Done |
| 33 | **Alert Quality Analytics** — `AlertFeedback` entity with per-user-per-alert constraint; `POST /alerts/{id}/feedback` server-side feedback submission with `alerts:feedback` permission (Nurse, Physician, Admin); `AlertQualityMetric` entity stores per-alert-type quality snapshots (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve); `AlertQualityAggregatorService` background service computes metrics periodically; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range + alert type filter) and `GET /alerts/quality-metrics/summary`; `AlertFeedbackConfiguration` and `AlertQualityMetricConfiguration` EF Core configs; `SubmitAlertFeedbackRequestValidator`; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges; Grafana `alert-quality-dashboard.json`; frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue` and `alertQuality` Pinia store; `AlertQualityAnalyticsTests`; `run-phase33-verification.sh` | Done | | 33 | **Alert Quality Analytics** — `AlertFeedback` entity with per-user-per-alert constraint; `POST /alerts/{id}/feedback` server-side feedback submission with `alerts:feedback` permission (Nurse, Physician, Admin); `AlertQualityMetric` entity stores per-alert-type quality snapshots (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve); `AlertQualityAggregatorService` background service computes metrics periodically; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range + alert type filter) and `GET /alerts/quality-metrics/summary`; `AlertFeedbackConfiguration` and `AlertQualityMetricConfiguration` EF Core configs; `SubmitAlertFeedbackRequestValidator`; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges; Grafana `alert-quality-dashboard.json`; frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue` and `alertQuality` Pinia store; `AlertQualityAnalyticsTests`; `run-phase33-verification.sh` | Done |
| 34 | **Explainable Alerts** — `AlertExplanation` value object (`ScoreContributor`, `TrendContext`, `MedicationContext`, `NarrativeSummary`); JSONB `ClinicalAlert.Explanation` column (immutable at creation); contributor builders for NEWS2, SOFA, GCS; `TrendContextBuilder`; `AlertExplanationBuilder` + `ClinicalAlertFactory`; NEWS2, SOFA, GCS, and `TrendDetector` wire explanation and include `explanation` in `alert.generated` outbox; `MedicationCorrelationHelper.TryGetContextAsync()` for structured medication context; `AlertResponse` DTO + `AlertResponseMapper`; GET/list/acknowledge/resolve return `AlertResponse`; ES indexer projects `NarrativeSummary`; data lake Parquet `explanation_json`; ward gateway `LocalClinicalAlert.ExplanationJson` + sync; dashboard `AlertReasoning.vue` + `alertExplanation.js`; simulator `ExpectedOutcomeValidator` with `narrativeContains`; `ExplainableAlertsTests`; `run-phase34-verification.sh` | Done | | 34 | **Explainable Alerts** — `AlertExplanation` value object (`ScoreContributor`, `TrendContext`, `MedicationContext`, `NarrativeSummary`); JSONB `ClinicalAlert.Explanation` column (immutable at creation); contributor builders for NEWS2, SOFA, GCS; `TrendContextBuilder`; `AlertExplanationBuilder` + `ClinicalAlertFactory`; NEWS2, SOFA, GCS, and `TrendDetector` wire explanation and include `explanation` in `alert.generated` outbox; `MedicationCorrelationHelper.TryGetContextAsync()` for structured medication context; `AlertResponse` DTO + `AlertResponseMapper`; GET/list/acknowledge/resolve return `AlertResponse`; ES indexer projects `NarrativeSummary`; data lake Parquet `explanation_json`; ward gateway `LocalClinicalAlert.ExplanationJson` + sync; dashboard `AlertReasoning.vue` + `alertExplanation.js`; simulator `ExpectedOutcomeValidator` with `narrativeContains`; `ExplainableAlertsTests`; `run-phase34-verification.sh` | Done |
@@ -2304,7 +2346,7 @@ Thirty-three phases from the project roadmap are implemented and verified, inclu
**FHIR R4 integration (Phase 30):** Inbound facade accepts FHIR R4 JSON from integration engines (Mirth Connect, Rhapsody). Supports per-resource endpoints and transaction Bundles for ADT admit workflows. LOINC/SNOMED code mapping, Fahrenheit conversion, and external identifier linking enable drop-in EHR integration without changing the internal clinical pipeline. **FHIR R4 integration (Phase 30):** Inbound facade accepts FHIR R4 JSON from integration engines (Mirth Connect, Rhapsody). Supports per-resource endpoints and transaction Bundles for ADT admit workflows. LOINC/SNOMED code mapping, Fahrenheit conversion, and external identifier linking enable drop-in EHR integration without changing the internal clinical pipeline.
**RBAC + audit logging (Phase 31):** JWT authentication with role-based permission gating on every endpoint. Four clinical roles with 18 granular permissions. Append-only audit logging records who did what, when, and why — with before/after state snapshots for compliance and incident review. Frontend login page with token-based session management. **RBAC + audit logging + token refresh (Phase 31):** JWT authentication with role-based permission gating on every endpoint. Four clinical roles with 18 granular permissions. Short-lived access tokens (15 min) paired with rotating opaque refresh tokens (7 days) stored in PostgreSQL — `POST /auth/refresh` rotates tokens, `POST /auth/logout` revokes server-side. Frontend auto-refreshes before expiry, retries on 401, and redirects to login on refresh failure; logout button in header, sidebar, and mobile nav. Append-only audit logging records who did what, when, and why — with before/after state snapshots for compliance and incident review, including `USER_LOGOUT` and `TOKEN_REFRESHED` actions.
**Degraded Operations Visibility (Phase 23):** Gateway fleet operations panel with stale gateway auto-detection (`GatewayStaleDetectorService`), discharge summary API with MinIO PDF retrieval, admin panels for user management, threshold management, audit log browsing, and reconciliation viewing. Frontend adds role-aware sidebar navigation, degraded-mode banner for offline gateways, and comprehensive admin CRUD views. **Degraded Operations Visibility (Phase 23):** Gateway fleet operations panel with stale gateway auto-detection (`GatewayStaleDetectorService`), discharge summary API with MinIO PDF retrieval, admin panels for user management, threshold management, audit log browsing, and reconciliation viewing. Frontend adds role-aware sidebar navigation, degraded-mode banner for offline gateways, and comprehensive admin CRUD views.
@@ -2320,8 +2362,9 @@ Thirty-three phases from the project roadmap are implemented and verified, inclu
- **FHIR API key rotation** — `Fhir:ApiKeys` array alongside existing `Fhir:ApiKey` for zero-downtime key rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals` prevents timing attacks - **FHIR API key rotation** — `Fhir:ApiKeys` array alongside existing `Fhir:ApiKey` for zero-downtime key rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals` prevents timing attacks
- **Authorization failure logging** — `PermissionAuthorizationHandler` logs denied requests with structured details (username, user ID, role, required permission, endpoint); Prometheus `authorization_failures_total` counter with `permission` and `role` labels - **Authorization failure logging** — `PermissionAuthorizationHandler` logs denied requests with structured details (username, user ID, role, required permission, endpoint); Prometheus `authorization_failures_total` counter with `permission` and `role` labels
- **JWT signing key validation** — startup guard rejects keys shorter than 256 bits (HMAC-SHA256 minimum); prevents silent misconfiguration that would weaken token verification - **JWT signing key validation** — startup guard rejects keys shorter than 256 bits (HMAC-SHA256 minimum); prevents silent misconfiguration that would weaken token verification
- **Token refresh and revocation** — `RefreshToken` entity with DB-backed opaque token storage; `POST /auth/refresh` rotates access + refresh tokens (previous refresh token revoked on each use); `POST /auth/logout` revokes refresh token server-side; access token reduced from 8 hours to 15 minutes; refresh token valid for 7 days; frontend auto-refreshes 1 minute before expiry with 401 retry fallback; logout button in header, sidebar, and mobile nav with session redirect; `USER_LOGOUT` and `TOKEN_REFRESHED` audit actions
- **Concurrency hardening** — `SepsisBundleService.TryCreateBundleAsync` wraps order + bundle creation in a single database transaction so the unique constraint rollback also reverts orphaned orders; `PatientService.OpenEncounterAsync` enforced by new partial unique index `ix_encounters_patient_active_type` on `(patient_id, encounter_type) WHERE status = 'ACTIVE'` with constraint-violation catch returning 409 Conflict; `ConcurrencyTests` validates parallel patient registration, sepsis bundle creation, observation idempotency, and encounter open race conditions - **Concurrency hardening** — `SepsisBundleService.TryCreateBundleAsync` wraps order + bundle creation in a single database transaction so the unique constraint rollback also reverts orphaned orders; `PatientService.OpenEncounterAsync` enforced by new partial unique index `ix_encounters_patient_active_type` on `(patient_id, encounter_type) WHERE status = 'ACTIVE'` with constraint-violation catch returning 409 Conflict; `ConcurrencyTests` validates parallel patient registration, sepsis bundle creation, observation idempotency, and encounter open race conditions
- **New Prometheus metrics** — `fhir_read_total` (resource_type, interaction, outcome), `authorization_failures_total` (permission, role) - **New Prometheus metrics** — `fhir_read_total` (resource_type, interaction, outcome), `authorization_failures_total` (permission, role)
- **New audit actions** — `THRESHOLD_DELETED`, `AUTHORIZATION_DENIED` - **New audit actions** — `THRESHOLD_DELETED`, `AUTHORIZATION_DENIED`, `USER_LOGOUT`, `TOKEN_REFRESHED`
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration. **Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.
@@ -5,5 +5,6 @@ public class JwtOptions
public string Issuer { get; set; } = "VigilCareClinical"; public string Issuer { get; set; } = "VigilCareClinical";
public string Audience { get; set; } = "VigilCareClinical.Dashboard"; public string Audience { get; set; } = "VigilCareClinical.Dashboard";
public string SigningKey { get; set; } = null!; public string SigningKey { get; set; } = null!;
public int ExpirationMinutes { get; set; } = 480; public int ExpirationMinutes { get; set; } = 15;
public int RefreshTokenExpirationDays { get; set; } = 7;
} }
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
/// <summary> /// <summary>
/// JWT authentication: login and current-user profile. /// JWT authentication: login, token refresh, logout, and current-user profile.
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("api/v1/auth")] [Route("api/v1/auth")]
@@ -24,6 +24,29 @@ public class AuthController : ControllerBase
return Ok(ApiResponse<LoginResponse>.Ok(result)); return Ok(ApiResponse<LoginResponse>.Ok(result));
} }
/// <summary>Exchange a refresh token for a new access + refresh token pair.</summary>
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<RefreshResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req)
{
var result = await _auth.RefreshAsync(req.RefreshToken);
return Ok(ApiResponse<RefreshResponse>.Ok(result));
}
/// <summary>Revoke the refresh token and end the session.</summary>
[HttpPost("logout")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout(
[FromBody] LogoutRequest req,
[FromServices] ICurrentUserService currentUser)
{
await _auth.LogoutAsync(req.RefreshToken, currentUser.UserId!.Value);
return NoContent();
}
/// <summary>Returns the authenticated user's profile.</summary> /// <summary>Returns the authenticated user's profile.</summary>
[HttpGet("me")] [HttpGet("me")]
[Authorize] [Authorize]
@@ -36,6 +36,7 @@ public class AppDbContext : DbContext
public DbSet<ClinicalSyncConflict> ClinicalSyncConflicts => Set<ClinicalSyncConflict>(); public DbSet<ClinicalSyncConflict> ClinicalSyncConflicts => Set<ClinicalSyncConflict>();
public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>(); public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>();
public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>(); public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
{
public void Configure(EntityTypeBuilder<RefreshToken> builder)
{
builder.ToTable("refresh_tokens");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(t => t.Token).HasColumnName("token").HasMaxLength(256).IsRequired();
builder.Property(t => t.UserId).HasColumnName("user_id").IsRequired();
builder.Property(t => t.ExpiresAt).HasColumnName("expires_at").IsRequired();
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(t => t.RevokedAt).HasColumnName("revoked_at");
builder.HasIndex(t => t.Token).IsUnique();
builder.HasIndex(t => t.UserId);
builder.HasOne(t => t.User)
.WithMany()
.HasForeignKey(t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,11 @@
public class RefreshToken
{
public Guid Id { get; set; }
public string Token { get; set; } = null!;
public Guid UserId { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public ClinicalUser User { get; set; } = null!;
}
@@ -12,6 +12,8 @@ public enum AuditAction
UserLogin, UserLogin,
AuthorizationDenied, AuthorizationDenied,
AlertFeedbackSubmitted, AlertFeedbackSubmitted,
UserLogout,
TokenRefreshed,
} }
public static class AuditActionExtensions public static class AuditActionExtensions
@@ -30,6 +32,8 @@ public static class AuditActionExtensions
AuditAction.UserLogin => "USER_LOGIN", AuditAction.UserLogin => "USER_LOGIN",
AuditAction.AuthorizationDenied => "AUTHORIZATION_DENIED", AuditAction.AuthorizationDenied => "AUTHORIZATION_DENIED",
AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED", AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED",
AuditAction.UserLogout => "USER_LOGOUT",
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
_ => throw new ArgumentOutOfRangeException(nameof(a)) _ => throw new ArgumentOutOfRangeException(nameof(a))
}; };
@@ -47,6 +51,8 @@ public static class AuditActionExtensions
"USER_LOGIN" => AuditAction.UserLogin, "USER_LOGIN" => AuditAction.UserLogin,
"AUTHORIZATION_DENIED" => AuditAction.AuthorizationDenied, "AUTHORIZATION_DENIED" => AuditAction.AuthorizationDenied,
"ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted, "ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted,
"USER_LOGOUT" => AuditAction.UserLogout,
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
_ => throw new ArgumentOutOfRangeException(nameof(v)) _ => throw new ArgumentOutOfRangeException(nameof(v))
}; };
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "refresh_tokens",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
token = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
revoked_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_refresh_tokens", x => x.id);
table.ForeignKey(
name: "FK_refresh_tokens_clinical_users_user_id",
column: x => x.user_id,
principalTable: "clinical_users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_refresh_tokens_token",
table: "refresh_tokens",
column: "token",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_refresh_tokens_user_id",
table: "refresh_tokens",
column: "user_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "refresh_tokens");
}
}
}
@@ -1377,6 +1377,48 @@ namespace VigilCareClinicalAPI.Migrations
}); });
}); });
modelBuilder.Entity("RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("token");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("refresh_tokens", (string)null);
});
modelBuilder.Entity("SepsisBundle", b => modelBuilder.Entity("SepsisBundle", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1769,6 +1811,17 @@ namespace VigilCareClinicalAPI.Migrations
b.Navigation("Patient"); b.Navigation("Patient");
}); });
modelBuilder.Entity("RefreshToken", b =>
{
b.HasOne("ClinicalUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("SepsisBundle", b => modelBuilder.Entity("SepsisBundle", b =>
{ {
b.HasOne("Encounter", "Encounter") b.HasOne("Encounter", "Encounter")
@@ -1,5 +1,6 @@
public record LoginResponse( public record LoginResponse(
string AccessToken, string AccessToken,
string RefreshToken,
DateTimeOffset ExpiresAt, DateTimeOffset ExpiresAt,
Guid UserId, Guid UserId,
string Username, string Username,
@@ -0,0 +1 @@
public record LogoutRequest(string RefreshToken);
@@ -0,0 +1 @@
public record RefreshRequest(string RefreshToken);
@@ -0,0 +1,4 @@
public record RefreshResponse(
string AccessToken,
string RefreshToken,
DateTimeOffset ExpiresAt);
+87 -3
View File
@@ -1,5 +1,6 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -40,10 +41,12 @@ public class AuthService : IAuthService
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes); var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
var token = GenerateToken(user, expires); var accessToken = GenerateAccessToken(user, expires);
var refreshToken = await CreateRefreshTokenAsync(user.Id);
return new LoginResponse( return new LoginResponse(
token, accessToken,
refreshToken.Token,
expires, expires,
user.Id, user.Id,
user.Username, user.Username,
@@ -51,7 +54,88 @@ public class AuthService : IAuthService
user.Role.ToDbString()); user.Role.ToDbString());
} }
private string GenerateToken(ClinicalUser user, DateTimeOffset expires) public async Task<RefreshResponse> RefreshAsync(string refreshToken)
{
var stored = await _db.RefreshTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == refreshToken);
if (stored is null || stored.RevokedAt is not null || stored.ExpiresAt < DateTimeOffset.UtcNow)
throw new ValidationException("Invalid or expired refresh token.", "INVALID_REFRESH_TOKEN");
if (!stored.User.IsActive)
throw new ValidationException("Account is deactivated.", "ACCOUNT_DEACTIVATED");
stored.RevokedAt = DateTimeOffset.UtcNow;
var newRefreshToken = await CreateRefreshTokenAsync(stored.UserId);
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
var accessToken = GenerateAccessToken(stored.User, expires);
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
{
Id = Guid.NewGuid(),
Action = AuditAction.TokenRefreshed,
EntityType = "ClinicalUser",
EntityId = stored.UserId,
UserId = stored.UserId,
UserDisplayName = stored.User.DisplayName,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
return new RefreshResponse(accessToken, newRefreshToken.Token, expires);
}
public async Task LogoutAsync(string refreshToken, Guid userId)
{
var stored = await _db.RefreshTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == refreshToken && t.UserId == userId);
if (stored is not null && stored.RevokedAt is null)
{
stored.RevokedAt = DateTimeOffset.UtcNow;
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
{
Id = Guid.NewGuid(),
Action = AuditAction.UserLogout,
EntityType = "ClinicalUser",
EntityId = userId,
UserId = userId,
UserDisplayName = stored.User.DisplayName,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
}
}
private async Task<RefreshToken> CreateRefreshTokenAsync(Guid userId)
{
var token = new RefreshToken
{
Id = Guid.NewGuid(),
Token = GenerateOpaqueToken(),
UserId = userId,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(_jwt.RefreshTokenExpirationDays),
CreatedAt = DateTimeOffset.UtcNow
};
_db.RefreshTokens.Add(token);
await _db.SaveChangesAsync();
return token;
}
private static string GenerateOpaqueToken()
{
var bytes = RandomNumberGenerator.GetBytes(64);
return Convert.ToBase64String(bytes);
}
private string GenerateAccessToken(ClinicalUser user, DateTimeOffset expires)
{ {
var claims = new[] var claims = new[]
{ {
@@ -1,4 +1,6 @@
public interface IAuthService public interface IAuthService
{ {
Task<LoginResponse> LoginAsync(LoginRequest req); Task<LoginResponse> LoginAsync(LoginRequest req);
Task<RefreshResponse> RefreshAsync(string refreshToken);
Task LogoutAsync(string refreshToken, Guid userId);
} }
+2 -1
View File
@@ -195,7 +195,8 @@
"Issuer": "VigilCareClinical", "Issuer": "VigilCareClinical",
"Audience": "VigilCareClinical.Dashboard", "Audience": "VigilCareClinical.Dashboard",
"SigningKey": "DEV-ONLY-REPLACE-WITH-256-BIT-SECRET-IN-PRODUCTION-abc123xyz", "SigningKey": "DEV-ONLY-REPLACE-WITH-256-BIT-SECRET-IN-PRODUCTION-abc123xyz",
"ExpirationMinutes": 480 "ExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
}, },
"PhiEncryption": { "PhiEncryption": {
"ProtectorPurpose": "VigilCare.PatientPhi.v1", "ProtectorPurpose": "VigilCare.PatientPhi.v1",
+3 -20
View File
@@ -565,26 +565,9 @@ Any compromised container on the Docker network can read/write/delete clinical d
--- ---
## P3 — No token refresh or revocation mechanism ## ~~P3 — No token refresh or revocation mechanism~~ DONE
### Problem Implemented: `RefreshToken` entity with DB-backed storage, `POST /api/v1/auth/refresh` (rotate refresh token + issue new access token), `POST /api/v1/auth/logout` (revoke refresh token server-side). Access token reduced to 15 min, refresh token 7 days. Frontend auto-refreshes before expiry, retries on 401, and redirects to login on refresh failure. Logout button in header, sidebar, and mobile nav. Audit logged as `USER_LOGOUT` and `TOKEN_REFRESHED`.
JWT tokens are issued with a configurable expiration but there is no refresh token flow and no token revocation/blacklist. A compromised token remains valid until natural expiration. There is no `POST /auth/refresh` or `POST /auth/revoke` endpoint.
### Why fix
Clinical sessions may last entire shifts (8-12 hours). Short token lifetimes require frequent re-authentication, disrupting clinical workflows. Long lifetimes without revocation mean a stolen token grants extended access. Compromised accounts cannot be locked out until the token expires.
### How to fix
1. Add refresh token support: issue a long-lived opaque refresh token stored in the database alongside the access token.
2. Add `POST /api/v1/auth/refresh` — validate refresh token, issue new access token.
3. Add `POST /api/v1/auth/revoke` — invalidate refresh token and optionally blacklist the access token (via Redis TTL set matching remaining token lifetime).
4. Add `LastLoginAt` update on token refresh (already exists on `ClinicalUser`).
**Files:** `AuthController.cs`, `AuthService.cs`, `ClinicalUser.cs` (add `RefreshToken`, `RefreshTokenExpiresAt`), migration.
**Dependency:** None.
--- ---
@@ -743,7 +726,7 @@ Different hospitals and clinical settings have different protocols. CMS Sepsis S
| 19 | JWT key not validated on startup | P3 | D | Open | | 19 | JWT key not validated on startup | P3 | D | Open |
| 20 | No authorization failure audit | P3 | D | Open | | 20 | No authorization failure audit | P3 | D | Open |
| 21 | Elasticsearch security disabled | P3 | D | Open | | 21 | Elasticsearch security disabled | P3 | D | Open |
| 22 | No token refresh/revocation | P3 | D | Open | | 22 | ~~No token refresh/revocation~~ | P3 | D | **Done** |
| 23 | No request timing metrics | P5 | E | Open | | 23 | No request timing metrics | P5 | E | Open |
| 24 | Background service error metrics | P5 | E | Open | | 24 | Background service error metrics | P5 | E | Open |
| 25 | Thin concurrent/resilience tests | P5 | E | Open | | 25 | Thin concurrent/resilience tests | P5 | E | Open |
+346
View File
@@ -0,0 +1,346 @@
# Guide 22: Clinical Scoring Engines (NEWS2, GCS, SOFA, qSOFA)
## What Are Clinical Scoring Systems?
In medicine, a single vital sign (like heart rate = 110) doesn't tell you much on its own — it could be a patient exercising or a patient in septic shock. **Clinical scoring systems** combine multiple vital signs and lab values into a single number that predicts how sick a patient is and whether they need urgent intervention.
Think of it like a weather severity index: temperature alone doesn't tell you if a storm is dangerous, but combining temperature, wind speed, pressure, and humidity gives you a meaningful risk score.
This project implements four clinical scoring systems:
| Score | Full Name | What It Measures | Parameters | Alert Threshold |
|-------|-----------|-----------------|------------|-----------------|
| **NEWS2** | National Early Warning Score 2 | General deterioration risk | 7 vital signs | Score >= 7 → Emergency |
| **GCS** | Glasgow Coma Scale | Level of consciousness | 3 components (Eye, Verbal, Motor) | Total <= 8 → Severe (Critical) |
| **qSOFA** | Quick SOFA | Bedside sepsis screen | 3 criteria | >= 2 criteria met → Screen positive |
| **SOFA** | Sequential Organ Failure Assessment | Organ dysfunction severity | 6 organ systems | Delta >= 2 from baseline → Sepsis |
---
## How Scoring Engines Work (Architecture)
All four scoring engines follow the same pattern: they're Kafka consumers that react to observation events.
```
Observation recorded (HTTP POST)
PostgreSQL + Outbox
▼ (OutboxRelay)
Kafka topic: observation.recorded
├──► SepsisEngineService → QsofaDetector → qSOFA evaluation
├──► News2ScoringService → News2Detector → NEWS2 score
├──► GcsScoringService → GcsDetector → GCS score
│ │
│ Kafka: gcs.scored ◄──┘
│ │
├──► SofaScoringService → SofaDetector → SOFA score
├──► TrendAnalyzerService → TrendDetector → Rate-of-change
└──► WarningAlertService → WarningEvaluator → Warning alerts
```
Each engine is a separate Kafka consumer group, so they process the same observation event independently and in parallel. A single heart rate reading can simultaneously trigger NEWS2 recalculation, qSOFA re-evaluation, trend analysis, and warning threshold checking.
### The Two-Class Pattern: Calculator + Detector
Each scoring system is split into two classes:
- **Calculator** (static, pure logic): Contains the scoring rules — "heart rate 45 scores 3 points in NEWS2." No database, no Redis, no side effects. Easy to unit test.
- **Detector** (stateful, orchestration): Manages Redis state, calls the calculator, persists scores to PostgreSQL, creates alerts, publishes events. Contains all the infrastructure plumbing.
---
## NEWS2 — National Early Warning Score 2
NEWS2 combines 7 vital sign parameters into a single risk score (020+). Higher scores indicate greater deterioration risk.
### The 7 Parameters and Their Scoring
```csharp
public static class News2Calculator
{
public static readonly IReadOnlyList<string> ParameterCodes = new[]
{
"RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE",
"AVPU", "TEMP_C", "SUPPLEMENTAL_O2"
};
public static int ScoreRespRate(decimal value) => value switch
{
<= 8 => 3, // Dangerously low
<= 11 => 1,
<= 20 => 0, // Normal range
<= 24 => 2,
_ => 3 // Dangerously high
};
public static int ScoreHeartRate(decimal value) => value switch
{
<= 40 => 3,
<= 50 => 1,
<= 90 => 0, // Normal range
<= 110 => 1,
<= 130 => 2,
_ => 3
};
// ... similar for SpO2, SystolicBp, Temperature, Consciousness, SupplementalO2
}
```
Each parameter scores 03 points. The total score determines the risk level:
| Total Score | Risk Level | Alert |
|-------------|-----------|-------|
| 04 | LOW | No alert |
| 56 or any single param = 3 | MEDIUM / LOW_MEDIUM | `NEWS2_WARNING` (Warning) |
| >= 7 | HIGH | `NEWS2_EMERGENCY` (Critical) |
### How NEWS2 Aggregates Over Time
The 7 parameters rarely arrive simultaneously. A nurse records respiratory rate at 14:01, heart rate at 14:03, blood pressure at 14:05. The `News2Detector` uses Redis to accumulate parameters until all 7 are present:
```
14:01 RESP_RATE=18 → Redis: news2:{enc}:RESP_RATE = {value:18, score:0} (1/7 present)
14:03 HEART_RATE=95 → Redis: news2:{enc}:HEART_RATE = {value:95, score:1} (2/7 present)
14:05 SYSTOLIC_BP=115 → Redis: news2:{enc}:SYSTOLIC_BP = ... (3/7 present)
... (more parameters arrive)
14:12 TEMP_C=37.5 → Redis: all 7 present → compute score = 5 (MEDIUM)
→ persist News2Score to PostgreSQL
→ create NEWS2_WARNING alert
```
Each Redis key has a **4-hour TTL**. If no new respiratory rate arrives within 4 hours, that parameter expires and the next NEWS2 calculation waits for a fresh reading.
### Consciousness Resolution: GCS-First, AVPU-Fallback
The consciousness parameter prefers GCS (Glasgow Coma Scale) over AVPU (a simpler alert/voice/pain/unresponsive scale). If GCS components are cached in Redis, they're used. Otherwise, the AVPU value is used:
```csharp
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
{
// Try GCS first
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (gcsValues.All(v => v.HasValue))
{
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
return News2Calculator.ScoreConsciousnessFromGcs(total);
}
// Fallback to AVPU
var avpuVal = await cache.StringGetAsync(
News2Calculator.ParameterKey(encounterId, "AVPU"));
return cached?.Score;
}
```
---
## GCS — Glasgow Coma Scale
GCS measures level of consciousness through 3 components:
| Component | Range | What It Assesses |
|-----------|-------|-----------------|
| Eye (E) | 14 | Eye opening response |
| Verbal (V) | 15 | Best verbal response |
| Motor (M) | 16 | Best motor response |
| **Total** | **315** | **Sum of all three** |
```csharp
public static string ClassifyGcs(int total) => total switch
{
<= 8 => "SEVERE", // Coma — Critical alert
<= 12 => "MODERATE", // Warning alert
_ => "MILD" // 13-15 — Normal or near-normal
};
```
### Component Assembly
Like NEWS2, GCS components may arrive separately. The `GcsDetector` caches each component in Redis and computes the total when all 3 are present:
```
14:00 GCS_EYE=3 → Redis: gcs:{enc}:GCS_EYE = 3 (1/3)
14:00 GCS_VERBAL=4 → Redis: gcs:{enc}:GCS_VERBAL = 4 (2/3)
14:01 GCS_MOTOR=5 → Redis: gcs:{enc}:GCS_MOTOR = 5 (3/3)
→ Total = 12 (MODERATE)
→ Persist GcsScore to PostgreSQL
→ Create GCS_WARNING alert
→ Publish gcs.scored to Kafka (for SOFA CNS rescoring)
→ Re-evaluate qSOFA altered mentation
```
### Downstream Effects
GCS has the most downstream effects of any scoring engine:
1. **GCS alert**: SEVERE (total <= 8) → Critical alert, MODERATE (912) → Warning alert
2. **Kafka `gcs.scored` event**: Triggers SOFA CNS organ re-scoring
3. **NEWS2 consciousness**: Replaces AVPU with GCS-derived score
4. **qSOFA altered mentation**: GCS < 15 counts as one of the 3 qSOFA criteria
---
## qSOFA — Quick SOFA (Sepsis Screen)
qSOFA is a bedside screening tool with 3 binary criteria:
| Criterion | Threshold | Meaning |
|-----------|-----------|---------|
| Respiratory rate | >= 22 /min | Breathing fast |
| Systolic blood pressure | <= 100 mmHg | Blood pressure low |
| Altered mentation | GCS < 15 or AVPU >= 1 | Not fully alert |
If **2 or more criteria** are met simultaneously, a `QSOFA_SCREEN` warning alert fires, recommending SOFA labs be ordered.
### Sliding Window with Redis TTL
Each criterion has a **30-minute TTL** in Redis. If a criterion is met, the key is set with a 30-minute expiry. If not met, the key is deleted immediately:
```csharp
if (QsofaCalculator.MeetsCriterion(observationCode, value))
await cache.StringSetAsync(key, value.ToString(), TimeSpan.FromSeconds(1800));
else
await cache.KeyDeleteAsync(key);
```
This means a patient's respiratory rate of 24 (meets criterion) at 14:00 expires automatically at 14:30 if no new high respiratory rate arrives. The qSOFA score naturally decreases as criteria expire.
### Alert Deduplication
qSOFA uses `INSERT ... WHERE NOT EXISTS` to prevent duplicate screen alerts:
```sql
INSERT INTO clinical_alerts (...)
SELECT ... WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = @encounterId
AND alert_type = 'QSOFA_SCREEN'
AND status IN ('OPEN', 'ESCALATED')
)
```
If an open qSOFA screen already exists for this encounter, no duplicate is created.
---
## SOFA — Sequential Organ Failure Assessment
SOFA is the most complex scoring system. It assesses 6 organ systems, each scored 04:
| Organ System | Data Source | Score 0 | Score 4 |
|-------------|------------|---------|---------|
| Respiratory | PaO2/FiO2 ratio (or SpO2/FiO2 fallback) | >= 400 | < 100 with mechanical ventilation |
| Coagulation | Platelet count | >= 150 k/µL | < 20 k/µL |
| Liver | Bilirubin | < 1.2 mg/dL | >= 12 mg/dL |
| Cardiovascular | MAP and vasopressor dose | MAP >= 70, no vasopressors | High-dose epinephrine/norepinephrine |
| CNS | GCS total (via `gcs.scored` Kafka event) | GCS 15 | GCS < 6 |
| Renal | Creatinine and urine output | Creatinine < 1.2 | Creatinine >= 5.0 or urine < 200 mL/day |
Total SOFA score: 024 (sum of all organ scores).
### Baseline and Delta
SOFA doesn't alert on the absolute score — it alerts on the **change from baseline**:
1. **Baseline established**: When >= 4 organ systems have data, the first score becomes the baseline
2. **Delta calculated**: Every subsequent score computes `delta = current total - baseline total`
3. **Alerts**:
- Delta >= 2 → `SOFA_SEPSIS` (Critical) — indicates acute organ dysfunction, triggers a sepsis bundle
- Delta = 1 → `SOFA_WARNING` (Warning)
This is clinically important because a patient with chronic kidney disease might have a baseline SOFA of 4. A score of 4 is not alarming for them — but a sudden jump to 6 (delta = 2) indicates new organ dysfunction.
### Lab Staleness Tracking
Lab values (platelets, bilirubin, creatinine) arrive infrequently — sometimes only once per day. The `SofaLabCache` tracks how old each value is:
| Age | Status | Behavior |
|-----|--------|----------|
| < 12 hours | Current | Used as-is |
| 1224 hours | Stale | Used but flagged in `staleness_flags` JSONB |
| > 24 hours | Expired | Organ score set to 0 (assume normal) |
### SpO2/FiO2 Fallback
Many ward patients don't have arterial blood gas (PaO2) measurements. When PaO2 is unavailable but SpO2 (pulse oximetry) is available, the SOFA detector uses a validated proxy ratio (Rice et al., 2007):
```csharp
if (pao2 is not null && fio2 is not null)
respiratory = SofaCalculator.ScoreRespiratory(pao2, fio2, onMechanicalVent);
else if (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null)
respiratory = SofaCalculator.ScoreRespiratoryFromSpo2(spo2, fio2, onMechanicalVent);
```
---
## How the Scores Connect
The scoring engines are not independent — they feed into each other:
```
Observation arrives
├── NEWS2: uses RESP_RATE, SPO2, SYSTOLIC_BP, HEART_RATE, TEMP_C,
│ SUPPLEMENTAL_O2, and consciousness (from GCS or AVPU)
├── qSOFA: uses RESP_RATE, SYSTOLIC_BP, and altered mentation (from GCS)
├── GCS: uses GCS_EYE, GCS_VERBAL, GCS_MOTOR
│ │
│ └── publishes gcs.scored ──► SOFA (CNS organ)
│ ──► NEWS2 (consciousness)
│ ──► qSOFA (altered mentation)
├── SOFA: uses labs + vitals + GCS + medications
│ │
│ └── SOFA_SEPSIS (delta >= 2) ──► Sepsis Bundle (Guide 23)
└── Trend: uses HEART_RATE, RESP_RATE, SYSTOLIC_BP, TEMP_C, SPO2
```
---
## Common Patterns Across All Engines
### Alert Suppression
Warning-level alerts check for suppression before firing:
```csharp
if (alertType == AlertType.News2Warning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
return false;
}
```
When a clinician acknowledges a WARNING alert, a Redis suppression key is set for 30 minutes. During that window, the same alert type won't fire again for that encounter. CRITICAL alerts are never suppressed.
### Duplicate Prevention
Every alert creation uses `INSERT ... WHERE NOT EXISTS` to prevent duplicates. If an open alert of the same type already exists for the encounter, no new alert is created.
### Prometheus Metrics
Every engine increments counters and records timing:
```csharp
using var timer = _metrics.SofaScoringDuration.NewTimer();
// ... compute score ...
_metrics.SofaScoresTotal.WithLabels(alertCreated ? "true" : "false").Inc();
```
---
## Key Takeaways
- **Scoring engines are Kafka consumers** — each runs independently, processing the same observation events in parallel
- **Redis aggregates parameters over time** — observations arrive individually; Redis holds partial state until all parameters are present
- **Calculator + Detector separation** — pure scoring logic is testable without infrastructure; orchestration logic handles Redis, PostgreSQL, and Kafka
- **TTL-based expiry prevents stale scores** — parameters automatically expire (30 minutes for qSOFA, 4 hours for NEWS2, 24 hours for SOFA labs)
- **Scoring engines cascade** — GCS feeds into NEWS2, qSOFA, and SOFA. SOFA delta >= 2 triggers sepsis bundles. One observation can ripple through multiple scoring pipelines.
- **Alert suppression prevents alarm fatigue** — warning alerts are silenced for 30 minutes after acknowledgment; critical alerts always fire
+320
View File
@@ -0,0 +1,320 @@
# Guide 23: Sepsis Bundle Automation
## What is a Sepsis Bundle?
**Sepsis** is a life-threatening condition where the body's response to an infection damages its own organs. It's one of the leading causes of death in hospitals, and early treatment dramatically improves survival. The **Surviving Sepsis Campaign** defines a set of mandatory interventions (a "bundle") that must be completed within 1 hour of sepsis recognition:
| Element | What It Is | Why It's Urgent |
|---------|-----------|----------------|
| Blood cultures | Draw blood samples before antibiotics | Identifies the infecting organism so treatment can be targeted |
| Serum lactate | Blood test for lactate level | High lactate indicates tissue damage from inadequate blood flow |
| Broad-spectrum antibiotics | Administer antibiotics immediately | Every hour of delay increases mortality by ~8% |
| IV fluid resuscitation | Administer 30 mL/kg crystalloid fluids | Restores blood volume and organ perfusion |
**What is a "bundle" in software terms?** It's a checklist of 4 orders that the system creates automatically when sepsis is detected. Each element is tracked as PENDING → COMPLETED, and the bundle as a whole is tracked as IN_PROGRESS → COMPLIANT or NON_COMPLIANT based on whether all 4 elements are completed within the 1-hour deadline.
---
## Why Automate Sepsis Bundles?
Without automation, a nurse sees a sepsis alert, mentally recalls the 4-element bundle, manually creates each order, and tracks compliance on paper. In a busy ICU with multiple deteriorating patients, elements get missed or delayed. Automation ensures:
1. **Instant order creation**: All 4 orders are created the moment sepsis is detected — no manual recall needed
2. **Deadline tracking**: The 1-hour clock starts automatically
3. **Compliance monitoring**: A background service checks every 5 minutes for overdue bundles
4. **Audit trail**: Every bundle is recorded with its triggering alert, deadline, and outcome
---
## Architecture Overview
```
SOFA score computed (delta >= 2 from baseline)
SofaDetector creates SOFA_SEPSIS alert
SepsisAlertHandler.OnSepsisAlertCreatedAsync()
SepsisBundleService.TryCreateBundleAsync()
├── Creates SepsisBundle (IN_PROGRESS, deadline = now + 1 hour)
├── Creates 4 SepsisBundleElements (PENDING)
├── Creates 4 Orders (orderedBy: "sepsis-bundle-engine")
└── All in one PostgreSQL transaction (atomic)
... 1 hour passes ...
SepsisBundleMonitorService (every 5 minutes)
├── Finds IN_PROGRESS bundles past deadline
└── Marks as NON_COMPLIANT if elements remain PENDING
```
---
## Trigger: SOFA Delta >= 2
Sepsis bundles are only triggered by `SOFA_SEPSIS` alerts — not by qSOFA screens, NEWS2 scores, or any other alert type:
```csharp
public class SepsisAlertHandler
{
public async Task OnSepsisAlertCreatedAsync(
Guid encounterId, Guid alertId, AlertType alertType, CancellationToken ct)
{
if (alertType != AlertType.SofaSepsis)
return; // Only SOFA_SEPSIS triggers a bundle
var bundle = await _bundleService.TryCreateBundleAsync(
encounterId, alertId, alertType, ct);
if (bundle is not null)
_logger.LogInformation(
"Sepsis bundle {BundleId} created for encounter {EncounterId}",
bundle.Id, encounterId);
}
}
```
**Why only SOFA_SEPSIS?** The Sepsis-3 definition requires evidence of organ dysfunction (SOFA delta >= 2 from baseline). A qSOFA screen (>= 2 criteria) is a bedside screen that recommends ordering SOFA labs — it doesn't confirm sepsis. Creating bundles on qSOFA would produce false positives. The clinical flow is: qSOFA screen → order labs → SOFA computed → if delta >= 2 → sepsis bundle.
---
## Bundle Creation: Atomic Transaction
The bundle, its 4 elements, and the 4 corresponding orders are all created in a single PostgreSQL transaction:
```csharp
public async Task<SepsisBundle?> TryCreateBundleAsync(
Guid encounterId, Guid alertId, AlertType alertType, CancellationToken ct)
{
// Idempotency: only one in-progress bundle per encounter
var existing = await _db.SepsisBundles
.AnyAsync(b => b.EncounterId == encounterId
&& b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress, ct);
if (existing) return null;
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var recognizedAt = DateTimeOffset.UtcNow;
var bundle = new SepsisBundle
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
TriggeringAlertId = alertId,
TriggeringAlertType = alertType.ToDbString(),
RecognizedAt = recognizedAt,
DeadlineAt = recognizedAt.AddHours(1), // 1-hour compliance window
ComplianceStatus = SepsisBundleComplianceStatus.InProgress,
};
_db.SepsisBundles.Add(bundle);
// Create the 4 bundle elements with linked orders
var elements = new[]
{
("BLOOD_CULTURE", "Draw blood cultures (2 sets, aerobic + anaerobic)"),
("SERUM_LACTATE", "Obtain serum lactate level"),
("ANTIBIOTICS", "Administer broad-spectrum antibiotics"),
("IV_FLUIDS", "Begin IV crystalloid fluid resuscitation (30 mL/kg)"),
};
foreach (var (code, description) in elements)
{
var order = new Order
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
OrderType = MapOrderType(code),
Description = description,
Status = OrderStatus.Pending,
OrderedBy = "sepsis-bundle-engine",
OrderedAt = recognizedAt,
};
_db.Orders.Add(order);
_db.SepsisBundleElements.Add(new SepsisBundleElement
{
Id = Guid.NewGuid(),
BundleId = bundle.Id,
ElementCode = code,
OrderId = order.Id,
Status = SepsisBundleElementStatus.Pending,
});
}
// Outbox events for downstream notification
_db.OutboxEvents.Add(/* sepsis.bundle.created event */);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_metrics.SepsisBundleComplianceTotal.WithLabels("CREATED").Inc();
return bundle;
}
```
**Why atomic?** If the bundle is created but one of the orders fails, you'd have a partially-created bundle with missing elements — clinicians would see a checklist with items missing. The transaction ensures all-or-nothing: either all 4 elements and their orders exist, or none do.
**Why `orderedBy: "sepsis-bundle-engine"`?** This identifies auto-created orders vs manually-created ones. Clinicians see that the order was system-generated and can distinguish it from orders they placed themselves.
---
## Bundle Element Lifecycle
Each bundle element starts as PENDING and moves to COMPLETED when the linked order is resulted:
```
PENDING ──(order resulted)──► COMPLETED
```
When a clinician marks an order as "resulted" (e.g., blood cultures drawn, antibiotics administered), the corresponding bundle element is updated. When all 4 elements are COMPLETED before the deadline, the bundle transitions:
```
IN_PROGRESS ──(all 4 elements completed within 1 hour)──► COMPLIANT
```
---
## Compliance Monitoring: SepsisBundleMonitorService
A background service runs every 5 minutes and checks for overdue bundles:
```csharp
public class SepsisBundleMonitorService : BackgroundService
{
private static readonly TimeSpan ScanInterval = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try { await ScanOverdueBundlesAsync(stoppingToken); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Sepsis bundle monitor error — will retry");
}
await Task.Delay(ScanInterval, stoppingToken);
}
}
internal async Task ScanOverdueBundlesAsync(CancellationToken ct)
{
var overdue = await db.SepsisBundles
.Where(b => b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress
&& b.DeadlineAt < DateTimeOffset.UtcNow)
.ToListAsync(ct);
foreach (var bundle in overdue)
{
bundle.ComplianceStatus = SepsisBundleComplianceStatus.NonCompliant;
_metrics.SepsisBundleComplianceTotal
.WithLabels("NON_COMPLIANT").Inc();
var incompleteCount = await db.SepsisBundleElements
.CountAsync(e => e.BundleId == bundle.Id
&& e.Status != SepsisBundleElementStatus.Completed, ct);
_logger.LogWarning(
"Sepsis bundle {BundleId} for encounter {EncounterId} marked NON_COMPLIANT — " +
"deadline {Deadline} passed with {Incomplete} incomplete elements",
bundle.Id, bundle.EncounterId, bundle.DeadlineAt, incompleteCount);
}
if (overdue.Count > 0)
await db.SaveChangesAsync(ct);
}
}
```
The scan finds all bundles that are still IN_PROGRESS but past their deadline, marks them NON_COMPLIANT, and logs which elements were incomplete. The `sepsis_bundle_compliance_total` Prometheus counter tracks compliance outcomes on the Grafana dashboard.
---
## Idempotency: One Bundle Per Encounter
The `TryCreateBundleAsync` method checks for existing in-progress bundles before creating a new one:
```csharp
var existing = await _db.SepsisBundles
.AnyAsync(b => b.EncounterId == encounterId
&& b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress, ct);
if (existing) return null;
```
This prevents multiple bundles from being created if the SOFA score triggers multiple `SOFA_SEPSIS` alerts (e.g., if the score worsens further). Only one bundle can be in progress per encounter at a time.
---
## The Complete Sepsis Detection Timeline
```
t=0:00 qSOFA screen: resp_rate=24, systolic_bp=95 (2/3 criteria)
→ QSOFA_SCREEN warning alert
→ Recommendation: "Order SOFA labs"
t=0:30 Labs drawn: platelets, bilirubin, creatinine
t=1:00 Lab results arrive + vitals recorded
→ SOFA baseline established (total = 3)
t=2:00 Patient deteriorates — new labs + vitals
→ SOFA current = 6, delta = 3 from baseline
→ SOFA_SEPSIS critical alert created
→ SepsisAlertHandler triggers bundle creation
t=2:00 Sepsis bundle created (deadline = t=3:00):
✓ Blood cultures order (PENDING)
✓ Serum lactate order (PENDING)
✓ Antibiotics order (PENDING)
✓ IV fluids order (PENDING)
t=2:10 Nurse draws blood cultures → order resulted → element COMPLETED (1/4)
t=2:15 Lactate result arrives → element COMPLETED (2/4)
t=2:20 Antibiotics administered → element COMPLETED (3/4)
t=2:35 IV fluids initiated → element COMPLETED (4/4)
→ Bundle status: COMPLIANT (within 1-hour deadline)
-- OR --
t=3:00 Deadline passes with 2/4 elements still PENDING
→ SepsisBundleMonitorService marks: NON_COMPLIANT
→ Prometheus counter: sepsis_bundle_compliance_total{status="NON_COMPLIANT"}
```
---
## Database Schema
```
sepsis_bundles
├── id (UUID)
├── encounter_id (FK)
├── triggering_alert_id (FK)
├── triggering_alert_type ("SOFA_SEPSIS")
├── recognized_at (timestamp)
├── deadline_at (recognized_at + 1 hour)
├── compliance_status ("IN_PROGRESS" | "COMPLIANT" | "NON_COMPLIANT")
└── completed_at (nullable)
sepsis_bundle_elements
├── id (UUID)
├── bundle_id (FK → sepsis_bundles)
├── element_code ("BLOOD_CULTURE" | "SERUM_LACTATE" | "ANTIBIOTICS" | "IV_FLUIDS")
├── order_id (FK → orders)
└── status ("PENDING" | "COMPLETED")
```
---
## Key Takeaways
- **Only SOFA_SEPSIS triggers bundles** — qSOFA is a screen, not a confirmation. Bundles require evidence of organ dysfunction (SOFA delta >= 2).
- **Atomic creation ensures completeness** — all 4 elements and orders are created in one transaction; no partially-created bundles
- **1-hour compliance window is automatically enforced** — the deadline is set at creation time and checked every 5 minutes
- **One bundle per encounter at a time** — prevents duplicate bundles from repeated SOFA alerts during deterioration
- **Auto-created orders are labeled**`orderedBy: "sepsis-bundle-engine"` distinguishes automated from manual orders
- **Compliance is tracked as a Prometheus metric** — trends in COMPLIANT vs NON_COMPLIANT rates are visible on the dashboard for quality improvement
@@ -0,0 +1,287 @@
# Guide 24: Trend Detection (Rate-of-Change Analysis)
## What is Trend Detection?
Traditional threshold alerts fire when a vital sign crosses a fixed boundary — "heart rate above 130, alert." But what about a heart rate that's at 85, then 95, then 105, then 115 — all within 30 minutes? No single reading crosses the threshold, but the patient is clearly deteriorating rapidly.
**Trend detection** (also called rate-of-change analysis) watches the _speed_ at which a vital sign is changing over time. It calculates the **velocity** — how many units per minute the value is rising or falling — and fires an alert when the velocity exceeds a threshold.
Think of it like a speedometer vs a position marker. A threshold alert says "you're past the speed limit." A trend alert says "you're accelerating dangerously fast and will hit the speed limit soon."
```
Heart Rate over 30 minutes:
130 ┤ ← Threshold (CRITICAL_HEART_RATE)
│ ╱
120 ┤ ╱╱
│ ╱╱
110 ┤ ╱╱
│ ╱╱
100 ┤ ╱╱
│╱╱
90 ┤ ← No individual reading crosses 130...
│ but the RATE (0.8 bpm/min) exceeds the trend threshold (0.5)
└──────────────────────────────────────
0 5 10 15 20 25 30 min
→ RAPID_DETERIORATION alert fires at ~20 minutes
```
---
## How Trend Detection Works
### The 5 Tracked Parameters
```csharp
public static class TrendCalculator
{
public static readonly IReadOnlyList<string> TrendCodes = new[]
{
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "TEMP_C", "SPO2"
};
}
```
Each parameter has a configured velocity threshold (from `appsettings.json`):
```json
{
"TrendDetection": {
"WindowMinutes": 30,
"MaxHistoryEntries": 10,
"HistoryTtlSeconds": 7200,
"RateThresholdsPerMinute": {
"HEART_RATE": 0.5,
"RESP_RATE": 0.3,
"SYSTOLIC_BP": 1.0,
"TEMP_C": 0.05,
"SPO2": 0.2
}
}
}
```
| Parameter | Threshold | Meaning |
|-----------|-----------|---------|
| HEART_RATE | 0.5 /min/min | Heart rate rising by 0.5 bpm each minute (15 bpm over 30 min) |
| RESP_RATE | 0.3 /min/min | Respiratory rate rising by 0.3/min each minute |
| SYSTOLIC_BP | 1.0 mmHg/min | Blood pressure dropping by 1 mmHg each minute (30 mmHg in 30 min) |
| TEMP_C | 0.05 °C/min | Temperature rising by 0.05°C each minute (1.5°C in 30 min) |
| SPO2 | 0.2 %/min | Oxygen saturation dropping by 0.2% each minute |
### Direction Matters
For HEART_RATE, RESP_RATE, and TEMP_C, rising values are concerning (tachycardia, tachypnea, fever). For SPO2 and SYSTOLIC_BP, falling values are concerning (desaturation, hypotension). The calculator handles this:
```csharp
public static bool ExceedsThreshold(
string observationCode, decimal ratePerMinute, decimal thresholdPerMinute) =>
observationCode switch
{
"SPO2" or "SYSTOLIC_BP" => ratePerMinute <= -thresholdPerMinute,
_ => ratePerMinute >= thresholdPerMinute
};
```
For SPO2: a rate of -0.3 %/min (dropping) exceeds the threshold of 0.2 (because |-0.3| > 0.2). For HEART_RATE: a rate of +0.7 bpm/min (rising) exceeds the threshold of 0.5.
---
## The Trend Detection Pipeline
```
Observation arrives (Kafka consumer: trend-analyzer)
Is it a trend code? (HEART_RATE, RESP_RATE, etc.)
│ no → return NotTrendCode
│ yes
Read history from Redis: trend:{encounterId}:{code}
Append new entry, trim to window (30 min, max 10 entries)
Write updated history back to Redis (2-hour TTL)
Enough history? (need >= 2 data points)
│ no → return InsufficientHistory
│ yes
Compute velocity: (newest value - oldest value) / time difference
Exceeds threshold?
│ no → return Stable
│ yes
Create RAPID_DETERIORATION alert (if not already open)
```
### Redis History Storage
The trend detector stores a list of recent readings in a single Redis key as a JSON array:
```csharp
var cache = _redis.GetDatabase();
var key = TrendCalculator.HistoryKey(encounterId, observationCode);
// Read existing history
var historyJson = await cache.StringGetAsync(key);
var history = historyJson.HasValue
? JsonSerializer.Deserialize<List<TrendHistoryEntry>>(historyJson!)
: new List<TrendHistoryEntry>();
// Append new entry
history.Add(new TrendHistoryEntry(value, recordedAt));
// Trim: remove entries outside window, keep max N entries
var cutoff = recordedAt.AddMinutes(-_options.WindowMinutes);
history = history
.Where(e => e.RecordedAt >= cutoff)
.TakeLast(_options.MaxHistoryEntries) // max 10
.ToList();
// Write back with TTL
await cache.StringSetAsync(
key, JsonSerializer.Serialize(history),
TimeSpan.FromSeconds(_options.HistoryTtlSeconds)); // 2 hours
```
**Why a JSON list instead of a Redis list?** Redis lists support push/pop operations, but trimming by time range (remove entries older than 30 minutes) requires scanning the entire list. Storing the entire history as a JSON string allows the application to deserialize, filter, and reserialize in one read-write cycle. At max 10 entries, the overhead is negligible.
### Velocity Calculation
```csharp
public static decimal? ComputeRatePerMinute(
IReadOnlyList<TrendHistoryEntry> entries, int windowMinutes)
{
if (entries.Count < 2) return null;
var newest = entries[^1]; // last entry
var oldest = entries[0]; // first entry
var deltaMinutes = (newest.RecordedAt - oldest.RecordedAt).TotalMinutes;
if (deltaMinutes <= 0 || deltaMinutes > windowMinutes) return null;
return (newest.Value - oldest.Value) / (decimal)deltaMinutes;
}
```
**Simple linear velocity**: The rate is `(newest - oldest) / time elapsed`. This is intentionally simple — no weighted averages, no curve fitting. For clinical safety, a simple slope between the oldest and newest readings in the window is sufficient and easy to reason about.
**Why require deltaMinutes > 0?** Two readings with identical timestamps would produce division by zero. Why check `> windowMinutes`? If the oldest and newest readings span more than the window (e.g., a stale entry wasn't properly trimmed), the rate would be artificially diluted.
---
## Alert Creation
When the velocity exceeds the threshold, a `RAPID_DETERIORATION` alert is created:
```csharp
var details = TrendCalculator.DescribeTrend(observationCode, rate.Value, value);
// "Rapid rise: HEART_RATE rising at 0.72/min (current 118)"
// "Rapid decline: SPO2 falling at 0.31/min (current 91)"
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details,
observation_code, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
'RAPID_DETERIORATION', 'WARNING', {fullDetails},
{observationCode}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = 'RAPID_DETERIORATION'
AND observation_code = {observationCode}
AND status IN ('OPEN', 'ESCALATED')
)
""", ct);
```
Key design decisions:
- **Alert severity is WARNING**, not CRITICAL — trend detection is predictive ("the patient may deteriorate"), not confirmatory ("the patient has a dangerous vital sign"). The actual threshold breach alert (CRITICAL_HEART_RATE, CRITICAL_SPO2) fires separately when the value crosses the absolute threshold.
- **`observation_code` in the WHERE clause** — a patient can have simultaneous trend alerts for different parameters (rising heart rate AND falling SpO2), but only one per parameter.
- **Outbox event for downstream consumers** — the alert appears on the dashboard, triggers Kafka consumers (ES indexer, notification publisher), and may eventually escalate through RabbitMQ if unacknowledged.
---
## The Kafka Consumer: TrendAnalyzerService
```csharp
public class TrendAnalyzerService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "trend-analyzer",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
var guard = new PoisonPillGuard("trend-analyzer",
_kafkaOptions.MaxPoisonRetries, _logger);
while (!stoppingToken.IsCancellationRequested)
{
var result = consumer.Consume(stoppingToken);
var evt = JsonSerializer.Deserialize<TrendObservationEvent>(result.Message.Value)!;
using var scope = _services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
var outcome = await detector.ProcessObservationAsync(
evt.EncounterId, evt.PatientId,
evt.ObservationCode, evt.Value, evt.RecordedAt, stoppingToken);
if (outcome.Outcome == TrendOutcome.RapidDeterioration)
_logger.LogWarning(
"RAPID_DETERIORATION for {Code} in encounter {EncounterId}",
evt.ObservationCode, evt.EncounterId);
consumer.Commit(result);
guard.OnSuccess();
}
}
}
```
This follows the standard Kafka consumer pattern from Guide 12: consume → process → commit → repeat.
---
## Example Scenario
A patient in the ICU has these heart rate readings over 20 minutes:
| Time | Heart Rate | History Window | Velocity |
|------|-----------|---------------|----------|
| 14:00 | 82 | [82] | — (need >= 2) |
| 14:05 | 88 | [82, 88] | +1.2/min (exceeds 0.5) |
| 14:10 | 95 | [82, 88, 95] | +1.3/min |
| 14:15 | 103 | [82, 88, 95, 103] | +1.4/min |
| 14:20 | 112 | [82, 88, 95, 103, 112] | +1.5/min |
At 14:05, the velocity (1.2/min) already exceeds the threshold (0.5/min). A `RAPID_DETERIORATION` alert fires. The alert details: "Rapid rise: HEART_RATE rising at 1.20/min (current 88) — velocity 1.20/min over 30min window."
The individual readings (82, 88, 95...) are all normal — none cross the critical threshold of 130. But the trend detector catches the rapid acceleration before any threshold is breached.
---
## Key Takeaways
- **Trend detection catches deterioration early** — before absolute thresholds are breached, alerting clinicians to accelerating decline
- **Velocity is simple and interpretable** — (newest - oldest) / time. No complex statistics. Clinicians can understand "heart rate rising at 0.7 bpm/min."
- **Direction-aware thresholds** — rising heart rate is bad, but rising SpO2 is good. Falling SpO2 is bad, but falling heart rate may be fine. Each parameter knows which direction to watch.
- **Redis history with JSON lists** — lightweight storage for sliding-window data with automatic TTL expiry
- **WARNING severity for predictive alerts** — trend alerts warn about future risk; threshold alerts confirm current danger. Both are needed.
- **One trend alert per parameter per encounter** — deduplication prevents alert storms when a patient is continuously deteriorating
+96 -1
View File
@@ -1,23 +1,90 @@
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5270' const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5270'
let authToken = null let authToken = null
let refreshToken = null
let refreshPromise = null
let onSessionExpired = null
export function setAuthToken(token) { export function setAuthToken(token) {
authToken = token authToken = token
} }
export function setRefreshToken(token) {
refreshToken = token
}
export function onSessionExpiredCallback(callback) {
onSessionExpired = callback
}
function authHeaders(extra = {}) { function authHeaders(extra = {}) {
const headers = { 'Content-Type': 'application/json', ...extra } const headers = { 'Content-Type': 'application/json', ...extra }
if (authToken) headers['Authorization'] = `Bearer ${authToken}` if (authToken) headers['Authorization'] = `Bearer ${authToken}`
return headers return headers
} }
async function attemptRefresh() {
if (!refreshToken) return false
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
try {
const res = await fetch(`${BASE_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
if (!res.ok) return false
const envelope = await res.json()
if (!envelope.success) return false
authToken = envelope.data.accessToken
refreshToken = envelope.data.refreshToken
if (onSessionExpired) {
onSessionExpired({
type: 'refreshed',
accessToken: envelope.data.accessToken,
refreshToken: envelope.data.refreshToken,
expiresAt: envelope.data.expiresAt,
})
}
return true
} catch {
return false
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
function handleSessionExpired() {
if (onSessionExpired) {
onSessionExpired({ type: 'expired' })
}
}
async function request(path, options = {}) { async function request(path, options = {}) {
const res = await fetch(`${BASE_URL}${path}`, { const res = await fetch(`${BASE_URL}${path}`, {
headers: authHeaders(options.headers), headers: authHeaders(options.headers),
...options, ...options,
}) })
if (res.status === 401) { if (res.status === 401) {
const refreshed = await attemptRefresh()
if (refreshed) {
const retry = await fetch(`${BASE_URL}${path}`, {
headers: authHeaders(options.headers),
...options,
})
const retryEnvelope = await retry.json()
if (!retry.ok || !retryEnvelope.success) {
const msg = retryEnvelope.error?.message ?? `API ${retry.status}: ${path}`
throw new Error(msg)
}
return retryEnvelope.data
}
handleSessionExpired()
throw new Error('Session expired — please log in again.') throw new Error('Session expired — please log in again.')
} }
const envelope = await res.json() const envelope = await res.json()
@@ -28,13 +95,26 @@ async function request(path, options = {}) {
return envelope.data return envelope.data
} }
/** Returns null when the resource does not exist yet (HTTP 404 or empty optional payload). */
async function requestOptional(path) { async function requestOptional(path) {
const res = await fetch(`${BASE_URL}${path}`, { const res = await fetch(`${BASE_URL}${path}`, {
headers: authHeaders(), headers: authHeaders(),
}) })
if (res.status === 404) return null if (res.status === 404) return null
if (res.status === 401) { if (res.status === 401) {
const refreshed = await attemptRefresh()
if (refreshed) {
const retry = await fetch(`${BASE_URL}${path}`, {
headers: authHeaders(),
})
if (retry.status === 404) return null
const retryEnvelope = await retry.json()
if (!retry.ok || !retryEnvelope.success) {
const msg = retryEnvelope.error?.message ?? `API ${retry.status}: ${path}`
throw new Error(msg)
}
return retryEnvelope.data ?? null
}
handleSessionExpired()
throw new Error('Session expired — please log in again.') throw new Error('Session expired — please log in again.')
} }
const envelope = await res.json() const envelope = await res.json()
@@ -66,6 +146,21 @@ export const api = {
headers: authHeaders(), headers: authHeaders(),
}) })
if (res.status === 401) { if (res.status === 401) {
const refreshed = await attemptRefresh()
if (refreshed) {
const retry = await fetch(`${BASE_URL}${path}`, {
method: 'DELETE',
headers: authHeaders(),
})
if (retry.status === 204) return null
const retryEnvelope = await retry.json()
if (!retry.ok || !retryEnvelope.success) {
const msg = retryEnvelope.error?.message ?? `API ${retry.status}: ${path}`
throw new Error(msg)
}
return retryEnvelope.data ?? null
}
handleSessionExpired()
throw new Error('Session expired — please log in again.') throw new Error('Session expired — please log in again.')
} }
if (res.status === 204) return null if (res.status === 204) return null
@@ -4,11 +4,13 @@ import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useWardStore } from '@/stores/ward' import { useWardStore } from '@/stores/ward'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useAuthStore } from '@/stores/auth'
import { useDarkMode } from '@/composables/useDarkMode' import { useDarkMode } from '@/composables/useDarkMode'
const route = useRoute() const route = useRoute()
const wardStore = useWardStore() const wardStore = useWardStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const authStore = useAuthStore()
const { department } = storeToRefs(wardStore) const { department } = storeToRefs(wardStore)
const { alertSoundMuted } = storeToRefs(settingsStore) const { alertSoundMuted } = storeToRefs(settingsStore)
const { darkMode, toggle } = useDarkMode() const { darkMode, toggle } = useDarkMode()
@@ -135,6 +137,22 @@ function onDepartmentChange(event) {
/> />
</svg> </svg>
</button> </button>
<div class="hidden items-center gap-3 border-l border-gray-200 pl-4 dark:border-gray-700 sm:flex">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ authStore.displayName }}
</span>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-600 transition duration-200 hover:bg-red-50 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 dark:text-gray-300 dark:hover:bg-red-950/50 dark:hover:text-red-400"
aria-label="Sign out"
@click="authStore.logout()"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -1,8 +1,10 @@
<script setup> <script setup>
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useRoleAccess } from '@/composables/roleAccess' import { useRoleAccess } from '@/composables/roleAccess'
import { useAuthStore } from '@/stores/auth'
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
const { mainNavLinks, adminNavLinks, showAdminSection } = useRoleAccess() const { mainNavLinks, adminNavLinks, showAdminSection } = useRoleAccess()
function linkClasses(path) { function linkClasses(path) {
@@ -219,5 +221,25 @@ function linkClasses(path) {
</ul> </ul>
</div> </div>
</nav> </nav>
<div class="border-t border-gray-200 px-4 py-4 dark:border-gray-800">
<div class="mb-2 px-4">
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ authStore.displayName }}
</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ authStore.role }}
</p>
</div>
<button
type="button"
class="flex w-full items-center gap-4 rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition duration-200 hover:bg-red-50 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 dark:text-gray-300 dark:hover:bg-red-950/50 dark:hover:text-red-400"
@click="authStore.logout()"
>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
</aside> </aside>
</template> </template>
@@ -2,8 +2,10 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useRoleAccess } from '@/composables/roleAccess' import { useRoleAccess } from '@/composables/roleAccess'
import { useAuthStore } from '@/stores/auth'
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
const { mobileNavLinks } = useRoleAccess() const { mobileNavLinks } = useRoleAccess()
const activePath = computed(() => route.path) const activePath = computed(() => route.path)
@@ -86,6 +88,18 @@ const activePath = computed(() => route.path)
{{ link.label }} {{ link.label }}
</RouterLink> </RouterLink>
</li> </li>
<li class="flex-1">
<button
type="button"
class="flex h-full w-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400"
@click="authStore.logout()"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</li>
</ul> </ul>
</nav> </nav>
</template> </template>
+82 -4
View File
@@ -1,10 +1,23 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api, setAuthToken } from '@/api/client' import { api, setAuthToken, setRefreshToken, onSessionExpiredCallback } from '@/api/client'
import router from '@/router'
let refreshTimer = null
function scheduleRefresh(expiresAt, store) {
clearTimeout(refreshTimer)
const expiresMs = new Date(expiresAt).getTime()
const now = Date.now()
const delay = Math.max((expiresMs - now) - 60_000, 5_000)
refreshTimer = setTimeout(() => store.silentRefresh(), delay)
}
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
token: localStorage.getItem('vigilcare_token') ?? null, token: localStorage.getItem('vigilcare_token') ?? null,
_refreshToken: localStorage.getItem('vigilcare_refresh_token') ?? null,
user: JSON.parse(localStorage.getItem('vigilcare_user') ?? 'null'), user: JSON.parse(localStorage.getItem('vigilcare_user') ?? 'null'),
expiresAt: localStorage.getItem('vigilcare_expires_at') ?? null,
}), }),
getters: { getters: {
@@ -22,6 +35,8 @@ export const useAuthStore = defineStore('auth', {
async login(username, password) { async login(username, password) {
const data = await api.post('/api/v1/auth/login', { username, password }) const data = await api.post('/api/v1/auth/login', { username, password })
this.token = data.accessToken this.token = data.accessToken
this._refreshToken = data.refreshToken
this.expiresAt = data.expiresAt
this.user = { this.user = {
userId: data.userId, userId: data.userId,
username: data.username, username: data.username,
@@ -29,20 +44,83 @@ export const useAuthStore = defineStore('auth', {
role: data.role, role: data.role,
} }
localStorage.setItem('vigilcare_token', this.token) localStorage.setItem('vigilcare_token', this.token)
localStorage.setItem('vigilcare_refresh_token', this._refreshToken)
localStorage.setItem('vigilcare_expires_at', this.expiresAt)
localStorage.setItem('vigilcare_user', JSON.stringify(this.user)) localStorage.setItem('vigilcare_user', JSON.stringify(this.user))
setAuthToken(this.token) setAuthToken(this.token)
setRefreshToken(this._refreshToken)
scheduleRefresh(this.expiresAt, this)
}, },
logout() { async logout() {
try {
if (this._refreshToken) {
await api.post('/api/v1/auth/logout', { refreshToken: this._refreshToken })
}
} catch {
// Best-effort server-side revocation
}
this._clearSession()
router.push('/login')
},
_clearSession() {
clearTimeout(refreshTimer)
this.token = null this.token = null
this._refreshToken = null
this.expiresAt = null
this.user = null this.user = null
localStorage.removeItem('vigilcare_token') localStorage.removeItem('vigilcare_token')
localStorage.removeItem('vigilcare_refresh_token')
localStorage.removeItem('vigilcare_expires_at')
localStorage.removeItem('vigilcare_user') localStorage.removeItem('vigilcare_user')
setAuthToken(null) setAuthToken(null)
setRefreshToken(null)
},
async silentRefresh() {
if (!this._refreshToken) return
try {
const data = await api.post('/api/v1/auth/refresh', {
refreshToken: this._refreshToken,
})
this._applyTokens(data.accessToken, data.refreshToken, data.expiresAt)
} catch {
this._clearSession()
router.push('/login')
}
},
_applyTokens(accessToken, newRefreshToken, expiresAt) {
this.token = accessToken
this._refreshToken = newRefreshToken
this.expiresAt = expiresAt
localStorage.setItem('vigilcare_token', accessToken)
localStorage.setItem('vigilcare_refresh_token', newRefreshToken)
localStorage.setItem('vigilcare_expires_at', expiresAt)
setAuthToken(accessToken)
setRefreshToken(newRefreshToken)
scheduleRefresh(expiresAt, this)
}, },
hydrate() { hydrate() {
if (this.token) setAuthToken(this.token) if (this.token) {
setAuthToken(this.token)
setRefreshToken(this._refreshToken)
onSessionExpiredCallback((event) => {
if (event.type === 'refreshed') {
this._applyTokens(event.accessToken, event.refreshToken, event.expiresAt)
} else {
this._clearSession()
router.push('/login')
}
})
if (this.expiresAt) {
scheduleRefresh(this.expiresAt, this)
}
}
}, },
}, },
}) })