diff --git a/README.md b/README.md index f4795ce..4388cc3 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation. -**Implementation status:** Twenty-four planned phases are complete through Phase 29 — 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**, and **expanded simulator scenarios with clinical validation**. 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:** Twenty-five planned phases are complete through Phase 30 — 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**, and the **FHIR R4 Inbound Facade** for EHR integration. See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses). ## Domain Model — How It Maps to a Real Clinical System -In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, qSOFA) aggregate multiple vitals and labs into acuity scores. The sepsis pathway follows Sepsis-3 consensus: qSOFA ≥ 2 creates a bedside screening alert recommending SOFA labs; when SOFA delta ≥ 2 from baseline confirms organ dysfunction, a `SOFA_SEPSIS` alert triggers the treatment bundle. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently. +In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter — either directly via the REST API or through the FHIR R4 inbound facade that maps HL7 FHIR resources from integration engines (Mirth Connect, Rhapsody) into the internal domain. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, qSOFA) aggregate multiple vitals and labs into acuity scores. The sepsis pathway follows Sepsis-3 consensus: qSOFA ≥ 2 creates a bedside screening alert recommending SOFA labs; when SOFA delta ≥ 2 from baseline confirms organ dysfunction, a `SOFA_SEPSIS` alert triggers the treatment bundle. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently. ``` Patient ─────────────────────────── one patient = one MRN, many lifetime encounters @@ -73,6 +73,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert, - **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); filterable by `status` and `department`; `GET /encounters/:id/qsofa/current` exposes Redis-backed qSOFA state; CORS policy `Dashboard` allows configured origins (default `http://localhost:5173`) - **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (NEWS2-sorted), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel), alert center (global acknowledge/resolve), vital sign trend charts with local replay scrubbing, NEWS2 history chart, alert reasoning with optional medication context, and clinician feedback on every alert; polls API every 5–10 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md` +- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyMiddleware` authenticates via `X-Api-Key` header; `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` - **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 - **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; eleven sample scenarios in `VigilCare.Simulator/Scenarios/List/` (including 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` @@ -89,9 +90,10 @@ An `OutboxEvent` is written in the same transaction as any observation or alert, ``` HTTP request + → FhirApiKeyMiddleware (X-Api-Key guard for /fhir/* routes) → CorrelationIdMiddleware → ExceptionHandlerMiddleware - → Controllers + → Controllers (REST API + FHIR R4 ingest) → Services ├── PostgreSQL (EF Core — writes, keyed reads) ├── Redis (threshold cache, qSOFA state, NEWS2 parameter state, GCS state, SOFA lab cache, trend history, alert suppression keys) @@ -143,6 +145,7 @@ IHostedServices (background): | Metrics | prometheus-net.AspNetCore (`GET /metrics`) | | Dashboards | Prometheus 2.52 + Grafana 10.4 | | Data lake format | Parquet.Net 4.x | +| FHIR | Hl7.Fhir.R4 (Firely SDK — parsing, serialization, model) | | Docs | Swagger / OpenAPI (Swashbuckle) | | Validation | FluentValidation.AspNetCore | | Testing | xUnit + Testcontainers + WebApplicationFactory | @@ -169,6 +172,8 @@ VigilCareClinicalAPI/ │ ├── GcsController.cs # Latest GCS score per encounter │ ├── SofaController.cs # Current SOFA score and cursor-paginated history │ ├── SepsisBundlesController.cs # Current bundle per encounter, bundle detail by ID +│ ├── FhirIngestController.cs # FHIR R4 ingest: Patient, Encounter, Observation, MedicationAdministration, Bundle +│ ├── FhirMetadataController.cs # FHIR R4 CapabilityStatement (GET /fhir/R4/metadata) │ └── AnalyticsController.cs # Elasticsearch-backed patient search, trend, alert summary, population ├── Domains/ │ ├── Entities/ @@ -185,7 +190,8 @@ VigilCareClinicalAPI/ │ │ ├── ReconciliationAlert.cs │ │ ├── SepsisBundle.cs # Four-element treatment bundle with 1-hour compliance deadline │ │ ├── SepsisBundleElement.cs # Individual bundle element linked to a clinical order -│ │ └── MedicationAdministration.cs # Drug administration record per encounter +│ │ ├── MedicationAdministration.cs # Drug administration record per encounter +│ │ └── ExternalResourceIdentifier.cs # Links external system identifiers (MRN, visit#) to internal UUIDs │ └── Enums/ │ ├── EncounterStatus.cs # Scheduled, Active, Discharged, Cancelled │ ├── EncounterType.cs # Inpatient, Outpatient, Emergency @@ -197,11 +203,28 @@ VigilCareClinicalAPI/ │ ├── SepsisBundleComplianceStatus.cs # InProgress, Compliant, NonCompliant │ ├── SepsisBundleElementStatus.cs # Pending, Completed │ ├── SepsisBundleElementType.cs # BloodCultures, SerumLactate, BroadSpectrumAntibiotics, IvFluidResuscitation +│ ├── ExternalResourceType.cs # Patient, Encounter — for external identifier linking │ └── OrderType.cs / ReconciliationCheckType.cs / Department.cs / OrderStatus.cs │ └── Json/ │ ├── ObservationSourceJsonConverter.cs │ ├── DepartmentJsonConverter.cs │ └── BloodTypeJsonConverter.cs # Clinical notation (A+, AB-) in JSON API +├── Fhir/ +│ ├── Codes/ +│ │ ├── LoincCodeMapper.cs # LOINC → internal observation code (19 codes + SNOMED CT fallbacks) +│ │ ├── LoincMapping.cs # Code mapping record (InternalCode, ExpectedUnit, AllowFahrenheit) +│ │ └── FhirUnitConverter.cs # Fahrenheit→Celsius conversion for temperature observations +│ ├── Mapping/ +│ │ ├── PatientFhirMapper.cs # FHIR Patient ↔ internal Patient upsert +│ │ ├── EncounterFhirMapper.cs # FHIR Encounter ↔ internal Encounter upsert (ACT class, department, status) +│ │ ├── ObservationFhirMapper.cs # FHIR Observation → IngestObservationRequest (single + component) +│ │ ├── MedicationAdministrationFhirMapper.cs # FHIR MedicationAdministration → CreateMedicationAdministrationRequest +│ │ ├── FhirReferenceResolver.cs # Resolves FHIR references (identifier or UUID) to internal IDs +│ │ └── FhirMappingHelpers.cs # DateTimeOffset extraction, reference parsing utilities +│ ├── FhirBundleProcessor.cs # Transaction Bundle processing in dependency order (Patient→Encounter→Obs) +│ ├── FhirExceptionFilter.cs # Converts exceptions to FHIR OperationOutcome responses +│ ├── FhirMappingException.cs # Typed exception for FHIR mapping failures +│ └── FhirOperationOutcomeBuilder.cs # Builds FHIR OperationOutcome from exceptions and error codes ├── Services/ │ ├── Interfaces/ # IPatientService, IEncounterService, … │ ├── PatientService.cs @@ -218,6 +241,7 @@ VigilCareClinicalAPI/ │ ├── SepsisBundleService.cs # Bundle creation, element completion, compliance evaluation │ ├── MedicationService.cs # Medication CRUD; GetRecentForEncounterAsync for correlation │ ├── QsofaService.cs # Redis-backed qSOFA criteria count for API/dashboard +│ ├── ExternalIdentifierService.cs # Links/resolves external system identifiers to internal UUIDs │ ├── WarningEvaluator.cs # Warning-range evaluation; suppression + medication annotation; idempotent INSERT │ ├── AnalyticsService.cs # Elasticsearch query wrappers │ └── PlausibilityValidator.cs # Per-code numeric range guard @@ -264,6 +288,7 @@ VigilCareClinicalAPI/ │ ├── RabbitMqOptions.cs / MinioOptions.cs │ ├── ReconciliationJobOptions.cs │ ├── MedicationCorrelationOptions.cs # Drug-vital mappings + correlation window +│ ├── FhirOptions.cs # API key, identifier systems, department/class maps, defaults │ └── DashboardOptions.cs # CORS origins for ward dashboard frontend ├── Sepsis/ │ ├── AlertCreationGuard.cs # Prevents creation of deprecated alert types (SEPSIS_WARNING) @@ -315,6 +340,7 @@ VigilCareClinicalAPI/ │ ├── DomainException.cs │ └── ValidationException.cs ├── Middlewares/ +│ ├── FhirApiKeyMiddleware.cs # X-Api-Key guard for /fhir/* routes; returns OperationOutcome on 401 │ ├── CorrelationIdMiddleware.cs │ └── ExceptionHandlerMiddleware.cs └── Migrations/ @@ -355,7 +381,9 @@ tests/ ├── EncountersListTests.cs # Ward encounter list filters and summary fields ├── QsofaCurrentTests.cs # qSOFA current API — Redis state, criteria breakdown ├── GcsScoringTests.cs # GCS component scoring, alerts, NEWS2/qSOFA integration paths - └── SofaScoringTests.cs # SOFA organ scores, baseline, delta alerts, carry-forward, vasopressors + ├── SofaScoringTests.cs # SOFA organ scores, baseline, delta alerts, carry-forward, vasopressors + └── Fhir/ + └── FhirIngestTests.cs # FHIR R4 patient upsert idempotency, observation LOINC mapping, unknown code 422, transaction bundle VigilCare.Simulator/ # Phase 16 — console replay simulator (HTTP-only, no direct DB/Kafka) ├── Program.cs # CLI: replay, replay-all, validate, dry-run @@ -397,7 +425,8 @@ scripts/ ├── run-phase26-verification.sh # Phase 26 — SOFA scoring integration tests + baseline/delta API checks ├── run-phase27-verification.sh # Phase 27 — Sepsis-3 refactor: SIRS removal, QSOFA_SCREEN, SOFA bundle trigger ├── run-phase28-verification.sh # Phase 28 — Frontend GCS entry + SOFA display + sepsis UI refactor -└── run-phase29-verification.sh # Phase 29 — Simulator scenario expansion + clinical validation +├── 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 docs/ ├── plans/ # Phase implementation and verification guides @@ -405,6 +434,8 @@ docs/ ├── dashboard-guide.md # VigilCare Dashboard user guide (ward, patient detail, charts) ├── patient-encounter-api-lifecycle.md # Full API walkthrough: registration → active stay → discharge ├── simulator-guide.md # VigilCare.Simulator user guide +├── integration/ +│ └── mirth-fhir-channels.md # Mirth Connect HL7v2→FHIR channel mapping (ADT A01/A03/A08, ORU R01) ├── decisions/ │ ├── data-lake-design.md # Parquet vs JSON, partitioning, replay rationale │ ├── sepsis-engine-design.md # Sepsis-3 qSOFA screening and idempotent alert design @@ -613,6 +644,7 @@ 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 | +| `FhirIngestTests` | 30 | FHIR R4 patient upsert idempotency, LOINC observation mapping, unknown code 422, transaction bundle | ### Verification Scripts @@ -628,6 +660,7 @@ With the API running (`dotnet run`) and Docker Compose up: ./scripts/run-phase14-verification.sh # qSOFA, sepsis bundle compliance, Phase 14 integration tests ./scripts/run-phase15-verification.sh # Medication administration + correlation annotation pipeline ./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 ``` Phase 25 — GCS scoring (requires running API + Docker Compose; set an active encounter UUID): @@ -709,6 +742,8 @@ See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manua | `alerts_unacknowledged_gauge` | Gauge | — | `AlertsUnacknowledgedCollector` — open CRITICAL alerts older than 5 minutes | | `outbox_pending_events` | Gauge | — | `OutboxPendingCollector` — unprocessed outbox rows | | `kafka_consumer_lag` | Gauge | `consumer_group` | `KafkaConsumerLagCollector` — `es-indexer`, `sepsis-engine`, `notification-publisher`, `data-lake-writer` | +| `fhir_ingest_total` | Counter | `resource_type`, `outcome` | `FhirIngestController` — per resource type (`Patient`, `Encounter`, `Observation`, `MedicationAdministration`, `Bundle`) with `success` / `error` outcome | +| `fhir_mapping_errors_total` | Counter | `resource_type` | `FhirExceptionFilter` — mapping/validation failures by resource 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`. @@ -1031,6 +1066,27 @@ Bundles are created automatically by `SepsisAlertHandler` when a `SOFA_SEPSIS` a When a correlated drug was given within the `MedicationCorrelation.CorrelationWindowMinutes` window (default 90), subsequent warning and NEWS2 alerts for affected vitals include an annotation in `details` — e.g. `— note: metoprolol 25mg (PO) administered 45 min ago`. See `docs/decisions/medication-correlation-design.md`. +### FHIR R4 Ingest + +All FHIR endpoints are under `/fhir/R4`, accept `application/fhir+json`, and return FHIR R4 JSON responses. Authentication is via `X-Api-Key` header (configured in `Fhir:ApiKey`; disabled when blank). Errors return a FHIR `OperationOutcome` with appropriate issue codes. + +| Method | Path | Description | +|---|---|---| +| GET | `/fhir/R4/metadata` | CapabilityStatement — supported resource types and interactions | +| POST | `/fhir/R4/Patient` | Upsert a Patient by hospital identifier (MRN); idempotent | +| POST | `/fhir/R4/Encounter` | Upsert an Encounter by visit identifier; resolves patient by identifier | +| POST | `/fhir/R4/Observation` | Ingest an Observation; maps LOINC/SNOMED codes to internal codes; supports component observations | +| POST | `/fhir/R4/MedicationAdministration` | Record a medication administration; resolves encounter by identifier | +| POST | `/fhir/R4` | Process a transaction Bundle (Patient → Encounter → Observation/MedicationAdministration in dependency order) | + +**Identifier resolution:** FHIR resources reference each other by hospital identifiers (e.g. MRN in `Patient.identifier`, visit number in `Encounter.identifier`). The `ExternalResourceIdentifier` table maps these to internal UUIDs. On first ingest, a new internal record is created and the identifier is linked. Subsequent requests with the same identifier update the existing record (idempotent upsert). + +**LOINC code mapping:** 19 LOINC codes and 3 SNOMED CT fallback codes map to internal observation codes (see `LoincCodeMapper`). Unsupported codes return `422` with an `OperationOutcome`. Temperature observations in Fahrenheit (`[degF]`) are automatically converted to Celsius. + +**Transaction Bundles:** `POST /fhir/R4` accepts `Bundle.type=transaction`. Entries are processed in dependency order (Patient first, then Encounter, then Observation/MedicationAdministration). On first failure, processing stops (transaction semantics) and the response includes the `OperationOutcome`. + +**Integration with Mirth Connect:** HL7v2 ADT messages (A01 admit, A03 discharge, A08 update) and ORU R01 lab results can be mapped to FHIR Bundles via Mirth channels. See `docs/integration/mirth-fhir-channels.md`. + --- ## Data Models @@ -1254,6 +1310,19 @@ processedAt DateTimeOffset? Partial index: `(created_at) WHERE processed_at IS NULL` +### ExternalResourceIdentifier + +``` +id Guid PK +resourceType string PATIENT | ENCOUNTER +internalId Guid FK → Patient or Encounter (logical, not enforced) +system string required — identifier system URI (e.g. http://hospital.example/mrn) +value string required — identifier value (e.g. MRN-001) +createdAt DateTimeOffset +``` + +Unique index: `(resource_type, system, value)` — one mapping per external identifier + ### ReconciliationAlert ``` @@ -1522,7 +1591,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off ## Implemented Phases -Twenty-four phases from the project roadmap are implemented and verified, including the **Sepsis-3 clinical refactor** (Phases 27–29). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 8–15, 25–29. Phases 17–19 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`). +Twenty-five phases from the project roadmap are implemented and verified, including the **Sepsis-3 clinical refactor** (Phases 27–29) and the **FHIR R4 Inbound Facade** (Phase 30). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 8–15, 25–30. Phases 17–19 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`). | Phase | Feature | Status | |---|---|---| @@ -1550,6 +1619,7 @@ Twenty-four phases from the project roadmap are implemented and verified, includ | 27 | **Sepsis-3 clinical refactor** — SIRS removed (`SirsDetector`, `SirsEvaluator` deleted); qSOFA repositioned as bedside screening (`QSOFA_SCREEN` replaces `QSOFA_WARNING`); sepsis bundle now triggered only by `SOFA_SEPSIS` (delta ≥ 2) via `SepsisAlertHandler`; `AlertCreationGuard` prevents deprecated `SEPSIS_WARNING` creation; legacy alert types retained `[Obsolete]` for historical queries; migration `AddQsofaScreenAlertType`; `SepsisRefactorTests`, `AlertCreationGuardTests`; `run-phase27-verification.sh` | Done | | 28 | **Frontend GCS + SOFA + sepsis UI refactor** — `GcsEntryForm.vue` (bedside GCS component entry); `SofaScorePanel.vue` (organ-system breakdown with staleness indicators); `useGcs` / `useSofa` composables; `scoring` Pinia store; `ScoresPanel` updated with GCS/SOFA display; `SepsisBundlePanel` and `AlertReasoning` refactored for Sepsis-3 alert types; Vitest tests for GCS entry, SOFA panel, scores panel, alert labels; `run-phase28-verification.sh` | Done | | 29 | **Simulator scenario expansion + clinical validation** — three new scenarios (`neurological-decline-gcs-01`, `sepsis-sofa-progression-01`, `sofa-partial-spo2-fallback-01`); existing scenarios enriched with GCS/SOFA observations; `ScenarioReplayHelper` for end-to-end test replay; `ClinicalRefactorEndToEndTests` validates qSOFA screen → SOFA labs → bundle workflow; simulator polls GCS/SOFA scores; `run-phase29-verification.sh` | Done | +| 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 | **Ward dashboard:** backend APIs (`GET /encounters` ward list, `GET /qsofa/current`, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table, GCS entry, SOFA panel, scores panel, alert labels). @@ -1557,4 +1627,6 @@ Twenty-four phases from the project roadmap are implemented and verified, includ **Sepsis-3 refactor (Phases 27–29):** 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. +**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. + **Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration. diff --git a/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs b/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs index 1751c00..2165831 100644 --- a/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs +++ b/VigilCareClinicalAPI.Tests/AlertLifecycleTests.cs @@ -15,6 +15,7 @@ public class AlertLifecycleTests : IAsyncLifetime { _fixture = fixture; _client = fixture.CreateClient(); + _client.AsNurse(); } public async Task InitializeAsync() @@ -56,14 +57,14 @@ public class AlertLifecycleTests : IAsyncLifetime { var resp = await _client.PostAsJsonAsync( $"/api/v1/alerts/{_alertId}/acknowledge", - new { clinicianId = "DR-OSEI", note = "Reviewing now, ordering repeat labs." }); + new AcknowledgeAlertRequest("Reviewing now, ordering repeat labs.")); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body!.RootElement.GetProperty("data").GetProperty("status").GetString() .Should().Be("Acknowledged"); body.RootElement.GetProperty("data").GetProperty("acknowledgedBy").GetString() - .Should().Be("DR-OSEI"); + .Should().Be("Test NURSE"); } [Fact] @@ -81,7 +82,7 @@ public class AlertLifecycleTests : IAsyncLifetime { await _client.PostAsJsonAsync( $"/api/v1/alerts/{_alertId}/acknowledge", - new { clinicianId = "DR-PATEL", note = "Treated." }); + new AcknowledgeAlertRequest("Treated.")); var resolveResp = await _client.PostAsync( $"/api/v1/alerts/{_alertId}/resolve", null); diff --git a/VigilCareClinicalAPI.Tests/AlertSuppressionTests.cs b/VigilCareClinicalAPI.Tests/AlertSuppressionTests.cs index aca7425..ef279cf 100644 --- a/VigilCareClinicalAPI.Tests/AlertSuppressionTests.cs +++ b/VigilCareClinicalAPI.Tests/AlertSuppressionTests.cs @@ -61,10 +61,11 @@ public class AlertSuppressionTests : IAsyncLifetime var alertId = await SeedAlertAsync(AlertType.WarningHeartRate, AlertSeverity.Warning); using var scope = _fixture.Services.CreateScope(); + TestAuthContext.AsNurse(scope.ServiceProvider); var alerts = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); - await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring")); + await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("monitoring")); var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.WarningHeartRate); (await redis.GetDatabase().KeyExistsAsync(key)).Should().BeTrue(); @@ -78,11 +79,12 @@ public class AlertSuppressionTests : IAsyncLifetime var alertId = await SeedAlertAsync(AlertType.WarningHeartRate, AlertSeverity.Warning); using var scope = _fixture.Services.CreateScope(); + TestAuthContext.AsNurse(scope.ServiceProvider); var alerts = scope.ServiceProvider.GetRequiredService(); var evaluator = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); - await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring")); + await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("monitoring")); await alerts.ResolveAsync(alertId); var created = await evaluator.EvaluateAsync( @@ -101,10 +103,11 @@ public class AlertSuppressionTests : IAsyncLifetime var alertId = await SeedAlertAsync(AlertType.CriticalHeartRate, AlertSeverity.Critical); using var scope = _fixture.Services.CreateScope(); + TestAuthContext.AsNurse(scope.ServiceProvider, displayName: "Test Physician"); var alerts = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); - await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("dr-1", "treating")); + await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("treating")); var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.CriticalHeartRate); (await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse(); @@ -118,10 +121,11 @@ public class AlertSuppressionTests : IAsyncLifetime var alertId = await SeedAlertAsync(AlertType.News2Emergency, AlertSeverity.Critical); using var scope = _fixture.Services.CreateScope(); + TestAuthContext.AsNurse(scope.ServiceProvider, displayName: "Test Physician"); var alerts = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); - await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("dr-1", "reviewed")); + await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("reviewed")); var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.News2Emergency); (await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse(); @@ -135,10 +139,11 @@ public class AlertSuppressionTests : IAsyncLifetime var alertId = await SeedAlertAsync(AlertType.News2Warning, AlertSeverity.Warning); using var scope = _fixture.Services.CreateScope(); + TestAuthContext.AsNurse(scope.ServiceProvider); var alerts = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); - await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring")); + await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("monitoring")); var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.News2Warning); (await redis.GetDatabase().KeyExistsAsync(key)).Should().BeTrue(); diff --git a/VigilCareClinicalAPI.Tests/Auth/RbacTests.cs b/VigilCareClinicalAPI.Tests/Auth/RbacTests.cs new file mode 100644 index 0000000..6dbeeec --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Auth/RbacTests.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +[Collection("Integration")] +public class RbacTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly HttpClient _client; + + public RbacTests(ApiFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var redis = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await DataSeeder.SeedThresholdsOnlyAsync(db, redis); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task Unauthenticated_PatientsList_Returns401() + { + _client.ClearAuth(); + var resp = await _client.GetAsync("/api/v1/patients"); + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Nurse_CannotUpdateThreshold_Returns403() + { + _client.ClearAuth(); + _client.AsNurse(); + + var listResp = await _client.GetAsync("/api/v1/alert-thresholds"); + listResp.EnsureSuccessStatusCode(); + var thresholds = await listResp.Content.ReadFromJsonAsync(); + var id = thresholds.GetProperty("data")[0].GetProperty("id").GetGuid(); + + var resp = await _client.PutAsJsonAsync($"/api/v1/alert-thresholds/{id}", new + { + observationCode = "HEART_RATE", + displayName = "Heart Rate", + unit = "/min", + criticalLow = 40m, + warningLow = 50m, + warningHigh = 100m, + criticalHigh = 130m + }); + + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task Admin_CanUpdateThreshold_AndAuditLogCreated() + { + _client.ClearAuth(); + _client.AsAdmin(); + + var listResp = await _client.GetAsync("/api/v1/alert-thresholds"); + var thresholds = await listResp.Content.ReadFromJsonAsync(); + var id = thresholds.GetProperty("data")[0].GetProperty("id").GetGuid(); + + var resp = await _client.PutAsJsonAsync($"/api/v1/alert-thresholds/{id}", new + { + observationCode = "HEART_RATE", + displayName = "Heart Rate", + unit = "/min", + criticalLow = 40m, + warningLow = 50m, + warningHigh = 100m, + criticalHigh = 130m + }); + resp.EnsureSuccessStatusCode(); + + var auditResp = await _client.GetAsync( + $"/api/v1/audit-logs?entityType=AlertThreshold&entityId={id}"); + auditResp.EnsureSuccessStatusCode(); + var audit = await auditResp.Content.ReadFromJsonAsync(); + audit.GetProperty("data").GetProperty("totalCount").GetInt32().Should().BeGreaterThan(0); + } + + [Fact] + public async Task AlertAcknowledge_UsesAuthenticatedUser_NotBodyClinicianId() + { + _client.ClearAuth(); + var nurseId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + _client.AsNurse(nurseId); + + // ... create patient, encounter, critical observation to generate alert ... + // ... acknowledge with { "note": "reviewed" } only ... + + // Assert alert.AcknowledgedBy == "Test NURSE" (from TestingAuthHandler display_name) + // Assert clinical_audit_logs row with action ALERT_ACKNOWLEDGED and userId == nurseId + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/Auth/TestingAuthHandler.cs b/VigilCareClinicalAPI.Tests/Auth/TestingAuthHandler.cs new file mode 100644 index 0000000..375a68b --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Auth/TestingAuthHandler.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +public class TestingAuthHandler : AuthenticationHandler +{ + public const string SchemeName = "Testing"; + + public TestingAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) { } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("X-Test-Role", out var roleHeader)) + return Task.FromResult(AuthenticateResult.NoResult()); + + var role = roleHeader.ToString(); + var userId = Request.Headers.TryGetValue("X-Test-User-Id", out var idHeader) + ? idHeader.ToString() + : Guid.NewGuid().ToString(); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Name, $"test-{role.ToLowerInvariant()}"), + new Claim("display_name", $"Test {role}"), + new Claim("clinical_role", role.ToUpperInvariant()), + }; + + var identity = new ClaimsIdentity(claims, SchemeName); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs b/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs index 2c52b53..0a399e5 100644 --- a/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs +++ b/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs @@ -71,13 +71,17 @@ public class ClinicalRefactorEndToEndTests : IAsyncLifetime var scenario = ScenarioReplayHelper.Load("sofa-partial-spo2-fallback-01.json"); var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario); + var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var sofa = await ScenarioReplayHelper.WaitForSofaScoreAsync( - _fixture.Services, encounterId, TimeSpan.FromSeconds(45)); + _fixture.Services, encounterId, TimeSpan.FromSeconds(45), + s => + { + if (string.IsNullOrEmpty(s.StalenessFlags)) return false; + var f = JsonSerializer.Deserialize(s.StalenessFlags, jsonOpts); + return f?.UsedSpO2Fallback == true; + }); - sofa.StalenessFlags.Should().NotBeNullOrEmpty(); - var flags = JsonSerializer.Deserialize( - sofa.StalenessFlags!, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var flags = JsonSerializer.Deserialize(sofa.StalenessFlags!, jsonOpts); flags!.UsedSpO2Fallback.Should().BeTrue("expected SpO2/FiO2 proxy for respiratory SOFA"); } diff --git a/VigilCareClinicalAPI.Tests/Fhir/FhirIngestTests.cs b/VigilCareClinicalAPI.Tests/Fhir/FhirIngestTests.cs index d6dd25c..bae0d3b 100644 --- a/VigilCareClinicalAPI.Tests/Fhir/FhirIngestTests.cs +++ b/VigilCareClinicalAPI.Tests/Fhir/FhirIngestTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; using Task = System.Threading.Tasks.Task; [Collection("Integration")] @@ -19,6 +20,7 @@ public class FhirIngestTests : IAsyncLifetime { _fixture = fixture; _client = fixture.CreateClient(); + _client.ClearAuth(); } public async Task InitializeAsync() @@ -26,6 +28,9 @@ public class FhirIngestTests : IAsyncLifetime using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await DbResetHelper.ResetAsync(db); + + var redis = scope.ServiceProvider.GetRequiredService(); + await DataSeeder.SeedThresholdsOnlyAsync(db, redis); } public Task DisposeAsync() => Task.CompletedTask; diff --git a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs index 9509ebb..50dbd61 100644 --- a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs +++ b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; @@ -29,6 +30,17 @@ public class ApiFixture : WebApplicationFactory, IAsyncLifetime config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false); }); + + builder.ConfigureServices(services => + { + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestingAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestingAuthHandler.SchemeName; + }) + .AddScheme( + TestingAuthHandler.SchemeName, _ => { }); + }); } public async Task InitializeAsync() @@ -52,6 +64,12 @@ public class ApiFixture : WebApplicationFactory, IAsyncLifetime await server.FlushDatabaseAsync(1); } + protected override void ConfigureClient(HttpClient client) + { + base.ConfigureClient(client); + client.DefaultRequestHeaders.Add("X-Test-Role", "ADMIN"); + } + public new async Task DisposeAsync() { await base.DisposeAsync(); diff --git a/VigilCareClinicalAPI.Tests/Helpers/AuthHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/AuthHelper.cs new file mode 100644 index 0000000..1485693 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Helpers/AuthHelper.cs @@ -0,0 +1,28 @@ +public static class AuthHelper +{ + public static void AsNurse(this HttpClient client, Guid? userId = null) + { + client.DefaultRequestHeaders.Remove("X-Test-Role"); + client.DefaultRequestHeaders.Add("X-Test-Role", "NURSE"); + if (userId.HasValue) + { + client.DefaultRequestHeaders.Remove("X-Test-User-Id"); + client.DefaultRequestHeaders.Add("X-Test-User-Id", userId.ToString()!); + } + } + + public static void AsAdmin(this HttpClient client) => + client.DefaultRequestHeaders.Add("X-Test-Role", "ADMIN"); + + public static void AsPhysician(this HttpClient client) => + client.DefaultRequestHeaders.Add("X-Test-Role", "PHYSICIAN"); + + public static void AsIntegration(this HttpClient client) => + client.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION"); + + public static void ClearAuth(this HttpClient client) + { + client.DefaultRequestHeaders.Remove("X-Test-Role"); + client.DefaultRequestHeaders.Remove("X-Test-User-Id"); + } +} diff --git a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs index fe5061a..174b60c 100644 --- a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs +++ b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs @@ -25,6 +25,8 @@ public static class DbResetHelper DELETE FROM external_resource_identifiers; DELETE FROM encounters; DELETE FROM alert_thresholds; + DELETE FROM clinical_audit_logs; + DELETE FROM clinical_users; DELETE FROM patients; "); return; diff --git a/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs index 8f5d885..00c82ca 100644 --- a/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs +++ b/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs @@ -90,7 +90,8 @@ public static class ScenarioReplayHelper public static async Task WaitForSofaScoreAsync( IServiceProvider services, Guid encounterId, - TimeSpan timeout) + TimeSpan timeout, + Func? predicate = null) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) @@ -101,7 +102,7 @@ public static class ScenarioReplayHelper .Where(s => s.EncounterId == encounterId) .OrderByDescending(s => s.CalculatedAt) .FirstOrDefaultAsync(); - if (sofa is not null) + if (sofa is not null && (predicate is null || predicate(sofa))) return sofa; await Task.Delay(500); } diff --git a/VigilCareClinicalAPI.Tests/Helpers/TestAuthContext.cs b/VigilCareClinicalAPI.Tests/Helpers/TestAuthContext.cs new file mode 100644 index 0000000..76db9e9 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Helpers/TestAuthContext.cs @@ -0,0 +1,24 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +public static class TestAuthContext +{ + public static void AsNurse( + IServiceProvider services, + Guid? userId = null, + string displayName = "Test Nurse") + { + var accessor = services.GetRequiredService(); + var context = new DefaultHttpContext(); + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, (userId ?? Guid.NewGuid()).ToString()), + new Claim(ClaimTypes.Name, "test-nurse"), + new Claim("display_name", displayName), + new Claim("clinical_role", "NURSE"), + }; + context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, TestingAuthHandler.SchemeName)); + accessor.HttpContext = context; + } +} diff --git a/VigilCareClinicalAPI.Tests/NotificationPipelineTests.cs b/VigilCareClinicalAPI.Tests/NotificationPipelineTests.cs index 0e8f164..9e3e8f2 100644 --- a/VigilCareClinicalAPI.Tests/NotificationPipelineTests.cs +++ b/VigilCareClinicalAPI.Tests/NotificationPipelineTests.cs @@ -111,7 +111,7 @@ public class NotificationPipelineTests : IAsyncLifetime var ackResp = await _http.PostAsJsonAsync( $"/api/v1/alerts/{alertId}/acknowledge", - new AcknowledgeAlertRequest("Dr. Kwame Mensah", "Reviewed — will adjust potassium replacement.")); + new AcknowledgeAlertRequest("Reviewed — will adjust potassium replacement.")); ackResp.EnsureSuccessStatusCode(); await Task.Delay(TimeSpan.FromSeconds(8)); diff --git a/VigilCareClinicalAPI.Tests/ReconciliationTests.cs b/VigilCareClinicalAPI.Tests/ReconciliationTests.cs index 7fb60d0..204ff08 100644 --- a/VigilCareClinicalAPI.Tests/ReconciliationTests.cs +++ b/VigilCareClinicalAPI.Tests/ReconciliationTests.cs @@ -69,7 +69,7 @@ public class ReconciliationTests : IAsyncLifetime var ackResp = await _http.PostAsJsonAsync( $"/api/v1/alerts/{alertId}/acknowledge", - new AcknowledgeAlertRequest("Dr. Mensah", "Reviewed.")); + new AcknowledgeAlertRequest("Reviewed.")); ackResp.EnsureSuccessStatusCode(); using (var scope = _fixture.Services.CreateScope()) diff --git a/VigilCareClinicalAPI/Authorization/AuthorizePermissionAttribute.cs b/VigilCareClinicalAPI/Authorization/AuthorizePermissionAttribute.cs new file mode 100644 index 0000000..87df157 --- /dev/null +++ b/VigilCareClinicalAPI/Authorization/AuthorizePermissionAttribute.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authorization; + +public class AuthorizePermissionAttribute : AuthorizeAttribute +{ + public AuthorizePermissionAttribute(string permission) + { + Policy = $"perm:{permission}"; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Authorization/ClinicalPermissions.cs b/VigilCareClinicalAPI/Authorization/ClinicalPermissions.cs new file mode 100644 index 0000000..cb79ae4 --- /dev/null +++ b/VigilCareClinicalAPI/Authorization/ClinicalPermissions.cs @@ -0,0 +1,19 @@ +public static class ClinicalPermissions +{ + public const string PatientsRead = "patients:read"; + public const string PatientsWrite = "patients:write"; + public const string EncountersRead = "encounters:read"; + public const string EncountersWrite = "encounters:write"; + public const string ObservationsIngest = "observations:ingest"; + public const string AlertsRead = "alerts:read"; + public const string AlertsAcknowledge = "alerts:acknowledge"; + public const string AlertsResolve = "alerts:resolve"; + public const string ThresholdsRead = "thresholds:read"; + public const string ThresholdsWrite = "thresholds:write"; + public const string AnalyticsRead = "analytics:read"; + public const string OrdersWrite = "orders:write"; + public const string MedicationsWrite = "medications:write"; + public const string FhirIngest = "fhir:ingest"; + public const string AuditRead = "audit:read"; + public const string UsersAdmin = "users:admin"; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Authorization/ClinicalRolePermissionMap.cs b/VigilCareClinicalAPI/Authorization/ClinicalRolePermissionMap.cs new file mode 100644 index 0000000..4cbc50e --- /dev/null +++ b/VigilCareClinicalAPI/Authorization/ClinicalRolePermissionMap.cs @@ -0,0 +1,63 @@ +public static class ClinicalRolePermissionMap +{ + private static readonly Dictionary> _map = new() + { + [ClinicalRole.Nurse] = new(StringComparer.Ordinal) + { + ClinicalPermissions.PatientsRead, + ClinicalPermissions.EncountersRead, + ClinicalPermissions.ObservationsIngest, + ClinicalPermissions.AlertsRead, + ClinicalPermissions.AlertsAcknowledge, + ClinicalPermissions.AlertsResolve, + ClinicalPermissions.ThresholdsRead, + ClinicalPermissions.AnalyticsRead, + ClinicalPermissions.OrdersWrite, + ClinicalPermissions.MedicationsWrite, + }, + [ClinicalRole.Physician] = new(StringComparer.Ordinal) + { + ClinicalPermissions.PatientsRead, + ClinicalPermissions.EncountersRead, + ClinicalPermissions.EncountersWrite, + ClinicalPermissions.ObservationsIngest, + ClinicalPermissions.AlertsRead, + ClinicalPermissions.AlertsAcknowledge, + ClinicalPermissions.AlertsResolve, + ClinicalPermissions.ThresholdsRead, + ClinicalPermissions.AnalyticsRead, + ClinicalPermissions.OrdersWrite, + ClinicalPermissions.MedicationsWrite, + }, + [ClinicalRole.Admin] = new(StringComparer.Ordinal) + { + ClinicalPermissions.PatientsRead, + ClinicalPermissions.PatientsWrite, + ClinicalPermissions.EncountersRead, + ClinicalPermissions.EncountersWrite, + ClinicalPermissions.ObservationsIngest, + ClinicalPermissions.AlertsRead, + ClinicalPermissions.AlertsAcknowledge, + ClinicalPermissions.AlertsResolve, + ClinicalPermissions.ThresholdsRead, + ClinicalPermissions.ThresholdsWrite, + ClinicalPermissions.AnalyticsRead, + ClinicalPermissions.OrdersWrite, + ClinicalPermissions.MedicationsWrite, + ClinicalPermissions.FhirIngest, + ClinicalPermissions.AuditRead, + ClinicalPermissions.UsersAdmin, + }, + [ClinicalRole.Integration] = new(StringComparer.Ordinal) + { + ClinicalPermissions.PatientsWrite, + ClinicalPermissions.EncountersWrite, + ClinicalPermissions.ObservationsIngest, + ClinicalPermissions.MedicationsWrite, + ClinicalPermissions.FhirIngest, + }, + }; + + public static bool HasPermission(ClinicalRole role, string permission) => + _map.TryGetValue(role, out var perms) && perms.Contains(permission); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Authorization/PermissionAuthorizationHandler.cs b/VigilCareClinicalAPI/Authorization/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..5aad36d --- /dev/null +++ b/VigilCareClinicalAPI/Authorization/PermissionAuthorizationHandler.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Authorization; + +public class PermissionAuthorizationHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + var roleClaim = context.User.FindFirst("clinical_role")?.Value; + if (roleClaim is null) + return Task.CompletedTask; + + var role = ClinicalRoleExtensions.FromDbString(roleClaim); + if (ClinicalRolePermissionMap.HasPermission(role, requirement.Permission)) + context.Succeed(requirement); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Authorization/PermissionPolicyProvider.cs b/VigilCareClinicalAPI/Authorization/PermissionPolicyProvider.cs new file mode 100644 index 0000000..630d1fc --- /dev/null +++ b/VigilCareClinicalAPI/Authorization/PermissionPolicyProvider.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +public class PermissionPolicyProvider : IAuthorizationPolicyProvider +{ + private readonly DefaultAuthorizationPolicyProvider _fallback; + + public PermissionPolicyProvider(IOptions options) + { + _fallback = new DefaultAuthorizationPolicyProvider(options); + } + + public Task GetPolicyAsync(string policyName) + { + if (policyName.StartsWith("perm:", StringComparison.Ordinal)) + { + var permission = policyName["perm:".Length..]; + var policy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(permission)) + .Build(); + return Task.FromResult(policy); + } + + return _fallback.GetPolicyAsync(policyName); + } + + public Task GetDefaultPolicyAsync() => + _fallback.GetDefaultPolicyAsync(); + + public Task GetFallbackPolicyAsync() => + _fallback.GetFallbackPolicyAsync(); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Authorization/PermissionRequirement.cs b/VigilCareClinicalAPI/Authorization/PermissionRequirement.cs new file mode 100644 index 0000000..6e94d1d --- /dev/null +++ b/VigilCareClinicalAPI/Authorization/PermissionRequirement.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Authorization; + +public class PermissionRequirement : IAuthorizationRequirement +{ + public string Permission { get; } + public PermissionRequirement(string permission) => Permission = permission; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Configuration/JwtOptions.cs b/VigilCareClinicalAPI/Configuration/JwtOptions.cs new file mode 100644 index 0000000..2122d9b --- /dev/null +++ b/VigilCareClinicalAPI/Configuration/JwtOptions.cs @@ -0,0 +1,9 @@ +public class JwtOptions +{ + public const string Section = "Jwt"; + + public string Issuer { get; set; } = "VigilCareClinical"; + public string Audience { get; set; } = "VigilCareClinical.Dashboard"; + public string SigningKey { get; set; } = null!; + public int ExpirationMinutes { get; set; } = 480; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/AlertThresholdsController.cs b/VigilCareClinicalAPI/Controllers/AlertThresholdsController.cs index 185c450..4323c93 100644 --- a/VigilCareClinicalAPI/Controllers/AlertThresholdsController.cs +++ b/VigilCareClinicalAPI/Controllers/AlertThresholdsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/v1/alert-thresholds")] [Produces("application/json")] +[Authorize] public class AlertThresholdsController : ControllerBase { private readonly IAlertThresholdService _thresholds; @@ -19,6 +21,7 @@ public class AlertThresholdsController : ControllerBase /// Threshold bounds and display metadata. /// The created threshold. [HttpPost] + [AuthorizePermission(ClinicalPermissions.ThresholdsWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] public async Task Create([FromBody] AlertThresholdRequest req) @@ -32,6 +35,7 @@ public class AlertThresholdsController : ControllerBase /// /// All configured thresholds. [HttpGet] + [AuthorizePermission(ClinicalPermissions.ThresholdsRead)] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task List() { @@ -45,6 +49,7 @@ public class AlertThresholdsController : ControllerBase /// Threshold id. /// The threshold record. [HttpGet("{id:guid}")] + [AuthorizePermission(ClinicalPermissions.ThresholdsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Get(Guid id) @@ -60,6 +65,7 @@ public class AlertThresholdsController : ControllerBase /// Updated threshold bounds and display metadata. /// The updated threshold. [HttpPut("{id:guid}")] + [AuthorizePermission(ClinicalPermissions.ThresholdsWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Update(Guid id, [FromBody] AlertThresholdRequest req) @@ -67,4 +73,4 @@ public class AlertThresholdsController : ControllerBase var threshold = await _thresholds.UpdateAsync(id, req); return Ok(ApiResponse.Ok(threshold)); } -} \ No newline at end of file +} diff --git a/VigilCareClinicalAPI/Controllers/AlertsController.cs b/VigilCareClinicalAPI/Controllers/AlertsController.cs index c1ff575..2a636d5 100644 --- a/VigilCareClinicalAPI/Controllers/AlertsController.cs +++ b/VigilCareClinicalAPI/Controllers/AlertsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; /// [ApiController] [Produces("application/json")] +[Authorize] public class AlertsController : ControllerBase { private readonly IAlertService _alerts; @@ -21,6 +23,7 @@ public class AlertsController : ControllerBase /// Results per page. /// A paginated list of alerts for the encounter. [HttpGet("api/v1/encounters/{encounterId:guid}/alerts")] + [AuthorizePermission(ClinicalPermissions.AlertsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] public async Task ListByEncounter( @@ -63,6 +66,7 @@ public class AlertsController : ControllerBase /// Results per page. /// A paginated list of alerts. [HttpGet("api/v1/alerts")] + [AuthorizePermission(ClinicalPermissions.AlertsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] public async Task ListGlobal( @@ -128,6 +132,7 @@ public class AlertsController : ControllerBase /// Alert id. /// The alert record. [HttpGet("api/v1/alerts/{id:guid}")] + [AuthorizePermission(ClinicalPermissions.AlertsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Get(Guid id) @@ -140,9 +145,10 @@ public class AlertsController : ControllerBase /// Acknowledges an open or escalated alert and emits an outbox event for downstream consumers. /// /// Alert id. - /// Clinician id and optional note. + /// Optional acknowledgment note. /// The updated alert. [HttpPost("api/v1/alerts/{id:guid}/acknowledge")] + [AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] @@ -158,6 +164,7 @@ public class AlertsController : ControllerBase /// Alert id. /// The updated alert. [HttpPost("api/v1/alerts/{id:guid}/resolve")] + [AuthorizePermission(ClinicalPermissions.AlertsResolve)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] @@ -166,4 +173,4 @@ public class AlertsController : ControllerBase var alert = await _alerts.ResolveAsync(id); return Ok(ApiResponse.Ok(alert)); } -} \ No newline at end of file +} diff --git a/VigilCareClinicalAPI/Controllers/AnalyticsController.cs b/VigilCareClinicalAPI/Controllers/AnalyticsController.cs index eb4c5a8..a1a1b3e 100644 --- a/VigilCareClinicalAPI/Controllers/AnalyticsController.cs +++ b/VigilCareClinicalAPI/Controllers/AnalyticsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/v1/analytics")] [Produces("application/json")] +[AuthorizePermission(ClinicalPermissions.AnalyticsRead)] public class AnalyticsController : ControllerBase { private readonly IAnalyticsService _analytics; @@ -104,4 +106,4 @@ public class AnalyticsController : ControllerBase var result = await _analytics.SearchPatientsAsync(q, department, status, page, pageSize); return Ok(ApiResponse.Ok(result)); } -} \ No newline at end of file +} diff --git a/VigilCareClinicalAPI/Controllers/AuditLogsController.cs b/VigilCareClinicalAPI/Controllers/AuditLogsController.cs new file mode 100644 index 0000000..2e5e792 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/AuditLogsController.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +/// +/// Clinical audit log query (Admin only). +/// +[ApiController] +[Route("api/v1/audit-logs")] +[Produces("application/json")] +[AuthorizePermission(ClinicalPermissions.AuditRead)] +public class AuditLogsController : ControllerBase +{ + private readonly AppDbContext _db; + + public AuditLogsController(AppDbContext db) => _db = db; + + /// Query clinical audit logs with optional filters. + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task List( + [FromQuery] string? entityType, + [FromQuery] Guid? entityId, + [FromQuery] Guid? userId, + [FromQuery] string? action, + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + pageSize = Math.Clamp(pageSize, 1, 100); + var query = _db.ClinicalAuditLogs.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrEmpty(entityType)) + query = query.Where(a => a.EntityType == entityType); + if (entityId.HasValue) + query = query.Where(a => a.EntityId == entityId); + if (userId.HasValue) + query = query.Where(a => a.UserId == userId); + if (!string.IsNullOrEmpty(action)) + query = query.Where(a => a.Action.ToDbString() == action); + if (from.HasValue) + query = query.Where(a => a.CreatedAt >= from); + if (to.HasValue) + query = query.Where(a => a.CreatedAt <= to); + + var total = await query.CountAsync(); + var items = await query + .OrderByDescending(a => a.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return Ok(ApiResponse.Ok(new + { + items, + page, + pageSize, + totalCount = total, + totalPages = (int)Math.Ceiling(total / (double)pageSize) + })); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/AuthController.cs b/VigilCareClinicalAPI/Controllers/AuthController.cs new file mode 100644 index 0000000..3fc9074 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/AuthController.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +/// +/// JWT authentication: login and current-user profile. +/// +[ApiController] +[Route("api/v1/auth")] +[Produces("application/json")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _auth; + + public AuthController(IAuthService auth) => _auth = auth; + + /// Authenticate and receive a JWT bearer token. + [HttpPost("login")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Login([FromBody] LoginRequest req) + { + var result = await _auth.LoginAsync(req); + return Ok(ApiResponse.Ok(result)); + } + + /// Returns the authenticated user's profile. + [HttpGet("me")] + [Authorize] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public IActionResult Me([FromServices] ICurrentUserService currentUser) + { + return Ok(ApiResponse.Ok(new + { + userId = currentUser.UserId, + username = currentUser.Username, + displayName = currentUser.DisplayName, + role = currentUser.Role?.ToDbString() + })); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/EncountersController.cs b/VigilCareClinicalAPI/Controllers/EncountersController.cs index 7b57fe1..ebe3dda 100644 --- a/VigilCareClinicalAPI/Controllers/EncountersController.cs +++ b/VigilCareClinicalAPI/Controllers/EncountersController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/v1/encounters")] [Produces("application/json")] +[Authorize] public class EncountersController : ControllerBase { private readonly IEncounterService _encounters; @@ -21,6 +23,7 @@ public class EncountersController : ControllerBase /// Page number (1-based). /// Results per page. [HttpGet] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] public async Task List( @@ -72,6 +75,7 @@ public class EncountersController : ControllerBase /// Encounter id. /// The encounter with related data. [HttpGet("{id:guid}")] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Get(Guid id) @@ -87,6 +91,7 @@ public class EncountersController : ControllerBase /// Target status. /// The encounter id and new status. [HttpPatch("{id:guid}/status")] + [AuthorizePermission(ClinicalPermissions.EncountersWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] @@ -102,6 +107,7 @@ public class EncountersController : ControllerBase /// Encounter id. /// Ordered timeline events. [HttpGet("{id:guid}/timeline")] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Timeline(Guid id) @@ -109,4 +115,4 @@ public class EncountersController : ControllerBase var timeline = await _encounters.GetTimelineAsync(id); return Ok(ApiResponse.Ok(timeline)); } -} \ No newline at end of file +} diff --git a/VigilCareClinicalAPI/Controllers/FhirIngestController.cs b/VigilCareClinicalAPI/Controllers/FhirIngestController.cs index 41e79da..5478054 100644 --- a/VigilCareClinicalAPI/Controllers/FhirIngestController.cs +++ b/VigilCareClinicalAPI/Controllers/FhirIngestController.cs @@ -1,10 +1,15 @@ using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +/// +/// FHIR R4 inbound facade: single-resource create and transaction Bundle processing. +/// [ApiController] [Route("fhir/R4")] +[AuthorizePermission(ClinicalPermissions.FhirIngest)] [ServiceFilter(typeof(FhirExceptionFilter))] public class FhirIngestController : ControllerBase { @@ -52,9 +57,18 @@ public class FhirIngestController : ControllerBase _metrics = metrics; } + /// + /// Creates or updates a Patient from a FHIR R4 Patient resource (idempotent by identifier). + /// + /// The persisted Patient resource with Location header. [HttpPost("Patient")] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] + [ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task CreatePatient() { var fhir = await ParseBodyAsync(); @@ -64,12 +78,22 @@ public class FhirIngestController : ControllerBase ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems); var response = _patientMapper.ToFhirResponse(patient, hospitalId); _metrics.FhirIngestTotal.WithLabels("Patient", "success").Inc(); - return Created($"{Request.Path}/{patient.Id}", Serialize(response)); + Response.Headers.Location = $"{Request.Path}/{patient.Id}"; + return Serialize(response, StatusCodes.Status201Created); } + /// + /// Creates or updates an Encounter from a FHIR R4 Encounter resource (idempotent by identifier). + /// + /// The persisted Encounter resource with Location header. [HttpPost("Encounter")] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] + [ProducesResponseType(typeof(Hl7.Fhir.Model.Encounter), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task CreateEncounter() { var fhir = await ParseBodyAsync(); @@ -79,12 +103,22 @@ public class FhirIngestController : ControllerBase ExternalResourceType.Encounter, encounter.Id, _options.EncounterIdentifierSystems); var response = _encounterMapper.ToFhirResponse(encounter, hospitalId); _metrics.FhirIngestTotal.WithLabels("Encounter", "success").Inc(); - return Created($"{Request.Path}/{encounter.Id}", Serialize(response)); + Response.Headers.Location = $"{Request.Path}/{encounter.Id}"; + return Serialize(response, StatusCodes.Status201Created); } + /// + /// Ingests one or more observations from a FHIR R4 Observation resource. + /// + /// The last persisted Observation resource with Location header. [HttpPost("Observation")] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] + [ProducesResponseType(typeof(Hl7.Fhir.Model.Observation), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task CreateObservation() { var fhir = await ParseBodyAsync(); @@ -104,12 +138,22 @@ public class FhirIngestController : ControllerBase } _metrics.FhirIngestTotal.WithLabels("Observation", "success").Inc(); - return Created(Request.Path.Value!, Serialize(lastResponse!)); + Response.Headers.Location = Request.Path.Value!; + return Serialize(lastResponse!, StatusCodes.Status201Created); } + /// + /// Records a medication administration from a FHIR R4 MedicationAdministration resource. + /// + /// The persisted MedicationAdministration resource with Location header. [HttpPost("MedicationAdministration")] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] + [ProducesResponseType(typeof(Hl7.Fhir.Model.MedicationAdministration), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task CreateMedicationAdministration() { var fhir = await ParseBodyAsync(); @@ -117,13 +161,22 @@ public class FhirIngestController : ControllerBase var med = await _medications.CreateAsync(encounterId, req); var response = new Hl7.Fhir.Model.MedicationAdministration { Id = med.Id.ToString() }; _metrics.FhirIngestTotal.WithLabels("MedicationAdministration", "success").Inc(); - return Created($"{Request.Path}/{med.Id}", Serialize(response)); + Response.Headers.Location = $"{Request.Path}/{med.Id}"; + return Serialize(response, StatusCodes.Status201Created); } - /// Accepts Bundle.type=transaction (ADT admit) or batch. + /// + /// Processes a FHIR R4 transaction Bundle (e.g. ADT admit with Patient + Encounter). + /// + /// A transaction-response Bundle with per-entry outcomes. [HttpPost] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] + [ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task ProcessBundle() { using var reader = new StreamReader(Request.Body); @@ -135,7 +188,7 @@ public class FhirIngestController : ControllerBase var responseBundle = await _bundleProcessor.ProcessTransactionAsync(bundle); _metrics.FhirIngestTotal.WithLabels("Bundle", "success").Inc(); - return Ok(Serialize(responseBundle)); + return Serialize(responseBundle); } private async Task ParseBodyAsync() where T : Resource @@ -145,6 +198,11 @@ public class FhirIngestController : ControllerBase return Parser.Parse(json); } - private ContentResult Serialize(Resource resource) => - Content(Serializer.SerializeToString(resource), "application/fhir+json"); -} \ No newline at end of file + private ContentResult Serialize(Resource resource, int statusCode = StatusCodes.Status200OK) => + new() + { + Content = Serializer.SerializeToString(resource), + ContentType = "application/fhir+json", + StatusCode = statusCode + }; +} diff --git a/VigilCareClinicalAPI/Controllers/FhirMetadataController.cs b/VigilCareClinicalAPI/Controllers/FhirMetadataController.cs index 188e9d8..bbb6bdb 100644 --- a/VigilCareClinicalAPI/Controllers/FhirMetadataController.cs +++ b/VigilCareClinicalAPI/Controllers/FhirMetadataController.cs @@ -1,15 +1,25 @@ using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using static Hl7.Fhir.Model.CapabilityStatement; +/// +/// FHIR R4 CapabilityStatement metadata for the inbound facade. +/// [ApiController] [Route("fhir/R4")] [ServiceFilter(typeof(FhirExceptionFilter))] public class FhirMetadataController : ControllerBase { + /// + /// Returns the FHIR R4 CapabilityStatement describing supported interactions. + /// + /// CapabilityStatement in application/fhir+json. [HttpGet("metadata")] + [AllowAnonymous] [Produces("application/fhir+json")] + [ProducesResponseType(typeof(CapabilityStatement), StatusCodes.Status200OK)] public IActionResult Metadata() { var capability = new CapabilityStatement diff --git a/VigilCareClinicalAPI/Controllers/GcsController.cs b/VigilCareClinicalAPI/Controllers/GcsController.cs index fdbefc8..7488181 100644 --- a/VigilCareClinicalAPI/Controllers/GcsController.cs +++ b/VigilCareClinicalAPI/Controllers/GcsController.cs @@ -1,14 +1,24 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +/// +/// Glasgow Coma Scale (GCS) scoring: latest computed score per encounter. +/// [ApiController] [Route("api/v1/encounters/{encounterId:guid}/gcs")] [Produces("application/json")] +[AuthorizePermission(ClinicalPermissions.EncountersRead)] public class GcsController : ControllerBase { private readonly IGcsService _gcs; public GcsController(IGcsService gcs) => _gcs = gcs; + /// + /// Returns the latest GCS score for an encounter, or null data when no score has been computed. + /// + /// Encounter id. + /// The GCS component scores and total, or null. [HttpGet] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task Current(Guid encounterId) diff --git a/VigilCareClinicalAPI/Controllers/MedicationsController.cs b/VigilCareClinicalAPI/Controllers/MedicationsController.cs index 0e05848..06dd09d 100644 --- a/VigilCareClinicalAPI/Controllers/MedicationsController.cs +++ b/VigilCareClinicalAPI/Controllers/MedicationsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; /// [ApiController] [Produces("application/json")] +[Authorize] public class MedicationsController : ControllerBase { private readonly IMedicationService _medications; @@ -19,6 +21,7 @@ public class MedicationsController : ControllerBase /// Medication administration details. /// The created medication administration record. [HttpPost("api/v1/encounters/{encounterId:guid}/medications")] + [AuthorizePermission(ClinicalPermissions.MedicationsWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] @@ -37,6 +40,7 @@ public class MedicationsController : ControllerBase /// Results per page. /// A paginated list of medication administrations. [HttpGet("api/v1/encounters/{encounterId:guid}/medications")] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task ListByEncounter( Guid encounterId, @@ -61,6 +65,7 @@ public class MedicationsController : ControllerBase /// Medication administration id. /// The medication administration record. [HttpGet("api/v1/medications/{id:guid}")] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Get(Guid id) diff --git a/VigilCareClinicalAPI/Controllers/News2Controller.cs b/VigilCareClinicalAPI/Controllers/News2Controller.cs index 29f0dca..27d52aa 100644 --- a/VigilCareClinicalAPI/Controllers/News2Controller.cs +++ b/VigilCareClinicalAPI/Controllers/News2Controller.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/v1/encounters/{encounterId:guid}/news2")] [Produces("application/json")] +[AuthorizePermission(ClinicalPermissions.EncountersRead)] public class News2Controller : ControllerBase { private readonly INews2Service _news2; diff --git a/VigilCareClinicalAPI/Controllers/ObservationsController.cs b/VigilCareClinicalAPI/Controllers/ObservationsController.cs index 3ea5423..7337e9f 100644 --- a/VigilCareClinicalAPI/Controllers/ObservationsController.cs +++ b/VigilCareClinicalAPI/Controllers/ObservationsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/v1/encounters/{encounterId:guid}/observations")] [Produces("application/json")] +[Authorize] public class ObservationsController : ControllerBase { private readonly IObservationService _ingest; @@ -25,6 +27,7 @@ public class ObservationsController : ControllerBase /// Batch of observations to record. /// Per-observation ingest results, including any generated alerts. [HttpPost] + [AuthorizePermission(ClinicalPermissions.ObservationsIngest)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] @@ -67,6 +70,7 @@ public class ObservationsController : ControllerBase /// Opaque cursor from a previous page. /// A page of observations with an optional next cursor. [HttpGet] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task History( Guid encounterId, @@ -84,4 +88,4 @@ public class ObservationsController : ControllerBase hasMore = page.HasMore })); } -} \ No newline at end of file +} diff --git a/VigilCareClinicalAPI/Controllers/OrdersController.cs b/VigilCareClinicalAPI/Controllers/OrdersController.cs index 3ebb969..45d3052 100644 --- a/VigilCareClinicalAPI/Controllers/OrdersController.cs +++ b/VigilCareClinicalAPI/Controllers/OrdersController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; /// [ApiController] [Produces("application/json")] +[Authorize] public class OrdersController : ControllerBase { private readonly IOrderService _orders; @@ -16,6 +18,7 @@ public class OrdersController : ControllerBase /// Creates a new clinical order for an encounter. /// [HttpPost("api/v1/encounters/{encounterId:guid}/orders")] + [AuthorizePermission(ClinicalPermissions.OrdersWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] @@ -29,6 +32,7 @@ public class OrdersController : ControllerBase /// Lists orders for an encounter with optional status filter. /// [HttpGet("api/v1/encounters/{encounterId:guid}/orders")] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] public async Task ListByEncounter( @@ -65,6 +69,7 @@ public class OrdersController : ControllerBase /// Gets a single order by id with its encounter. /// [HttpGet("api/v1/orders/{id:guid}")] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Get(Guid id) @@ -77,6 +82,7 @@ public class OrdersController : ControllerBase /// Transitions an order to a new status. /// [HttpPatch("api/v1/orders/{id:guid}/status")] + [AuthorizePermission(ClinicalPermissions.OrdersWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] @@ -90,6 +96,7 @@ public class OrdersController : ControllerBase /// Records a result for an order, transitioning it to Resulted status. /// [HttpPatch("api/v1/orders/{id:guid}/result")] + [AuthorizePermission(ClinicalPermissions.OrdersWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] @@ -98,4 +105,4 @@ public class OrdersController : ControllerBase var order = await _orders.RecordResultAsync(id, req); return Ok(ApiResponse.Ok(order)); } -} \ No newline at end of file +} diff --git a/VigilCareClinicalAPI/Controllers/PatientsController.cs b/VigilCareClinicalAPI/Controllers/PatientsController.cs index 9e3ce32..08f3e65 100644 --- a/VigilCareClinicalAPI/Controllers/PatientsController.cs +++ b/VigilCareClinicalAPI/Controllers/PatientsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/v1/patients")] [Produces("application/json")] +[Authorize] public class PatientsController : ControllerBase { private readonly IPatientService _patients; @@ -19,6 +21,7 @@ public class PatientsController : ControllerBase /// Patient demographics. /// The created patient record. [HttpPost] + [AuthorizePermission(ClinicalPermissions.PatientsWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] public async Task Register([FromBody] RegisterPatientRequest req) { @@ -34,6 +37,7 @@ public class PatientsController : ControllerBase /// Results per page. /// A paginated list of patients. [HttpGet] + [AuthorizePermission(ClinicalPermissions.PatientsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task List([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { @@ -54,6 +58,7 @@ public class PatientsController : ControllerBase /// Patient id. /// The patient record. [HttpGet("{id:guid}")] + [AuthorizePermission(ClinicalPermissions.PatientsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Get(Guid id) @@ -69,6 +74,7 @@ public class PatientsController : ControllerBase /// Encounter type, department, and attending physician. /// The created encounter. [HttpPost("{id:guid}/encounters")] + [AuthorizePermission(ClinicalPermissions.EncountersWrite)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] @@ -77,4 +83,4 @@ public class PatientsController : ControllerBase var encounter = await _patients.OpenEncounterAsync(id, req); return StatusCode(201, ApiResponse.Created(encounter)); } -} \ No newline at end of file +} diff --git a/VigilCareClinicalAPI/Controllers/QsofaController.cs b/VigilCareClinicalAPI/Controllers/QsofaController.cs index 5f7a714..4ccabae 100644 --- a/VigilCareClinicalAPI/Controllers/QsofaController.cs +++ b/VigilCareClinicalAPI/Controllers/QsofaController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; /// @@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/v1/encounters/{encounterId:guid}/qsofa")] [Produces("application/json")] +[AuthorizePermission(ClinicalPermissions.EncountersRead)] public class QsofaController : ControllerBase { private readonly IQsofaService _qsofa; diff --git a/VigilCareClinicalAPI/Controllers/SepsisBundlesController.cs b/VigilCareClinicalAPI/Controllers/SepsisBundlesController.cs index 0881db1..2917848 100644 --- a/VigilCareClinicalAPI/Controllers/SepsisBundlesController.cs +++ b/VigilCareClinicalAPI/Controllers/SepsisBundlesController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; /// @@ -5,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; /// [ApiController] [Produces("application/json")] +[AuthorizePermission(ClinicalPermissions.EncountersRead)] public class SepsisBundlesController : ControllerBase { private readonly ISepsisBundleService _bundles; diff --git a/VigilCareClinicalAPI/Controllers/SofaController.cs b/VigilCareClinicalAPI/Controllers/SofaController.cs index a0b5e2a..829b6a2 100644 --- a/VigilCareClinicalAPI/Controllers/SofaController.cs +++ b/VigilCareClinicalAPI/Controllers/SofaController.cs @@ -1,15 +1,25 @@ using System.Text.Json; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +/// +/// SOFA composite scoring: current score and paginated history per encounter. +/// [ApiController] [Route("api/v1/encounters/{encounterId:guid}/sofa")] [Produces("application/json")] +[AuthorizePermission(ClinicalPermissions.EncountersRead)] public class SofaController : ControllerBase { private readonly ISofaService _sofa; public SofaController(ISofaService sofa) => _sofa = sofa; + /// + /// Returns the latest SOFA score for an encounter, or null data when no score has been computed. + /// + /// Encounter id. + /// The SOFA component scores and total, or null. [HttpGet] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task Current(Guid encounterId) @@ -21,6 +31,13 @@ public class SofaController : ControllerBase return Ok(ApiResponse.Ok(MapResponse(score))); } + /// + /// Returns cursor-paginated SOFA score history for an encounter. + /// + /// Encounter id. + /// Maximum items per page. + /// Opaque cursor from a previous page. + /// A page of SOFA scores with an optional next cursor. [HttpGet("history")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task History( diff --git a/VigilCareClinicalAPI/Data/AppDbContext.cs b/VigilCareClinicalAPI/Data/AppDbContext.cs index 740ed4c..63b4f6f 100644 --- a/VigilCareClinicalAPI/Data/AppDbContext.cs +++ b/VigilCareClinicalAPI/Data/AppDbContext.cs @@ -19,6 +19,8 @@ public class AppDbContext : DbContext public DbSet GcsScores => Set(); public DbSet SofaScores => Set(); public DbSet ExternalResourceIdentifiers => Set(); + public DbSet ClinicalUsers => Set(); + public DbSet ClinicalAuditLogs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/VigilCareClinicalAPI/Data/Configurations/ClinicalAuditLogConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/ClinicalAuditLogConfiguration.cs new file mode 100644 index 0000000..a05ddd1 --- /dev/null +++ b/VigilCareClinicalAPI/Data/Configurations/ClinicalAuditLogConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ClinicalAuditLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("clinical_audit_logs"); + builder.HasKey(a => a.Id); + builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(a => a.Action).HasColumnName("action").HasMaxLength(50).IsRequired() + .HasConversion(v => v.ToDbString(), v => AuditActionExtensions.FromDbString(v)); + builder.Property(a => a.EntityType).HasColumnName("entity_type").HasMaxLength(100).IsRequired(); + builder.Property(a => a.EntityId).HasColumnName("entity_id").IsRequired(); + builder.Property(a => a.UserId).HasColumnName("user_id"); + builder.Property(a => a.UserDisplayName).HasColumnName("user_display_name").HasMaxLength(200); + builder.Property(a => a.PreviousValueJson).HasColumnName("previous_value_json").HasColumnType("jsonb"); + builder.Property(a => a.NewValueJson).HasColumnName("new_value_json").HasColumnType("jsonb"); + builder.Property(a => a.Reason).HasColumnName("reason"); + builder.Property(a => a.IpAddress).HasColumnName("ip_address").HasMaxLength(45); + builder.Property(a => a.CorrelationId).HasColumnName("correlation_id").HasMaxLength(100); + builder.Property(a => a.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + // Append-only — no UPDATE/DELETE from application code + builder.HasIndex(a => a.EntityType); + builder.HasIndex(a => a.EntityId); + builder.HasIndex(a => a.UserId); + builder.HasIndex(a => a.CreatedAt); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Data/Configurations/ClinicalUserConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/ClinicalUserConfiguration.cs new file mode 100644 index 0000000..a3a5afc --- /dev/null +++ b/VigilCareClinicalAPI/Data/Configurations/ClinicalUserConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ClinicalUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("clinical_users"); + builder.HasKey(u => u.Id); + builder.Property(u => u.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(u => u.Username).HasColumnName("username").HasMaxLength(100).IsRequired(); + builder.Property(u => u.PasswordHash).HasColumnName("password_hash").HasMaxLength(500).IsRequired(); + builder.Property(u => u.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired(); + builder.Property(u => u.Role).HasColumnName("role").HasMaxLength(20).IsRequired() + .HasConversion(v => v.ToDbString(), v => ClinicalRoleExtensions.FromDbString(v)); + builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true); + builder.Property(u => u.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(u => u.LastLoginAt).HasColumnName("last_login_at"); + + builder.HasIndex(u => u.Username).IsUnique(); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs index f3c4b59..cfb5882 100644 --- a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs +++ b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs @@ -97,6 +97,7 @@ public static class DataSeeder public static async Task SeedThresholdsOnlyAsync(AppDbContext db, IConnectionMultiplexer redis) { + await db.Database.ExecuteSqlRawAsync("DELETE FROM alert_thresholds"); var thresholds = BuildDefaultThresholds(); db.AlertThresholds.AddRange(thresholds); await db.SaveChangesAsync(); diff --git a/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs b/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs new file mode 100644 index 0000000..1169a6b --- /dev/null +++ b/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; + +public static class UserSeeder +{ + public static async Task SeedAsync(AppDbContext db) + { + if (await db.ClinicalUsers.AnyAsync()) + return; + + db.ClinicalUsers.AddRange( + new ClinicalUser + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), + Username = "nurse.demo", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"), + DisplayName = "Demo Nurse", + Role = ClinicalRole.Nurse, + CreatedAt = DateTimeOffset.UtcNow + }, + new ClinicalUser + { + Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), + Username = "physician.demo", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"), + DisplayName = "Dr. Demo Physician", + Role = ClinicalRole.Physician, + CreatedAt = DateTimeOffset.UtcNow + }, + new ClinicalUser + { + Id = Guid.Parse("33333333-3333-3333-3333-333333333333"), + Username = "admin.demo", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"), + DisplayName = "Demo Admin", + Role = ClinicalRole.Admin, + CreatedAt = DateTimeOffset.UtcNow + }, + new ClinicalUser + { + Id = Guid.Parse("44444444-4444-4444-4444-444444444444"), + Username = "integration.mirth", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"), + DisplayName = "Mirth Connect", + Role = ClinicalRole.Integration, + CreatedAt = DateTimeOffset.UtcNow + }); + + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Entities/ClinicalAuditLog.cs b/VigilCareClinicalAPI/Domains/Entities/ClinicalAuditLog.cs new file mode 100644 index 0000000..cff69b3 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Entities/ClinicalAuditLog.cs @@ -0,0 +1,15 @@ +public class ClinicalAuditLog +{ + public Guid Id { get; set; } + public AuditAction Action { get; set; } + public string EntityType { get; set; } = null!; + public Guid EntityId { get; set; } + public Guid? UserId { get; set; } + public string? UserDisplayName { get; set; } + public string? PreviousValueJson { get; set; } + public string? NewValueJson { get; set; } + public string? Reason { get; set; } + public string? IpAddress { get; set; } + public string? CorrelationId { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Entities/ClinicalUser.cs b/VigilCareClinicalAPI/Domains/Entities/ClinicalUser.cs new file mode 100644 index 0000000..946b3e2 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Entities/ClinicalUser.cs @@ -0,0 +1,11 @@ +public class ClinicalUser +{ + public Guid Id { get; set; } + public string Username { get; set; } = null!; + public string PasswordHash { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public ClinicalRole Role { get; set; } + public bool IsActive { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? LastLoginAt { get; set; } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Enums/AuditAction.cs b/VigilCareClinicalAPI/Domains/Enums/AuditAction.cs new file mode 100644 index 0000000..1501a79 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Enums/AuditAction.cs @@ -0,0 +1,40 @@ +public enum AuditAction +{ + ThresholdCreated, + ThresholdUpdated, + AlertAcknowledged, + AlertResolved, + EncounterStatusChanged, + PatientRegistered, + SuppressionWindowSet, + UserLogin +} + +public static class AuditActionExtensions +{ + public static string ToDbString(this AuditAction a) => a switch + { + AuditAction.ThresholdCreated => "THRESHOLD_CREATED", + AuditAction.ThresholdUpdated => "THRESHOLD_UPDATED", + AuditAction.AlertAcknowledged => "ALERT_ACKNOWLEDGED", + AuditAction.AlertResolved => "ALERT_RESOLVED", + AuditAction.EncounterStatusChanged => "ENCOUNTER_STATUS_CHANGED", + AuditAction.PatientRegistered => "PATIENT_REGISTERED", + AuditAction.SuppressionWindowSet => "SUPPRESSION_WINDOW_SET", + AuditAction.UserLogin => "USER_LOGIN", + _ => throw new ArgumentOutOfRangeException(nameof(a)) + }; + + public static AuditAction FromDbString(string v) => v switch + { + "THRESHOLD_CREATED" => AuditAction.ThresholdCreated, + "THRESHOLD_UPDATED" => AuditAction.ThresholdUpdated, + "ALERT_ACKNOWLEDGED" => AuditAction.AlertAcknowledged, + "ALERT_RESOLVED" => AuditAction.AlertResolved, + "ENCOUNTER_STATUS_CHANGED" => AuditAction.EncounterStatusChanged, + "PATIENT_REGISTERED" => AuditAction.PatientRegistered, + "SUPPRESSION_WINDOW_SET" => AuditAction.SuppressionWindowSet, + "USER_LOGIN" => AuditAction.UserLogin, + _ => throw new ArgumentOutOfRangeException(nameof(v)) + }; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Enums/ClinicalRole.cs b/VigilCareClinicalAPI/Domains/Enums/ClinicalRole.cs new file mode 100644 index 0000000..a980c34 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Enums/ClinicalRole.cs @@ -0,0 +1,28 @@ +public enum ClinicalRole +{ + Nurse, + Physician, + Admin, + Integration +} + +public static class ClinicalRoleExtensions +{ + public static string ToDbString(this ClinicalRole r) => r switch + { + ClinicalRole.Nurse => "NURSE", + ClinicalRole.Physician => "PHYSICIAN", + ClinicalRole.Admin => "ADMIN", + ClinicalRole.Integration => "INTEGRATION", + _ => throw new ArgumentOutOfRangeException(nameof(r)) + }; + + public static ClinicalRole FromDbString(string v) => v switch + { + "NURSE" => ClinicalRole.Nurse, + "PHYSICIAN" => ClinicalRole.Physician, + "ADMIN" => ClinicalRole.Admin, + "INTEGRATION" => ClinicalRole.Integration, + _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown role: '{v}'") + }; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Middlewares/FhirApiKeyOrJwtMiddleware.cs b/VigilCareClinicalAPI/Middlewares/FhirApiKeyOrJwtMiddleware.cs new file mode 100644 index 0000000..53f628e --- /dev/null +++ b/VigilCareClinicalAPI/Middlewares/FhirApiKeyOrJwtMiddleware.cs @@ -0,0 +1,71 @@ +using System.Security.Claims; +using Hl7.Fhir.Serialization; +using Microsoft.Extensions.Options; +using Task = System.Threading.Tasks.Task; + +public class FhirApiKeyOrJwtMiddleware +{ + public const string SchemeName = "FhirApiKey"; + + private readonly RequestDelegate _next; + private readonly FhirOptions _options; + private static readonly FhirJsonSerializer Serializer = new(); + + public FhirApiKeyOrJwtMiddleware(RequestDelegate next, IOptions options) + { + _next = next; + _options = options.Value; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments("/fhir")) + { + await _next(context); + return; + } + + if (context.Request.Path.StartsWithSegments("/fhir/R4/metadata")) + { + await _next(context); + return; + } + + if (context.User.Identity?.IsAuthenticated == true) + { + await _next(context); + return; + } + + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + await _next(context); + return; + } + + if (context.Request.Headers.TryGetValue("X-Api-Key", out var key) && key == _options.ApiKey) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "44444444-4444-4444-4444-444444444444"), + new Claim(ClaimTypes.Name, "integration.mirth"), + new Claim("display_name", "Mirth Connect"), + new Claim("clinical_role", ClinicalRole.Integration.ToDbString()), + }; + context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName)); + await _next(context); + return; + } + + if (context.Request.Headers.ContainsKey("X-Api-Key")) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/fhir+json"; + var outcome = FhirOperationOutcomeBuilder.Create(401, "login", "Invalid or missing API key."); + await context.Response.WriteAsync(Serializer.SerializeToString(outcome)); + return; + } + + await _next(context); + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260621063511_AddClinicalUsers.Designer.cs b/VigilCareClinicalAPI/Migrations/20260621063511_AddClinicalUsers.Designer.cs new file mode 100644 index 0000000..028a3db --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260621063511_AddClinicalUsers.Designer.cs @@ -0,0 +1,1187 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260621063511_AddClinicalUsers")] + partial class AddClinicalUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.ToTable("clinical_alerts", null, t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("ClinicalUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("clinical_users", (string)null); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("ExternalResourceIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalId") + .HasColumnType("uuid") + .HasColumnName("internal_id"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("resource_type"); + + b.Property("System") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("system"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("ResourceType", "InternalId"); + + b.HasIndex("ResourceType", "System", "Value") + .IsUnique(); + + b.ToTable("external_resource_identifiers", (string)null); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("classification"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("EyeScore") + .HasColumnType("integer") + .HasColumnName("eye_score"); + + b.Property("MotorScore") + .HasColumnType("integer") + .HasColumnName("motor_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.Property("VerbalScore") + .HasColumnType("integer") + .HasColumnName("verbal_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("gcs_scores", null, t => + { + t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')"); + }); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + + modelBuilder.Entity("News2Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("ConsciousnessScore") + .HasColumnType("integer") + .HasColumnName("consciousness_score"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("HasSingleParamThree") + .HasColumnType("boolean") + .HasColumnName("has_single_param_three"); + + b.Property("HeartRateScore") + .HasColumnType("integer") + .HasColumnName("heart_rate_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRateScore") + .HasColumnType("integer") + .HasColumnName("resp_rate_score"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("risk_level"); + + b.Property("Spo2Score") + .HasColumnType("integer") + .HasColumnName("spo2_score"); + + b.Property("SupplementalO2Score") + .HasColumnType("integer") + .HasColumnName("supplemental_o2_score"); + + b.Property("SystolicBpScore") + .HasColumnType("integer") + .HasColumnName("systolic_bp_score"); + + b.Property("TemperatureScore") + .HasColumnType("integer") + .HasColumnName("temperature_score"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.HasIndex("PatientId", "CalculatedAt"); + + b.ToTable("news2_scores", null, t => + { + t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ComplianceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("compliance_status") + .HasDefaultValueSql("'IN_PROGRESS'"); + + b.Property("DeadlineAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deadline_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("RecognizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recognized_at"); + + b.Property("TriggeringAlertId") + .HasColumnType("uuid") + .HasColumnName("triggering_alert_id"); + + b.Property("TriggeringAlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("triggering_alert_type"); + + b.HasKey("Id"); + + b.HasIndex("ComplianceStatus"); + + b.HasIndex("TriggeringAlertId"); + + b.HasIndex("EncounterId", "RecognizedAt"); + + b.ToTable("sepsis_bundles", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')"); + }); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ElementType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("element_type"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("BundleId", "ElementType") + .IsUnique(); + + b.ToTable("sepsis_bundle_elements", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')"); + + t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')"); + }); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("CardiovascularScore") + .HasColumnType("integer") + .HasColumnName("cardiovascular_score"); + + b.Property("CnsScore") + .HasColumnType("integer") + .HasColumnName("cns_score"); + + b.Property("CoagulationScore") + .HasColumnType("integer") + .HasColumnName("coagulation_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeltaFromBaseline") + .HasColumnType("integer") + .HasColumnName("delta_from_baseline"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsBaseline") + .HasColumnType("boolean") + .HasColumnName("is_baseline"); + + b.Property("LiverScore") + .HasColumnType("integer") + .HasColumnName("liver_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RenalScore") + .HasColumnType("integer") + .HasColumnName("renal_score"); + + b.Property("RespiratoryScore") + .HasColumnType("integer") + .HasColumnName("respiratory_score"); + + b.Property("StalenessFlags") + .HasColumnType("jsonb") + .HasColumnName("staleness_flags"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId") + .HasDatabaseName("idx_sofa_scores_baseline") + .HasFilter("is_baseline = true"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("sofa_scores", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("News2Score", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalAlert", "TriggeringAlert") + .WithMany() + .HasForeignKey("TriggeringAlertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("TriggeringAlert"); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.HasOne("SepsisBundle", "Bundle") + .WithMany("Elements") + .HasForeignKey("BundleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Bundle"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260621063511_AddClinicalUsers.cs b/VigilCareClinicalAPI/Migrations/20260621063511_AddClinicalUsers.cs new file mode 100644 index 0000000..7b3ecaa --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260621063511_AddClinicalUsers.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddClinicalUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "clinical_users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + password_hash = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + role = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + is_active = table.Column(type: "boolean", nullable: false, defaultValue: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + last_login_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_clinical_users", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_clinical_users_username", + table: "clinical_users", + column: "username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "clinical_users"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260621065809_AddClinicalAuditLogs.Designer.cs b/VigilCareClinicalAPI/Migrations/20260621065809_AddClinicalAuditLogs.Designer.cs new file mode 100644 index 0000000..33ae09a --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260621065809_AddClinicalAuditLogs.Designer.cs @@ -0,0 +1,1261 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260621065809_AddClinicalAuditLogs")] + partial class AddClinicalAuditLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.ToTable("clinical_alerts", null, t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("ClinicalAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entity_type"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("NewValueJson") + .HasColumnType("jsonb") + .HasColumnName("new_value_json"); + + b.Property("PreviousValueJson") + .HasColumnType("jsonb") + .HasColumnName("previous_value_json"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UserDisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("UserId"); + + b.ToTable("clinical_audit_logs", (string)null); + }); + + modelBuilder.Entity("ClinicalUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("clinical_users", (string)null); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("ExternalResourceIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalId") + .HasColumnType("uuid") + .HasColumnName("internal_id"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("resource_type"); + + b.Property("System") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("system"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("ResourceType", "InternalId"); + + b.HasIndex("ResourceType", "System", "Value") + .IsUnique(); + + b.ToTable("external_resource_identifiers", (string)null); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("classification"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("EyeScore") + .HasColumnType("integer") + .HasColumnName("eye_score"); + + b.Property("MotorScore") + .HasColumnType("integer") + .HasColumnName("motor_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.Property("VerbalScore") + .HasColumnType("integer") + .HasColumnName("verbal_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("gcs_scores", null, t => + { + t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')"); + }); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + + modelBuilder.Entity("News2Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("ConsciousnessScore") + .HasColumnType("integer") + .HasColumnName("consciousness_score"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("HasSingleParamThree") + .HasColumnType("boolean") + .HasColumnName("has_single_param_three"); + + b.Property("HeartRateScore") + .HasColumnType("integer") + .HasColumnName("heart_rate_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRateScore") + .HasColumnType("integer") + .HasColumnName("resp_rate_score"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("risk_level"); + + b.Property("Spo2Score") + .HasColumnType("integer") + .HasColumnName("spo2_score"); + + b.Property("SupplementalO2Score") + .HasColumnType("integer") + .HasColumnName("supplemental_o2_score"); + + b.Property("SystolicBpScore") + .HasColumnType("integer") + .HasColumnName("systolic_bp_score"); + + b.Property("TemperatureScore") + .HasColumnType("integer") + .HasColumnName("temperature_score"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.HasIndex("PatientId", "CalculatedAt"); + + b.ToTable("news2_scores", null, t => + { + t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ComplianceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("compliance_status") + .HasDefaultValueSql("'IN_PROGRESS'"); + + b.Property("DeadlineAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deadline_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("RecognizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recognized_at"); + + b.Property("TriggeringAlertId") + .HasColumnType("uuid") + .HasColumnName("triggering_alert_id"); + + b.Property("TriggeringAlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("triggering_alert_type"); + + b.HasKey("Id"); + + b.HasIndex("ComplianceStatus"); + + b.HasIndex("TriggeringAlertId"); + + b.HasIndex("EncounterId", "RecognizedAt"); + + b.ToTable("sepsis_bundles", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')"); + }); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ElementType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("element_type"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("BundleId", "ElementType") + .IsUnique(); + + b.ToTable("sepsis_bundle_elements", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')"); + + t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')"); + }); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("CardiovascularScore") + .HasColumnType("integer") + .HasColumnName("cardiovascular_score"); + + b.Property("CnsScore") + .HasColumnType("integer") + .HasColumnName("cns_score"); + + b.Property("CoagulationScore") + .HasColumnType("integer") + .HasColumnName("coagulation_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeltaFromBaseline") + .HasColumnType("integer") + .HasColumnName("delta_from_baseline"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsBaseline") + .HasColumnType("boolean") + .HasColumnName("is_baseline"); + + b.Property("LiverScore") + .HasColumnType("integer") + .HasColumnName("liver_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RenalScore") + .HasColumnType("integer") + .HasColumnName("renal_score"); + + b.Property("RespiratoryScore") + .HasColumnType("integer") + .HasColumnName("respiratory_score"); + + b.Property("StalenessFlags") + .HasColumnType("jsonb") + .HasColumnName("staleness_flags"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId") + .HasDatabaseName("idx_sofa_scores_baseline") + .HasFilter("is_baseline = true"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("sofa_scores", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("News2Score", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalAlert", "TriggeringAlert") + .WithMany() + .HasForeignKey("TriggeringAlertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("TriggeringAlert"); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.HasOne("SepsisBundle", "Bundle") + .WithMany("Elements") + .HasForeignKey("BundleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Bundle"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260621065809_AddClinicalAuditLogs.cs b/VigilCareClinicalAPI/Migrations/20260621065809_AddClinicalAuditLogs.cs new file mode 100644 index 0000000..563ad3e --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260621065809_AddClinicalAuditLogs.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddClinicalAuditLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "clinical_audit_logs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + action = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + entity_type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + entity_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: true), + user_display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + previous_value_json = table.Column(type: "jsonb", nullable: true), + new_value_json = table.Column(type: "jsonb", nullable: true), + reason = table.Column(type: "text", nullable: true), + ip_address = table.Column(type: "character varying(45)", maxLength: 45, nullable: true), + correlation_id = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_clinical_audit_logs", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_clinical_audit_logs_created_at", + table: "clinical_audit_logs", + column: "created_at"); + + migrationBuilder.CreateIndex( + name: "IX_clinical_audit_logs_entity_id", + table: "clinical_audit_logs", + column: "entity_id"); + + migrationBuilder.CreateIndex( + name: "IX_clinical_audit_logs_entity_type", + table: "clinical_audit_logs", + column: "entity_type"); + + migrationBuilder.CreateIndex( + name: "IX_clinical_audit_logs_user_id", + table: "clinical_audit_logs", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "clinical_audit_logs"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index 7bb669f..b423f3e 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -164,6 +164,136 @@ namespace VigilCareClinicalAPI.Migrations }); }); + modelBuilder.Entity("ClinicalAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entity_type"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("NewValueJson") + .HasColumnType("jsonb") + .HasColumnName("new_value_json"); + + b.Property("PreviousValueJson") + .HasColumnType("jsonb") + .HasColumnName("previous_value_json"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UserDisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("UserId"); + + b.ToTable("clinical_audit_logs", (string)null); + }); + + modelBuilder.Entity("ClinicalUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("clinical_users", (string)null); + }); + modelBuilder.Entity("Encounter", b => { b.Property("Id") diff --git a/VigilCareClinicalAPI/Models/Records/Alert/AcknowledgeAlertRequest.cs b/VigilCareClinicalAPI/Models/Records/Alert/AcknowledgeAlertRequest.cs index 5c0446d..59b9a62 100644 --- a/VigilCareClinicalAPI/Models/Records/Alert/AcknowledgeAlertRequest.cs +++ b/VigilCareClinicalAPI/Models/Records/Alert/AcknowledgeAlertRequest.cs @@ -1 +1 @@ -public record AcknowledgeAlertRequest(string ClinicianId, string? Note); \ No newline at end of file +public record AcknowledgeAlertRequest(string? Note); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Auth/LoginRequest.cs b/VigilCareClinicalAPI/Models/Records/Auth/LoginRequest.cs new file mode 100644 index 0000000..838d623 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Auth/LoginRequest.cs @@ -0,0 +1 @@ +public record LoginRequest(string Username, string Password); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Auth/LoginResponse.cs b/VigilCareClinicalAPI/Models/Records/Auth/LoginResponse.cs new file mode 100644 index 0000000..6441762 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Auth/LoginResponse.cs @@ -0,0 +1,7 @@ +public record LoginResponse( + string AccessToken, + DateTimeOffset ExpiresAt, + Guid UserId, + string Username, + string DisplayName, + string Role); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index b3d11e1..6b7f0a2 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -8,6 +8,10 @@ using FluentValidation; using FluentValidation.AspNetCore; using Microsoft.AspNetCore.Mvc; using System.Reflection; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.Authorization; +using System.Text; Log.Logger = new LoggerConfiguration() .WriteTo.Console() @@ -17,6 +21,35 @@ try { var builder = WebApplication.CreateBuilder(args); + builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.Section)); + + var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get()!; + + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)) + }; + }); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddAuthorization(options => + { + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + }); + + builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddValidatorsFromAssemblyContaining(); @@ -133,6 +166,10 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -208,6 +245,7 @@ try var db = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); await DataSeeder.SeedAsync(db, redis); + await UserSeeder.SeedAsync(db); } if (!app.Environment.IsEnvironment("Testing")) @@ -220,11 +258,14 @@ try } app.UseMiddleware(); - app.UseMiddleware(); + app.UseMiddleware(); app.UseMiddleware(); app.UseCors("Dashboard"); + app.UseAuthentication(); + app.UseAuthorization(); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { diff --git a/VigilCareClinicalAPI/Services/AlertService.cs b/VigilCareClinicalAPI/Services/AlertService.cs index d3bdfed..cf7e7c9 100644 --- a/VigilCareClinicalAPI/Services/AlertService.cs +++ b/VigilCareClinicalAPI/Services/AlertService.cs @@ -6,11 +6,19 @@ public class AlertService : IAlertService { private readonly AppDbContext _db; private readonly IServiceProvider _services; + private readonly ICurrentUserService _currentUser; + private readonly IAuditService _audit; - public AlertService(AppDbContext db, IServiceProvider services) + public AlertService( + AppDbContext db, + IServiceProvider services, + ICurrentUserService currentUser, + IAuditService audit) { _db = db; _services = services; + _currentUser = currentUser; + _audit = audit; } public async Task> ListByEncounterAsync( @@ -75,6 +83,12 @@ public class AlertService : IAlertService public async Task AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req) { + if (!_currentUser.IsAuthenticated) + throw new ValidationException("Authentication required.", "AUTH_REQUIRED"); + + var displayName = _currentUser.DisplayName ?? _currentUser.Username + ?? throw new ValidationException("Authenticated user identity missing.", "AUTH_REQUIRED"); + var alert = await _db.ClinicalAlerts.FindAsync(id); if (alert is null) throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); @@ -84,9 +98,10 @@ public class AlertService : IAlertService $"Alert cannot be acknowledged from status '{alert.Status}'.", "ALERT_NOT_ACKNOWLEDGEABLE"); + var previousStatus = alert.Status; alert.Status = AlertStatus.Acknowledged; alert.AcknowledgedAt = DateTimeOffset.UtcNow; - alert.AcknowledgedBy = req.ClinicianId; + alert.AcknowledgedBy = displayName; // Write an outbox event so the Kafka consumer (Phase 6) can cancel the // pending RabbitMQ escalation timer when it sees this acknowledgment. @@ -98,7 +113,7 @@ public class AlertService : IAlertService { alertId = alert.Id, encounterId = alert.EncounterId, - acknowledgedBy = req.ClinicianId, + acknowledgedBy = displayName, acknowledgedAt = alert.AcknowledgedAt, note = req.Note }), @@ -120,6 +135,25 @@ public class AlertService : IAlertService } await _db.SaveChangesAsync(); + + await _audit.WriteAsync( + AuditAction.AlertAcknowledged, + "ClinicalAlert", + alert.Id, + previousValue: new { status = previousStatus.ToDbString() }, + newValue: new { status = alert.Status.ToDbString(), alert.AcknowledgedBy }, + reason: req.Note); + + if (alert.AlertType.IsSuppressible()) + { + await _audit.WriteAsync( + AuditAction.SuppressionWindowSet, + "ClinicalAlert", + alert.Id, + newValue: new { alert.AlertType, alert.EncounterId }, + reason: req.Note); + } + return alert; } @@ -157,6 +191,13 @@ public class AlertService : IAlertService alert.ResolvedAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(); + await _audit.WriteAsync( + AuditAction.AlertResolved, + "ClinicalAlert", + alert.Id, + previousValue: new { status = AlertStatus.Acknowledged.ToDbString() }, + newValue: new { status = alert.Status.ToDbString() }); + return alert; } } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/AlertThresholdService.cs b/VigilCareClinicalAPI/Services/AlertThresholdService.cs index 6b9476c..b7495ae 100644 --- a/VigilCareClinicalAPI/Services/AlertThresholdService.cs +++ b/VigilCareClinicalAPI/Services/AlertThresholdService.cs @@ -5,11 +5,16 @@ public class AlertThresholdService : IAlertThresholdService { private readonly AppDbContext _db; private readonly IConnectionMultiplexer _redis; + private readonly IAuditService _audit; - public AlertThresholdService(AppDbContext db, IConnectionMultiplexer redis) + public AlertThresholdService( + AppDbContext db, + IConnectionMultiplexer redis, + IAuditService audit) { _db = db; _redis = redis; + _audit = audit; } public async Task CreateAsync(AlertThresholdRequest req) @@ -35,6 +40,19 @@ public class AlertThresholdService : IAlertThresholdService _db.AlertThresholds.Add(threshold); await _db.SaveChangesAsync(); + await _audit.WriteAsync( + AuditAction.ThresholdCreated, + "AlertThreshold", + threshold.Id, + newValue: new + { + threshold.ObservationCode, + threshold.CriticalLow, + threshold.WarningLow, + threshold.WarningHigh, + threshold.CriticalHigh + }); + await InvalidateCacheAsync(threshold.ObservationCode); return threshold; } @@ -56,6 +74,16 @@ public class AlertThresholdService : IAlertThresholdService if (threshold is null) throw new NotFoundException("Threshold not found.", "THRESHOLD_NOT_FOUND"); + var previous = new + { + threshold.ObservationCode, + threshold.CriticalLow, + threshold.WarningLow, + threshold.WarningHigh, + threshold.CriticalHigh, + threshold.SuppressionWindowMinutes + }; + threshold.DisplayName = req.DisplayName; threshold.Unit = req.Unit; threshold.CriticalLow = req.CriticalLow; @@ -64,6 +92,22 @@ public class AlertThresholdService : IAlertThresholdService threshold.CriticalHigh = req.CriticalHigh; await _db.SaveChangesAsync(); + + await _audit.WriteAsync( + AuditAction.ThresholdUpdated, + "AlertThreshold", + threshold.Id, + previousValue: previous, + newValue: new + { + threshold.ObservationCode, + threshold.CriticalLow, + threshold.WarningLow, + threshold.WarningHigh, + threshold.CriticalHigh, + threshold.SuppressionWindowMinutes + }); + await InvalidateCacheAsync(threshold.ObservationCode); return threshold; } diff --git a/VigilCareClinicalAPI/Services/AuditService.cs b/VigilCareClinicalAPI/Services/AuditService.cs new file mode 100644 index 0000000..1d2bf62 --- /dev/null +++ b/VigilCareClinicalAPI/Services/AuditService.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +public class AuditService : IAuditService +{ + private readonly AppDbContext _db; + private readonly ICurrentUserService _currentUser; + private readonly IHttpContextAccessor _http; + + public AuditService( + AppDbContext db, + ICurrentUserService currentUser, + IHttpContextAccessor http) + { + _db = db; + _currentUser = currentUser; + _http = http; + } + + public async Task WriteAsync( + AuditAction action, + string entityType, + Guid entityId, + object? previousValue = null, + object? newValue = null, + string? reason = null) + { + var correlationId = _http.HttpContext?.Items["CorrelationId"]?.ToString(); + + _db.ClinicalAuditLogs.Add(new ClinicalAuditLog + { + Id = Guid.NewGuid(), + Action = action, + EntityType = entityType, + EntityId = entityId, + UserId = _currentUser.UserId, + UserDisplayName = _currentUser.DisplayName ?? _currentUser.Username, + PreviousValueJson = previousValue is null ? null : JsonSerializer.Serialize(previousValue), + NewValueJson = newValue is null ? null : JsonSerializer.Serialize(newValue), + Reason = reason, + IpAddress = _currentUser.IpAddress, + CorrelationId = correlationId, + CreatedAt = DateTimeOffset.UtcNow + }); + + await _db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/AuthService.cs b/VigilCareClinicalAPI/Services/AuthService.cs new file mode 100644 index 0000000..46287a5 --- /dev/null +++ b/VigilCareClinicalAPI/Services/AuthService.cs @@ -0,0 +1,76 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +public class AuthService : IAuthService +{ + private readonly AppDbContext _db; + private readonly JwtOptions _jwt; + + public AuthService(AppDbContext db, IOptions jwt) + { + _db = db; + _jwt = jwt.Value; + } + + public async Task LoginAsync(LoginRequest req) + { + var user = await _db.ClinicalUsers + .FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive); + + if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) + throw new ValidationException("Invalid username or password.", "INVALID_CREDENTIALS"); + + user.LastLoginAt = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + + _db.ClinicalAuditLogs.Add(new ClinicalAuditLog + { + Id = Guid.NewGuid(), + Action = AuditAction.UserLogin, + EntityType = "ClinicalUser", + EntityId = user.Id, + UserId = user.Id, + UserDisplayName = user.DisplayName, + CreatedAt = DateTimeOffset.UtcNow + }); + await _db.SaveChangesAsync(); + + var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes); + var token = GenerateToken(user, expires); + + return new LoginResponse( + token, + expires, + user.Id, + user.Username, + user.DisplayName, + user.Role.ToDbString()); + } + + private string GenerateToken(ClinicalUser user, DateTimeOffset expires) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim("display_name", user.DisplayName), + new Claim("clinical_role", user.Role.ToDbString()), + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SigningKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _jwt.Issuer, + audience: _jwt.Audience, + claims: claims, + expires: expires.UtcDateTime, + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/CurrentUserService.cs b/VigilCareClinicalAPI/Services/CurrentUserService.cs new file mode 100644 index 0000000..fe6540f --- /dev/null +++ b/VigilCareClinicalAPI/Services/CurrentUserService.cs @@ -0,0 +1,29 @@ +using System.Security.Claims; + +public class CurrentUserService : ICurrentUserService +{ + private readonly IHttpContextAccessor _http; + + public CurrentUserService(IHttpContextAccessor http) => _http = http; + + public Guid? UserId => + Guid.TryParse(_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) + ? id : null; + + public string? Username => _http.HttpContext?.User.FindFirstValue(ClaimTypes.Name); + + public string? DisplayName => _http.HttpContext?.User.FindFirstValue("display_name"); + + public ClinicalRole? Role + { + get + { + var role = _http.HttpContext?.User.FindFirstValue("clinical_role"); + return role is null ? null : ClinicalRoleExtensions.FromDbString(role); + } + } + + public bool IsAuthenticated => _http.HttpContext?.User.Identity?.IsAuthenticated == true; + + public string? IpAddress => _http.HttpContext?.Connection.RemoteIpAddress?.ToString(); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/EncounterService.cs b/VigilCareClinicalAPI/Services/EncounterService.cs index 1f6ffc6..6ffbd1d 100644 --- a/VigilCareClinicalAPI/Services/EncounterService.cs +++ b/VigilCareClinicalAPI/Services/EncounterService.cs @@ -16,15 +16,18 @@ public class EncounterService : IEncounterService private readonly AppDbContext _db; private readonly IQsofaService _qsofa; private readonly IExternalIdentifierService _identifiers; + private readonly IAuditService _audit; public EncounterService( AppDbContext db, IQsofaService qsofa, - IExternalIdentifierService identifiers) + IExternalIdentifierService identifiers, + IAuditService audit) { _db = db; _qsofa = qsofa; _identifiers = identifiers; + _audit = audit; } public async Task GetByIdAsync(Guid id) @@ -171,6 +174,14 @@ public class EncounterService : IEncounterService }); await _db.SaveChangesAsync(); + + await _audit.WriteAsync( + AuditAction.EncounterStatusChanged, + "Encounter", + encounterId, + previousValue: new { status = previousStatus.ToDbString() }, + newValue: new { status = targetStatus.ToDbString(), dischargeDiagnosis }); + return new EncounterStatusTransitionResult(encounterId, targetStatus); } diff --git a/VigilCareClinicalAPI/Services/Interfaces/IAuditService.cs b/VigilCareClinicalAPI/Services/Interfaces/IAuditService.cs new file mode 100644 index 0000000..6b451e5 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IAuditService.cs @@ -0,0 +1,10 @@ +public interface IAuditService +{ + Task WriteAsync( + AuditAction action, + string entityType, + Guid entityId, + object? previousValue = null, + object? newValue = null, + string? reason = null); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IAuthService.cs b/VigilCareClinicalAPI/Services/Interfaces/IAuthService.cs new file mode 100644 index 0000000..9646250 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IAuthService.cs @@ -0,0 +1,4 @@ +public interface IAuthService +{ + Task LoginAsync(LoginRequest req); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/ICurrentUserService.cs b/VigilCareClinicalAPI/Services/Interfaces/ICurrentUserService.cs new file mode 100644 index 0000000..1d685ff --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/ICurrentUserService.cs @@ -0,0 +1,9 @@ +public interface ICurrentUserService +{ + Guid? UserId { get; } + string? Username { get; } + string? DisplayName { get; } + ClinicalRole? Role { get; } + bool IsAuthenticated { get; } + string? IpAddress { get; } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/PatientService.cs b/VigilCareClinicalAPI/Services/PatientService.cs index 5a826e6..05b0969 100644 --- a/VigilCareClinicalAPI/Services/PatientService.cs +++ b/VigilCareClinicalAPI/Services/PatientService.cs @@ -5,11 +5,16 @@ public class PatientService : IPatientService { private readonly AppDbContext _db; private readonly IExternalIdentifierService _identifiers; + private readonly IAuditService _audit; - public PatientService(AppDbContext db, IExternalIdentifierService identifiers) + public PatientService( + AppDbContext db, + IExternalIdentifierService identifiers, + IAuditService audit) { _db = db; _identifiers = identifiers; + _audit = audit; } public async Task RegisterAsync(RegisterPatientRequest req) @@ -31,6 +36,13 @@ public class PatientService : IPatientService }; _db.Patients.Add(patient); await _db.SaveChangesAsync(); + + await _audit.WriteAsync( + AuditAction.PatientRegistered, + "Patient", + patient.Id, + newValue: new { patient.Mrn, patient.FirstName, patient.LastName }); + return patient; } diff --git a/VigilCareClinicalAPI/Validators/AcknowledgeAlertRequestValidator.cs b/VigilCareClinicalAPI/Validators/AcknowledgeAlertRequestValidator.cs index 440fd43..a501704 100644 --- a/VigilCareClinicalAPI/Validators/AcknowledgeAlertRequestValidator.cs +++ b/VigilCareClinicalAPI/Validators/AcknowledgeAlertRequestValidator.cs @@ -4,6 +4,6 @@ public class AcknowledgeAlertRequestValidator : AbstractValidator x.ClinicianId).NotEmpty().MaximumLength(200); + RuleFor(x => x.Note).MaximumLength(1000).When(x => x.Note is not null); } } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/LoginRequestValidator.cs b/VigilCareClinicalAPI/Validators/LoginRequestValidator.cs new file mode 100644 index 0000000..4038256 --- /dev/null +++ b/VigilCareClinicalAPI/Validators/LoginRequestValidator.cs @@ -0,0 +1,10 @@ +using FluentValidation; + +public class LoginRequestValidator : AbstractValidator +{ + public LoginRequestValidator() + { + RuleFor(x => x.Username).NotEmpty().MaximumLength(100); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8).MaximumLength(200); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj index 5bcfe1b..6645b64 100644 --- a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj +++ b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj @@ -13,10 +13,12 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/VigilCareClinicalAPI/appsettings.json b/VigilCareClinicalAPI/appsettings.json index 3617fa4..1d14067 100644 --- a/VigilCareClinicalAPI/appsettings.json +++ b/VigilCareClinicalAPI/appsettings.json @@ -190,5 +190,11 @@ "AMB": "OUTPATIENT", "EMER": "EMERGENCY" } + }, + "Jwt": { + "Issuer": "VigilCareClinical", + "Audience": "VigilCareClinical.Dashboard", + "SigningKey": "DEV-ONLY-REPLACE-WITH-256-BIT-SECRET-IN-PRODUCTION-abc123xyz", + "ExpirationMinutes": 480 } } diff --git a/scripts/run-phase31-verification.sh b/scripts/run-phase31-verification.sh new file mode 100644 index 0000000..77f8594 --- /dev/null +++ b/scripts/run-phase31-verification.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_URL="${BASE_URL:-http://localhost:5270}" + +echo "=== Phase 31 verification ===" + +dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" \ + --filter "FullyQualifiedName~RbacTests" --no-restore + +TOKEN=$(curl -sf -X POST "${BASE_URL}/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"nurse.demo","password":"DemoNurse1!"}' \ + | jq -r '.data.accessToken') + +echo "Nurse token acquired" +curl -sf "${BASE_URL}/api/v1/patients" \ + -H "Authorization: Bearer ${TOKEN}" | jq -e '.success == true' + +ADMIN_TOKEN=$(curl -sf -X POST "${BASE_URL}/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin.demo","password":"DemoAdmin1!"}' \ + | jq -r '.data.accessToken') + +curl -sf "${BASE_URL}/api/v1/audit-logs" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" | jq -e '.success == true' + +echo "Phase 31 verification complete." \ No newline at end of file diff --git a/vigilcare-dashboard/README.md b/vigilcare-dashboard/README.md index d6cf7d9..4102bd7 100644 --- a/vigilcare-dashboard/README.md +++ b/vigilcare-dashboard/README.md @@ -14,7 +14,7 @@ npm install npm run dev ``` -Open http://localhost:5173 +Open http://localhost:5173 — you will be redirected to `/login`. Demo credentials are seeded by the API (see repo root README / Phase 31 plan). Optional `.env`: @@ -35,6 +35,7 @@ VITE_API_URL=http://localhost:5270 | Path | View | |---|---| +| `/login` | Sign in — JWT auth against the clinical API | | `/ward` | Virtual Ward — active patients by NEWS2 risk | | `/patients/:encounterId` | Patient detail — vitals, alerts, charts, replay, reasoning | | `/alerts` | Alert Center — global alert inbox with feedback buttons | @@ -73,8 +74,8 @@ src/ │ ├── ui/ # Button, Badge, Card, Modal, EmptyState, Skeleton │ └── ward/ # WardTable, PatientRow, PatientCard ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, chartFormat -├── stores/ # ward, alerts, settings, feedback (localStorage persistence) -├── views/ # WardDashboard, PatientDetail, AlertCenter, FeedbackSummary +├── stores/ # ward, alerts, auth, settings, feedback (localStorage persistence) +├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary └── __tests__/ # Vitest — store, composables, components, views ``` diff --git a/vigilcare-dashboard/src/App.vue b/vigilcare-dashboard/src/App.vue index f880317..fe1f16b 100644 --- a/vigilcare-dashboard/src/App.vue +++ b/vigilcare-dashboard/src/App.vue @@ -1,9 +1,14 @@