feature: RBAC + Clinical Audit Logging
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<JsonDocument>();
|
||||
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);
|
||||
|
||||
@@ -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<IAlertService>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
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<IAlertService>();
|
||||
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
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<IAlertService>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
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<IAlertService>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
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<IAlertService>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<AppDbContext>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "Testing";
|
||||
|
||||
public TestingAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
protected override Task<AuthenticateResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<SofaStalenessFlags>(s.StalenessFlags, jsonOpts);
|
||||
return f?.UsedSpO2Fallback == true;
|
||||
});
|
||||
|
||||
sofa.StalenessFlags.Should().NotBeNullOrEmpty();
|
||||
var flags = JsonSerializer.Deserialize<SofaStalenessFlags>(
|
||||
sofa.StalenessFlags!,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
var flags = JsonSerializer.Deserialize<SofaStalenessFlags>(sofa.StalenessFlags!, jsonOpts);
|
||||
flags!.UsedSpO2Fallback.Should().BeTrue("expected SpO2/FiO2 proxy for respiratory SOFA");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
@@ -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<Program>, 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<AuthenticationSchemeOptions, TestingAuthHandler>(
|
||||
TestingAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
@@ -52,6 +64,12 @@ public class ApiFixture : WebApplicationFactory<Program>, 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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -90,7 +90,8 @@ public static class ScenarioReplayHelper
|
||||
public static async Task<SofaScore> WaitForSofaScoreAsync(
|
||||
IServiceProvider services,
|
||||
Guid encounterId,
|
||||
TimeSpan timeout)
|
||||
TimeSpan timeout,
|
||||
Func<SofaScore, bool>? 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);
|
||||
}
|
||||
|
||||
@@ -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<IHttpContextAccessor>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
public class AuthorizePermissionAttribute : AuthorizeAttribute
|
||||
{
|
||||
public AuthorizePermissionAttribute(string permission)
|
||||
{
|
||||
Policy = $"perm:{permission}";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
public static class ClinicalRolePermissionMap
|
||||
{
|
||||
private static readonly Dictionary<ClinicalRole, HashSet<string>> _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);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
||||
{
|
||||
private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||
|
||||
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||
{
|
||||
_fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||
}
|
||||
|
||||
public Task<AuthorizationPolicy?> 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<AuthorizationPolicy?>(policy);
|
||||
}
|
||||
|
||||
return _fallback.GetPolicyAsync(policyName);
|
||||
}
|
||||
|
||||
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() =>
|
||||
_fallback.GetDefaultPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() =>
|
||||
_fallback.GetFallbackPolicyAsync();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
public class PermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public string Permission { get; }
|
||||
public PermissionRequirement(string permission) => Permission = permission;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
/// <param name="req">Threshold bounds and display metadata.</param>
|
||||
/// <returns>The created threshold.</returns>
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] AlertThresholdRequest req)
|
||||
@@ -32,6 +35,7 @@ public class AlertThresholdsController : ControllerBase
|
||||
/// </summary>
|
||||
/// <returns>All configured thresholds.</returns>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<AlertThreshold>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
@@ -45,6 +49,7 @@ public class AlertThresholdsController : ControllerBase
|
||||
/// <param name="id">Threshold id.</param>
|
||||
/// <returns>The threshold record.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -60,6 +65,7 @@ public class AlertThresholdsController : ControllerBase
|
||||
/// <param name="req">Updated threshold bounds and display metadata.</param>
|
||||
/// <returns>The updated threshold.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] AlertThresholdRequest req)
|
||||
@@ -67,4 +73,4 @@ public class AlertThresholdsController : ControllerBase
|
||||
var threshold = await _thresholds.UpdateAsync(id, req);
|
||||
return Ok(ApiResponse<AlertThreshold>.Ok(threshold));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class AlertsController : ControllerBase
|
||||
{
|
||||
private readonly IAlertService _alerts;
|
||||
@@ -21,6 +23,7 @@ public class AlertsController : ControllerBase
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of alerts for the encounter.</returns>
|
||||
[HttpGet("api/v1/encounters/{encounterId:guid}/alerts")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ListByEncounter(
|
||||
@@ -63,6 +66,7 @@ public class AlertsController : ControllerBase
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of alerts.</returns>
|
||||
[HttpGet("api/v1/alerts")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ListGlobal(
|
||||
@@ -128,6 +132,7 @@ public class AlertsController : ControllerBase
|
||||
/// <param name="id">Alert id.</param>
|
||||
/// <returns>The alert record.</returns>
|
||||
[HttpGet("api/v1/alerts/{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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.
|
||||
/// </summary>
|
||||
/// <param name="id">Alert id.</param>
|
||||
/// <param name="req">Clinician id and optional note.</param>
|
||||
/// <param name="req">Optional acknowledgment note.</param>
|
||||
/// <returns>The updated alert.</returns>
|
||||
[HttpPost("api/v1/alerts/{id:guid}/acknowledge")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)]
|
||||
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -158,6 +164,7 @@ public class AlertsController : ControllerBase
|
||||
/// <param name="id">Alert id.</param>
|
||||
/// <returns>The updated alert.</returns>
|
||||
[HttpPost("api/v1/alerts/{id:guid}/resolve")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsResolve)]
|
||||
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -166,4 +173,4 @@ public class AlertsController : ControllerBase
|
||||
var alert = await _alerts.ResolveAsync(id);
|
||||
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<object>.Ok(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Clinical audit log query (Admin only).
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>Query clinical audit logs with optional filters.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> 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<object>.Ok(new
|
||||
{
|
||||
items,
|
||||
page,
|
||||
pageSize,
|
||||
totalCount = total,
|
||||
totalPages = (int)Math.Ceiling(total / (double)pageSize)
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// JWT authentication: login and current-user profile.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
[Produces("application/json")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _auth;
|
||||
|
||||
public AuthController(IAuthService auth) => _auth = auth;
|
||||
|
||||
/// <summary>Authenticate and receive a JWT bearer token.</summary>
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var result = await _auth.LoginAsync(req);
|
||||
return Ok(ApiResponse<LoginResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>Returns the authenticated user's profile.</summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public IActionResult Me([FromServices] ICurrentUserService currentUser)
|
||||
{
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
userId = currentUser.UserId,
|
||||
username = currentUser.Username,
|
||||
displayName = currentUser.DisplayName,
|
||||
role = currentUser.Role?.ToDbString()
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// <param name="page">Page number (1-based).</param>
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> List(
|
||||
@@ -72,6 +75,7 @@ public class EncountersController : ControllerBase
|
||||
/// <param name="id">Encounter id.</param>
|
||||
/// <returns>The encounter with related data.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Encounter>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -87,6 +91,7 @@ public class EncountersController : ControllerBase
|
||||
/// <param name="req">Target status.</param>
|
||||
/// <returns>The encounter id and new status.</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -102,6 +107,7 @@ public class EncountersController : ControllerBase
|
||||
/// <param name="id">Encounter id.</param>
|
||||
/// <returns>Ordered timeline events.</returns>
|
||||
[HttpGet("{id:guid}/timeline")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Timeline(Guid id)
|
||||
@@ -109,4 +115,4 @@ public class EncountersController : ControllerBase
|
||||
var timeline = await _encounters.GetTimelineAsync(id);
|
||||
return Ok(ApiResponse<object>.Ok(timeline));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Hl7.Fhir.Serialization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
/// <summary>
|
||||
/// FHIR R4 inbound facade: single-resource create and transaction Bundle processing.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("fhir/R4")]
|
||||
[AuthorizePermission(ClinicalPermissions.FhirIngest)]
|
||||
[ServiceFilter(typeof(FhirExceptionFilter))]
|
||||
public class FhirIngestController : ControllerBase
|
||||
{
|
||||
@@ -52,9 +57,18 @@ public class FhirIngestController : ControllerBase
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a Patient from a FHIR R4 Patient resource (idempotent by identifier).
|
||||
/// </summary>
|
||||
/// <returns>The persisted Patient resource with Location header.</returns>
|
||||
[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<IActionResult> CreatePatient()
|
||||
{
|
||||
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Patient>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates an Encounter from a FHIR R4 Encounter resource (idempotent by identifier).
|
||||
/// </summary>
|
||||
/// <returns>The persisted Encounter resource with Location header.</returns>
|
||||
[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<IActionResult> CreateEncounter()
|
||||
{
|
||||
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Encounter>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests one or more observations from a FHIR R4 Observation resource.
|
||||
/// </summary>
|
||||
/// <returns>The last persisted Observation resource with Location header.</returns>
|
||||
[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<IActionResult> CreateObservation()
|
||||
{
|
||||
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Observation>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a medication administration from a FHIR R4 MedicationAdministration resource.
|
||||
/// </summary>
|
||||
/// <returns>The persisted MedicationAdministration resource with Location header.</returns>
|
||||
[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<IActionResult> CreateMedicationAdministration()
|
||||
{
|
||||
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.MedicationAdministration>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Accepts Bundle.type=transaction (ADT admit) or batch.</summary>
|
||||
/// <summary>
|
||||
/// Processes a FHIR R4 transaction Bundle (e.g. ADT admit with Patient + Encounter).
|
||||
/// </summary>
|
||||
/// <returns>A transaction-response Bundle with per-entry outcomes.</returns>
|
||||
[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<IActionResult> 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<T> ParseBodyAsync<T>() where T : Resource
|
||||
@@ -145,6 +198,11 @@ public class FhirIngestController : ControllerBase
|
||||
return Parser.Parse<T>(json);
|
||||
}
|
||||
|
||||
private ContentResult Serialize(Resource resource) =>
|
||||
Content(Serializer.SerializeToString(resource), "application/fhir+json");
|
||||
}
|
||||
private ContentResult Serialize(Resource resource, int statusCode = StatusCodes.Status200OK) =>
|
||||
new()
|
||||
{
|
||||
Content = Serializer.SerializeToString(resource),
|
||||
ContentType = "application/fhir+json",
|
||||
StatusCode = statusCode
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// FHIR R4 CapabilityStatement metadata for the inbound facade.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("fhir/R4")]
|
||||
[ServiceFilter(typeof(FhirExceptionFilter))]
|
||||
public class FhirMetadataController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the FHIR R4 CapabilityStatement describing supported interactions.
|
||||
/// </summary>
|
||||
/// <returns>CapabilityStatement in application/fhir+json.</returns>
|
||||
[HttpGet("metadata")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/fhir+json")]
|
||||
[ProducesResponseType(typeof(CapabilityStatement), StatusCodes.Status200OK)]
|
||||
public IActionResult Metadata()
|
||||
{
|
||||
var capability = new CapabilityStatement
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Glasgow Coma Scale (GCS) scoring: latest computed score per encounter.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the latest GCS score for an encounter, or null data when no score has been computed.
|
||||
/// </summary>
|
||||
/// <param name="encounterId">Encounter id.</param>
|
||||
/// <returns>The GCS component scores and total, or null.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<GcsScoreResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Current(Guid encounterId)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class MedicationsController : ControllerBase
|
||||
{
|
||||
private readonly IMedicationService _medications;
|
||||
@@ -19,6 +21,7 @@ public class MedicationsController : ControllerBase
|
||||
/// <param name="req">Medication administration details.</param>
|
||||
/// <returns>The created medication administration record.</returns>
|
||||
[HttpPost("api/v1/encounters/{encounterId:guid}/medications")]
|
||||
[AuthorizePermission(ClinicalPermissions.MedicationsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MedicationAdministration>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -37,6 +40,7 @@ public class MedicationsController : ControllerBase
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of medication administrations.</returns>
|
||||
[HttpGet("api/v1/encounters/{encounterId:guid}/medications")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ListByEncounter(
|
||||
Guid encounterId,
|
||||
@@ -61,6 +65,7 @@ public class MedicationsController : ControllerBase
|
||||
/// <param name="id">Medication administration id.</param>
|
||||
/// <returns>The medication administration record.</returns>
|
||||
[HttpGet("api/v1/medications/{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MedicationAdministration>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
/// <param name="req">Batch of observations to record.</param>
|
||||
/// <returns>Per-observation ingest results, including any generated alerts.</returns>
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.ObservationsIngest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
@@ -67,6 +70,7 @@ public class ObservationsController : ControllerBase
|
||||
/// <param name="cursor">Opaque cursor from a previous page.</param>
|
||||
/// <returns>A page of observations with an optional next cursor.</returns>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> History(
|
||||
Guid encounterId,
|
||||
@@ -84,4 +88,4 @@ public class ObservationsController : ControllerBase
|
||||
hasMore = page.HasMore
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
/// </summary>
|
||||
[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.
|
||||
/// </summary>
|
||||
[HttpPost("api/v1/encounters/{encounterId:guid}/orders")]
|
||||
[AuthorizePermission(ClinicalPermissions.OrdersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -29,6 +32,7 @@ public class OrdersController : ControllerBase
|
||||
/// Lists orders for an encounter with optional status filter.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/encounters/{encounterId:guid}/orders")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ListByEncounter(
|
||||
@@ -65,6 +69,7 @@ public class OrdersController : ControllerBase
|
||||
/// Gets a single order by id with its encounter.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/orders/{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -77,6 +82,7 @@ public class OrdersController : ControllerBase
|
||||
/// Transitions an order to a new status.
|
||||
/// </summary>
|
||||
[HttpPatch("api/v1/orders/{id:guid}/status")]
|
||||
[AuthorizePermission(ClinicalPermissions.OrdersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -90,6 +96,7 @@ public class OrdersController : ControllerBase
|
||||
/// Records a result for an order, transitioning it to Resulted status.
|
||||
/// </summary>
|
||||
[HttpPatch("api/v1/orders/{id:guid}/result")]
|
||||
[AuthorizePermission(ClinicalPermissions.OrdersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -98,4 +105,4 @@ public class OrdersController : ControllerBase
|
||||
var order = await _orders.RecordResultAsync(id, req);
|
||||
return Ok(ApiResponse<Order>.Ok(order));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <param name="req">Patient demographics.</param>
|
||||
/// <returns>The created patient record.</returns>
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.PatientsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Patient>), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterPatientRequest req)
|
||||
{
|
||||
@@ -34,6 +37,7 @@ public class PatientsController : ControllerBase
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of patients.</returns>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.PatientsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
@@ -54,6 +58,7 @@ public class PatientsController : ControllerBase
|
||||
/// <param name="id">Patient id.</param>
|
||||
/// <returns>The patient record.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.PatientsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Patient>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -69,6 +74,7 @@ public class PatientsController : ControllerBase
|
||||
/// <param name="req">Encounter type, department, and attending physician.</param>
|
||||
/// <returns>The created encounter.</returns>
|
||||
[HttpPost("{id:guid}/encounters")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Encounter>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -77,4 +83,4 @@ public class PatientsController : ControllerBase
|
||||
var encounter = await _patients.OpenEncounterAsync(id, req);
|
||||
return StatusCode(201, ApiResponse<Encounter>.Created(encounter));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
@@ -5,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
public class SepsisBundlesController : ControllerBase
|
||||
{
|
||||
private readonly ISepsisBundleService _bundles;
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// SOFA composite scoring: current score and paginated history per encounter.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the latest SOFA score for an encounter, or null data when no score has been computed.
|
||||
/// </summary>
|
||||
/// <param name="encounterId">Encounter id.</param>
|
||||
/// <returns>The SOFA component scores and total, or null.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<SofaScoreResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Current(Guid encounterId)
|
||||
@@ -21,6 +31,13 @@ public class SofaController : ControllerBase
|
||||
return Ok(ApiResponse<SofaScoreResponse>.Ok(MapResponse(score)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cursor-paginated SOFA score history for an encounter.
|
||||
/// </summary>
|
||||
/// <param name="encounterId">Encounter id.</param>
|
||||
/// <param name="limit">Maximum items per page.</param>
|
||||
/// <param name="cursor">Opaque cursor from a previous page.</param>
|
||||
/// <returns>A page of SOFA scores with an optional next cursor.</returns>
|
||||
[HttpGet("history")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> History(
|
||||
|
||||
@@ -19,6 +19,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
|
||||
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
|
||||
public DbSet<ExternalResourceIdentifier> ExternalResourceIdentifiers => Set<ExternalResourceIdentifier>();
|
||||
public DbSet<ClinicalUser> ClinicalUsers => Set<ClinicalUser>();
|
||||
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ClinicalAuditLogConfiguration : IEntityTypeConfiguration<ClinicalAuditLog>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ClinicalAuditLog> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ClinicalUserConfiguration : IEntityTypeConfiguration<ClinicalUser>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ClinicalUser> 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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}'")
|
||||
};
|
||||
}
|
||||
@@ -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<FhirOptions> 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);
|
||||
}
|
||||
}
|
||||
+1187
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddClinicalUsers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "clinical_users",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
password_hash = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
role = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
last_login_at = table.Column<DateTimeOffset>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "clinical_users");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1261
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddClinicalAuditLogs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "clinical_audit_logs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
action = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
entity_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
entity_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
user_display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
previous_value_json = table.Column<string>(type: "jsonb", nullable: true),
|
||||
new_value_json = table.Column<string>(type: "jsonb", nullable: true),
|
||||
reason = table.Column<string>(type: "text", nullable: true),
|
||||
ip_address = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
|
||||
correlation_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "clinical_audit_logs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,136 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAuditLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("action");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("correlation_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<string>("EntityType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<string>("NewValueJson")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("new_value_json");
|
||||
|
||||
b.Property<string>("PreviousValueJson")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("previous_value_json");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<string>("UserDisplayName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("user_display_name");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_login_at");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
|
||||
@@ -1 +1 @@
|
||||
public record AcknowledgeAlertRequest(string ClinicianId, string? Note);
|
||||
public record AcknowledgeAlertRequest(string? Note);
|
||||
@@ -0,0 +1 @@
|
||||
public record LoginRequest(string Username, string Password);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record LoginResponse(
|
||||
string AccessToken,
|
||||
DateTimeOffset ExpiresAt,
|
||||
Guid UserId,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string Role);
|
||||
@@ -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<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
|
||||
|
||||
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
|
||||
|
||||
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<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
||||
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.FallbackPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
});
|
||||
|
||||
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
@@ -133,6 +166,10 @@ try
|
||||
builder.Services.AddScoped<MedicationAdministrationFhirMapper>();
|
||||
builder.Services.AddScoped<FhirBundleProcessor>();
|
||||
builder.Services.AddScoped<FhirExceptionFilter>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||
@@ -208,6 +245,7 @@ try
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
await DataSeeder.SeedAsync(db, redis);
|
||||
await UserSeeder.SeedAsync(db);
|
||||
}
|
||||
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
@@ -220,11 +258,14 @@ try
|
||||
}
|
||||
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseMiddleware<FhirApiKeyMiddleware>();
|
||||
app.UseMiddleware<FhirApiKeyOrJwtMiddleware>();
|
||||
app.UseMiddleware<ExceptionHandlerMiddleware>();
|
||||
|
||||
app.UseCors("Dashboard");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
|
||||
@@ -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<PagedResult<ClinicalAlert>> ListByEncounterAsync(
|
||||
@@ -75,6 +83,12 @@ public class AlertService : IAlertService
|
||||
|
||||
public async Task<ClinicalAlert> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<AlertThreshold> 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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<JwtOptions> jwt)
|
||||
{
|
||||
_db = db;
|
||||
_jwt = jwt.Value;
|
||||
}
|
||||
|
||||
public async Task<LoginResponse> LoginAsync(LoginRequest req)
|
||||
{
|
||||
var user = await _db.ClinicalUsers
|
||||
.FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive);
|
||||
|
||||
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
throw new ValidationException("Invalid username or password.", "INVALID_CREDENTIALS");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<Encounter> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
public interface IAuditService
|
||||
{
|
||||
Task WriteAsync(
|
||||
AuditAction action,
|
||||
string entityType,
|
||||
Guid entityId,
|
||||
object? previousValue = null,
|
||||
object? newValue = null,
|
||||
string? reason = null);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<LoginResponse> LoginAsync(LoginRequest req);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<Patient> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ public class AcknowledgeAlertRequestValidator : AbstractValidator<AcknowledgeAle
|
||||
{
|
||||
public AcknowledgeAlertRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.ClinicianId).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Note).MaximumLength(1000).When(x => x.Note is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class LoginRequestValidator : AbstractValidator<LoginRequest>
|
||||
{
|
||||
public LoginRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Username).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.Password).NotEmpty().MinimumLength(8).MaximumLength(200);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
|
||||
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const useShell = computed(() => !route.meta.public)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<AppShell v-if="useShell">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive include="WardDashboard">
|
||||
<Transition name="fade" mode="out-in">
|
||||
@@ -12,6 +17,7 @@ import AppShell from '@/components/layout/AppShell.vue'
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
</AppShell>
|
||||
<RouterView v-else />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -12,8 +12,8 @@ export function fetchAllAlerts(status) {
|
||||
return api.get(`/api/v1/alerts?${params}`)
|
||||
}
|
||||
|
||||
export function acknowledgeAlert(alertId, clinicianId, note) {
|
||||
return api.post(`/api/v1/alerts/${alertId}/acknowledge`, { clinicianId, note })
|
||||
export function acknowledgeAlert(alertId, note) {
|
||||
return api.post(`/api/v1/alerts/${alertId}/acknowledge`, { note })
|
||||
}
|
||||
|
||||
export function resolveAlert(alertId) {
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5270'
|
||||
|
||||
let authToken = null
|
||||
|
||||
export function setAuthToken(token) {
|
||||
authToken = token
|
||||
}
|
||||
|
||||
function authHeaders(extra = {}) {
|
||||
const headers = { 'Content-Type': 'application/json', ...extra }
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
|
||||
return headers
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
headers: authHeaders(options.headers),
|
||||
...options,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
throw new Error('Session expired — please log in again.')
|
||||
}
|
||||
const envelope = await res.json()
|
||||
if (!res.ok || !envelope.success) {
|
||||
const msg = envelope.error?.message ?? `API ${res.status}: ${path}`
|
||||
@@ -16,10 +31,13 @@ async function request(path, options = {}) {
|
||||
/** Returns null when the resource does not exist yet (HTTP 404 or empty optional payload). */
|
||||
async function requestOptional(path) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
})
|
||||
const envelope = await res.json()
|
||||
if (res.status === 404) return null
|
||||
if (res.status === 401) {
|
||||
throw new Error('Session expired — please log in again.')
|
||||
}
|
||||
const envelope = await res.json()
|
||||
if (!res.ok || !envelope.success) {
|
||||
const msg = envelope.error?.message ?? `API ${res.status}: ${path}`
|
||||
throw new Error(msg)
|
||||
@@ -34,4 +52,4 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
@@ -18,7 +17,6 @@ const props = defineProps({
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
const settings = useSettingsStore()
|
||||
const { alerts, loading } = storeToRefs(alertStore)
|
||||
|
||||
function loadEncounterAlerts() {
|
||||
@@ -36,7 +34,7 @@ function severityVariant(severity) {
|
||||
}
|
||||
|
||||
async function acknowledge(alertId) {
|
||||
await alertStore.acknowledge(alertId, settings.clinicianId)
|
||||
await alertStore.acknowledge(alertId)
|
||||
await loadEncounterAlerts()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,12 @@ import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(pinia)
|
||||
useAuthStore(pinia).hydrate()
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/ward',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { title: 'Sign In', public: true },
|
||||
},
|
||||
{
|
||||
path: '/ward',
|
||||
name: 'WardDashboard',
|
||||
@@ -38,6 +45,14 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to) => {
|
||||
document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare`
|
||||
|
||||
const auth = useAuthStore()
|
||||
if (!to.meta.public && !auth.isAuthenticated) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.path === '/login' && auth.isAuthenticated) {
|
||||
return { path: '/ward' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
||||
@@ -35,8 +35,8 @@ export const useAlertStore = defineStore('alerts', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge(alertId, clinicianId, note) {
|
||||
await alertsApi.acknowledgeAlert(alertId, clinicianId, note)
|
||||
async function acknowledge(alertId, note) {
|
||||
await alertsApi.acknowledgeAlert(alertId, note)
|
||||
const alert = alerts.value.find(a => a.id === alertId)
|
||||
if (alert) alert.status = 'Acknowledged'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api, setAuthToken } from '@/api/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('vigilcare_token') ?? null,
|
||||
user: JSON.parse(localStorage.getItem('vigilcare_user') ?? 'null'),
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token,
|
||||
role: (state) => state.user?.role ?? null,
|
||||
},
|
||||
|
||||
actions: {
|
||||
async login(username, password) {
|
||||
const data = await api.post('/api/v1/auth/login', { username, password })
|
||||
this.token = data.accessToken
|
||||
this.user = {
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
displayName: data.displayName,
|
||||
role: data.role,
|
||||
}
|
||||
localStorage.setItem('vigilcare_token', this.token)
|
||||
localStorage.setItem('vigilcare_user', JSON.stringify(this.user))
|
||||
setAuthToken(this.token)
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.token = null
|
||||
this.user = null
|
||||
localStorage.removeItem('vigilcare_token')
|
||||
localStorage.removeItem('vigilcare_user')
|
||||
setAuthToken(null)
|
||||
},
|
||||
|
||||
hydrate() {
|
||||
if (this.token) setAuthToken(this.token)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2,7 +2,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { alertStatusToApiFilter } from '@/api/normalize'
|
||||
import AlertCard from '@/components/alerts/AlertCard.vue'
|
||||
import AlertFilters from '@/components/alerts/AlertFilters.vue'
|
||||
@@ -11,7 +10,6 @@ import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
const settings = useSettingsStore()
|
||||
const { alerts, loading } = storeToRefs(alertStore)
|
||||
|
||||
const activeFilter = ref('Open')
|
||||
@@ -23,7 +21,7 @@ watch(activeFilter, (status) => {
|
||||
|
||||
async function handleAcknowledge() {
|
||||
if (!confirmingAlert.value) return
|
||||
await alertStore.acknowledge(confirmingAlert.value.id, settings.clinicianId)
|
||||
await alertStore.acknowledge(confirmingAlert.value.id)
|
||||
confirmingAlert.value = null
|
||||
alertStore.loadGlobalAlerts(alertStatusToApiFilter(activeFilter.value))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/ward'
|
||||
await router.push(redirect)
|
||||
} catch (e) {
|
||||
error.value = e.message ?? 'Login failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<form
|
||||
class="w-full max-w-sm space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">VigilCare</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Username
|
||||
</span>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<Button type="submit" variant="primary" class="w-full" :disabled="loading">
|
||||
{{ loading ? 'Signing in…' : 'Sign in' }}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user