Finish: Phase 33 — Alert Quality Analytics

This commit is contained in:
voltsrage
2026-06-24 03:39:24 +08:00
parent 185dc93fa1
commit 68c397350b
26 changed files with 934 additions and 169 deletions
+182 -26
View File
@@ -2,7 +2,7 @@
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:** Twenty-nine planned phases are complete through Phase 31 (plus Phases 2022) — 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), and the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry form, sortable/filterable ward table). 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-one planned phases are complete through Phase 33 (plus Phases 2023) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, **Role-Based Access Control (RBAC) with clinical audit logging**, the **Dashboard Gap Analysis Fixes** (SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers on vital charts), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry form, sortable/filterable ward table), **Degraded Operations Visibility** (gateway fleet operations panel, stale gateway auto-detection, discharge summary API, admin panels for user/threshold/audit/reconciliation management, degraded-mode banner), and **Alert Quality Analytics** (server-side clinician feedback with `AlertFeedback` entity, `AlertQualityAggregatorService` background metrics, quality metrics API, Grafana alert quality dashboard). Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
## Domain Model — How It Maps to a Real Clinical System
@@ -72,10 +72,10 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Medication Administration** — `POST /encounters/:id/medications` records drug administrations (name, dose, route, timestamp, administered-by); `GET /encounters/:id/medications` lists with optional `since` filter; `GET /medications/:id` detail; active-encounter guard; FluentValidation on request DTOs
- **Medication Correlation Annotations** — `MedicationCorrelationHelper` appends medication context to warning and NEWS2 alert details when a mapped drug was administered within the correlation window (default 90 min); drug-to-vital mappings in `MedicationCorrelation` config (`appsettings.json`); annotates rather than suppresses — alerts still fire; sepsis, trend, and critical sync-path alerts are never annotated; design rationale in `docs/decisions/medication-correlation-design.md`
- **Ward Dashboard APIs** — `GET /encounters` returns paginated `WardEncounterSummary` rows (patient name/MRN, room/bed, department, status, latest NEWS2 score, live qSOFA criteria count from Redis, sepsis bundle status, open alert count, SOFA score/delta, GCS score/classification, attending physician, admitted-at, last observation time); filterable by `status` and `department`; `GET /encounters/:id/qsofa/current` exposes Redis-backed qSOFA state; `GET /sepsis-bundles` lists bundles hospital-wide with optional `status` filter (returns `SepsisBundleSummary` with patient demographics, elements, and deadlines); CORS policy `Dashboard` allows configured origins (default `http://localhost:5173`)
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (multi-column sortable, patient search, quick-filters for critical/alerts/sepsis), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel, patient banner with demographics/allergies/emergency contact, encounter timeline, vitals entry form for manual observation recording), alert center (global acknowledge/resolve with role-aware modal and acknowledgment note preview), department overview (unit-level snapshot cards with acuity bars, patient/alert/bundle counts per department), sepsis bundle board (real-time countdown timers, on-track/at-risk/overdue urgency sorting), critical alert banner with browser notifications and audible tone, shift handoff report generator (SBAR format with ward summary, exportable via print/PDF), vital sign trend charts with medication administration markers and local replay scrubbing, NEWS2 history chart, SOFA history chart with organ-system breakdown, GCS history chart with component tracking, qSOFA evaluation history, alert reasoning with optional medication context, and clinician feedback on every alert; 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, alert reasoning with optional medication context, clinician feedback on every alert, admin panels (threshold management, user management, audit log viewer, reconciliation viewer), gateway operations dashboard with degraded-mode banner, and alert quality analytics with quality charts; role-aware sidebar navigation; polls API every 510 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyOrJwtMiddleware` authenticates via JWT bearer or `X-Api-Key` header (supports multiple keys via `Fhir:ApiKeys` array for zero-downtime rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals`); `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md`
- **FHIR R4 Read/Search** — `GET /fhir/R4/Patient/{id}` reads a Patient by internal ID; `GET /fhir/R4/Patient` searches by `identifier` (system|value) or lists all patients; `GET /fhir/R4/Encounter/{id}` reads an Encounter by internal ID; `GET /fhir/R4/Encounter` searches by `patient` (UUID) and/or `status` (`in-progress`, `finished`, `cancelled`); all return FHIR R4 JSON (`application/fhir+json`); search endpoints return `Bundle.type=searchset`; requires `fhir:read` permission (Admin and Integration roles); internal resources mapped back to FHIR via `PatientFhirMapper.ToFhirResponse` / `EncounterFhirMapper.ToFhirResponse` with hospital identifier resolution; Prometheus `fhir_read_total` counter with `resource_type`, `interaction`, `outcome` labels
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 17 granular permissions (`patients:read`, `alerts:acknowledge`, `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); four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`); frontend login page with `localStorage` token persistence and automatic `Authorization: Bearer` header injection
- **Clinical Audit Logging** — append-only `clinical_audit_logs` table records clinical write actions with user identity, entity type/ID, before/after state (JSONB), reason, IP address, and correlation ID; ten audit actions (`THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`); `AuditService` writes log entries inline with domain operations; `GET /audit-logs` admin-only query endpoint with filters by entity type, entity ID, user ID, action, and time range; indexed on entity type, entity ID, user ID, and timestamp
- **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
@@ -86,7 +86,11 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **ThresholdCacheLoader Resilience** — retries Redis connection up to 3 times with exponential backoff (2s, 4s, 8s); if Redis remains unavailable, the application starts without the cache and observation ingest falls back to PostgreSQL queries for threshold lookups
- **MRN Sequence Generation** — MRN numbers are generated via a PostgreSQL sequence (`mrn_seq`) instead of MAX+1 queries; eliminates race conditions under concurrent patient registration; configurable prefix and digit count via `PatientOptions`
- **FHIR Bundle Transaction Rollback** — `FhirBundleProcessor` wraps all bundle entry processing in a database transaction; on any entry failure, the transaction is rolled back and the response includes the `OperationOutcome` for the failed entry; prevents partial state from orphaned Patient/Encounter records
- **Clinician Feedback Mode** — six quick ratings per alert (useful, too early, too late, false positive, missing context, would act); optional notes; Feedback Summary with aggregate stats and JSON/CSV export; client-side persistence for product research
- **Clinician Feedback Mode** — six quick ratings per alert (useful, too early, too late, false positive, missing context, would act); optional notes; server-side `AlertFeedback` entity persisted per user per alert (`POST /alerts/{id}/feedback`); `alerts:feedback` permission for Nurse, Physician, and Admin roles; Feedback Summary with aggregate stats and JSON/CSV export; client-side persistence for product research
- **Alert Quality Analytics** — `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve) into `alert_quality_metrics` table; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range filterable, optional alert type) and `GET /alerts/quality-metrics/summary`; Grafana alert quality dashboard (`infra/grafana/dashboards/alert-quality-dashboard.json`); frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue`; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges
- **Degraded Operations Visibility** — `GatewayStaleDetectorService` auto-marks gateways OFFLINE when heartbeat exceeds configurable `StaleThresholdMinutes`; `OperationsController` (`GET /operations/gateways`, `GET /operations/gateways/{id}`, `GET /operations/sites/{siteId}/summary`) provides fleet management API; `DischargeSummaryService` with `GET /encounters/{id}/discharge-summary` (info) and `GET /encounters/{id}/discharge-summary/content` (MinIO PDF download); `DischargeSummaryPanel.vue` on patient detail; `DegradedModeBanner.vue` warns when gateways are offline; `GatewayOperations.vue` operations dashboard
- **User Management** — `UsersController` (`GET /users`, `POST /users`, `PATCH /users/{id}`) for admin user account CRUD; `UserService` with BCrypt password hashing; `UserManagementView.vue` with `UserFormModal.vue` (create/edit users, role assignment, active toggle)
- **Admin Dashboard Panels** — `ThresholdManagementView.vue` with `ThresholdFormModal.vue` (create/edit alert thresholds); `AuditLogView.vue` (filterable audit log viewer with action/entity/user/date filters); `ReconciliationView.vue` (safety finding viewer); sidebar navigation with role-aware admin section; `CollapsibleSection.vue` and `SeverityBadge.vue` UI components
- **Console Replay Simulator** — standalone `VigilCare.Simulator` .NET console app replays JSON scenario files against the live API with configurable speed (`--speed 0` instant, `60` = 60× faster); commands: `replay`, `replay-all`, `validate`, `dry-run`; optional `--poll` shows alerts, NEWS2, GCS, SOFA, and sepsis bundle state during replay; `--gateway` targets the ward gateway (`http://localhost:5081`) with `--encounter-id`, `--skip-setup`, and `--gateway-token`; `alert_ack` events poll for open alerts on central before acknowledging (handles async alert pipeline at `--speed 0`); twelve sample scenarios in `VigilCare.Simulator/Scenarios/List/` (including ward outage reconnect, GCS neurological decline, SOFA sepsis progression, and SpO₂/FiO₂ fallback); user guide in `docs/simulator-guide.md`
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
- **Data Lake Writer** — `DataLakeWriterService` (consumer group `data-lake-writer`) buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes date-partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/`), and commits Kafka offsets only for topic-partitions where all uploads succeeded; failed partition buffers are retained in memory and retried on the next flush cycle (prevents data loss from partial upload failures); shutdown flush uses an uncanceled token so MinIO writes complete on Ctrl+C; `kafka_partition` and `kafka_offset` columns provide audit lineage
@@ -140,7 +144,9 @@ IHostedServices (background):
OutboxPendingCollector → polls outbox every 30s → outbox_pending_events
KafkaConsumerLagCollector → polls four consumer groups every 30s → kafka_consumer_lag
WardGatewayMetricsCollector → polls gateway status/buffer depth every 60s → ward_gateways_offline_gauge, ward_gateway_buffer_depth
ClinicalMetrics (singleton) → inline counters/histogram from ingest, qSOFA, NEWS2, GCS, SOFA, trend, suppression, bundle compliance, escalation paths
GatewayStaleDetectorService → polls gateways on interval → marks OFFLINE when heartbeat exceeds StaleThresholdMinutes
AlertQualityAggregatorService → periodically computes per-alert-type quality metrics → alert_quality_metrics table + Prometheus gauges
ClinicalMetrics (singleton) → inline counters/histogram from ingest, qSOFA, NEWS2, GCS, SOFA, trend, suppression, bundle compliance, escalation paths, alert quality
Ward Gateway (VigilCare.WardGateway — separate deployable on port 5081):
HTTP request → ExceptionHandlerMiddleware → Controllers (Observations, Alerts, Encounters, CentralRequired)
@@ -212,6 +218,9 @@ VigilCareClinicalAPI/
│ ├── SepsisBundlesController.cs # Current bundle per encounter, bundle detail by ID
│ ├── SitesController.cs # Clinical site CRUD (create, list, get)
│ ├── GatewaysController.cs # Gateway register, list, get, heartbeat (dual auth: JWT + API key)
│ ├── OperationsController.cs # Gateway fleet operations: fleet list, gateway detail, site summary (Admin only)
│ ├── UsersController.cs # Clinical user account management: list, create, update (Admin only)
│ ├── AlertQualityMetricsController.cs # Alert quality metric snapshots and aggregate summary
│ ├── FhirIngestController.cs # FHIR R4 ingest: Patient, Encounter, Observation, MedicationAdministration, Bundle
│ ├── FhirReadController.cs # FHIR R4 read/search: GET Patient/{id}, GET Patient, GET Encounter/{id}, GET Encounter
│ ├── FhirMetadataController.cs # FHIR R4 CapabilityStatement (GET /fhir/R4/metadata)
@@ -237,7 +246,9 @@ VigilCareClinicalAPI/
│ │ ├── ClinicalSite.cs # Hospital site with site code, name, address
│ │ ├── WardGateway.cs # Ward edge node with status, buffer depth, heartbeat, sync timestamps
│ │ ├── ClinicalUser.cs # Username, BCrypt password hash, display name, role, active flag
│ │ ── 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)
│ │ └── AlertQualityMetric.cs # Per-alert-type quality metric snapshots (acknowledgement/false-positive/useful rates)
│ └── Enums/
│ ├── EncounterStatus.cs # Scheduled, Active, Discharged, Cancelled
│ ├── ClinicalRole.cs # Nurse, Physician, Admin, Integration
@@ -246,6 +257,7 @@ VigilCareClinicalAPI/
│ ├── AlertSeverity.cs # Warning, Critical
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
│ ├── AlertType.cs # Threshold breach, QSOFA_SCREEN, SOFA_SEPSIS, warning*, NEWS2_*, GCS_*, …
│ ├── AlertFeedbackType.cs # Useful, TooEarly, TooLate, FalsePositive, MissingContext, WouldAct
│ ├── GatewayStatus.cs # Online, Degraded, Offline with ToDbString/FromDbString
│ ├── BloodType.cs # A+, O-, AB-, … with ToDbString/FromDbString
│ ├── ObservationSource.cs # Device, Manual, Lab
@@ -305,6 +317,10 @@ VigilCareClinicalAPI/
│ ├── ExternalIdentifierService.cs # Links/resolves external system identifiers to internal UUIDs
│ ├── SiteService.cs # Clinical site CRUD
│ ├── GatewayRegistryService.cs # Gateway register, list, heartbeat, mark offline
│ ├── OperationsService.cs # Gateway fleet queries, gateway detail, site summary aggregation
│ ├── UserService.cs # Clinical user account CRUD with BCrypt password hashing
│ ├── DischargeSummaryService.cs # Discharge summary info and MinIO PDF content retrieval
│ ├── AlertQualityMetricsService.cs # Alert quality metric listing and summary computation
│ ├── WarningEvaluator.cs # Warning-range evaluation; suppression + medication annotation; idempotent INSERT
│ ├── AnalyticsService.cs # Elasticsearch query wrappers
│ └── PlausibilityValidator.cs # Per-code numeric range guard
@@ -326,6 +342,8 @@ VigilCareClinicalAPI/
│ │ ├── OutboxPendingCollector.cs # Polls unprocessed outbox rows → outbox_pending_events
│ │ ├── KafkaConsumerLagCollector.cs # Lag for es-indexer, sepsis-engine, notification-publisher, data-lake-writer
│ │ └── WardGatewayMetricsCollector.cs # Polls gateway status/buffer depth every 60s → offline gauge, buffer depth
│ ├── GatewayStaleDetectorService.cs # Periodic stale gateway detection → auto-marks OFFLINE
│ ├── AlertQualityAggregatorService.cs # Periodic alert quality metric computation → alert_quality_metrics table
│ ├── ElasticsSearch/
│ │ ├── ElasticIndexProvisioner.cs # Creates patient_encounters, observations, clinical_alerts indices
│ │ └── EsIndexerService.cs # consumer group: es-indexer; upserts Elasticsearch documents
@@ -362,7 +380,9 @@ VigilCareClinicalAPI/
│ ├── PatientOptions.cs # MRN prefix + digit count for sequence-based generation
│ ├── FhirOptions.cs # API key (single + rotation array), identifier systems, department/class maps, defaults
│ ├── JwtOptions.cs # Issuer, audience, signing key, expiration (default 8 hours)
── DashboardOptions.cs # CORS origins for ward dashboard frontend
── DashboardOptions.cs # CORS origins for ward dashboard frontend
│ ├── GatewayMonitoringOptions.cs # Stale gateway detection interval and threshold
│ └── AlertQualityOptions.cs # Alert quality aggregation interval
├── Sepsis/
│ ├── AlertCreationGuard.cs # Prevents creation of deprecated alert types (SEPSIS_WARNING)
│ ├── QsofaCalculator.cs # Pure static qSOFA scoring (3 criteria, no I/O)
@@ -402,7 +422,7 @@ VigilCareClinicalAPI/
│ └── Sepsis/QsofaResult.cs, QsofaOutcome.cs # qSOFA detector result and screening outcome enum
├── Data/
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
│ ├── Configurations/ # IEntityTypeConfiguration per entity; ClinicalUserConfiguration, ClinicalAuditLogConfiguration, ClinicalSiteConfiguration, WardGatewayConfiguration, QsofaEvaluationConfiguration; ElasticsearchOptions, ElasticIndexOptions
│ ├── Configurations/ # IEntityTypeConfiguration per entity; ClinicalUserConfiguration, ClinicalAuditLogConfiguration, ClinicalSiteConfiguration, WardGatewayConfiguration, QsofaEvaluationConfiguration, AlertFeedbackConfiguration, AlertQualityMetricConfiguration; ElasticsearchOptions, ElasticIndexOptions
│ └── Seed/
│ ├── DataSeeder.cs # Seeds patients, encounters, thresholds, observations
│ ├── GatewayRegistrySeeder.cs # Seeds demo site (SITE-DEMO) and gateway (GW-ICU-3B) with fixed GUIDs
@@ -426,7 +446,7 @@ infra/
│ └── prometheus.yml # Scrape config for vigilcare_api /metrics
└── grafana/
├── provisioning/ # Datasource + dashboard provider config
└── dashboards/ # vigilcare.json clinical dashboard
└── dashboards/ # vigilcare.json clinical dashboard, alert-quality-dashboard.json
tests/
└── VigilCareClinicalAPI.Tests/
@@ -462,7 +482,10 @@ tests/
├── ConcurrencyTests.cs # Parallel patient MRN, sepsis bundle, observation idempotency, encounter open
├── GapAnalysisFixTests.cs # Phase 22 — GCS history, qSOFA evaluation persistence/history, encounter timeline
├── GatewayRegistryTests.cs # Gateway register, heartbeat, API key auth, department filter
├── OperationsApiTests.cs # Operations fleet listing, gateway detail, site summary
├── Helpers/GatewayAuthHelper.cs # WithGatewayApiKey extension method for test clients
├── Alerts/
│ └── AlertQualityAnalyticsTests.cs # Alert feedback submission, quality aggregation, metrics API
├── Auth/
│ └── RbacTests.cs # RBAC — unauthenticated 401, nurse 403 on threshold write, admin audit log creation
└── Fhir/
@@ -547,27 +570,28 @@ VigilCare.Simulator/ # Phase 16 — console replay
├── Output/SimulatorConsole.cs # Colored output with GCS/SOFA score display
├── Polling/ApiPoller.cs # Optional post-event alert/score/bundle/GCS/SOFA polling
├── Scenarios/ # schema.json, ScenarioLoader, ScenarioValidator
└── Scenarios/List/ # Eleven sample scenarios (sepsis, GCS, SOFA, NEWS2, stable, …)
└── Scenarios/List/ # Twelve sample scenarios (sepsis, GCS, SOFA, NEWS2, stable, ward outage, …)
vigilcare-dashboard/ # Phases 1719, 22, 2728, 31 — Vue 3 ward dashboard SPA
vigilcare-dashboard/ # Phases 1719, 22, 23, 2728, 31, 33 — Vue 3 ward dashboard SPA
├── src/
│ ├── api/ # HTTP client (auto Bearer header), encounters, clinical (GCS, SOFA, qSOFA history), alerts, analytics, sepsis, normalize
│ ├── api/ # HTTP client (auto Bearer header), encounters, clinical (GCS, SOFA, qSOFA history), alerts, analytics, sepsis, thresholds, users, audit, reconciliation, operations, alertQuality, normalize
│ ├── components/
│ │ ├── admin/ # ThresholdFormModal, UserFormModal (admin CRUD modals)
│ │ ├── alerts/ # AlertCard, AcknowledgeModal (role-aware), CriticalAlertBanner (browser notifications + audible tone)
│ │ ├── charts/ # SofaHistory, GcsHistory, QsofaHistory, VitalChart with medication markers
│ │ ├── charts/ # SofaHistory, GcsHistory, QsofaHistory, VitalChart with medication markers, AlertQualityChart
│ │ ├── departments/ # DepartmentCard, AcuityBar (unit-level snapshot)
│ │ ├── feedback/ # FeedbackButtons, FeedbackSummary
│ │ ├── layout/ # AppShell, AppHeader, AppSidebar
│ │ ├── patient/ # GcsEntryForm, SofaScorePanel, PatientBanner, EncounterTimeline, VitalsEntryForm, VitalsPanel, AlertsList
│ │ ├── layout/ # AppShell, AppHeader, AppSidebar (role-aware admin section)
│ │ ├── patient/ # GcsEntryForm, SofaScorePanel, PatientBanner, EncounterTimeline, VitalsEntryForm, VitalsPanel, AlertsList, DischargeSummaryPanel
│ │ ├── replay/ # ReplayControls
│ │ ├── sepsis/ # SepsisBundleTable, SepsisBundleRow, SepsisBundleCard (countdown timer)
│ │ ├── ward/ # WardTable (sortable headers), PatientRow, PatientCard, WardToolbar, SortableHeader, HandoffReport (SBAR + print)
│ │ └── ui/ # Button, Card, Badge, Skeleton, EmptyState
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, chartFormat, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, useAlertNotification, useCriticalAlertPolling, handoffReport, vitalsForm
│ │ └── ui/ # Button, Card, Badge, Skeleton, EmptyState, Modal, CollapsibleSection, SeverityBadge, DegradedModeBanner
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, useApiMode, useChartTheme, useFocusTrap, chartFormat, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, useAlertNotification, useCriticalAlertPolling, handoffReport, vitalsForm, roleAccess, auditFormat, reconciliationFormat, thresholdForm, userForm
│ ├── 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
│ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary, DepartmentOverviewView, SepsisBoardView
│ └── __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)
│ ├── stores/ # Pinia — ward (sort + filter + search), alerts (banner + polling), settings (sort prefs + sound mute), feedback, scoring, auth, departments, sepsis, operationsStore, alertQuality
│ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary, DepartmentOverviewView, SepsisBoardView, ThresholdManagementView, UserManagementView, AuditLogView, ReconciliationView, GatewayOperations, AlertQualityAnalytics
│ └── __tests__/ # Vitest — tests (store, feedback, replay, charts, alerts, ward, GCS, SOFA, qSOFA, PatientBanner, EncounterTimeline, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, HandoffReport, handoffReport, VitalsEntryForm, vitalsForm, useAlertStore, useWardStore, DepartmentOverviewView, SepsisBoardView, AcknowledgeModal, CriticalAlertBanner, roleAccess, ThresholdManagementView, thresholdForm, DischargeSummaryPanel, GatewayOperations, alertQuality)
├── vite.config.js
└── README.md # Dev quick start → docs/dashboard-guide.md
@@ -596,7 +620,11 @@ scripts/
├── run-phase24-verification.sh # Phase 24 — Ward outage reconnect scenario (central + gateway replay, ack sync)
├── run-phase29-verification.sh # Phase 29 — Simulator scenario expansion + clinical validation
├── run-phase30-verification.sh # Phase 30 — FHIR R4 ingest integration tests + manual bundle/metadata checks
── run-phase31-verification.sh # Phase 31 — RBAC integration tests + JWT login + audit log query
── run-phase31-verification.sh # Phase 31 — RBAC integration tests + JWT login + audit log query
├── run-phase23-verification.sh # Phase 23 — Degraded operations visibility + gateway fleet + admin panels
├── run-phase33-verification.sh # Phase 33 — Alert quality analytics integration tests
├── demo-network-partition.sh # Gateway network partition demo script
└── mint-gateway-jwt.sh # JWT minting helper for gateway testing
docs/
├── plans/ # Phase implementation and verification guides
@@ -852,6 +880,8 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
| `QsofaCurrentTests` | — | `GET /qsofa/current` — criteria count and breakdown from Redis |
| `GcsScoringTests` | 25 | GCS component scoring, classification, alerts, CNS integration with SOFA |
| `SofaScoringTests` | 26 | SOFA organ scores, baseline eligibility, delta alerts, carry-forward, vasopressors |
| `OperationsApiTests` | 23 | Operations fleet listing, gateway detail, site gateway summary |
| `AlertQualityAnalyticsTests` | 33 | Alert feedback submission, quality aggregation, metrics API |
| `GatewayRegistryTests` | 20 | Gateway register, heartbeat status/buffer, API key auth 401, degraded status, department filter |
| `ClinicalContractsTests` | 20 | ClinicalSyncBatchRequest JSON round-trip serialization |
| `FhirIngestTests` | 30 | FHIR R4 patient upsert idempotency, LOINC observation mapping, unknown code 422, transaction bundle |
@@ -882,6 +912,8 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-phase27-verification.sh # Sepsis-3 refactor: SIRS removal, QSOFA_SCREEN, SOFA bundle trigger
./scripts/run-phase30-verification.sh # FHIR R4 ingest integration tests + manual bundle/metadata checks
./scripts/run-phase31-verification.sh # RBAC integration tests + JWT login + audit log query
./scripts/run-phase23-verification.sh # Degraded operations visibility + gateway fleet + admin panels
./scripts/run-phase33-verification.sh # Alert quality analytics integration tests
```
Phase 25 — GCS scoring (requires running API + Docker Compose; set an active encounter UUID):
@@ -936,6 +968,18 @@ Phase 22 dashboard gap analysis tests only:
dotnet test --filter "FullyQualifiedName~GapAnalysisFix"
```
Phase 23 operations tests only:
```bash
dotnet test --filter "FullyQualifiedName~OperationsApi"
```
Phase 33 alert quality analytics tests only:
```bash
dotnet test --filter "FullyQualifiedName~AlertQuality"
```
Per-phase test runners (subset of `dotnet test`):
```bash
@@ -994,6 +1038,9 @@ See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manua
| `ward_gateways_offline_gauge` | Gauge | `site_code` | `WardGatewayMetricsCollector` — count of gateways with status OFFLINE or DEGRADED per site |
| `ward_gateway_buffer_depth` | Gauge | `gateway_code`, `department` | `WardGatewayMetricsCollector` — reported unsynced event count per gateway |
| `kafka_poison_pills_skipped_total` | Counter | `consumer_group`, `topic` | `PoisonPillGuard` — messages skipped as permanently un-processable (malformed JSON, format errors, or transient failures exceeding MaxPoisonRetries) |
| `alert_quality_useful_rate` | Gauge | `alert_type` | `AlertQualityAggregatorService` — proportion of feedback rated "useful" per alert type |
| `alert_quality_false_positive_rate` | Gauge | `alert_type` | `AlertQualityAggregatorService` — proportion of feedback rated "false positive" per alert type |
| `alert_feedback_total` | Counter | `feedback_type` | `AlertService` — feedback submissions by type |
Prometheus scrapes the API via `infra/prometheus/prometheus.yml` (`job: vigilcare_api` → `host.docker.internal:5270`). Grafana loads the clinical dashboard from `infra/grafana/dashboards/vigilcare.json`.
@@ -1051,6 +1098,8 @@ Error response:
| GET | `/encounters/{id}` | Encounter detail with recent observations and open alerts |
| PATCH | `/encounters/{id}/status` | Advance encounter status |
| GET | `/encounters/{id}/timeline` | Merged chronological view: status changes, observations, alerts |
| GET | `/encounters/{id}/discharge-summary` | Discharge summary info (status, availability) |
| GET | `/encounters/{id}/discharge-summary/content` | Download discharge summary PDF from MinIO |
| GET | `/encounters/{id}/qsofa/current` | Current qSOFA active criteria count (03) from Redis |
| GET | `/encounters/{id}/qsofa/history` | Cursor-paginated qSOFA evaluation history |
@@ -1185,6 +1234,7 @@ Uses cursor pagination on `(recorded_at DESC, id DESC)` — offset pagination wo
| GET | `/alerts/{id}` | Alert detail |
| POST | `/alerts/{id}/acknowledge` | Acknowledge with clinician ID and optional note |
| POST | `/alerts/{id}/resolve` | Resolve (must be acknowledged first) |
| POST | `/alerts/{id}/feedback` | Submit clinician feedback (one per user per alert); requires `alerts:feedback` |
**Alert lifecycle:**
@@ -1410,6 +1460,7 @@ When a correlated drug was given within the `MedicationCorrelation.CorrelationWi
| `alerts:read` | yes | yes | yes | — |
| `alerts:acknowledge` | yes | yes | yes | — |
| `alerts:resolve` | yes | yes | yes | — |
| `alerts:feedback` | yes | yes | yes | — |
| `thresholds:read` | yes | yes | yes | — |
| `thresholds:write` | — | — | yes | — |
| `analytics:read` | yes | yes | yes | — |
@@ -1434,6 +1485,66 @@ All endpoints except `POST /auth/login` and `GET /fhir/R4/metadata` require auth
Each audit log entry includes `action`, `entityType`, `entityId`, `userId`, `userDisplayName`, `previousValueJson` (JSONB), `newValueJson` (JSONB), `reason`, `ipAddress`, `correlationId`, and `createdAt`.
### Users
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/users` | JWT + `users:admin` | List all clinical user accounts |
| POST | `/users` | JWT + `users:admin` | Create a new clinical user account |
| PATCH | `/users/{id}` | JWT + `users:admin` | Update role, display name, or active status |
**POST body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | string | yes | Unique username (max 100) |
| `password` | string | yes | Password (BCrypt hashed) |
| `displayName` | string | yes | Display name (max 200) |
| `role` | string | yes | `NURSE`, `PHYSICIAN`, `ADMIN`, `INTEGRATION` |
**PATCH body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `displayName` | string | no | Updated display name |
| `role` | string | no | Updated role |
| `isActive` | bool | no | Enable/disable account |
### Operations
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/operations/gateways` | JWT + `users:admin` | List registered gateways; optional `status` and `siteId` filters |
| GET | `/operations/gateways/{gatewayId}` | JWT + `users:admin` | Gateway detail with buffer depth, heartbeat metadata, sync history |
| GET | `/operations/sites/{siteId}/summary` | JWT + `users:admin` | Aggregate gateway summary for a site |
### Discharge Summary
| Method | Path | Description |
|---|---|---|
| GET | `/encounters/{id}/discharge-summary` | Discharge summary info (status, availability) |
| GET | `/encounters/{id}/discharge-summary/content` | Download discharge summary PDF from MinIO |
### Alert Quality Metrics
| Method | Path | Description |
|---|---|---|
| GET | `/alerts/quality-metrics` | Per-alert-type quality metric snapshots; optional `alertType`, `from`, `to` filters |
| GET | `/alerts/quality-metrics/summary` | Aggregate alert quality rates across all alert types; optional `from`, `to` |
### Alert Feedback
| Method | Path | Description |
|---|---|---|
| POST | `/alerts/{id}/feedback` | Submit clinician feedback for an acknowledged/resolved alert; one per user per alert |
**POST body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `feedbackType` | string | yes | `USEFUL`, `TOO_EARLY`, `TOO_LATE`, `FALSE_POSITIVE`, `MISSING_CONTEXT`, `WOULD_ACT` |
| `comment` | string | no | Optional free-text comment |
### FHIR R4 Ingest
All FHIR endpoints are under `/fhir/R4`, accept `application/fhir+json`, and return FHIR R4 JSON responses. Authentication is via JWT bearer token or `X-Api-Key` header (configured in `Fhir:ApiKey` or the `Fhir:ApiKeys` array for zero-downtime key rotation; disabled when blank). API key validation uses `CryptographicOperations.FixedTimeEquals` to prevent timing attacks. When a valid JWT is present, the API key check is skipped — this allows both integration engines (API key) and authenticated admin users (JWT) to access FHIR resources. Write endpoints (`POST`) require `fhir:ingest` permission; read endpoints (`GET`) require `fhir:read` permission. Errors return a FHIR `OperationOutcome` with appropriate issue codes.
@@ -1799,6 +1910,45 @@ resolvedAt DateTimeOffset?
createdAt DateTimeOffset
```
### AlertFeedback
```
id Guid PK
alertId Guid FK → ClinicalAlert
userId Guid FK → ClinicalUser
feedbackType string USEFUL | TOO_EARLY | TOO_LATE | FALSE_POSITIVE | MISSING_CONTEXT | WOULD_ACT
comment string? optional free-text
createdAt DateTimeOffset
```
Unique index: `(alert_id, user_id)` — one feedback per user per alert
### AlertQualityMetric
```
id Guid PK
alertType string alert type (DB literal)
windowStart DateTimeOffset
windowEnd DateTimeOffset
totalAlerts int
acknowledgedCount int
resolvedCount int
escalatedCount int
feedbackUsefulCount int
feedbackFalsePositiveCount int
feedbackWouldActCount int
feedbackCount int
acknowledgementRate double
falsePositiveRate double
usefulRate double
wouldActRate double
avgSecondsToAcknowledge double
avgSecondsToResolution double
computedAt DateTimeOffset
```
Indexes: `(alert_type, window_start)`
---
## Elasticsearch Index Shapes
@@ -2055,7 +2205,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
## Implemented Phases
Twenty-nine phases from the project roadmap are implemented and verified, including the **Site & Gateway Registry** (Phase 20), the **Ward Gateway Service** (Phase 21), the **Dashboard Gap Analysis Fixes** (Phase 22), the **Sepsis-3 clinical refactor** (Phases 2729), the **FHIR R4 Inbound Facade** (Phase 30), **RBAC with clinical audit logging** (Phase 31), and the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry, sortable/filterable ward table). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 815, 2022, 2531. Phases 1719 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
Thirty-one phases from the project roadmap are implemented and verified, including the **Site & Gateway Registry** (Phase 20), the **Ward Gateway Service** (Phase 21), the **Dashboard Gap Analysis Fixes** (Phase 22), the **Degraded Operations Visibility** (Phase 23), the **Sepsis-3 clinical refactor** (Phases 2729), the **FHIR R4 Inbound Facade** (Phase 30), **RBAC with clinical audit logging** (Phase 31), the **Alert Quality Analytics** (Phase 33), and the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry, sortable/filterable ward table). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 815, 2023, 2531, 33. Phases 1719 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
| Phase | Feature | Status |
|---|---|---|
@@ -2087,9 +2237,11 @@ Twenty-nine phases from the project roadmap are implemented and verified, includ
| 28 | **Frontend GCS + SOFA + sepsis UI refactor** — `GcsEntryForm.vue` (bedside GCS component entry); `SofaScorePanel.vue` (organ-system breakdown with staleness indicators); `useGcs` / `useSofa` composables; `scoring` Pinia store; `ScoresPanel` updated with GCS/SOFA display; `SepsisBundlePanel` and `AlertReasoning` refactored for Sepsis-3 alert types; Vitest tests for GCS entry, SOFA panel, scores panel, alert labels; `run-phase28-verification.sh` | Done |
| 29 | **Simulator scenario expansion + clinical validation** — three new scenarios (`neurological-decline-gcs-01`, `sepsis-sofa-progression-01`, `sofa-partial-spo2-fallback-01`); existing scenarios enriched with GCS/SOFA observations; `ScenarioReplayHelper` for end-to-end test replay; `ClinicalRefactorEndToEndTests` validates qSOFA screen → SOFA labs → bundle workflow; simulator polls GCS/SOFA scores; `run-phase29-verification.sh` | Done |
| 30 | **FHIR R4 Inbound Facade** — `FhirIngestController` (`POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}`); `FhirMetadataController` (CapabilityStatement); `FhirBundleProcessor` (transaction Bundles in dependency order); `LoincCodeMapper` (19 LOINC + 3 SNOMED CT → internal codes); `FhirUnitConverter` (°F→°C); `ExternalResourceIdentifier` table + `ExternalIdentifierService` for hospital MRN/visit number ↔ internal UUID linking; `FhirApiKeyMiddleware` (`X-Api-Key` auth); `FhirExceptionFilter` (→ OperationOutcome); `PatientFhirMapper`, `EncounterFhirMapper`, `ObservationFhirMapper`, `MedicationAdministrationFhirMapper`, `FhirReferenceResolver`; idempotent patient/encounter upserts (`RegisterOrUpdateByIdentifierAsync`, `OpenOrUpdateByIdentifierAsync`); configurable identifier systems, department codes, encounter class maps (`FhirOptions`); Prometheus `fhir_ingest_total`, `fhir_mapping_errors_total`; Mirth Connect integration guide; `FhirIngestTests`; `run-phase30-verification.sh` | Done |
| 31 | **RBAC + Clinical Audit Logging** — JWT bearer authentication (`AuthService`, `AuthController`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 17 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** — 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 |
| 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 |
**Ward dashboard:** backend APIs (`GET /encounters` ward list with extended summary fields including SOFA/GCS/attending/admitted-at, `GET /qsofa/current`, `GET /qsofa/history`, `GET /gcs/history`, `GET /sepsis-bundles` hospital-wide list, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `GapAnalysisFixTests`, `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table, ward sort, ward filter, department format, sepsis format, alert acknowledge, critical alert detect, handoff report, vitals form, GCS entry/history, SOFA panel/history, qSOFA history, scores panel, alert labels, PatientBanner, EncounterTimeline, medication chart markers, AcknowledgeModal, CriticalAlertBanner, DepartmentOverviewView, SepsisBoardView, VitalsEntryForm, useAlertStore, useWardStore).
**Ward dashboard:** backend APIs (`GET /encounters` ward list with extended summary fields including SOFA/GCS/attending/admitted-at, `GET /qsofa/current`, `GET /qsofa/history`, `GET /gcs/history`, `GET /sepsis-bundles` hospital-wide list, `GET /operations/gateways` fleet management, `GET /users` user management, `GET /alerts/quality-metrics` alert quality, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `GapAnalysisFixTests`, `OperationsApiTests`, `AlertQualityAnalyticsTests`, `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table, ward sort, ward filter, department format, sepsis format, alert acknowledge, critical alert detect, handoff report, vitals form, GCS entry/history, SOFA panel/history, qSOFA history, scores panel, alert labels, PatientBanner, EncounterTimeline, medication chart markers, AcknowledgeModal, CriticalAlertBanner, DepartmentOverviewView, SepsisBoardView, VitalsEntryForm, useAlertStore, useWardStore, roleAccess, ThresholdManagementView, DischargeSummaryPanel, GatewayOperations, alertQuality).
**Enhanced Dashboard (post-Phase 22):** Major dashboard feature expansion addressing clinical workflow gaps. **Department Overview** (`/departments`) — unit-level snapshot cards showing patient count, critical/alert/bundle totals per department with acuity distribution bars; click-through to ward filtered by department. **Sepsis Bundle Board** (`/sepsis`) — real-time bundle compliance tracking with countdown timers to 1-hour deadline, urgency-sorted (overdue → at-risk → on-track), live 1-second tick updates. **Critical Alert Notifications** — `CriticalAlertBanner` surfaces new critical alerts from polling cycle with audible 880Hz two-tone alert, browser title flash, and native `Notification` API integration; mute toggle persisted in settings. **Shift Handoff Report** — `HandoffReport.vue` generates SBAR-format (Situation, Background, Assessment, Recommendation) structured reports for all ward patients, enriched with latest vitals, open alerts, pending orders, and sepsis bundle status; ward summary with department stats; print/PDF export. **Vitals Entry Form** — `VitalsEntryForm.vue` on patient detail page enables manual observation recording (7 vital parameters with AVPU dropdown) with client-side plausibility validation matching server-side ranges. **Ward Table Enhancements** — multi-column sorting (room, patient, department, NEWS2, qSOFA, sepsis, alerts) with sortable column headers, debounced patient search (name/MRN), quick-filter toggles (critical, has alerts, active sepsis), clear-all filters. **Acknowledge Modal** — role-aware acknowledgment with clinician identity pre-populated from JWT, role-specific guidance text, and acknowledgment note preview. Backend additions: `GET /sepsis-bundles` paginated hospital-wide list with `SepsisBundleSummary` (patient demographics, elements, deadlines); `WardEncounterSummary` extended with `sofaScore`, `sofaDelta`, `gcsScore`, `gcsClassification`, `lastObservationAt`, `attendingPhysician`, `admittedAt`.
@@ -2101,11 +2253,15 @@ Twenty-nine phases from the project roadmap are implemented and verified, includ
**Scoring pipeline (Phases 2526):** GCS components → `gcs_scores` + `gcs.scored` → SOFA CNS organ system; SOFA lab/vital observations → `sofa_scores` with baseline tracking → delta sepsis alerts when organ dysfunction worsens.
**Sepsis-3 refactor (Phases 2729):** SIRS removed; qSOFA repositioned as bedside screening (`QSOFA_SCREEN`); SOFA delta ≥ 2 triggers `SOFA_SEPSIS` → sepsis bundle. Frontend gains GCS entry form and SOFA score panel. Eleven simulator scenarios validate the full clinical pipeline end-to-end.
**Sepsis-3 refactor (Phases 2729):** SIRS removed; qSOFA repositioned as bedside screening (`QSOFA_SCREEN`); SOFA delta ≥ 2 triggers `SOFA_SEPSIS` → sepsis bundle. Frontend gains GCS entry form and SOFA score panel. Twelve simulator scenarios validate the full clinical pipeline end-to-end.
**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 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 (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.
**Degraded Operations Visibility (Phase 23):** Gateway fleet operations panel with stale gateway auto-detection (`GatewayStaleDetectorService`), discharge summary API with MinIO PDF retrieval, admin panels for user management, threshold management, audit log browsing, and reconciliation viewing. Frontend adds role-aware sidebar navigation, degraded-mode banner for offline gateways, and comprehensive admin CRUD views.
**Alert Quality Analytics (Phase 33):** Server-side clinician feedback persisted as `AlertFeedback` entities (one per user per alert, six feedback types). `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, response times). REST API exposes quality metric snapshots and aggregate summaries. Grafana dashboard visualizes alert quality trends. Frontend analytics view with quality charts.
**Post-phase hardening (after Phase 31):**
- **FHIR R4 read/search** — `FhirReadController` adds `GET /fhir/R4/Patient/{id}`, `GET /fhir/R4/Patient` (search by `identifier`), `GET /fhir/R4/Encounter/{id}`, `GET /fhir/R4/Encounter` (search by `patient`/`status`); new `fhir:read` permission for Admin and Integration roles; CapabilityStatement updated to advertise `read` and `searchType` interactions for Patient and Encounter
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
public class TestingAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Testing";
public const string DefaultTestUserId = "00000000-0000-0000-0000-000000000001";
public TestingAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
@@ -22,7 +23,7 @@ public class TestingAuthHandler : AuthenticationHandler<AuthenticationSchemeOpti
var role = roleHeader.ToString();
var userId = Request.Headers.TryGetValue("X-Test-User-Id", out var idHeader)
? idHeader.ToString()
: Guid.NewGuid().ToString();
: DefaultTestUserId;
var claims = new[]
{
@@ -18,12 +18,12 @@ public static class RabbitMqTestHelper
using var connection = factory.CreateConnection("test-queue-purge");
using var channel = connection.CreateModel();
foreach (var queue in new[]
{
"alerts.paging.queue",
"alerts.paging.dlq",
"alerts.escalation.queue",
})
// x-message-ttl is immutable once the queue exists. A concurrently running dev API
// (300000 ms) can recreate the DLQ after the test host provisions 5000 ms, leaving
// escalation stuck until the 5-minute TTL expires — far beyond the test poll window.
RecreatePagingDlq(channel, o.PagingAckTimeoutMs);
foreach (var queue in new[] { "alerts.paging.queue", "alerts.escalation.queue" })
{
try
{
@@ -35,4 +35,35 @@ public static class RabbitMqTestHelper
}
}
}
private static void RecreatePagingDlq(IModel channel, int pagingAckTimeoutMs)
{
const string dlq = "alerts.paging.dlq";
try
{
channel.QueueDelete(dlq, ifUnused: false, ifEmpty: false);
}
catch (OperationInterruptedException)
{
// Queue may not exist yet on a cold broker.
}
channel.ExchangeDeclare(
RabbitMqTopologyProvisioner.Exchange,
ExchangeType.Direct,
durable: true);
channel.QueueDeclare(
queue: dlq,
durable: true,
exclusive: false,
autoDelete: false,
arguments: new Dictionary<string, object>
{
["x-message-ttl"] = pagingAckTimeoutMs,
["x-dead-letter-exchange"] = RabbitMqTopologyProvisioner.Exchange,
["x-dead-letter-routing-key"] = RabbitMqTopologyProvisioner.EscalKey,
});
}
}
@@ -0,0 +1,15 @@
[Collection("Integration")]
public class RabbitMqOptionsTests
{
private readonly ApiFixture _fixture;
public RabbitMqOptionsTests(ApiFixture fixture) => _fixture = fixture;
[Fact]
public void PagingAckTimeoutMs_IsConfiguredForIntegrationTests()
{
using var scope = _fixture.Services.CreateScope();
var opts = scope.ServiceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<RabbitMqOptions>>().Value;
opts.PagingAckTimeoutMs.Should().Be(5000);
}
}
@@ -0,0 +1,5 @@
public class BadRequestException : DomainException
{
public BadRequestException(string message, string errorCode = "BAD_REQUEST")
: base(message, errorCode) { }
}
@@ -21,6 +21,12 @@ public class ExceptionHandlerMiddleware
await WriteAsync(context, StatusCodes.Status404NotFound,
ApiResponse<object>.Fail(StatusCodes.Status404NotFound, ex.Message, ex.ErrorCode));
}
catch (BadRequestException ex)
{
_logger.LogWarning("{Message}", ex.Message);
await WriteAsync(context, StatusCodes.Status400BadRequest,
ApiResponse<object>.Fail(StatusCodes.Status400BadRequest, ex.Message, ex.ErrorCode));
}
catch (ValidationException ex)
{
_logger.LogWarning("{Message}", ex.Message);
@@ -86,17 +86,7 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
// Messages land here after NACK from the paging worker.
// After x-message-ttl expires, re-routes to clinical.notifications.exchange
// with routing key alerts.escalation → reaches alerts.escalation.queue.
channel.QueueDeclare(
queue: "alerts.paging.dlq",
durable: true,
exclusive: false,
autoDelete: false,
arguments: new Dictionary<string, object>
{
["x-message-ttl"] = (int)_opts.PagingAckTimeoutMs,
["x-dead-letter-exchange"] = Exchange,
["x-dead-letter-routing-key"] = EscalKey,
});
DeclarePagingDlq(connection, channel);
// DLQ is reached via the default exchange — no binding to the direct exchange needed.
// --- alerts.escalation.queue ---
@@ -153,6 +143,34 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
private void DeclarePagingDlq(IConnection connection, IModel channel)
{
const string dlq = "alerts.paging.dlq";
var args = new Dictionary<string, object>
{
["x-message-ttl"] = _opts.PagingAckTimeoutMs,
["x-dead-letter-exchange"] = Exchange,
["x-dead-letter-routing-key"] = EscalKey,
};
try
{
channel.QueueDeclare(dlq, durable: true, exclusive: false, autoDelete: false, arguments: args);
}
catch (OperationInterruptedException ex)
{
// Another process (e.g. local dev API) may have created the DLQ with a different
// x-message-ttl. Delete and recreate so PagingAckTimeoutMs drives escalation timing.
_logger.LogWarning(ex,
"Paging DLQ arguments mismatch — deleting and recreating with PagingDlqTtlMs={Ttl}",
_opts.PagingAckTimeoutMs);
using var cleanup = connection.CreateModel();
cleanup.QueueDelete(dlq, ifUnused: false, ifEmpty: false);
cleanup.QueueDeclare(dlq, durable: true, exclusive: false, autoDelete: false, arguments: args);
}
}
public IConnectionFactory BuildFactory() => new ConnectionFactory
{
HostName = _opts.Host,
@@ -176,7 +176,7 @@ public class AlertService : IAlertService
throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
if (alert.Status == AlertStatus.Open)
throw new ValidationException(
throw new BadRequestException(
"Feedback can only be submitted on acknowledged or resolved alerts.",
"ALERT_NOT_REVIEWABLE");
+10
View File
@@ -0,0 +1,10 @@
```markdown
### Scenario E — Ward isolation during infrastructure failure
A typhoon damages the hospital uplink. ICU bedside monitoring continues on the ward server; critical values still alert the charge nurse; acknowledgments are recorded locally. When connectivity returns, the ward backlog syncs to central with a full audit trail — no duplicate pages, no lost observations.
**What we bring:** Ward gateway architecture (`VigilCare.WardGateway`), idempotent clinical sync batch engine, ops fleet visibility for degraded gateways, and six documented chaos experiments proving the design.
**What the hospital brings:** UPS-backed ward server, network partition procedures, clinical policy for local-only mode (who owns escalation when composite scores are unavailable).
**Demo assets:** `ward-outage-reconnect-01` simulator scenario, `scripts/demo-network-partition.sh`, Grafana fleet panels, `docs/resilience/climate/` experiment artifacts.
+20
View File
@@ -0,0 +1,20 @@
```markdown
# VigilCare Clinical — Interview Questions
## Climate Resilience & Ward Gateway (Phases 2024)
### 29. Why split Tier 1 safety onto a ward gateway instead of relying on hospital datacenter HA?
Datacenter HA protects against server failure, not ward-to-datacenter link failure. Typhoons, construction, and router failures isolate the ward LAN from upstream. Colocating critical threshold evaluation with the ward keeps the observe-alert-acknowledge loop on the ward LAN. Central adds NEWS2, SOFA scoring, Elasticsearch, and Parquet when the link returns.
### 30. How does clinical sync batch processing differ from POS offline sync?
Same mechanical pattern: `batchReference` idempotency, async processor, partial conflicts, server-authoritative resolution. Clinical adds ordering dependencies (ack before alert → `ALERT_NOT_YET_SYNCED` conflict), preserved `recorded_at` timestamps for audit timeline, and paging suppression policy to avoid duplicate critical pages after reconnect. POS conflicts are inventory; clinical conflicts are temporal ordering and duplicate device retries.
### 31. What happens to NEWS2 and sepsis detection during a 4-hour ward outage?
Tier 1 thresholds and warnings run locally. NEWS2, SOFA, qSOFA, trend, and sepsis bundle require the Kafka multi-consumer pipeline on central — intentionally deferred. When sync uploads observations, `observation.recorded` events replay through Kafka; scoring engines catch up with correct timestamps. SOFA delta ≥ 2 sepsis confirmation and the hour-1 bundle activate after replay, not during the outage. Trade-off: composite scores lag during outage but critical single-value breaches (potassium, glucose) still fire locally.
### 32. How do you prove the climate resilience design works in an interview without production deployment?
Six documented chaos experiments with Grafana before/after snapshots in `docs/resilience/climate/`; `ward-outage-reconnect-01` scenario replay against gateway; `ClimateResilienceTests` for idempotency and ordering conflicts; live demo via `scripts/demo-network-partition.sh` showing DEGRADED → buffer → ONLINE → buffer 0. Evidence is measured failure modes, not slide claims.
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "=== Phase 24 Climate Resilience Verification ==="
fail() { echo "FAIL: $1" >&2; exit 1; }
ok() { echo "OK: $1"; }
[[ -f VigilCare.Simulator/Scenarios/List/ward-outage-reconnect-01.json ]] \
|| fail "Missing ward-outage-reconnect-01.json"
ok "ward-outage-reconnect-01.json"
[[ -f docs/resilience/climate/README.md ]] \
|| fail "Missing docs/resilience/climate/README.md"
ok "resilience README"
for n in 20 21 22 23 24; do
[[ -f "docs/plans/phase-${n}-plan.md" ]] || fail "Missing phase-${n}-plan.md"
done
ok "phase plans 20-24"
dotnet build VigilCare.ClinicalContracts >/dev/null \
&& ok "ClinicalContracts builds" \
|| echo "WARN: ClinicalContracts build failed"
dotnet build VigilCareClinical.sln >/dev/null \
&& ok "Solution builds" \
|| echo "WARN: Solution build failed"
if dotnet test VigilCareClinicalAPI.Tests \
--filter "FullyQualifiedName~ClimateResilience" --no-build 2>/dev/null; then
ok "ClimateResilience tests pass"
else
echo "WARN: ClimateResilience tests not found or failed"
fi
grep -qi "ward gateway" docs/interview-questions.md \
|| fail "Interview questions missing ward gateway section"
ok "interview questions #29-32"
[[ -x scripts/demo-network-partition.sh ]] \
|| fail "Missing or non-executable demo-network-partition.sh"
ok "partition demo script"
# Count experiment artifact sets (expect 6 experiments × 4 files = 24)
ARTIFACT_COUNT=$(find docs/resilience/climate -maxdepth 1 -name '2026-*' 2>/dev/null | wc -l)
if [[ "$ARTIFACT_COUNT" -lt 4 ]]; then
echo "WARN: Only $ARTIFACT_COUNT chaos artifacts found (expect 24 for full completion)"
else
ok "$ARTIFACT_COUNT chaos experiment artifacts"
fi
echo ""
echo "Phase 24 verification passed."
@@ -1,8 +1,26 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
const { feedbackByAlert } = vi.hoisted(() => {
const { ref } = require('vue')
return { feedbackByAlert: ref({}) }
})
vi.mock('@/stores/alertQuality', () => ({
useAlertQualityStore: () => ({
async submitFeedback(alertId, rating, comment = '') {
feedbackByAlert.value = {
...feedbackByAlert.value,
[alertId]: { rating, comment, submittedAt: '2026-06-23T00:00:00Z' },
}
return { id: 'fb-1', createdAt: '2026-06-23T00:00:00Z' }
},
getFeedback: (alertId) => feedbackByAlert.value[alertId] ?? null,
}),
}))
const defaultProps = {
alertId: 'alert-1',
alertType: 'SepsisWarning',
@@ -19,7 +37,7 @@ function ratingButton(wrapper, label) {
describe('FeedbackButtons', () => {
beforeEach(() => {
localStorage.clear()
feedbackByAlert.value = {}
setActivePinia(createPinia())
})
@@ -28,11 +46,21 @@ describe('FeedbackButtons', () => {
expect(ratingButtons(wrapper)).toHaveLength(6)
})
it('hidesRatingButtonsWhenCannotSubmit', () => {
const wrapper = mount(FeedbackButtons, {
props: { ...defaultProps, canSubmit: false },
})
expect(ratingButtons(wrapper)).toHaveLength(0)
expect(wrapper.text()).toContain('Acknowledge this alert to rate it.')
})
it('selectingRatingHighlightsButton', async () => {
const wrapper = mount(FeedbackButtons, { props: defaultProps })
const usefulBtn = ratingButton(wrapper, 'Useful')
await usefulBtn.trigger('click')
await flushPromises()
expect(usefulBtn.classes()).toContain('ring-2')
expect(usefulBtn.classes()).toContain('bg-green-100')
@@ -41,6 +69,7 @@ describe('FeedbackButtons', () => {
it('showNotesFieldOnPlusNote', async () => {
const wrapper = mount(FeedbackButtons, { props: defaultProps })
await ratingButton(wrapper, 'Useful').trigger('click')
await flushPromises()
const noteBtn = wrapper.findAll('button').find(b => b.text() === '+ Note')
await noteBtn.trigger('click')
@@ -54,6 +83,7 @@ describe('FeedbackButtons', () => {
const fpBtn = ratingButton(wrapper, 'False positive')
await usefulBtn.trigger('click')
await flushPromises()
expect(usefulBtn.attributes('aria-checked')).toBe('true')
expect(fpBtn.attributes('aria-checked')).toBe('false')
@@ -66,11 +96,13 @@ describe('FeedbackButtons', () => {
expect(buttons.every(b => b.element.tagName === 'BUTTON')).toBe(true)
await ratingButton(wrapper, 'Useful').trigger('click')
await flushPromises()
expect(ratingButton(wrapper, 'Useful').attributes('aria-checked')).toBe('true')
await ratingButton(wrapper, 'Too early').trigger('click')
expect(ratingButton(wrapper, 'Too early').attributes('aria-checked')).toBe('true')
expect(ratingButton(wrapper, 'Useful').attributes('aria-checked')).toBe('false')
await flushPromises()
expect(ratingButton(wrapper, 'Too early').attributes('aria-checked')).toBe('false')
expect(ratingButton(wrapper, 'Useful').attributes('aria-checked')).toBe('true')
const noteBtn = wrapper.findAll('button').find(b => b.text() === '+ Note')
await noteBtn.trigger('click')
@@ -78,6 +110,7 @@ describe('FeedbackButtons', () => {
const input = wrapper.get('input')
await input.setValue('Expected after metoprolol')
await input.trigger('keydown.enter')
await flushPromises()
expect(wrapper.find('input').exists()).toBe(false)
})
@@ -1,18 +1,32 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import FeedbackSummary from '@/views/FeedbackSummary.vue'
import { useFeedbackStore } from '@/stores/feedback'
const { mockSummaryStats, mockByAlertType } = vi.hoisted(() => ({
mockSummaryStats: {
totalFeedback: 3,
usefulRate: 67,
falsePositiveRate: 33,
},
mockByAlertType: {
SepsisWarning: [{ rating: 'useful' }, { rating: 'would-act' }],
WarningHeartRate: [{ rating: 'false-positive' }],
},
}))
vi.mock('@/stores/alertQuality', () => ({
useAlertQualityStore: () => ({
summaryStats: mockSummaryStats,
byAlertType: mockByAlertType,
submitFeedback: vi.fn(),
getFeedback: vi.fn(() => null),
}),
}))
describe('FeedbackSummary', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
const store = useFeedbackStore()
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
store.addFeedback('a2', 'SepsisWarning', 'Critical', 'would-act')
store.addFeedback('a3', 'WarningHeartRate', 'Warning', 'false-positive')
})
it('showsAggregateStats', () => {
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAlertQualityStore } from './alertQuality'
vi.mock('@/api/alertQuality', () => ({
submitAlertFeedback: vi.fn().mockResolvedValue({ id: 'fb-1', createdAt: '2026-06-23T00:00:00Z' }),
fetchQualityMetricsSummary: vi.fn().mockResolvedValue({
totalAlerts: 10, totalFeedback: 4,
acknowledgementRate: 0.8, usefulRate: 0.75,
falsePositiveRate: 0.1, wouldActRate: 0.6,
avgSecondsToAcknowledge: 300, avgSecondsToResolution: 1200,
}),
fetchQualityMetrics: vi.fn().mockResolvedValue({
items: [{ alertType: 'News2Warning', usefulRate: 0.75, falsePositiveRate: 0.1, acknowledgementRate: 0.8, totalAlerts: 5 }],
}),
FEEDBACK_TYPE_MAP: { useful: 'Useful' },
}))
describe('alertQuality store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('loads dashboard summary and snapshots', async () => {
const store = useAlertQualityStore()
await store.loadDashboard()
expect(store.summaryStats.totalAlerts).toBe(10)
expect(store.summaryStats.usefulRate).toBe(75)
expect(store.snapshots).toHaveLength(1)
})
it('caches submitted feedback per alert', async () => {
const store = useAlertQualityStore()
await store.submitFeedback('alert-1', 'useful', 'test note')
expect(store.getFeedback('alert-1').rating).toBe('useful')
})
})
@@ -4,11 +4,11 @@ import { canAccessOps, filterNavLinks, isDashboardRole, roleCanAccessRoute, MAIN
describe('roleAccess', () => {
it('filtersNavLinksByRole', () => {
const nurseLinks = filterNavLinks(MAIN_NAV_LINKS, 'NURSE')
expect(nurseLinks.some((l) => l.to === '/feedback')).toBe(false)
expect(nurseLinks.some((l) => l.to === '/analytics/alerts')).toBe(true)
expect(nurseLinks.some((l) => l.to === '/alerts')).toBe(true)
const physicianLinks = filterNavLinks(MAIN_NAV_LINKS, 'PHYSICIAN')
expect(physicianLinks.some((l) => l.to === '/feedback')).toBe(true)
expect(physicianLinks.some((l) => l.to === '/analytics/alerts')).toBe(true)
})
it('rejectsIntegrationDashboardRole', () => {
@@ -0,0 +1,37 @@
import { api } from './client'
/** Maps frontend pill values to backend PascalCase enum strings. */
export const FEEDBACK_TYPE_MAP = {
'useful': 'Useful',
'too-early': 'TooEarly',
'too-late': 'TooLate',
'false-positive': 'FalsePositive',
'missing-context': 'MissingContext',
'would-act': 'WouldAct',
}
export function submitAlertFeedback(alertId, rating, comment = '') {
const feedbackType = FEEDBACK_TYPE_MAP[rating]
if (!feedbackType) throw new Error(`Unknown feedback rating: ${rating}`)
return api.post(`/api/v1/alerts/${alertId}/feedback`, {
feedbackType,
comment: comment || null,
})
}
export function fetchQualityMetricsSummary(from, to) {
const params = new URLSearchParams()
if (from) params.set('from', from)
if (to) params.set('to', to)
const qs = params.toString()
return api.get(`/api/v1/alerts/quality-metrics/summary${qs ? `?${qs}` : ''}`)
}
export function fetchQualityMetrics({ alertType, from, to } = {}) {
const params = new URLSearchParams()
if (alertType) params.set('alertType', alertType)
if (from) params.set('from', from)
if (to) params.set('to', to)
const qs = params.toString()
return api.get(`/api/v1/alerts/quality-metrics${qs ? `?${qs}` : ''}`)
}
@@ -91,11 +91,15 @@ function formatTime(iso) {
</div>
</div>
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
<div
v-if="alert.status === 'Acknowledged' || alert.status === 'Resolved'"
class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700"
>
<FeedbackButtons
:alert-id="alert.id"
:alert-type="alert.alertType"
:severity="alert.severity"
:can-submit="true"
/>
</div>
</Card>
@@ -0,0 +1,65 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart, registerables } from 'chart.js'
import { alertTypeLabel } from '@/api/normalize'
Chart.register(...registerables)
const props = defineProps({
snapshots: { type: Array, required: true },
metric: {
type: String,
default: 'usefulRate',
validator: v => ['usefulRate', 'falsePositiveRate', 'acknowledgementRate', 'wouldActRate'].includes(v),
},
title: { type: String, default: 'Alert Quality Over Time' },
})
const METRIC_LABELS = {
usefulRate: 'Useful Rate %',
falsePositiveRate: 'False Positive Rate %',
acknowledgementRate: 'Acknowledgement Rate %',
wouldActRate: 'Would Act Rate %',
}
const chartData = computed(() => {
const sorted = [...props.snapshots].sort(
(a, b) => new Date(a.windowStart) - new Date(b.windowStart),
)
return {
labels: sorted.map(s => alertTypeLabel(s.alertType)),
datasets: [{
label: METRIC_LABELS[props.metric],
data: sorted.map(s => Math.round(s[props.metric] * 100)),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.15)',
fill: true,
tension: 0.3,
pointRadius: 4,
}],
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
scales: {
y: { min: 0, max: 100, title: { display: true, text: '%' } },
},
plugins: { legend: { display: false } },
}))
</script>
<template>
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 class="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">{{ title }}</h3>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<Line v-if="snapshots.length" :data="chartData" :options="chartOptions" />
<p v-else class="text-sm text-gray-400">No metrics for this period yet.</p>
</div>
</div>
</template>
@@ -1,18 +1,22 @@
<script setup>
import { ref, computed } from 'vue'
import { useFeedbackStore } from '@/stores/feedback'
import { useAlertQualityStore } from '@/stores/alertQuality'
const props = defineProps({
alertId: { type: String, required: true },
alertType: { type: String, required: true },
severity: { type: String, required: true },
/** Feedback only allowed after acknowledgement (backend enforces). */
canSubmit: { type: Boolean, default: true },
})
const feedbackStore = useFeedbackStore()
const alertQuality = useAlertQualityStore()
const showNotes = ref(false)
const notes = ref('')
const submitting = ref(false)
const submitError = ref(null)
const existing = computed(() => feedbackStore.getFeedback(props.alertId))
const existing = computed(() => alertQuality.getFeedback(props.alertId))
const selectedRating = computed(() => existing.value?.rating ?? null)
const ratings = [
@@ -24,27 +28,48 @@ const ratings = [
{ value: 'would-act', label: 'Would act', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' },
]
function select(rating) {
feedbackStore.addFeedback(props.alertId, props.alertType, props.severity, rating, notes.value)
async function select(rating) {
if (!props.canSubmit || submitting.value || selectedRating.value) return
submitting.value = true
submitError.value = null
try {
await alertQuality.submitFeedback(props.alertId, rating, notes.value)
} catch (e) {
submitError.value = e.message
} finally {
submitting.value = false
}
}
function submitNotes() {
if (selectedRating.value) {
feedbackStore.addFeedback(props.alertId, props.alertType, props.severity, selectedRating.value, notes.value)
}
async function submitNotes() {
if (!selectedRating.value) return
submitting.value = true
submitError.value = null
try {
await alertQuality.submitFeedback(props.alertId, selectedRating.value, notes.value)
showNotes.value = false
} catch (e) {
submitError.value = e.message
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Rate this alert">
<p v-if="!canSubmit" class="text-xs text-gray-400 dark:text-gray-500">
Acknowledge this alert to rate it.
</p>
<div v-else class="flex flex-wrap gap-2" role="radiogroup" aria-label="Rate this alert">
<button
v-for="r in ratings"
:key="r.value"
role="radio"
:aria-checked="selectedRating === r.value"
class="rounded-full px-4 py-2 text-xs font-medium transition duration-150 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
:disabled="submitting || !!selectedRating"
class="rounded-full px-4 py-2 text-xs font-medium transition duration-150 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:opacity-50"
:class="[
selectedRating === r.value ? r.color : 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
selectedRating === r.value ? 'ring-2 ring-current' : '',
@@ -63,8 +88,10 @@ function submitNotes() {
</button>
</div>
<p v-if="submitError" class="text-xs text-red-600 dark:text-red-400">{{ submitError }}</p>
<Transition name="slide">
<div v-if="showNotes" class="flex flex-col gap-2 sm:flex-row">
<div v-if="showNotes && canSubmit" class="flex flex-col gap-2 sm:flex-row">
<input
v-model.trim="notes"
type="text"
@@ -73,7 +100,8 @@ function submitNotes() {
@keydown.enter="submitNotes"
/>
<button
class="rounded bg-blue-500 px-4 py-2 text-xs text-white hover:bg-blue-600"
class="rounded bg-blue-500 px-4 py-2 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
:disabled="submitting"
@click.stop="submitNotes"
>
Save
@@ -116,6 +116,21 @@ function linkClasses(path) {
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
<svg
v-else-if="link.icon === 'analytics'"
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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<svg
v-else
class="h-6 w-6 shrink-0"
@@ -15,7 +15,7 @@ export const MAIN_NAV_LINKS = [
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
{ to: '/sepsis', label: 'Sepsis Board', icon: 'sepsis', roles: CLINICAL_ROLES },
{ to: '/alerts', label: 'Alert Center', icon: 'alerts', roles: CLINICAL_ROLES },
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback', roles: ['PHYSICIAN', 'ADMIN'] },
{ to: '/analytics/alerts', label: 'Alert Quality', icon: 'analytics', roles: CLINICAL_ROLES },
{ to: '/admin/reconciliation', label: 'Data Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
]
+10 -6
View File
@@ -45,12 +45,6 @@ const routes = [
component: () => import('@/views/SepsisBoardView.vue'),
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/feedback',
name: 'FeedbackSummary',
component: () => import('@/views/FeedbackSummary.vue'),
meta: { title: 'Feedback Summary', layout: 'default', allowedRoles: ['PHYSICIAN', 'ADMIN'] },
},
{
path: '/admin/thresholds',
name: 'ThresholdManagement',
@@ -81,6 +75,16 @@ const routes = [
component: () => import('@/views/GatewayOperations.vue'),
meta: { title: 'Gateway Operations', layout: 'default', opsRoute: true },
},
{
path: '/analytics/alerts',
name: 'AlertQualityAnalytics',
component: () => import('@/views/AlertQualityAnalytics.vue'),
meta: { title: 'Alert Quality', layout: 'default' },
},
{
path: '/feedback',
redirect: '/analytics/alerts',
},
]
const router = createRouter({
@@ -0,0 +1,121 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
submitAlertFeedback,
fetchQualityMetricsSummary,
fetchQualityMetrics,
} from '@/api/alertQuality'
export const useAlertQualityStore = defineStore('alertQuality', () => {
const summary = ref(null)
const snapshots = ref([])
const periodDays = ref(7)
const selectedAlertType = ref(null)
const loading = ref(false)
const error = ref(null)
// Per-alert feedback cache: alertId → { rating, comment, submittedAt }
const feedbackByAlert = ref({})
const summaryStats = computed(() => {
const s = summary.value
if (!s) {
return {
totalAlerts: 0,
totalFeedback: 0,
acknowledgementRate: 0,
usefulRate: 0,
falsePositiveRate: 0,
wouldActRate: 0,
avgAckMinutes: 0,
avgResolveMinutes: 0,
}
}
return {
totalAlerts: s.totalAlerts,
totalFeedback: s.totalFeedback,
acknowledgementRate: Math.round(s.acknowledgementRate * 100),
usefulRate: Math.round(s.usefulRate * 100),
falsePositiveRate: Math.round(s.falsePositiveRate * 100),
wouldActRate: Math.round(s.wouldActRate * 100),
avgAckMinutes: Math.round(s.avgSecondsToAcknowledge / 60),
avgResolveMinutes: Math.round(s.avgSecondsToResolution / 60),
}
})
const byAlertType = computed(() => {
const map = {}
for (const snap of snapshots.value) {
if (!map[snap.alertType]) map[snap.alertType] = []
map[snap.alertType].push(snap)
}
return map
})
function periodRange() {
const to = new Date().toISOString()
const from = new Date(Date.now() - periodDays.value * 86_400_000).toISOString()
return { from, to }
}
async function loadDashboard() {
loading.value = true
error.value = null
try {
const { from, to } = periodRange()
const [sum, list] = await Promise.all([
fetchQualityMetricsSummary(from, to),
fetchQualityMetrics({
alertType: selectedAlertType.value,
from,
to,
}),
])
summary.value = sum
snapshots.value = list.items ?? []
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function submitFeedback(alertId, rating, comment = '') {
const result = await submitAlertFeedback(alertId, rating, comment)
feedbackByAlert.value[alertId] = {
rating,
comment,
submittedAt: result.createdAt,
feedbackType: result.feedbackType,
}
return result
}
function getFeedback(alertId) {
return feedbackByAlert.value[alertId] ?? null
}
function setPeriodDays(days) {
periodDays.value = days
}
function setAlertTypeFilter(type) {
selectedAlertType.value = type
}
return {
summary,
snapshots,
periodDays,
selectedAlertType,
loading,
error,
summaryStats,
byAlertType,
loadDashboard,
submitFeedback,
getFeedback,
setPeriodDays,
setAlertTypeFilter,
}
})
+18 -85
View File
@@ -1,88 +1,21 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useFeedbackStore = defineStore('feedback', () => {
const entries = ref(JSON.parse(localStorage.getItem('vigilcare-feedback') || '[]'))
const stats = computed(() => {
const total = entries.value.length
if (total === 0) return { total: 0, useful: 0, falsePositive: 0, usefulPct: 0, fpPct: 0 }
const useful = entries.value.filter(e => e.rating === 'useful' || e.rating === 'would-act').length
const fp = entries.value.filter(e => e.rating === 'false-positive').length
import { computed } from 'vue'
import { useAlertQualityStore } from './alertQuality'
export function useFeedbackStore() {
const store = useAlertQualityStore()
return {
total,
useful,
falsePositive: fp,
usefulPct: Math.round((useful / total) * 100),
fpPct: Math.round((fp / total) * 100),
entries: computed(() => []),
stats: computed(() => ({
total: store.summaryStats.totalFeedback,
usefulPct: store.summaryStats.usefulRate,
fpPct: store.summaryStats.falsePositiveRate,
})),
byAlertType: store.byAlertType,
addFeedback: (alertId, _type, _severity, rating, notes) =>
store.submitFeedback(alertId, rating, notes),
getFeedback: store.getFeedback,
exportAsJson: () => {},
exportAsCsv: () => {},
clearAll: () => {},
}
})
const byAlertType = computed(() => {
const map = {}
for (const entry of entries.value) {
if (!map[entry.alertType]) map[entry.alertType] = []
map[entry.alertType].push(entry)
}
return map
})
function addFeedback(alertId, alertType, severity, rating, notes = '') {
const existing = entries.value.findIndex(e => e.alertId === alertId)
const entry = {
alertId,
alertType,
severity,
rating,
notes: notes.trim(),
timestamp: new Date().toISOString(),
}
if (existing >= 0) {
entries.value[existing] = entry
} else {
entries.value.push(entry)
}
persist()
}
function getFeedback(alertId) {
return entries.value.find(e => e.alertId === alertId) ?? null
}
function persist() {
localStorage.setItem('vigilcare-feedback', JSON.stringify(entries.value))
}
function exportAsJson() {
const blob = new Blob([JSON.stringify(entries.value, null, 2)], { type: 'application/json' })
downloadBlob(blob, 'vigilcare-feedback.json')
}
function exportAsCsv() {
const headers = ['alertId', 'alertType', 'severity', 'rating', 'notes', 'timestamp']
const rows = entries.value.map(e => headers.map(h => `"${(e[h] ?? '').toString().replace(/"/g, '""')}"`).join(','))
const csv = [headers.join(','), ...rows].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
downloadBlob(blob, 'vigilcare-feedback.csv')
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function clearAll() {
entries.value = []
persist()
}
return { entries, stats, byAlertType, addFeedback, getFeedback, exportAsJson, exportAsCsv, clearAll }
})
}
@@ -0,0 +1,156 @@
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useAlertQualityStore } from '@/stores/alertQuality'
import { alertTypeLabel } from '@/api/normalize'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import AlertQualityChart from '@/components/charts/AlertQualityChart.vue'
const store = useAlertQualityStore()
const { summaryStats, byAlertType, snapshots, loading, error, periodDays } = storeToRefs(store)
const periodOptions = [
{ label: '7 days', value: 7 },
{ label: '14 days', value: 14 },
{ label: '30 days', value: 30 },
]
onMounted(() => store.loadDashboard())
function changePeriod(days) {
store.setPeriodDays(days)
store.loadDashboard()
}
</script>
<template>
<div class="w-full min-w-0 space-y-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-xl font-bold dark:text-white">Alert Quality Analytics</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Clinician feedback and acknowledgement metrics from the last {{ periodDays }} days.
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button
v-for="opt in periodOptions"
:key="opt.value"
size="sm"
:variant="periodDays === opt.value ? 'primary' : 'secondary'"
@click="changePeriod(opt.value)"
>
{{ opt.label }}
</Button>
<Button variant="secondary" size="sm" :disabled="loading" @click="store.loadDashboard()">
Refresh
</Button>
</div>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<!-- KPI cards -->
<div v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Skeleton v-for="n in 4" :key="n" class="h-24" />
</div>
<div v-else class="grid w-full min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<div class="text-center">
<div class="text-3xl font-bold dark:text-white">{{ summaryStats.totalAlerts }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Total Alerts</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold text-green-600">{{ summaryStats.usefulRate }}%</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Useful Rate</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold text-red-600">{{ summaryStats.falsePositiveRate }}%</div>
<div class="text-sm text-gray-500 dark:text-gray-400">False Positive Rate</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold text-blue-600">{{ summaryStats.acknowledgementRate }}%</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Acknowledgement Rate</div>
</div>
</Card>
</div>
<!-- Secondary KPIs -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card>
<div class="text-center">
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.totalFeedback }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Feedback Submissions</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.avgAckMinutes }} min</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Time to Acknowledge</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.avgResolveMinutes }} min</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Time to Resolve</div>
</div>
</Card>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<AlertQualityChart
:snapshots="snapshots"
metric="usefulRate"
title="Useful Rate by Alert Type"
/>
<AlertQualityChart
:snapshots="snapshots"
metric="falsePositiveRate"
title="False Positive Rate by Alert Type"
/>
</div>
<!-- Per alert type table -->
<Card>
<h2 class="mb-4 text-lg font-semibold dark:text-white">By Alert Type</h2>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<div
v-for="(rows, alertType) in byAlertType"
:key="alertType"
class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<span class="text-sm font-medium dark:text-white">{{ alertTypeLabel(alertType) }}</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
({{ rows.reduce((s, r) => s + r.totalAlerts, 0) }} alerts)
</span>
</div>
<div class="flex flex-wrap gap-2">
<Badge variant="success" size="xs">
{{ Math.round(rows.at(-1)?.usefulRate * 100 ?? 0) }}% useful
</Badge>
<Badge variant="critical" size="xs">
{{ Math.round(rows.at(-1)?.falsePositiveRate * 100 ?? 0) }}% FP
</Badge>
<Badge variant="info" size="xs">
{{ Math.round(rows.at(-1)?.acknowledgementRate * 100 ?? 0) }}% ack
</Badge>
</div>
</div>
<p v-if="!Object.keys(byAlertType).length" class="py-4 text-sm text-gray-400">
No aggregated metrics yet. Metrics appear after the hourly aggregation cycle runs.
</p>
</div>
</Card>
</div>
</template>
@@ -1,5 +1,4 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useFeedbackStore } from '@/stores/feedback'
import { alertTypeLabel } from '@/api/normalize'
import Badge from '@/components/ui/Badge.vue'
@@ -7,7 +6,7 @@ import Button from '@/components/ui/Button.vue'
import Card from '@/components/ui/Card.vue'
const feedbackStore = useFeedbackStore()
const { entries, stats, byAlertType } = storeToRefs(feedbackStore)
const { entries, stats, byAlertType } = feedbackStore
</script>
<template>