feature: Live Capture (Track B)
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A clinical records intake backend built with ASP.NET Core 8, PostgreSQL, MinIO, and Redis. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
|
A clinical records intake backend built with ASP.NET Core 8, PostgreSQL, MinIO, and Redis. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
|
||||||
|
|
||||||
**Implementation status:** Five planned phases are complete through Phase 5 — from schema, authentication, and batch CRUD through draft data entry, verification/rejection with separation of duties, approval with atomic promotion to VigilCareClinical live tables, and correction batches that supersede erroneous promoted observations without silent edits. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
**Implementation status:** Six planned phases are complete through Phase 6 — from schema, authentication, and batch CRUD through draft data entry, verification/rejection with separation of duties, approval with atomic promotion to VigilCareClinical live tables, correction batches that supersede erroneous promoted observations without silent edits, and Track B live capture with clinician attestation and synchronous critical alerting. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
||||||
|
|
||||||
## Domain Model — How It Maps to a Real Clinical System
|
## Domain Model — How It Maps to a Real Clinical System
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ Append-only audit log entry for every state transition, field-level correction,
|
|||||||
- **Corrections and Supersession** — approved live observations are never silently edited; a correction uploads a new batch with `supersedesBatchId`, goes through the full entry → verify → approve cycle, and on promotion marks the original batch's `live_observations` rows as `is_superseded` (append-only — never deleted); `422 SUPERSEDED_BATCH_NOT_PROMOTED`, `409 BATCH_ALREADY_SUPERSEDED`, and `404 SUPERSEDED_BATCH_NOT_FOUND` guard invalid supersession targets; correction batches inherit patient context and reuse the original promotion encounter
|
- **Corrections and Supersession** — approved live observations are never silently edited; a correction uploads a new batch with `supersedesBatchId`, goes through the full entry → verify → approve cycle, and on promotion marks the original batch's `live_observations` rows as `is_superseded` (append-only — never deleted); `422 SUPERSEDED_BATCH_NOT_PROMOTED`, `409 BATCH_ALREADY_SUPERSEDED`, and `404 SUPERSEDED_BATCH_NOT_FOUND` guard invalid supersession targets; correction batches inherit patient context and reuse the original promotion encounter
|
||||||
- **Patient Digitization History** — `GET /patients/:id/digitization-history` returns all batches for a patient with correction chain metadata (`isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`), live vs superseded observation counts, summary totals, and per-batch audit trails; `404 PATIENT_HISTORY_NOT_FOUND` when no batches exist for the patient
|
- **Patient Digitization History** — `GET /patients/:id/digitization-history` returns all batches for a patient with correction chain metadata (`isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`), live vs superseded observation counts, summary totals, and per-batch audit trails; `404 PATIENT_HISTORY_NOT_FOUND` when no batches exist for the patient
|
||||||
- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`, sorted by submission time ASC); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); role-restricted access
|
- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`, sorted by submission time ASC); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); role-restricted access
|
||||||
|
- **Live Capture (Track B)** — `POST /live-capture/encounters/{encounterId}/observations` and `POST /live-capture/encounters` for credentialed clinicians entering vitals at bedside; clinician attestation + password re-confirm replaces the dual-human verification gate; observations promote synchronously to live tables in a single transaction with `source = live_capture`; critical threshold evaluation runs before the response returns, with inline `criticalAlert` per observation, committed `ClinicalAlert` rows, and `observation.recorded` / `alert.generated` outbox events; `422 ATTESTATION_REQUIRED`, `422 PASSWORD_CONFIRM_INVALID`, `422 EMPTY_OBSERVATIONS`, `409 ENCOUNTER_NOT_ACTIVE`, `409 ACTIVE_ENCOUNTER_EXISTS`
|
||||||
- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` is terminal — corrections require a new batch with `supersedesBatchId`
|
- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` is terminal — corrections require a new batch with `supersedesBatchId`
|
||||||
- **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing
|
- **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing
|
||||||
- **Role-Based Access** — six roles (`INTAKE_CLERK`, `DATA_ENTRY_CLERK`, `VERIFIER`, `CLINICAL_APPROVER`, `CLINICIAN`, `ADMINISTRATOR`) with role-based endpoint authorization; twelve seeded demo users (two per role)
|
- **Role-Based Access** — six roles (`INTAKE_CLERK`, `DATA_ENTRY_CLERK`, `VERIFIER`, `CLINICAL_APPROVER`, `CLINICIAN`, `ADMINISTRATOR`) with role-based endpoint authorization; twelve seeded demo users (two per role)
|
||||||
@@ -85,6 +86,8 @@ HTTP request
|
|||||||
├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
|
├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
|
||||||
├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
|
├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
|
||||||
├── WorkQueueService (verification, entry, clinical approval queues)
|
├── WorkQueueService (verification, entry, clinical approval queues)
|
||||||
|
├── AttestationService (clinician role + password re-confirm for live capture)
|
||||||
|
├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation)
|
||||||
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
|
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
|
||||||
├── PlausibilityValidator (per-code numeric range guard)
|
├── PlausibilityValidator (per-code numeric range guard)
|
||||||
├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables)
|
├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables)
|
||||||
@@ -120,7 +123,7 @@ HTTP request
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Server | ASP.NET Core 8 (.NET 8.0) |
|
| Server | ASP.NET Core 8 (.NET 8.0) |
|
||||||
| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) |
|
| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) |
|
||||||
| Cache / locking | Redis 7 (batch assignment locks) |
|
| Cache / locking | Redis 7 (batch assignment locks, alert threshold cache for live capture) |
|
||||||
| Object storage | MinIO (scanned documents — PDF, JPEG, PNG) |
|
| Object storage | MinIO (scanned documents — PDF, JPEG, PNG) |
|
||||||
| Authentication | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) |
|
| Authentication | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) |
|
||||||
| Password hashing | BCrypt.Net-Next |
|
| Password hashing | BCrypt.Net-Next |
|
||||||
@@ -142,6 +145,7 @@ VigilCareRecordsAPI/
|
|||||||
│ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
|
│ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
|
||||||
│ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
|
│ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
|
||||||
│ ├── PatientsController.cs # Patient digitization history with correction chain
|
│ ├── PatientsController.cs # Patient digitization history with correction chain
|
||||||
|
│ ├── LiveCaptureController.cs # Track B live capture: record vitals, open encounter + vitals
|
||||||
│ ├── VerificationController.cs # Batch verification and rejection with separation of duties
|
│ ├── VerificationController.cs # Batch verification and rejection with separation of duties
|
||||||
│ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval
|
│ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval
|
||||||
├── Domain/
|
├── Domain/
|
||||||
@@ -150,7 +154,9 @@ VigilCareRecordsAPI/
|
|||||||
│ │ │ ├── Patient.cs # Live patient record with MRN (promoted from draft)
|
│ │ │ ├── Patient.cs # Live patient record with MRN (promoted from draft)
|
||||||
│ │ │ ├── Encounter.cs # Live encounter (promoted from draft)
|
│ │ │ ├── Encounter.cs # Live encounter (promoted from draft)
|
||||||
│ │ │ ├── Observation.cs # Live observation with source traceability (batchId, draftObsId)
|
│ │ │ ├── Observation.cs # Live observation with source traceability (batchId, draftObsId)
|
||||||
│ │ │ ├── OutboxEvent.cs # Transactional outbox for downstream consumers (Kafka)
|
│ │ │ ├── OutboxEvent.cs # Transactional outbox for downstream consumers (eventType, aggregateType, payloadJson)
|
||||||
|
│ │ │ ├── AlertThreshold.cs # Critical/warning bounds per observation code (live capture alerting)
|
||||||
|
│ │ │ ├── ClinicalAlert.cs # Synchronous critical alerts (AlertType, Severity, Details)
|
||||||
│ │ │ └── IdempotencyRecord.cs # Idempotency-Key storage for safe promotion retries
|
│ │ │ └── IdempotencyRecord.cs # Idempotency-Key storage for safe promotion retries
|
||||||
│ │ ├── Draft/
|
│ │ ├── Draft/
|
||||||
│ │ │ ├── DraftPatient.cs # Structured patient demographics from paper chart
|
│ │ │ ├── DraftPatient.cs # Structured patient demographics from paper chart
|
||||||
@@ -171,9 +177,12 @@ VigilCareRecordsAPI/
|
|||||||
│ ├── UserRole.cs # IntakeClerk, DataEntryClerk, Verifier, ClinicalApprover, Clinician, Administrator
|
│ ├── UserRole.cs # IntakeClerk, DataEntryClerk, Verifier, ClinicalApprover, Clinician, Administrator
|
||||||
│ ├── DigitizationEventType.cs # 18 event types covering full lifecycle + corrections + promotion retry
|
│ ├── DigitizationEventType.cs # 18 event types covering full lifecycle + corrections + promotion retry
|
||||||
│ ├── BloodType.cs # A+, A-, B+, B-, AB+, AB-, O+, O-
|
│ ├── BloodType.cs # A+, A-, B+, B-, AB+, AB-, O+, O-
|
||||||
│ └── Department.cs # Clinical departments
|
│ ├── Department.cs # Clinical departments
|
||||||
|
│ ├── AlertType.cs # Critical/warning alert types (ported from VigilCareClinical)
|
||||||
|
│ ├── AlertSeverity.cs # WARNING, CRITICAL
|
||||||
|
│ └── AlertStatus.cs # OPEN, ACKNOWLEDGED, RESOLVED, ESCALATED
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── Interfaces/ # IAuthService, IBatchService, IDraftService, IVerificationService, IWorkQueueService, IDocumentStorageService, IPromotionService, IIdempotencyService, IMrnGenerator, IDigitizationHistoryService
|
│ ├── Interfaces/ # IAuthService, IBatchService, IDraftService, IVerificationService, IWorkQueueService, IDocumentStorageService, IPromotionService, IIdempotencyService, IMrnGenerator, IDigitizationHistoryService, ILiveCaptureService, IAttestationService
|
||||||
│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh rotation, logout, audit logging
|
│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh rotation, logout, audit logging
|
||||||
│ ├── BatchService.cs # Batch CRUD, status machine transitions, Redis assignment lock, duplicate detection, supersession validation on create
|
│ ├── BatchService.cs # Batch CRUD, status machine transitions, Redis assignment lock, duplicate detection, supersession validation on create
|
||||||
│ ├── DraftService.cs # Draft CRUD with plausibility validation, batch-type completeness on submit (relaxed for correction batches)
|
│ ├── DraftService.cs # Draft CRUD with plausibility validation, batch-type completeness on submit (relaxed for correction batches)
|
||||||
@@ -184,6 +193,8 @@ VigilCareRecordsAPI/
|
|||||||
│ ├── MrnGenerator.cs # PostgreSQL sequence-backed MRN generation (VCR-000001)
|
│ ├── MrnGenerator.cs # PostgreSQL sequence-backed MRN generation (VCR-000001)
|
||||||
│ ├── WorkQueueService.cs # Verification, entry, and clinical approval work queues
|
│ ├── WorkQueueService.cs # Verification, entry, and clinical approval work queues
|
||||||
│ ├── DocumentStorageService.cs # MinIO upload with SHA-256, presigned URL generation
|
│ ├── DocumentStorageService.cs # MinIO upload with SHA-256, presigned URL generation
|
||||||
|
│ ├── AttestationService.cs # Clinician role + password re-confirm for live capture
|
||||||
|
│ ├── Interfaces/LiveCaptureService.cs # Track B synchronous promotion + critical alert evaluation
|
||||||
│ └── PlausibilityValidator.cs # Per-code numeric range guard (reused from VigilCareClinical)
|
│ └── PlausibilityValidator.cs # Per-code numeric range guard (reused from VigilCareClinical)
|
||||||
├── Configurations/
|
├── Configurations/
|
||||||
│ ├── JwtOptions.cs # Issuer, audience, signing key, access/refresh token expiration
|
│ ├── JwtOptions.cs # Issuer, audience, signing key, access/refresh token expiration
|
||||||
@@ -197,18 +208,19 @@ VigilCareRecordsAPI/
|
|||||||
│ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest
|
│ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest
|
||||||
│ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest
|
│ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest
|
||||||
│ ├── WorkQueue/ # WorkQueueResponse, WorkQueueItemResponse
|
│ ├── WorkQueue/ # WorkQueueResponse, WorkQueueItemResponse
|
||||||
|
│ ├── LiveCapture/ # LiveCaptureResponse, RecordObservationsRequest, ThresholdCacheEntry, ...
|
||||||
│ └── Common/ # PagedResult
|
│ └── Common/ # PagedResult
|
||||||
├── Data/
|
├── Data/
|
||||||
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
|
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
|
||||||
│ ├── Configurations/
|
│ ├── Configurations/
|
||||||
│ │ ├── Clinical/ # PatientConfiguration, EncounterConfiguration, ObservationConfiguration, OutboxEventConfiguration, IdempotencyRecordConfiguration
|
│ │ ├── Clinical/ # PatientConfiguration, EncounterConfiguration, ObservationConfiguration, OutboxEventConfiguration, AlertThresholdConfiguration, ClinicalAlertConfiguration, IdempotencyRecordConfiguration
|
||||||
│ │ ├── Draft/ # DraftPatientConfiguration, DraftEncounterConfiguration, DraftObservationConfiguration
|
│ │ ├── Draft/ # DraftPatientConfiguration, DraftEncounterConfiguration, DraftObservationConfiguration
|
||||||
│ │ ├── LiveEncounterConfiguration.cs # live_encounters table mapping
|
│ │ ├── LiveEncounterConfiguration.cs # live_encounters table mapping
|
||||||
│ │ ├── LiveObservationConfiguration.cs # live_observations with supersession columns and partial index
|
│ │ ├── LiveObservationConfiguration.cs # live_observations with supersession columns and partial index
|
||||||
│ │ └── ... # DigitizationBatch, DigitizationEvent, ScannedDocument, User, RefreshToken, AuthAuditEvent configs
|
│ │ └── ... # DigitizationBatch, DigitizationEvent, ScannedDocument, User, RefreshToken, AuthAuditEvent configs
|
||||||
│ ├── Seed/
|
│ ├── Seed/
|
||||||
│ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup
|
│ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup
|
||||||
│ └── Migrations/ # InitialCreate through AddLiveObservationAndLiveEncounter
|
│ └── Migrations/ # InitialCreate through AddAlertThresholdsAndClinicalAlerts
|
||||||
├── Common/
|
├── Common/
|
||||||
│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope
|
│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope
|
||||||
│ └── Exceptions/
|
│ └── Exceptions/
|
||||||
@@ -230,6 +242,7 @@ tests/
|
|||||||
├── VerificationTests.cs # Verification, rejection, separation of duties, clinical approval routing
|
├── VerificationTests.cs # Verification, rejection, separation of duties, clinical approval routing
|
||||||
├── PromotionTests.cs # Approval, atomic promotion, idempotency, separation of duties, retroactive alerts, patient dedup
|
├── PromotionTests.cs # Approval, atomic promotion, idempotency, separation of duties, retroactive alerts, patient dedup
|
||||||
├── CorrectionSupersessionTests.cs # Correction batch supersession, validation guards, patient digitization history
|
├── CorrectionSupersessionTests.cs # Correction batch supersession, validation guards, patient digitization history
|
||||||
|
├── LiveCaptureIntegrationTests.cs # Track B attestation, synchronous promotion, critical alerts, open encounter workflow
|
||||||
├── Fixtures/
|
├── Fixtures/
|
||||||
│ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers
|
│ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers
|
||||||
│ └── DatabaseCollection.cs # Shared test collection
|
│ └── DatabaseCollection.cs # Shared test collection
|
||||||
@@ -245,7 +258,8 @@ scripts/
|
|||||||
├── run-vigilcare-records-phase-2-verification.sh # Phase 2 — draft entry, plausibility, submit-for-verification
|
├── run-vigilcare-records-phase-2-verification.sh # Phase 2 — draft entry, plausibility, submit-for-verification
|
||||||
├── run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties, work queues
|
├── run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties, work queues
|
||||||
├── run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, atomic promotion, idempotency, patient dedup, outbox events
|
├── run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, atomic promotion, idempotency, patient dedup, outbox events
|
||||||
└── run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history, audit trail
|
├── run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history, audit trail
|
||||||
|
└── run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, synchronous alerts, outbox events
|
||||||
|
|
||||||
docs/
|
docs/
|
||||||
├── plans/ # Phase 1–9 implementation and verification guides
|
├── plans/ # Phase 1–9 implementation and verification guides
|
||||||
@@ -343,11 +357,11 @@ The person who enters data cannot verify their own entry. This is enforced in th
|
|||||||
|
|
||||||
### Two-Track Workflow (Backfill vs Live Capture)
|
### Two-Track Workflow (Backfill vs Live Capture)
|
||||||
|
|
||||||
Track A (backfill) is the full pipeline for historical charts: scan → entry → verification → clinical approval → promotion. Track B (live capture) is for credentialed clinicians entering vitals at bedside — attestation replaces the dual-human gate. Both tracks create `DigitizationBatch` records with full audit trails, keeping metrics and coverage stats consistent.
|
Track A (backfill) is the full pipeline for historical charts: scan → entry → verification → clinical approval → promotion. Track B (live capture) is for credentialed clinicians entering vitals at bedside via `POST /live-capture/encounters/{encounterId}/observations` or `POST /live-capture/encounters` — clinician attestation + password re-confirm replaces the dual-human gate, and observations promote synchronously with critical alerting before the response returns. Both tracks create `DigitizationBatch` records with full audit trails, keeping metrics and coverage stats consistent.
|
||||||
|
|
||||||
### Redis for Batch Assignment Locking
|
### Redis for Batch Assignment Locking and Alert Threshold Cache
|
||||||
|
|
||||||
Redis serves one purpose in this project: preventing double-assignment of batches to entry clerks. `SET batch:assign:{id} NX EX 3600` acquires an exclusive lock with a 1-hour TTL. Work-queue counters are derived from PostgreSQL queries, not Redis counters.
|
Redis serves two purposes in this project: (1) preventing double-assignment of batches to entry clerks via `SET batch:assign:{id} NX EX 3600`, and (2) caching alert threshold definitions for synchronous critical evaluation during live capture (`threshold:{observationCode}`). Work-queue counters are derived from PostgreSQL queries, not Redis counters.
|
||||||
|
|
||||||
### Integrated Database Deployment
|
### Integrated Database Deployment
|
||||||
|
|
||||||
@@ -376,7 +390,7 @@ docker compose up -d
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| PostgreSQL 16 | 5437 | Database: `vigilcare_records`, user: `postgres`, password: `password` |
|
| PostgreSQL 16 | 5437 | Database: `vigilcare_records`, user: `postgres`, password: `password` |
|
||||||
| Redis 7 | 6383 | No auth |
|
| Redis 7 | 6383 | No auth |
|
||||||
| Seq | 5346 | UI at `http://localhost:5346` |
|
| Seq | 5346 | UI at `http://localhost:5346`, login: `admin` / `seqadmin` |
|
||||||
| MinIO | 9012 (S3 API), 9013 (console) | login: `minioadmin` / `minioadmin` |
|
| MinIO | 9012 (S3 API), 9013 (console) | login: `minioadmin` / `minioadmin` |
|
||||||
|
|
||||||
### Install and Run
|
### Install and Run
|
||||||
@@ -407,6 +421,7 @@ Integration tests use `WebApplicationFactory` with PostgreSQL, Redis, and MinIO
|
|||||||
| `VerificationTests` | 3 | Verification, rejection, separation of duties enforcement, clinical approval routing |
|
| `VerificationTests` | 3 | Verification, rejection, separation of duties enforcement, clinical approval routing |
|
||||||
| `PromotionTests` | 4 | Approval, atomic promotion to live tables, idempotency replay, separation of duties, retroactive alert policy, patient deduplication, MRN generation |
|
| `PromotionTests` | 4 | Approval, atomic promotion to live tables, idempotency replay, separation of duties, retroactive alert policy, patient deduplication, MRN generation |
|
||||||
| `CorrectionSupersessionTests` | 5 | Correction batch supersession, validation guards (non-promoted, already superseded), patient digitization history, unknown patient 404 |
|
| `CorrectionSupersessionTests` | 5 | Correction batch supersession, validation guards (non-promoted, already superseded), patient digitization history, unknown patient 404 |
|
||||||
|
| `LiveCaptureIntegrationTests` | 6 | Attestation and password confirm, synchronous promotion, critical low/high potassium alerts, mixed-batch alerting, open encounter + vitals, role and validation guards, audit events |
|
||||||
|
|
||||||
### Verification Scripts
|
### Verification Scripts
|
||||||
|
|
||||||
@@ -418,6 +433,7 @@ With the API running (`dotnet run`) and Docker Compose up:
|
|||||||
./scripts/run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties
|
./scripts/run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties
|
||||||
./scripts/run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, promotion, idempotency, patient dedup
|
./scripts/run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, promotion, idempotency, patient dedup
|
||||||
./scripts/run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history
|
./scripts/run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history
|
||||||
|
./scripts/run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, critical alerts
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -668,6 +684,72 @@ All work queue endpoints support pagination via `?page=1&pageSize=20`.
|
|||||||
| 200 | History returned |
|
| 200 | History returned |
|
||||||
| 404 | No digitization batches for patient (`PATIENT_HISTORY_NOT_FOUND`) |
|
| 404 | No digitization batches for patient (`PATIENT_HISTORY_NOT_FOUND`) |
|
||||||
|
|
||||||
|
### Live Capture (Track B)
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/live-capture/encounters/{encounterId}/observations` | Clinician | Record observations against an existing active encounter; promotes synchronously with inline critical alerts |
|
||||||
|
| POST | `/live-capture/encounters` | Clinician | Open a new encounter and record initial vitals in one request (outpatient workflow) |
|
||||||
|
|
||||||
|
**Request body (both endpoints):**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `observations` | array | yes | One or more observation objects (see below) |
|
||||||
|
| `clinicianAttestation` | bool | yes | Must be `true` — clinician attests values are accurate |
|
||||||
|
| `passwordConfirm` | string | yes | Re-enter password to confirm identity |
|
||||||
|
|
||||||
|
**Open encounter only — additional fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `patientId` | Guid | yes | Existing patient ID |
|
||||||
|
| `department` | string | yes | e.g. `Outpatient Clinic`, `Internal Medicine` |
|
||||||
|
| `roomBed` | string | no | Ward/bed assignment |
|
||||||
|
| `admissionReason` | string | no | Reason for visit or admission |
|
||||||
|
|
||||||
|
**Observation object:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `observationCode` | string | yes | e.g. `HEART_RATE`, `POTASSIUM_MEQ_L`, `TEMP_C` |
|
||||||
|
| `value` | decimal | yes | Numeric measurement |
|
||||||
|
| `unit` | string | yes | Unit of measure |
|
||||||
|
| `recordedAt` | DateTimeOffset | yes | When the measurement was taken |
|
||||||
|
| `note` | string | no | Optional note |
|
||||||
|
|
||||||
|
**Response (`LiveCaptureResponse`):**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `batchId` | Guid | `DigitizationBatch` created in `PROMOTED` status with `track = LIVE_CAPTURE` |
|
||||||
|
| `encounterId` | Guid | Live encounter ID |
|
||||||
|
| `observations` | array | Promoted observations with `liveObservationId` and optional inline `criticalAlert` |
|
||||||
|
| `criticalAlertCount` | int | Number of synchronous critical alerts generated |
|
||||||
|
| `promotedAt` | DateTimeOffset | Promotion timestamp |
|
||||||
|
|
||||||
|
**Inline critical alert object (`criticalAlert` on each observation):**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `alertId` | Guid | Committed `ClinicalAlert` row ID |
|
||||||
|
| `severity` | string | `CRITICAL` |
|
||||||
|
| `thresholdBound` | string | `CRITICAL_LOW` or `CRITICAL_HIGH` |
|
||||||
|
| `thresholdValue` | decimal | Breached threshold value |
|
||||||
|
| `message` | string | Human-readable breach description |
|
||||||
|
|
||||||
|
**Status codes:**
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| 201 | Observations promoted; critical alerts (if any) committed before response |
|
||||||
|
| 403 | Caller lacks `CLINICIAN` role |
|
||||||
|
| 404 | Patient or encounter not found |
|
||||||
|
| 409 | Encounter not active (`ENCOUNTER_NOT_ACTIVE`); patient already has active encounter (`ACTIVE_ENCOUNTER_EXISTS`) |
|
||||||
|
| 422 | Attestation false (`ATTESTATION_REQUIRED`); wrong password (`PASSWORD_CONFIRM_INVALID`); empty observations list (`EMPTY_OBSERVATIONS`) |
|
||||||
|
|
||||||
|
Track B still creates a full audit trail: each submission writes a `DigitizationBatch` (status `PROMOTED`, `documentRef = "live-capture"`), draft observation rows, `live_capture_attested` and `promoted` digitization events, live `Observation` rows with `source = live_capture`, and outbox events for downstream alerting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Models
|
## Data Models
|
||||||
@@ -759,7 +841,7 @@ uploadedAt DateTimeOffset
|
|||||||
```
|
```
|
||||||
id Guid PK
|
id Guid PK
|
||||||
batchId Guid FK → DigitizationBatch
|
batchId Guid FK → DigitizationBatch
|
||||||
eventType string uploaded | entry_started | submitted_for_verification | rejected | verified | awaiting_clinical_approval | approved | promoted | correction_uploaded | correction_promoted | superseded | ...
|
eventType string uploaded | entry_started | submitted_for_verification | rejected | verified | awaiting_clinical_approval | approved | promoted | live_capture_attested | correction_uploaded | correction_promoted | superseded | ...
|
||||||
actorUserId Guid FK → User
|
actorUserId Guid FK → User
|
||||||
occurredAt DateTimeOffset
|
occurredAt DateTimeOffset
|
||||||
metadataJson string? optional JSON (field checks, rejection reason, assigned-to)
|
metadataJson string? optional JSON (field checks, rejection reason, assigned-to)
|
||||||
@@ -838,8 +920,8 @@ createdAt DateTimeOffset
|
|||||||
|
|
||||||
```
|
```
|
||||||
id Guid PK
|
id Guid PK
|
||||||
eventType string e.g. observation.created
|
eventType string e.g. observation.created | observation.recorded | alert.generated
|
||||||
aggregateType string e.g. Observation
|
aggregateType string e.g. Observation | ClinicalAlert
|
||||||
aggregateId Guid FK → the created entity
|
aggregateId Guid FK → the created entity
|
||||||
payloadJson string full event payload for downstream consumers
|
payloadJson string full event payload for downstream consumers
|
||||||
createdAt DateTimeOffset
|
createdAt DateTimeOffset
|
||||||
@@ -847,6 +929,36 @@ processedAt DateTimeOffset? set when consumed
|
|||||||
retryCount int default 0
|
retryCount int default 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### AlertThreshold
|
||||||
|
|
||||||
|
```
|
||||||
|
id Guid PK
|
||||||
|
observationCode string required, unique (e.g. POTASSIUM_MEQ_L)
|
||||||
|
displayName string required
|
||||||
|
unit string required
|
||||||
|
criticalLow decimal?
|
||||||
|
warningLow decimal?
|
||||||
|
warningHigh decimal?
|
||||||
|
criticalHigh decimal?
|
||||||
|
suppressionWindowMinutes int?
|
||||||
|
createdAt DateTimeOffset
|
||||||
|
```
|
||||||
|
|
||||||
|
### ClinicalAlert
|
||||||
|
|
||||||
|
```
|
||||||
|
id Guid PK
|
||||||
|
encounterId Guid FK → Encounter
|
||||||
|
patientId Guid FK → Patient
|
||||||
|
observationId Guid? FK → Observation (triggering value)
|
||||||
|
alertType string e.g. CRITICAL_POTASSIUM_MEQ_L
|
||||||
|
severity string WARNING | CRITICAL
|
||||||
|
details string human-readable breach message
|
||||||
|
observationCode string?
|
||||||
|
status string OPEN | ACKNOWLEDGED | RESOLVED | ESCALATED
|
||||||
|
triggeredAt DateTimeOffset
|
||||||
|
```
|
||||||
|
|
||||||
### IdempotencyRecord
|
### IdempotencyRecord
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -922,7 +1034,7 @@ Response shape:
|
|||||||
|
|
||||||
## Implemented Phases
|
## Implemented Phases
|
||||||
|
|
||||||
Five phases from the project roadmap are implemented and verified. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 1–5.
|
Six phases from the project roadmap are implemented and verified. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 1–6.
|
||||||
|
|
||||||
| Phase | Feature | Status |
|
| Phase | Feature | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -931,7 +1043,7 @@ Five phases from the project roadmap are implemented and verified. Integration t
|
|||||||
| 3 | Verification with field-level checks (`ok`, `warning`, `error` per field), rejection with mandatory reason (min 10 chars), separation of duties enforcement (`SEPARATION_OF_DUTIES_VIOLATION`), site-configurable clinical approval routing per batch type, work queue endpoints (verification, entry, clinical approval) with role-based access, `VerificationTests` integration tests | Done |
|
| 3 | Verification with field-level checks (`ok`, `warning`, `error` per field), rejection with mandatory reason (min 10 chars), separation of duties enforcement (`SEPARATION_OF_DUTIES_VIOLATION`), site-configurable clinical approval routing per batch type, work queue endpoints (verification, entry, clinical approval) with role-based access, `VerificationTests` integration tests | Done |
|
||||||
| 4 | Approval with three-person separation of duties (entry ≠ verifier ≠ approver), atomic promotion to live `patients`/`encounters`/`observations` tables in a single PostgreSQL transaction, PostgreSQL sequence-backed MRN generation (`VCR-NNNNNN`), patient dedup by name + DOB, encounter matching by patient + department + active status, transactional outbox (`observation.created` events), retroactive alert policy per batch, `Idempotency-Key` header with 24h TTL for safe retries, `PromotionTests` integration tests, Phase 4 verification script | Done |
|
| 4 | Approval with three-person separation of duties (entry ≠ verifier ≠ approver), atomic promotion to live `patients`/`encounters`/`observations` tables in a single PostgreSQL transaction, PostgreSQL sequence-backed MRN generation (`VCR-NNNNNN`), patient dedup by name + DOB, encounter matching by patient + department + active status, transactional outbox (`observation.created` events), retroactive alert policy per batch, `Idempotency-Key` header with 24h TTL for safe retries, `PromotionTests` integration tests, Phase 4 verification script | Done |
|
||||||
| 5 | Correction batches via `supersedesBatchId`, supersession validation on create (`422`/`404`/`409`), append-only `live_observations` supersession flags (`is_superseded`, `superseded_by_batch_id`, `superseded_at`), correction promotion reuses original encounter and linked patient, `correction_uploaded`/`correction_promoted`/`superseded` audit events, `GET /patients/:id/digitization-history` with correction chain and per-batch audit trails, `CorrectionSupersessionTests` integration tests, Phase 5 verification script | Done |
|
| 5 | Correction batches via `supersedesBatchId`, supersession validation on create (`422`/`404`/`409`), append-only `live_observations` supersession flags (`is_superseded`, `superseded_by_batch_id`, `superseded_at`), correction promotion reuses original encounter and linked patient, `correction_uploaded`/`correction_promoted`/`superseded` audit events, `GET /patients/:id/digitization-history` with correction chain and per-batch audit trails, `CorrectionSupersessionTests` integration tests, Phase 5 verification script | Done |
|
||||||
| 6 | Track B live capture with clinician attestation, synchronous alert evaluation | Planned |
|
| 6 | Track B live capture: `LiveCaptureController` with clinician-only endpoints, `AttestationService` (role + password re-confirm), synchronous promotion via `LiveCaptureService`, Redis-backed critical threshold evaluation, inline critical alerts + committed `ClinicalAlert` rows, `observation.recorded` and `alert.generated` outbox events, open-encounter + vitals outpatient workflow, `LiveCaptureIntegrationTests`, Phase 6 verification script | Done |
|
||||||
| 7 | Digitization workstation UI (Vue 3 side-by-side scan viewer + entry form) | Planned |
|
| 7 | Digitization workstation UI (Vue 3 side-by-side scan viewer + entry form) | Planned |
|
||||||
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Planned |
|
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Planned |
|
||||||
| 9 | Seed data, E2E verification script, clinical scenario documentation | Planned |
|
| 9 | Seed data, E2E verification script, clinical scenario documentation | Planned |
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ public static class DbResetHelper
|
|||||||
{
|
{
|
||||||
await db.Database.ExecuteSqlRawAsync(@"
|
await db.Database.ExecuteSqlRawAsync(@"
|
||||||
TRUNCATE TABLE live_observations, live_encounters,
|
TRUNCATE TABLE live_observations, live_encounters,
|
||||||
clinical.outbox_events, clinical.observations,
|
clinical.clinical_alerts, clinical.alert_thresholds,
|
||||||
clinical.encounters, clinical.patients,
|
clinical.outbox_events, clinical.observations,
|
||||||
idempotency_records,
|
clinical.encounters, clinical.patients,
|
||||||
digitization_events, draft_observations,
|
idempotency_records,
|
||||||
draft_encounters, draft_patients,
|
digitization_events, draft_observations,
|
||||||
scanned_documents, digitization_batches, users
|
draft_encounters, draft_patients,
|
||||||
|
scanned_documents, digitization_batches, users
|
||||||
RESTART IDENTITY CASCADE;
|
RESTART IDENTITY CASCADE;
|
||||||
");
|
");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,630 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
[Collection("Database")]
|
||||||
|
public class LiveCaptureIntegrationTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private HttpClient _client = null!;
|
||||||
|
|
||||||
|
public LiveCaptureIntegrationTests(ApiFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
await DataSeeder.SeedAsync(db);
|
||||||
|
_client = _fixture.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Happy path: normal observations, no alert
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_NormalValues_PromotesSynchronouslyWithNoAlert()
|
||||||
|
{
|
||||||
|
// Arrange — create patient and encounter via setup
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (patientId, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null),
|
||||||
|
new("TEMP_C", 36.8m, "C", DateTimeOffset.UtcNow, null),
|
||||||
|
new("SPO2", 98m, "%", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||||
|
Assert.True(body!.Success);
|
||||||
|
Assert.Equal(encounterId, body.Data!.EncounterId);
|
||||||
|
Assert.Equal(3, body.Data.Observations.Count);
|
||||||
|
Assert.Equal(0, body.Data.CriticalAlertCount);
|
||||||
|
|
||||||
|
// Verify all observations have live IDs
|
||||||
|
foreach (var obs in body.Data.Observations)
|
||||||
|
{
|
||||||
|
Assert.NotEqual(Guid.Empty, obs.LiveObservationId);
|
||||||
|
Assert.NotEqual(Guid.Empty, obs.DraftObservationId);
|
||||||
|
Assert.Null(obs.CriticalAlert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify batch exists in database with Promoted status
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var batch = await db.DigitizationBatches.FindAsync(body.Data.BatchId);
|
||||||
|
Assert.NotNull(batch);
|
||||||
|
Assert.Equal(BatchStatus.Promoted, batch!.Status);
|
||||||
|
Assert.Equal(BatchTrack.LiveCapture, batch.Track);
|
||||||
|
Assert.True(batch.ClinicianAttestation);
|
||||||
|
Assert.NotNull(batch.PromotedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Critical path: alert fires BEFORE response returns
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_CriticalPotassium_AlertFiresBeforeResponseReturns()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (patientId, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
|
||||||
|
// Seed a critical threshold for potassium:
|
||||||
|
// critical_low = 2.5, warning_low = 3.5, warning_high = 5.0, critical_high = 6.5
|
||||||
|
await SeedPotassiumThresholdAsync();
|
||||||
|
|
||||||
|
// Value of 2.1 is below critical_low of 2.5 — life-threatening
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", DateTimeOffset.UtcNow,
|
||||||
|
"Bedside iSTAT result")
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
// Assert — response contains the alert inline
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||||
|
Assert.True(body!.Success);
|
||||||
|
Assert.Equal(1, body.Data!.CriticalAlertCount);
|
||||||
|
Assert.Single(body.Data.Observations);
|
||||||
|
|
||||||
|
var obsResponse = body.Data.Observations[0];
|
||||||
|
Assert.NotNull(obsResponse.CriticalAlert);
|
||||||
|
Assert.Equal("CRITICAL", obsResponse.CriticalAlert!.Severity);
|
||||||
|
Assert.Equal("CRITICAL_LOW", obsResponse.CriticalAlert.ThresholdBound);
|
||||||
|
Assert.Equal(2.5m, obsResponse.CriticalAlert.ThresholdValue);
|
||||||
|
Assert.Contains("below critical low", obsResponse.CriticalAlert.Message);
|
||||||
|
|
||||||
|
// Verify the alert exists in the database BEFORE we read anything else —
|
||||||
|
// the alert was committed in the same transaction as the observation
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var alert = await db.ClinicalAlerts.FindAsync(obsResponse.CriticalAlert.AlertId);
|
||||||
|
Assert.NotNull(alert);
|
||||||
|
Assert.Equal(AlertStatus.Open, alert!.Status);
|
||||||
|
Assert.Equal(AlertSeverity.Critical, alert.Severity);
|
||||||
|
Assert.Equal(AlertType.CriticalPotassiumMeqL, alert.AlertType);
|
||||||
|
Assert.Contains("below critical low", alert.Details);
|
||||||
|
|
||||||
|
// Verify outbox event for alert was written in the same transaction
|
||||||
|
var alertOutbox = await db.OutboxEvents
|
||||||
|
.Where(e => e.EventType == "alert.generated" &&
|
||||||
|
e.AggregateId == obsResponse.CriticalAlert.AlertId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
Assert.NotNull(alertOutbox);
|
||||||
|
Assert.Contains(obsResponse.CriticalAlert.AlertId.ToString(), alertOutbox!.PayloadJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Critical high: value above critical_high threshold
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_CriticalHighPotassium_AlertWithCorrectBound()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
await SeedPotassiumThresholdAsync();
|
||||||
|
|
||||||
|
// Value of 7.2 is above critical_high of 6.5
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("POTASSIUM_MEQ_L", 7.2m, "mEq/L", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||||
|
|
||||||
|
var alert = body!.Data!.Observations[0].CriticalAlert;
|
||||||
|
Assert.NotNull(alert);
|
||||||
|
Assert.Equal("CRITICAL_HIGH", alert!.ThresholdBound);
|
||||||
|
Assert.Equal(6.5m, alert.ThresholdValue);
|
||||||
|
Assert.Contains("above critical high", alert.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Mixed batch: one critical, two normal
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_MixedBatch_OnlyCriticalObservationGetsAlert()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
await SeedPotassiumThresholdAsync();
|
||||||
|
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 80m, "bpm", DateTimeOffset.UtcNow, null),
|
||||||
|
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", DateTimeOffset.UtcNow, null),
|
||||||
|
new("TEMP_C", 37.0m, "C", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||||
|
|
||||||
|
Assert.Equal(1, body!.Data!.CriticalAlertCount);
|
||||||
|
Assert.Null(body.Data.Observations[0].CriticalAlert); // heart rate — normal
|
||||||
|
Assert.NotNull(body.Data.Observations[1].CriticalAlert); // potassium — critical
|
||||||
|
Assert.Null(body.Data.Observations[2].CriticalAlert); // temp — normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Open encounter + vitals in one request
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenEncounterWithVitals_CreatesEncounterAndPromotesObservations()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var patientId = await CreateTestPatientAsync();
|
||||||
|
|
||||||
|
var request = new OpenEncounterWithVitalsRequest(
|
||||||
|
PatientId: patientId,
|
||||||
|
Department: "Outpatient Clinic",
|
||||||
|
RoomBed: "OPD-3",
|
||||||
|
AdmissionReason: "Follow-up consultation",
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 68m, "bpm", DateTimeOffset.UtcNow, null),
|
||||||
|
new("BP_SYSTOLIC", 120m, "mmHg", DateTimeOffset.UtcNow, null),
|
||||||
|
new("BP_DIASTOLIC", 80m, "mmHg", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
"/api/v1/live-capture/encounters", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||||
|
|
||||||
|
Assert.True(body!.Success);
|
||||||
|
Assert.NotEqual(Guid.Empty, body.Data!.EncounterId);
|
||||||
|
Assert.Equal(3, body.Data.Observations.Count);
|
||||||
|
Assert.Equal(0, body.Data.CriticalAlertCount);
|
||||||
|
|
||||||
|
// Verify encounter was created in VigilCareClinical
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var encounter = await db.Encounters.FindAsync(body.Data.EncounterId);
|
||||||
|
Assert.NotNull(encounter);
|
||||||
|
Assert.Equal("active", encounter!.Status);
|
||||||
|
Assert.Equal(Department.OutpatientClinic, encounter.Department);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Authorization: non-clinician role rejected
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_NonClinicianRole_Returns403()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "entry1");
|
||||||
|
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{Guid.NewGuid()}/observations", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Attestation: false attestation rejected
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_AttestationFalse_Returns422()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: false, // Must be true
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||||
|
Assert.Equal("ATTESTATION_REQUIRED", body!.Error!.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Attestation: wrong password rejected
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_WrongPassword_Returns422()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "wrong-password" // Incorrect
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||||
|
Assert.Equal("PASSWORD_CONFIRM_INVALID", body!.Error!.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Edge case: discharged encounter rejected
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_DischargedEncounter_Returns409()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (_, encounterId) = await CreateTestPatientAndDischargedEncounterAsync();
|
||||||
|
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||||
|
Assert.Equal("ENCOUNTER_NOT_ACTIVE", body!.Error!.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Edge case: duplicate active encounter
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenEncounterWithVitals_PatientHasActiveEncounter_Returns409()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (patientId, _) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
|
||||||
|
var request = new OpenEncounterWithVitalsRequest(
|
||||||
|
PatientId: patientId,
|
||||||
|
Department: Department.EmergencyDepartment.ToDbString(),
|
||||||
|
RoomBed: "ER-1",
|
||||||
|
AdmissionReason: "Chest pain",
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 90m, "bpm", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
"/api/v1/live-capture/encounters", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||||
|
Assert.Equal("ACTIVE_ENCOUNTER_EXISTS", body!.Error!.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Edge case: empty observations rejected
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_EmptyList_Returns422()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>(),
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||||
|
Assert.Equal("EMPTY_OBSERVATIONS", body!.Error!.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Audit trail: verify digitization events written
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordObservations_WritesAttestationAndPromotionEvents()
|
||||||
|
{
|
||||||
|
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||||
|
|
||||||
|
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||||
|
|
||||||
|
var request = new RecordObservationsRequest(
|
||||||
|
Observations: new List<LiveCaptureObservationRequest>
|
||||||
|
{
|
||||||
|
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||||
|
},
|
||||||
|
ClinicianAttestation: true,
|
||||||
|
PasswordConfirm: "password"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
var body = await response.Content
|
||||||
|
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var events = await db.DigitizationEvents
|
||||||
|
.Where(e => e.BatchId == body!.Data!.BatchId)
|
||||||
|
.OrderBy(e => e.OccurredAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, events.Count);
|
||||||
|
Assert.Equal(DigitizationEventType.LiveCaptureAttested, events[0].EventType);
|
||||||
|
Assert.Equal(DigitizationEventType.Promoted, events[1].EventType);
|
||||||
|
|
||||||
|
// Verify metadata contains clinician info
|
||||||
|
Assert.Contains("clinicianName", events[0].MetadataJson!);
|
||||||
|
Assert.Contains("LIVE_CAPTURE", events[0].MetadataJson!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<Guid> CreateTestPatientAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var patient = new Patient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
|
||||||
|
FullName = "Test Patient",
|
||||||
|
DateOfBirth = new DateOnly(1985, 3, 15),
|
||||||
|
Sex = "M",
|
||||||
|
BloodType = BloodType.OPos,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Patients.Add(patient);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return patient.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(Guid patientId, Guid encounterId)> CreateTestPatientAndEncounterAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var patient = new Patient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
|
||||||
|
FullName = "Test Patient",
|
||||||
|
DateOfBirth = new DateOnly(1985, 3, 15),
|
||||||
|
Sex = "M",
|
||||||
|
BloodType = BloodType.OPos,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var encounter = new Encounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = patient.Id,
|
||||||
|
Department = Department.InternalMedicine,
|
||||||
|
RoomBed = "IM-201A",
|
||||||
|
AdmissionReason = "Observation",
|
||||||
|
Status = "active",
|
||||||
|
AdmissionDate = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Patients.Add(patient);
|
||||||
|
db.Encounters.Add(encounter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (patient.Id, encounter.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(Guid patientId, Guid encounterId)> CreateTestPatientAndDischargedEncounterAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var patient = new Patient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
|
||||||
|
FullName = "Discharged Patient",
|
||||||
|
DateOfBirth = new DateOnly(1970, 7, 20),
|
||||||
|
Sex = "F",
|
||||||
|
BloodType = BloodType.ANeg,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var encounter = new Encounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = patient.Id,
|
||||||
|
Department = Department.Surgery,
|
||||||
|
RoomBed = "SURG-105",
|
||||||
|
AdmissionReason = "Post-op recovery",
|
||||||
|
Status = "discharged",
|
||||||
|
AdmissionDate = DateTimeOffset.UtcNow.AddDays(-3),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow.AddDays(-3),
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Patients.Add(patient);
|
||||||
|
db.Encounters.Add(encounter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (patient.Id, encounter.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedPotassiumThresholdAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
|
||||||
|
// Only seed if not already present
|
||||||
|
var existing = await db.AlertThresholds
|
||||||
|
.FirstOrDefaultAsync(t => t.ObservationCode == "POTASSIUM_MEQ_L");
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
var threshold = new AlertThreshold
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ObservationCode = "POTASSIUM_MEQ_L",
|
||||||
|
DisplayName = "Serum Potassium",
|
||||||
|
Unit = "mEq/L",
|
||||||
|
CriticalLow = 2.5m,
|
||||||
|
WarningLow = 3.5m,
|
||||||
|
WarningHigh = 5.0m,
|
||||||
|
CriticalHigh = 6.5m,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AlertThresholds.Add(threshold);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Pre-warm Redis cache with ThresholdCacheEntry (same shape as LiveCaptureService)
|
||||||
|
var cache = redis.GetDatabase();
|
||||||
|
await cache.StringSetAsync(
|
||||||
|
"threshold:POTASSIUM_MEQ_L",
|
||||||
|
JsonSerializer.Serialize(new ThresholdCacheEntry(
|
||||||
|
threshold.ObservationCode,
|
||||||
|
threshold.CriticalLow,
|
||||||
|
threshold.WarningLow,
|
||||||
|
threshold.WarningHigh,
|
||||||
|
threshold.CriticalHigh)),
|
||||||
|
TimeSpan.FromMinutes(30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track B live capture endpoints for credentialed clinicians.
|
||||||
|
/// Observations entered via these endpoints skip the verification queue
|
||||||
|
/// and are promoted synchronously to VigilCareClinical with full
|
||||||
|
/// critical value alerting.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/live-capture")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Authorize(Roles = "CLINICIAN")]
|
||||||
|
public class LiveCaptureController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILiveCaptureService _liveCapture;
|
||||||
|
|
||||||
|
public LiveCaptureController(ILiveCaptureService liveCapture) =>
|
||||||
|
_liveCapture = liveCapture;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records observations against an existing VigilCareClinical encounter.
|
||||||
|
/// Requires clinician attestation and password re-confirmation.
|
||||||
|
/// Returns promoted observation IDs and any synchronous critical alerts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Track B workflow: no verification queue. Clinician attestation replaces
|
||||||
|
/// the dual-human gate used in Track A (backfill). Each observation is
|
||||||
|
/// promoted immediately and evaluated against critical alert thresholds
|
||||||
|
/// before this response returns.
|
||||||
|
///
|
||||||
|
/// Critical alerts are generated synchronously — a critical potassium
|
||||||
|
/// value will have an open ClinicalAlert row in the database before
|
||||||
|
/// the 201 response reaches the clinician's tablet.
|
||||||
|
/// </remarks>
|
||||||
|
[HttpPost("encounters/{encounterId:guid}/observations")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<LiveCaptureResponse>), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> RecordObservations(
|
||||||
|
Guid encounterId,
|
||||||
|
[FromBody] RecordObservationsRequest request)
|
||||||
|
{
|
||||||
|
var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
var result = await _liveCapture.RecordObservationsAsync(
|
||||||
|
encounterId, request, clinicianUserId);
|
||||||
|
|
||||||
|
return StatusCode(201, ApiResponse<LiveCaptureResponse>.Created(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a new encounter and records initial vitals in a single request.
|
||||||
|
/// Designed for outpatient workflows where the encounter does not yet
|
||||||
|
/// exist in VigilCareClinical.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Creates the encounter with status "active", records all observations,
|
||||||
|
/// promotes immediately, and evaluates critical thresholds — all within
|
||||||
|
/// a single database transaction.
|
||||||
|
///
|
||||||
|
/// If the patient already has an active encounter, returns 409 with
|
||||||
|
/// ACTIVE_ENCOUNTER_EXISTS. Use the observation-only endpoint instead.
|
||||||
|
/// </remarks>
|
||||||
|
[HttpPost("encounters")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<LiveCaptureResponse>), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> OpenEncounterWithVitals(
|
||||||
|
[FromBody] OpenEncounterWithVitalsRequest request)
|
||||||
|
{
|
||||||
|
var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
var result = await _liveCapture.OpenEncounterWithVitalsAsync(
|
||||||
|
request, clinicianUserId);
|
||||||
|
|
||||||
|
return StatusCode(201, ApiResponse<LiveCaptureResponse>.Created(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<Encounter> Encounters => Set<Encounter>();
|
public DbSet<Encounter> Encounters => Set<Encounter>();
|
||||||
public DbSet<Observation> Observations => Set<Observation>();
|
public DbSet<Observation> Observations => Set<Observation>();
|
||||||
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
||||||
|
public DbSet<AlertThreshold> AlertThresholds => Set<AlertThreshold>();
|
||||||
|
public DbSet<ClinicalAlert> ClinicalAlerts => Set<ClinicalAlert>();
|
||||||
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
|
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
|
||||||
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class AlertThresholdConfiguration : IEntityTypeConfiguration<AlertThreshold>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<AlertThreshold> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("alert_thresholds", "clinical");
|
||||||
|
builder.HasKey(t => t.Id);
|
||||||
|
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
builder.Property(t => t.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||||
|
builder.Property(t => t.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
|
||||||
|
builder.Property(t => t.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(t => t.CriticalLow).HasColumnName("critical_low").HasColumnType("decimal(10,3)");
|
||||||
|
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
|
||||||
|
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
|
||||||
|
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
|
||||||
|
builder.Property(t => t.SuppressionWindowMinutes).HasColumnName("suppression_window_minutes");
|
||||||
|
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
builder.HasIndex(t => t.ObservationCode).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ClinicalAlert> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("clinical_alerts", "clinical", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("chk_clinical_alerts_severity",
|
||||||
|
"severity IN ('WARNING', 'CRITICAL')");
|
||||||
|
t.HasCheckConstraint("chk_clinical_alerts_status",
|
||||||
|
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||||
|
t.HasCheckConstraint("chk_clinical_alerts_alert_type",
|
||||||
|
"alert_type IN (" +
|
||||||
|
"'SEPSIS_WARNING', " +
|
||||||
|
"'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', " +
|
||||||
|
"'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " +
|
||||||
|
"'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " +
|
||||||
|
"'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', " +
|
||||||
|
"'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', " +
|
||||||
|
"'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', " +
|
||||||
|
"'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', " +
|
||||||
|
"'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " +
|
||||||
|
"'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " +
|
||||||
|
"'WARNING_GLUCOSE_MG_DL', " +
|
||||||
|
"'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', " +
|
||||||
|
"'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', " +
|
||||||
|
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
|
||||||
|
"'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', " +
|
||||||
|
"'GCS_CRITICAL', 'GCS_WARNING', " +
|
||||||
|
"'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||||
|
});
|
||||||
|
builder.HasKey(a => a.Id);
|
||||||
|
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
builder.Property(a => a.EncounterId).HasColumnName("encounter_id");
|
||||||
|
builder.Property(a => a.PatientId).HasColumnName("patient_id");
|
||||||
|
builder.Property(a => a.ObservationId).HasColumnName("observation_id");
|
||||||
|
builder.Property(a => a.AlertType)
|
||||||
|
.HasColumnName("alert_type")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasConversion(
|
||||||
|
v => v.ToDbString(),
|
||||||
|
v => AlertTypeExtensions.FromDbString(v))
|
||||||
|
.IsRequired();
|
||||||
|
builder.Property(a => a.Severity)
|
||||||
|
.HasColumnName("severity")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasConversion(
|
||||||
|
v => v.ToDbString(),
|
||||||
|
v => AlertSeverityExtensions.FromDbString(v))
|
||||||
|
.IsRequired();
|
||||||
|
builder.Property(a => a.Details).HasColumnName("details").IsRequired();
|
||||||
|
builder.Property(a => a.ObservationCode).HasColumnName("observation_code").HasMaxLength(50);
|
||||||
|
builder.Property(a => a.Status)
|
||||||
|
.HasColumnName("status")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasConversion(
|
||||||
|
v => v.ToDbString(),
|
||||||
|
v => AlertStatusExtensions.FromDbString(v))
|
||||||
|
.HasDefaultValueSql("'OPEN'")
|
||||||
|
.HasSentinel((AlertStatus)(-1));
|
||||||
|
builder.Property(a => a.AcknowledgedAt).HasColumnName("acknowledged_at");
|
||||||
|
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
|
||||||
|
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
|
||||||
|
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id");
|
||||||
|
builder.Property(a => a.SyncedFromGateway).HasColumnName("synced_from_gateway").HasDefaultValue(false);
|
||||||
|
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
||||||
|
builder.Property(a => a.FeedbackReceived).HasColumnName("feedback_received").HasDefaultValue(false);
|
||||||
|
|
||||||
|
builder.HasIndex(a => new { a.EncounterId, a.TriggeredAt });
|
||||||
|
builder.HasIndex(a => new { a.PatientId, a.TriggeredAt });
|
||||||
|
builder.HasIndex(a => new { a.Severity, a.TriggeredAt })
|
||||||
|
.HasFilter("status = 'OPEN'");
|
||||||
|
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
|
||||||
|
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||||
|
builder.HasIndex(a => a.ClientAlertId)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("client_alert_id IS NOT NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1400
File diff suppressed because it is too large
Load Diff
+119
@@ -0,0 +1,119 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareRecordsAPI.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAlertThresholdsAndClinicalAlerts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "alert_thresholds",
|
||||||
|
schema: "clinical",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
critical_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||||
|
warning_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||||
|
warning_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||||
|
critical_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||||
|
suppression_window_minutes = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_alert_thresholds", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "clinical_alerts",
|
||||||
|
schema: "clinical",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
observation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
alert_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
severity = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
details = table.Column<string>(type: "text", nullable: false),
|
||||||
|
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'OPEN'"),
|
||||||
|
acknowledged_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
acknowledged_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
resolved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
triggered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||||
|
client_alert_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
synced_from_gateway = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
feedback_received = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_clinical_alerts", x => x.id);
|
||||||
|
table.CheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||||
|
table.CheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||||
|
table.CheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_alert_thresholds_observation_code",
|
||||||
|
schema: "clinical",
|
||||||
|
table: "alert_thresholds",
|
||||||
|
column: "observation_code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_clinical_alerts_client_alert_id",
|
||||||
|
schema: "clinical",
|
||||||
|
table: "clinical_alerts",
|
||||||
|
column: "client_alert_id",
|
||||||
|
unique: true,
|
||||||
|
filter: "client_alert_id IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_clinical_alerts_encounter_id_alert_type_observation_code",
|
||||||
|
schema: "clinical",
|
||||||
|
table: "clinical_alerts",
|
||||||
|
columns: new[] { "encounter_id", "alert_type", "observation_code" },
|
||||||
|
filter: "status IN ('OPEN', 'ESCALATED')");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_clinical_alerts_encounter_id_triggered_at",
|
||||||
|
schema: "clinical",
|
||||||
|
table: "clinical_alerts",
|
||||||
|
columns: new[] { "encounter_id", "triggered_at" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_clinical_alerts_patient_id_triggered_at",
|
||||||
|
schema: "clinical",
|
||||||
|
table: "clinical_alerts",
|
||||||
|
columns: new[] { "patient_id", "triggered_at" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_clinical_alerts_severity_triggered_at",
|
||||||
|
schema: "clinical",
|
||||||
|
table: "clinical_alerts",
|
||||||
|
columns: new[] { "severity", "triggered_at" },
|
||||||
|
filter: "status = 'OPEN'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "alert_thresholds",
|
||||||
|
schema: "clinical");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "clinical_alerts",
|
||||||
|
schema: "clinical");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,66 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlertThreshold", 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<decimal?>("CriticalHigh")
|
||||||
|
.HasColumnType("decimal(10,3)")
|
||||||
|
.HasColumnName("critical_high");
|
||||||
|
|
||||||
|
b.Property<decimal?>("CriticalLow")
|
||||||
|
.HasColumnType("decimal(10,3)")
|
||||||
|
.HasColumnName("critical_low");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)")
|
||||||
|
.HasColumnName("display_name");
|
||||||
|
|
||||||
|
b.Property<string>("ObservationCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("observation_code");
|
||||||
|
|
||||||
|
b.Property<int?>("SuppressionWindowMinutes")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("suppression_window_minutes");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("unit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("WarningHigh")
|
||||||
|
.HasColumnType("decimal(10,3)")
|
||||||
|
.HasColumnName("warning_high");
|
||||||
|
|
||||||
|
b.Property<decimal?>("WarningLow")
|
||||||
|
.HasColumnType("decimal(10,3)")
|
||||||
|
.HasColumnName("warning_low");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ObservationCode")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("alert_thresholds", "clinical");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("AuthAuditEvent", b =>
|
modelBuilder.Entity("AuthAuditEvent", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -59,6 +119,117 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClinicalAlert", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("AcknowledgedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("acknowledged_at");
|
||||||
|
|
||||||
|
b.Property<string>("AcknowledgedBy")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)")
|
||||||
|
.HasColumnName("acknowledged_by");
|
||||||
|
|
||||||
|
b.Property<string>("AlertType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("alert_type");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ClientAlertId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("client_alert_id");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("details");
|
||||||
|
|
||||||
|
b.Property<Guid>("EncounterId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("encounter_id");
|
||||||
|
|
||||||
|
b.Property<bool>("FeedbackReceived")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("feedback_received");
|
||||||
|
|
||||||
|
b.Property<string>("ObservationCode")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("observation_code");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ObservationId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("observation_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("PatientId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("patient_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("resolved_at");
|
||||||
|
|
||||||
|
b.Property<string>("Severity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("severity");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("status")
|
||||||
|
.HasDefaultValueSql("'OPEN'");
|
||||||
|
|
||||||
|
b.Property<bool>("SyncedFromGateway")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("synced_from_gateway");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TriggeredAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("triggered_at")
|
||||||
|
.HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ClientAlertId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("client_alert_id IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("EncounterId", "TriggeredAt");
|
||||||
|
|
||||||
|
b.HasIndex("PatientId", "TriggeredAt");
|
||||||
|
|
||||||
|
b.HasIndex("Severity", "TriggeredAt")
|
||||||
|
.HasFilter("status = 'OPEN'");
|
||||||
|
|
||||||
|
b.HasIndex("EncounterId", "AlertType", "ObservationCode")
|
||||||
|
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||||
|
|
||||||
|
b.ToTable("clinical_alerts", "clinical", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||||
|
|
||||||
|
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||||
|
|
||||||
|
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DigitizationBatch", b =>
|
modelBuilder.Entity("DigitizationBatch", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
public class AlertThreshold
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string ObservationCode { get; set; } = null!;
|
||||||
|
public string DisplayName { get; set; } = null!;
|
||||||
|
public string Unit { get; set; } = null!;
|
||||||
|
public decimal? CriticalLow { get; set; }
|
||||||
|
public decimal? WarningLow { get; set; }
|
||||||
|
public decimal? WarningHigh { get; set; }
|
||||||
|
public decimal? CriticalHigh { get; set; }
|
||||||
|
public int? SuppressionWindowMinutes { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
public class ClinicalAlert
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid EncounterId { get; set; }
|
||||||
|
public Guid PatientId { get; set; }
|
||||||
|
public Guid? ObservationId { get; set; }
|
||||||
|
public AlertType AlertType { get; set; }
|
||||||
|
public AlertSeverity Severity { get; set; }
|
||||||
|
public string Details { get; set; } = null!;
|
||||||
|
public string? ObservationCode { get; set; }
|
||||||
|
public AlertStatus Status { get; set; } = AlertStatus.Open;
|
||||||
|
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||||
|
public string? AcknowledgedBy { get; set; }
|
||||||
|
public DateTimeOffset? ResolvedAt { get; set; }
|
||||||
|
public DateTimeOffset TriggeredAt { get; set; }
|
||||||
|
public Guid? ClientAlertId { get; set; }
|
||||||
|
public bool SyncedFromGateway { get; set; }
|
||||||
|
public bool FeedbackReceived { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
public enum AlertSeverity { Warning, Critical }
|
||||||
|
|
||||||
|
public static class AlertSeverityExtensions
|
||||||
|
{
|
||||||
|
public static string ToDbString(this AlertSeverity s) => s switch
|
||||||
|
{
|
||||||
|
AlertSeverity.Warning => "WARNING",
|
||||||
|
AlertSeverity.Critical => "CRITICAL",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AlertSeverity FromDbString(string v) => v switch
|
||||||
|
{
|
||||||
|
"WARNING" => AlertSeverity.Warning,
|
||||||
|
"CRITICAL" => AlertSeverity.Critical,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert severity: '{v}'")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
public enum AlertStatus { Open, Acknowledged, Resolved, Escalated }
|
||||||
|
|
||||||
|
public static class AlertStatusExtensions
|
||||||
|
{
|
||||||
|
public static string ToDbString(this AlertStatus s) => s switch
|
||||||
|
{
|
||||||
|
AlertStatus.Open => "OPEN",
|
||||||
|
AlertStatus.Acknowledged => "ACKNOWLEDGED",
|
||||||
|
AlertStatus.Resolved => "RESOLVED",
|
||||||
|
AlertStatus.Escalated => "ESCALATED",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AlertStatus FromDbString(string v) => v switch
|
||||||
|
{
|
||||||
|
"OPEN" => AlertStatus.Open,
|
||||||
|
"ACKNOWLEDGED" => AlertStatus.Acknowledged,
|
||||||
|
"RESOLVED" => AlertStatus.Resolved,
|
||||||
|
"ESCALATED" => AlertStatus.Escalated,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert status: '{v}'")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
public enum AlertType
|
||||||
|
{
|
||||||
|
[Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")]
|
||||||
|
SepsisWarning,
|
||||||
|
CriticalHeartRate,
|
||||||
|
CriticalTempC,
|
||||||
|
CriticalPotassiumMeqL,
|
||||||
|
CriticalSpo2,
|
||||||
|
CriticalRespRate,
|
||||||
|
CriticalWbcKUl,
|
||||||
|
CriticalSystolicBp,
|
||||||
|
CriticalDiastolicBp,
|
||||||
|
CriticalLactateMmolL,
|
||||||
|
CriticalAvpu,
|
||||||
|
CriticalGlucoseMgDl,
|
||||||
|
|
||||||
|
// New — warning-level threshold alerts
|
||||||
|
WarningHeartRate,
|
||||||
|
WarningTempC,
|
||||||
|
WarningPotassiumMeqL,
|
||||||
|
WarningSpo2,
|
||||||
|
WarningRespRate,
|
||||||
|
WarningWbcKUl,
|
||||||
|
WarningSystolicBp,
|
||||||
|
WarningDiastolicBp,
|
||||||
|
WarningLactateMmolL,
|
||||||
|
WarningGlucoseMgDl,
|
||||||
|
|
||||||
|
News2Warning,
|
||||||
|
News2Emergency,
|
||||||
|
|
||||||
|
RapidDeterioration,
|
||||||
|
|
||||||
|
[Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")]
|
||||||
|
QsofaWarning,
|
||||||
|
|
||||||
|
QsofaScreen,
|
||||||
|
|
||||||
|
GcsCritical,
|
||||||
|
GcsWarning,
|
||||||
|
|
||||||
|
CriticalPao2MmHg,
|
||||||
|
WarningPao2MmHg,
|
||||||
|
CriticalPlateletKUl,
|
||||||
|
WarningPlateletKUl,
|
||||||
|
CriticalBilirubinMgDl,
|
||||||
|
WarningBilirubinMgDl,
|
||||||
|
CriticalCreatinineMgDl,
|
||||||
|
WarningCreatinineMgDl,
|
||||||
|
|
||||||
|
SofaSepsis,
|
||||||
|
SofaWarning,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AlertTypeExtensions
|
||||||
|
{
|
||||||
|
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||||
|
public static string ToDbString(this AlertType t) => t switch
|
||||||
|
{
|
||||||
|
AlertType.SepsisWarning => "SEPSIS_WARNING",
|
||||||
|
AlertType.CriticalHeartRate => "CRITICAL_HEART_RATE",
|
||||||
|
AlertType.CriticalTempC => "CRITICAL_TEMP_C",
|
||||||
|
AlertType.CriticalPotassiumMeqL => "CRITICAL_POTASSIUM_MEQ_L",
|
||||||
|
AlertType.CriticalSpo2 => "CRITICAL_SPO2",
|
||||||
|
AlertType.CriticalRespRate => "CRITICAL_RESP_RATE",
|
||||||
|
AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL",
|
||||||
|
AlertType.CriticalSystolicBp => "CRITICAL_SYSTOLIC_BP",
|
||||||
|
AlertType.CriticalDiastolicBp => "CRITICAL_DIASTOLIC_BP",
|
||||||
|
AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L",
|
||||||
|
AlertType.CriticalAvpu => "CRITICAL_AVPU",
|
||||||
|
AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL",
|
||||||
|
AlertType.WarningHeartRate => "WARNING_HEART_RATE",
|
||||||
|
AlertType.WarningTempC => "WARNING_TEMP_C",
|
||||||
|
AlertType.WarningPotassiumMeqL => "WARNING_POTASSIUM_MEQ_L",
|
||||||
|
AlertType.WarningSpo2 => "WARNING_SPO2",
|
||||||
|
AlertType.WarningRespRate => "WARNING_RESP_RATE",
|
||||||
|
AlertType.WarningWbcKUl => "WARNING_WBC_K_UL",
|
||||||
|
AlertType.WarningSystolicBp => "WARNING_SYSTOLIC_BP",
|
||||||
|
AlertType.WarningDiastolicBp => "WARNING_DIASTOLIC_BP",
|
||||||
|
AlertType.WarningLactateMmolL => "WARNING_LACTATE_MMOL_L",
|
||||||
|
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
|
||||||
|
AlertType.News2Warning => "NEWS2_WARNING",
|
||||||
|
AlertType.News2Emergency => "NEWS2_EMERGENCY",
|
||||||
|
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
|
||||||
|
AlertType.QsofaWarning => "QSOFA_WARNING",
|
||||||
|
AlertType.GcsCritical => "GCS_CRITICAL",
|
||||||
|
AlertType.GcsWarning => "GCS_WARNING",
|
||||||
|
AlertType.CriticalPao2MmHg => "CRITICAL_PAO2_MMHG",
|
||||||
|
AlertType.WarningPao2MmHg => "WARNING_PAO2_MMHG",
|
||||||
|
AlertType.CriticalPlateletKUl => "CRITICAL_PLATELET_K_UL",
|
||||||
|
AlertType.WarningPlateletKUl => "WARNING_PLATELET_K_UL",
|
||||||
|
AlertType.CriticalBilirubinMgDl => "CRITICAL_BILIRUBIN_MG_DL",
|
||||||
|
AlertType.WarningBilirubinMgDl => "WARNING_BILIRUBIN_MG_DL",
|
||||||
|
AlertType.CriticalCreatinineMgDl => "CRITICAL_CREATININE_MG_DL",
|
||||||
|
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
|
||||||
|
AlertType.SofaSepsis => "SOFA_SEPSIS",
|
||||||
|
AlertType.SofaWarning => "SOFA_WARNING",
|
||||||
|
AlertType.QsofaScreen => "QSOFA_SCREEN",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||||
|
};
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||||
|
public static AlertType FromDbString(string v) => v switch
|
||||||
|
{
|
||||||
|
"SEPSIS_WARNING" => AlertType.SepsisWarning,
|
||||||
|
"CRITICAL_HEART_RATE" => AlertType.CriticalHeartRate,
|
||||||
|
"CRITICAL_TEMP_C" => AlertType.CriticalTempC,
|
||||||
|
"CRITICAL_POTASSIUM_MEQ_L"=> AlertType.CriticalPotassiumMeqL,
|
||||||
|
"CRITICAL_SPO2" => AlertType.CriticalSpo2,
|
||||||
|
"CRITICAL_RESP_RATE" => AlertType.CriticalRespRate,
|
||||||
|
"CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||||
|
"CRITICAL_SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
|
||||||
|
"CRITICAL_DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
|
||||||
|
"CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||||
|
"CRITICAL_AVPU" => AlertType.CriticalAvpu,
|
||||||
|
"CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||||
|
"WARNING_HEART_RATE" => AlertType.WarningHeartRate,
|
||||||
|
"WARNING_TEMP_C" => AlertType.WarningTempC,
|
||||||
|
"WARNING_POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||||
|
"WARNING_SPO2" => AlertType.WarningSpo2,
|
||||||
|
"WARNING_RESP_RATE" => AlertType.WarningRespRate,
|
||||||
|
"WARNING_WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||||
|
"WARNING_SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||||
|
"WARNING_DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||||
|
"WARNING_LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||||
|
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||||
|
"NEWS2_WARNING" => AlertType.News2Warning,
|
||||||
|
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
|
||||||
|
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
|
||||||
|
"QSOFA_WARNING" => AlertType.QsofaWarning,
|
||||||
|
"QSOFA_SCREEN" => AlertType.QsofaScreen,
|
||||||
|
"GCS_CRITICAL" => AlertType.GcsCritical,
|
||||||
|
"GCS_WARNING" => AlertType.GcsWarning,
|
||||||
|
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
|
||||||
|
"WARNING_PAO2_MMHG" => AlertType.WarningPao2MmHg,
|
||||||
|
"CRITICAL_PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
|
||||||
|
"WARNING_PLATELET_K_UL" => AlertType.WarningPlateletKUl,
|
||||||
|
"CRITICAL_BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
|
||||||
|
"WARNING_BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
|
||||||
|
"CRITICAL_CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
|
||||||
|
"WARNING_CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
|
||||||
|
"SOFA_SEPSIS" => AlertType.SofaSepsis,
|
||||||
|
"SOFA_WARNING" => AlertType.SofaWarning,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
||||||
|
};
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
// Threshold alerts are derived from observation codes in alert_thresholds — not free-form strings.
|
||||||
|
public static AlertType CriticalFor(string observationCode) => observationCode switch
|
||||||
|
{
|
||||||
|
"HEART_RATE" => AlertType.CriticalHeartRate,
|
||||||
|
"TEMP_C" => AlertType.CriticalTempC,
|
||||||
|
"POTASSIUM_MEQ_L" => AlertType.CriticalPotassiumMeqL,
|
||||||
|
"SPO2" => AlertType.CriticalSpo2,
|
||||||
|
"RESP_RATE" => AlertType.CriticalRespRate,
|
||||||
|
"WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||||
|
"SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
|
||||||
|
"DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
|
||||||
|
"LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||||
|
"AVPU" => AlertType.CriticalAvpu,
|
||||||
|
"GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||||
|
"PAO2_MMHG" => AlertType.CriticalPao2MmHg,
|
||||||
|
"PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
|
||||||
|
"BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
|
||||||
|
"CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AlertType WarningFor(string observationCode) => observationCode switch
|
||||||
|
{
|
||||||
|
"HEART_RATE" => AlertType.WarningHeartRate,
|
||||||
|
"TEMP_C" => AlertType.WarningTempC,
|
||||||
|
"POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||||
|
"SPO2" => AlertType.WarningSpo2,
|
||||||
|
"RESP_RATE" => AlertType.WarningRespRate,
|
||||||
|
"WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||||
|
"SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||||
|
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||||
|
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||||
|
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||||
|
"PAO2_MMHG" => AlertType.WarningPao2MmHg,
|
||||||
|
"PLATELET_K_UL" => AlertType.WarningPlateletKUl,
|
||||||
|
"BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
|
||||||
|
"CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
|
||||||
|
};
|
||||||
|
|
||||||
|
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||||
|
public static bool IsSuppressible(this AlertType t) => t switch
|
||||||
|
{
|
||||||
|
AlertType.SepsisWarning or AlertType.News2Emergency => false,
|
||||||
|
AlertType.CriticalHeartRate or AlertType.CriticalTempC or AlertType.CriticalPotassiumMeqL
|
||||||
|
or AlertType.CriticalSpo2 or AlertType.CriticalRespRate or AlertType.CriticalWbcKUl
|
||||||
|
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
|
||||||
|
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
|
||||||
|
or AlertType.CriticalGlucoseMgDl => false,
|
||||||
|
AlertType.RapidDeterioration => false,
|
||||||
|
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
|
||||||
|
AlertType.SofaSepsis => false,
|
||||||
|
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
|
||||||
|
};
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
public static string? ObservationCodeForWarning(this AlertType t) => t switch
|
||||||
|
{
|
||||||
|
AlertType.WarningHeartRate => "HEART_RATE",
|
||||||
|
AlertType.WarningTempC => "TEMP_C",
|
||||||
|
AlertType.WarningPotassiumMeqL => "POTASSIUM_MEQ_L",
|
||||||
|
AlertType.WarningSpo2 => "SPO2",
|
||||||
|
AlertType.WarningRespRate => "RESP_RATE",
|
||||||
|
AlertType.WarningWbcKUl => "WBC_K_UL",
|
||||||
|
AlertType.WarningSystolicBp => "SYSTOLIC_BP",
|
||||||
|
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
|
||||||
|
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
|
||||||
|
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
|
||||||
|
AlertType.WarningPao2MmHg => "PAO2_MMHG",
|
||||||
|
AlertType.WarningPlateletKUl => "PLATELET_K_UL",
|
||||||
|
AlertType.WarningBilirubinMgDl => "BILIRUBIN_MG_DL",
|
||||||
|
AlertType.WarningCreatinineMgDl => "CREATININE_MG_DL",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Internal result type for critical alert evaluation. Not exposed in API responses;
|
||||||
|
/// mapped to LiveCaptureCriticalAlert in the response builder.
|
||||||
|
/// </summary>
|
||||||
|
internal record CriticalAlertResult(
|
||||||
|
Guid Id,
|
||||||
|
string Severity,
|
||||||
|
string Message,
|
||||||
|
decimal ThresholdValue,
|
||||||
|
string ThresholdBound
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// A critical alert that was generated synchronously during live capture promotion.
|
||||||
|
/// Returned inline so the clinician sees the alert before the HTTP response completes.
|
||||||
|
/// </summary>
|
||||||
|
public record LiveCaptureCriticalAlert(
|
||||||
|
Guid AlertId,
|
||||||
|
string Severity,
|
||||||
|
string Message,
|
||||||
|
decimal ThresholdValue,
|
||||||
|
string ThresholdBound
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// A single observation entered at bedside by a credentialed clinician.
|
||||||
|
/// </summary>
|
||||||
|
public record LiveCaptureObservationRequest(
|
||||||
|
string ObservationCode,
|
||||||
|
decimal Value,
|
||||||
|
string Unit,
|
||||||
|
DateTimeOffset RecordedAt,
|
||||||
|
string? Note
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Response for a single promoted observation, including any synchronous
|
||||||
|
/// critical alert generated during promotion.
|
||||||
|
/// </summary>
|
||||||
|
public record LiveCaptureObservationResponse(
|
||||||
|
Guid DraftObservationId,
|
||||||
|
Guid LiveObservationId,
|
||||||
|
string ObservationCode,
|
||||||
|
decimal Value,
|
||||||
|
string Unit,
|
||||||
|
DateTimeOffset RecordedAt,
|
||||||
|
LiveCaptureCriticalAlert? CriticalAlert
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Full response for a live capture operation. Contains the batch ID,
|
||||||
|
/// the VigilCareClinical encounter ID, all promoted observations with
|
||||||
|
/// their live IDs, and any critical alerts generated synchronously.
|
||||||
|
/// </summary>
|
||||||
|
public record LiveCaptureResponse(
|
||||||
|
Guid BatchId,
|
||||||
|
Guid EncounterId,
|
||||||
|
IReadOnlyList<LiveCaptureObservationResponse> Observations,
|
||||||
|
int CriticalAlertCount,
|
||||||
|
DateTimeOffset PromotedAt
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Opens a new encounter and records initial vitals in a single request.
|
||||||
|
/// Used for outpatient workflows where the encounter does not yet exist.
|
||||||
|
/// </summary>
|
||||||
|
public record OpenEncounterWithVitalsRequest(
|
||||||
|
Guid PatientId,
|
||||||
|
string Department,
|
||||||
|
string? RoomBed,
|
||||||
|
string AdmissionReason,
|
||||||
|
List<LiveCaptureObservationRequest> Observations,
|
||||||
|
bool ClinicianAttestation,
|
||||||
|
string PasswordConfirm
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Records one or more observations against an existing VigilCareClinical encounter.
|
||||||
|
/// Requires clinician attestation and password re-confirmation.
|
||||||
|
/// </summary>
|
||||||
|
public record RecordObservationsRequest(
|
||||||
|
List<LiveCaptureObservationRequest> Observations,
|
||||||
|
bool ClinicianAttestation,
|
||||||
|
string PasswordConfirm
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
public record ThresholdCacheEntry(
|
||||||
|
string ObservationCode,
|
||||||
|
decimal? CriticalLow,
|
||||||
|
decimal? WarningLow,
|
||||||
|
decimal? WarningHigh,
|
||||||
|
decimal? CriticalHigh);
|
||||||
@@ -66,6 +66,8 @@ try
|
|||||||
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
||||||
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
||||||
builder.Services.AddScoped<IDigitizationHistoryService, DigitizationHistoryService>();
|
builder.Services.AddScoped<IDigitizationHistoryService, DigitizationHistoryService>();
|
||||||
|
builder.Services.AddScoped<IAttestationService, AttestationService>();
|
||||||
|
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
public class AttestationService : IAttestationService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<AttestationService> _logger;
|
||||||
|
|
||||||
|
public AttestationService(AppDbContext db, ILogger<AttestationService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User> ValidateAttestationAsync(
|
||||||
|
Guid userId, bool clinicianAttestation, string passwordConfirm)
|
||||||
|
{
|
||||||
|
// 1. Attestation flag must be explicitly true
|
||||||
|
if (!clinicianAttestation)
|
||||||
|
throw new ValidationException(
|
||||||
|
"Clinician attestation is required for live capture.",
|
||||||
|
"ATTESTATION_REQUIRED");
|
||||||
|
|
||||||
|
// 2. Load user
|
||||||
|
var user = await _db.Users.FindAsync(userId);
|
||||||
|
if (user is null)
|
||||||
|
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
|
||||||
|
|
||||||
|
// 3. Role check — only Clinician role can use live capture
|
||||||
|
if (user.Role != UserRole.Clinician)
|
||||||
|
throw new ValidationException(
|
||||||
|
"Only users with the Clinician role can perform live capture.",
|
||||||
|
"CLINICIAN_ROLE_REQUIRED");
|
||||||
|
|
||||||
|
// 4. Account must be active
|
||||||
|
if (!user.IsActive)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Account is disabled.", "ACCOUNT_DISABLED");
|
||||||
|
|
||||||
|
// 5. Password re-confirmation — prevents unattended sessions from
|
||||||
|
// submitting clinical data without the clinician present
|
||||||
|
if (string.IsNullOrWhiteSpace(passwordConfirm))
|
||||||
|
throw new ValidationException(
|
||||||
|
"Password re-confirmation is required.",
|
||||||
|
"PASSWORD_CONFIRM_REQUIRED");
|
||||||
|
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(passwordConfirm, user.PasswordHash))
|
||||||
|
throw new ValidationException(
|
||||||
|
"Password re-confirmation failed.",
|
||||||
|
"PASSWORD_CONFIRM_INVALID");
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Clinician attestation validated for user {UserId} ({FullName})",
|
||||||
|
user.Id, user.FullName);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
public interface IAttestationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that the user is a credentialed clinician and that the
|
||||||
|
/// password re-confirm matches their stored hash. Throws on failure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The authenticated user's ID from JWT claims.</param>
|
||||||
|
/// <param name="clinicianAttestation">Must be true; false throws ValidationException.</param>
|
||||||
|
/// <param name="passwordConfirm">Raw password for re-confirmation.</param>
|
||||||
|
/// <returns>The validated User entity.</returns>
|
||||||
|
Task<User> ValidateAttestationAsync(Guid userId, bool clinicianAttestation, string passwordConfirm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
public interface ILiveCaptureService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Records observations against an existing VigilCareClinical encounter.
|
||||||
|
/// Validates clinician attestation, creates a live_capture batch, promotes
|
||||||
|
/// synchronously, and returns live observation IDs with any critical alerts.
|
||||||
|
/// </summary>
|
||||||
|
Task<LiveCaptureResponse> RecordObservationsAsync(
|
||||||
|
Guid encounterId,
|
||||||
|
RecordObservationsRequest request,
|
||||||
|
Guid clinicianUserId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a new encounter in VigilCareClinical and records initial vitals
|
||||||
|
/// in a single atomic operation. Used for outpatient workflows.
|
||||||
|
/// </summary>
|
||||||
|
Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
|
||||||
|
OpenEncounterWithVitalsRequest request,
|
||||||
|
Guid clinicianUserId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
public class LiveCaptureService : ILiveCaptureService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IAttestationService _attestation;
|
||||||
|
private readonly IConnectionMultiplexer _redis;
|
||||||
|
private readonly ILogger<LiveCaptureService> _logger;
|
||||||
|
|
||||||
|
// Redis key prefix for cached alert thresholds (same as VigilCareClinical)
|
||||||
|
private const string ThresholdCachePrefix = "threshold:";
|
||||||
|
|
||||||
|
public LiveCaptureService(
|
||||||
|
AppDbContext db,
|
||||||
|
IAttestationService attestation,
|
||||||
|
IConnectionMultiplexer redis,
|
||||||
|
ILogger<LiveCaptureService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_attestation = attestation;
|
||||||
|
_redis = redis;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LiveCaptureResponse> RecordObservationsAsync(
|
||||||
|
Guid encounterId, RecordObservationsRequest request, Guid clinicianUserId)
|
||||||
|
{
|
||||||
|
// 1. Validate attestation (role + password re-confirm)
|
||||||
|
var clinician = await _attestation.ValidateAttestationAsync(
|
||||||
|
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
|
||||||
|
|
||||||
|
// 2. Validate encounter exists and is active in VigilCareClinical
|
||||||
|
var encounter = await _db.Encounters.FindAsync(encounterId);
|
||||||
|
if (encounter is null)
|
||||||
|
throw new NotFoundException(
|
||||||
|
"Encounter not found in VigilCareClinical.", "ENCOUNTER_NOT_FOUND");
|
||||||
|
|
||||||
|
if (encounter.Status != "active")
|
||||||
|
throw new ConflictException(
|
||||||
|
"Observations can only be recorded against active encounters.",
|
||||||
|
"ENCOUNTER_NOT_ACTIVE");
|
||||||
|
|
||||||
|
// 3. Validate observations
|
||||||
|
if (request.Observations is null || request.Observations.Count == 0)
|
||||||
|
throw new ValidationException(
|
||||||
|
"At least one observation is required.", "EMPTY_OBSERVATIONS");
|
||||||
|
|
||||||
|
if (request.Observations.Count > 10)
|
||||||
|
throw new ValidationException(
|
||||||
|
"Maximum 10 observations per live capture request.",
|
||||||
|
"TOO_MANY_OBSERVATIONS");
|
||||||
|
|
||||||
|
foreach (var obs in request.Observations)
|
||||||
|
{
|
||||||
|
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
|
||||||
|
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Execute synchronous promotion within a single transaction
|
||||||
|
return await PromoteSynchronouslyAsync(
|
||||||
|
encounter.Id, encounter.PatientId, clinician, request.Observations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
|
||||||
|
OpenEncounterWithVitalsRequest request, Guid clinicianUserId)
|
||||||
|
{
|
||||||
|
// 1. Validate attestation
|
||||||
|
var clinician = await _attestation.ValidateAttestationAsync(
|
||||||
|
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
|
||||||
|
|
||||||
|
// 2. Validate patient exists
|
||||||
|
var patient = await _db.Patients.FindAsync(request.PatientId);
|
||||||
|
if (patient is null)
|
||||||
|
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||||
|
|
||||||
|
// 3. Validate observations
|
||||||
|
if (request.Observations is null || request.Observations.Count == 0)
|
||||||
|
throw new ValidationException(
|
||||||
|
"At least one observation is required.", "EMPTY_OBSERVATIONS");
|
||||||
|
|
||||||
|
if (request.Observations.Count > 10)
|
||||||
|
throw new ValidationException(
|
||||||
|
"Maximum 10 observations per live capture request.",
|
||||||
|
"TOO_MANY_OBSERVATIONS");
|
||||||
|
|
||||||
|
foreach (var obs in request.Observations)
|
||||||
|
{
|
||||||
|
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
|
||||||
|
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check no duplicate active encounter for this patient
|
||||||
|
var existingActive = await _db.Encounters.AnyAsync(e =>
|
||||||
|
e.PatientId == request.PatientId &&
|
||||||
|
e.Status == "active");
|
||||||
|
|
||||||
|
if (existingActive)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Patient already has an active encounter. Record observations against the existing encounter.",
|
||||||
|
"ACTIVE_ENCOUNTER_EXISTS");
|
||||||
|
|
||||||
|
if (!DepartmentExtensions.TryFromDbString(request.Department, out var department))
|
||||||
|
throw new ValidationException(
|
||||||
|
$"Invalid department '{request.Department}'. Must be a recognized hospital department.",
|
||||||
|
"INVALID_DEPARTMENT");
|
||||||
|
|
||||||
|
// 5. Create the encounter in VigilCareClinical
|
||||||
|
var encounter = new Encounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = request.PatientId,
|
||||||
|
Department = department,
|
||||||
|
RoomBed = request.RoomBed,
|
||||||
|
AdmissionReason = request.AdmissionReason,
|
||||||
|
Status = "active",
|
||||||
|
AdmissionDate = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Encounters.Add(encounter);
|
||||||
|
|
||||||
|
// 6. Promote observations synchronously
|
||||||
|
return await PromoteSynchronouslyAsync(
|
||||||
|
encounter.Id, request.PatientId, clinician, request.Observations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Core promotion logic shared by both endpoints. Creates the batch, draft
|
||||||
|
/// observations, live observations, evaluates critical thresholds, and writes
|
||||||
|
/// all audit and outbox events within a single database transaction.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<LiveCaptureResponse> PromoteSynchronouslyAsync(
|
||||||
|
Guid encounterId, Guid patientId, User clinician,
|
||||||
|
List<LiveCaptureObservationRequest> observations)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var observationResponses = new List<LiveCaptureObservationResponse>();
|
||||||
|
var criticalAlertCount = 0;
|
||||||
|
|
||||||
|
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// --- Create the live_capture batch (already in terminal Promoted state) ---
|
||||||
|
var batch = new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batchId,
|
||||||
|
Status = BatchStatus.Promoted,
|
||||||
|
BatchType = BatchType.VitalsSheet,
|
||||||
|
Track = BatchTrack.LiveCapture,
|
||||||
|
PatientId = patientId,
|
||||||
|
DocumentRef = "live-capture", // No scanned document for Track B
|
||||||
|
DocumentSha256 = ComputeLiveCaptureHash(clinician.Id, encounterId, now),
|
||||||
|
EnableRetroactiveAlerts = false, // Not applicable — live capture always alerts
|
||||||
|
EnteredByUserId = clinician.Id,
|
||||||
|
VerifiedByUserId = clinician.Id, // Clinician attestation replaces verifier
|
||||||
|
ApprovedByUserId = clinician.Id,
|
||||||
|
ClinicianAttestation = true,
|
||||||
|
PromotedAt = now,
|
||||||
|
PromotionEncounterId = encounterId,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.DigitizationBatches.Add(batch);
|
||||||
|
|
||||||
|
// --- Write attestation and promotion events ---
|
||||||
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BatchId = batchId,
|
||||||
|
EventType = DigitizationEventType.LiveCaptureAttested,
|
||||||
|
ActorUserId = clinician.Id,
|
||||||
|
OccurredAt = now,
|
||||||
|
MetadataJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
clinicianId = clinician.Id,
|
||||||
|
clinicianName = clinician.FullName,
|
||||||
|
encounterId,
|
||||||
|
observationCount = observations.Count,
|
||||||
|
track = "LIVE_CAPTURE"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BatchId = batchId,
|
||||||
|
EventType = DigitizationEventType.Promoted,
|
||||||
|
ActorUserId = clinician.Id,
|
||||||
|
OccurredAt = now.AddMilliseconds(1),
|
||||||
|
MetadataJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
promotionType = "synchronous_live_capture",
|
||||||
|
encounterId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Process each observation ---
|
||||||
|
var cache = _redis.GetDatabase();
|
||||||
|
|
||||||
|
foreach (var obs in observations)
|
||||||
|
{
|
||||||
|
var draftObsId = Guid.NewGuid();
|
||||||
|
var liveObsId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Create draft observation (audit trail)
|
||||||
|
var draftObservation = new DraftObservation
|
||||||
|
{
|
||||||
|
Id = draftObsId,
|
||||||
|
BatchId = batchId,
|
||||||
|
ObservationCode = obs.ObservationCode,
|
||||||
|
Value = obs.Value,
|
||||||
|
Unit = obs.Unit,
|
||||||
|
RecordedAt = obs.RecordedAt,
|
||||||
|
Note = obs.Note,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.DraftObservations.Add(draftObservation);
|
||||||
|
|
||||||
|
// Create live observation in VigilCareClinical tables
|
||||||
|
var liveObservation = new Observation
|
||||||
|
{
|
||||||
|
Id = liveObsId,
|
||||||
|
EncounterId = encounterId,
|
||||||
|
PatientId = patientId,
|
||||||
|
ObservationCode = obs.ObservationCode,
|
||||||
|
Value = obs.Value,
|
||||||
|
Unit = obs.Unit,
|
||||||
|
RecordedAt = obs.RecordedAt,
|
||||||
|
Note = obs.Note,
|
||||||
|
Source = "live_capture",
|
||||||
|
SourceDraftObservationId = draftObsId,
|
||||||
|
SourceBatchId = batchId,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Observations.Add(liveObservation);
|
||||||
|
|
||||||
|
_db.OutboxEvents.Add(new OutboxEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EventType = "observation.recorded",
|
||||||
|
AggregateType = "Observation",
|
||||||
|
AggregateId = liveObsId,
|
||||||
|
PayloadJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
observationId = liveObsId,
|
||||||
|
encounterId,
|
||||||
|
patientId,
|
||||||
|
observationCode = obs.ObservationCode,
|
||||||
|
value = obs.Value,
|
||||||
|
unit = obs.Unit,
|
||||||
|
recordedAt = obs.RecordedAt,
|
||||||
|
source = "live_capture",
|
||||||
|
batchId
|
||||||
|
}),
|
||||||
|
CreatedAt = now,
|
||||||
|
ProcessedAt = null,
|
||||||
|
RetryCount = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Synchronous critical value detection ---
|
||||||
|
var alert = await EvaluateCriticalThresholdAsync(
|
||||||
|
cache, liveObsId, encounterId, patientId,
|
||||||
|
obs.ObservationCode, obs.Value, obs.Unit, now);
|
||||||
|
|
||||||
|
if (alert is not null)
|
||||||
|
{
|
||||||
|
criticalAlertCount++;
|
||||||
|
|
||||||
|
observationResponses.Add(new LiveCaptureObservationResponse(
|
||||||
|
draftObsId, liveObsId,
|
||||||
|
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
|
||||||
|
new LiveCaptureCriticalAlert(
|
||||||
|
alert.Id,
|
||||||
|
alert.Severity,
|
||||||
|
alert.Message,
|
||||||
|
alert.ThresholdValue,
|
||||||
|
alert.ThresholdBound)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
observationResponses.Add(new LiveCaptureObservationResponse(
|
||||||
|
draftObsId, liveObsId,
|
||||||
|
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Live capture batch {BatchId} promoted synchronously: " +
|
||||||
|
"{ObsCount} observations, {AlertCount} critical alerts, " +
|
||||||
|
"encounter {EncounterId}, clinician {ClinicianId}",
|
||||||
|
batchId, observations.Count, criticalAlertCount,
|
||||||
|
encounterId, clinician.Id);
|
||||||
|
|
||||||
|
return new LiveCaptureResponse(
|
||||||
|
batchId, encounterId, observationResponses,
|
||||||
|
criticalAlertCount, now);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates a single observation against Redis-cached alert thresholds.
|
||||||
|
/// If the value breaches a CRITICAL bound, creates a ClinicalAlert row and
|
||||||
|
/// an outbox event within the current transaction (before SaveChanges).
|
||||||
|
///
|
||||||
|
/// Returns null if no critical threshold is breached.
|
||||||
|
/// Warning thresholds are handled asynchronously by the Kafka consumer —
|
||||||
|
/// the same split as VigilCareClinical Phase 2.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<CriticalAlertResult?> EvaluateCriticalThresholdAsync(
|
||||||
|
StackExchange.Redis.IDatabase cache, Guid observationId, Guid encounterId, Guid patientId,
|
||||||
|
string observationCode, decimal value, string unit, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
var threshold = await LoadThresholdAsync(cache, observationCode);
|
||||||
|
if (threshold is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var breach = GetCriticalBreach(value, threshold);
|
||||||
|
if (breach is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var (thresholdValue, thresholdBound) = breach.Value;
|
||||||
|
var details = BuildCriticalDetails(observationCode, value, unit, threshold, thresholdBound);
|
||||||
|
|
||||||
|
AlertType alertType;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
alertType = AlertTypeExtensions.CriticalFor(observationCode);
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No critical alert type configured for observation code {ObservationCode}",
|
||||||
|
observationCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var alertId = Guid.NewGuid();
|
||||||
|
var clinicalAlert = new ClinicalAlert
|
||||||
|
{
|
||||||
|
Id = alertId,
|
||||||
|
EncounterId = encounterId,
|
||||||
|
PatientId = patientId,
|
||||||
|
ObservationId = observationId,
|
||||||
|
ObservationCode = observationCode,
|
||||||
|
AlertType = alertType,
|
||||||
|
Severity = AlertSeverity.Critical,
|
||||||
|
Details = details,
|
||||||
|
Status = AlertStatus.Open,
|
||||||
|
TriggeredAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.ClinicalAlerts.Add(clinicalAlert);
|
||||||
|
|
||||||
|
_db.OutboxEvents.Add(new OutboxEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EventType = "alert.generated",
|
||||||
|
AggregateType = "ClinicalAlert",
|
||||||
|
AggregateId = alertId,
|
||||||
|
PayloadJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
alertId,
|
||||||
|
encounterId,
|
||||||
|
patientId,
|
||||||
|
observationId,
|
||||||
|
observationCode,
|
||||||
|
alertType = alertType.ToDbString(),
|
||||||
|
severity = AlertSeverity.Critical.ToDbString(),
|
||||||
|
details,
|
||||||
|
triggeredValue = value,
|
||||||
|
thresholdValue,
|
||||||
|
thresholdBound,
|
||||||
|
source = "live_capture",
|
||||||
|
triggeredAt = now
|
||||||
|
}),
|
||||||
|
CreatedAt = now,
|
||||||
|
ProcessedAt = null,
|
||||||
|
RetryCount = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"CRITICAL alert {AlertId} generated via live capture: " +
|
||||||
|
"{ObservationCode} = {Value} {Unit} ({ThresholdBound} = {ThresholdValue}), " +
|
||||||
|
"encounter {EncounterId}, patient {PatientId}",
|
||||||
|
alertId, observationCode, value, unit,
|
||||||
|
thresholdBound, thresholdValue, encounterId, patientId);
|
||||||
|
|
||||||
|
return new CriticalAlertResult(
|
||||||
|
alertId,
|
||||||
|
AlertSeverity.Critical.ToDbString(),
|
||||||
|
details,
|
||||||
|
thresholdValue,
|
||||||
|
thresholdBound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(
|
||||||
|
StackExchange.Redis.IDatabase cache, string observationCode)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{ThresholdCachePrefix}{observationCode}";
|
||||||
|
var cached = await cache.StringGetAsync(cacheKey);
|
||||||
|
if (cached.HasValue)
|
||||||
|
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
|
||||||
|
|
||||||
|
var threshold = await _db.AlertThresholds
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.ObservationCode == observationCode);
|
||||||
|
|
||||||
|
if (threshold is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var entry = new ThresholdCacheEntry(
|
||||||
|
threshold.ObservationCode,
|
||||||
|
threshold.CriticalLow,
|
||||||
|
threshold.WarningLow,
|
||||||
|
threshold.WarningHigh,
|
||||||
|
threshold.CriticalHigh);
|
||||||
|
|
||||||
|
await cache.StringSetAsync(
|
||||||
|
cacheKey,
|
||||||
|
JsonSerializer.Serialize(entry),
|
||||||
|
TimeSpan.FromMinutes(30));
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (decimal ThresholdValue, string ThresholdBound)? GetCriticalBreach(
|
||||||
|
decimal value, ThresholdCacheEntry threshold)
|
||||||
|
{
|
||||||
|
if (threshold.CriticalLow.HasValue && value < threshold.CriticalLow.Value)
|
||||||
|
return (threshold.CriticalLow.Value, "CRITICAL_LOW");
|
||||||
|
|
||||||
|
if (threshold.CriticalHigh.HasValue && value > threshold.CriticalHigh.Value)
|
||||||
|
return (threshold.CriticalHigh.Value, "CRITICAL_HIGH");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCriticalDetails(
|
||||||
|
string observationCode, decimal value, string unit,
|
||||||
|
ThresholdCacheEntry threshold, string thresholdBound)
|
||||||
|
{
|
||||||
|
if (thresholdBound == "CRITICAL_LOW")
|
||||||
|
{
|
||||||
|
return $"{observationCode} value {value} {unit} is below critical low " +
|
||||||
|
$"threshold of {threshold.CriticalLow} {unit}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{observationCode} value {value} {unit} is above critical high " +
|
||||||
|
$"threshold of {threshold.CriticalHigh} {unit}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a deterministic hash for live capture batches (no scanned document).
|
||||||
|
/// Uses clinician ID, encounter ID, and timestamp to generate uniqueness.
|
||||||
|
/// </summary>
|
||||||
|
private static string ComputeLiveCaptureHash(Guid clinicianId, Guid encounterId, DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
var input = $"{clinicianId}:{encounterId}:{timestamp:O}";
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ services:
|
|||||||
image: datalust/seq:latest
|
image: datalust/seq:latest
|
||||||
environment:
|
environment:
|
||||||
ACCEPT_EULA: "Y"
|
ACCEPT_EULA: "Y"
|
||||||
|
# Required on first run for Seq 2025+ (CreateAdminUser migration)
|
||||||
|
SEQ_FIRSTRUN_ADMINPASSWORD: seqadmin
|
||||||
ports:
|
ports:
|
||||||
- "5346:80"
|
- "5346:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+943
@@ -0,0 +1,943 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs Phase 6 verification checks from docs/plans/phase-6-plan.md.
|
||||||
|
#
|
||||||
|
# Covers Track B live capture: clinician attestation, synchronous promotion,
|
||||||
|
# critical threshold alerting, outbox events, open-encounter workflow, and
|
||||||
|
# integration tests.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# docker compose up -d (PostgreSQL + Redis + MinIO)
|
||||||
|
# dotnet ef database update --project VigilCareRecordsAPI
|
||||||
|
# dotnet run --project VigilCareRecordsAPI
|
||||||
|
# Phase 1 seed data (clinician1, entry1, and other demo users)
|
||||||
|
#
|
||||||
|
# PostgreSQL checks use docker compose exec when the postgres service is running,
|
||||||
|
# otherwise host psql against VIGILCARE_PG_HOST:VIGILCARE_PG_PORT.
|
||||||
|
# Environment overrides (same defaults as Phase 1–5 scripts):
|
||||||
|
# VIGILCARE_API_URL default: http://localhost:5217
|
||||||
|
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
|
||||||
|
# VIGILCARE_PG_HOST default: localhost
|
||||||
|
# VIGILCARE_PG_PORT default: 5437
|
||||||
|
# VIGILCARE_PG_DB default: vigilcare_records
|
||||||
|
# VIGILCARE_PG_USER default: postgres
|
||||||
|
# VIGILCARE_PG_PASSWORD default: password
|
||||||
|
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions
|
||||||
|
# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests
|
||||||
|
# VIGILCARE_RECORDED_AT default: 2026-06-25T10:00:00Z
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
|
||||||
|
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
|
||||||
|
COMPOSE=(docker compose -f "$COMPOSE_FILE")
|
||||||
|
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
|
||||||
|
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
|
||||||
|
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
|
||||||
|
PG_USER="${VIGILCARE_PG_USER:-postgres}"
|
||||||
|
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
|
||||||
|
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
|
||||||
|
SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}"
|
||||||
|
|
||||||
|
RECORDED_AT="${VIGILCARE_RECORDED_AT:-2026-06-25T10:00:00Z}"
|
||||||
|
RECORDED_AT_CRITICAL="${VIGILCARE_RECORDED_AT_CRITICAL:-2026-06-25T10:05:00Z}"
|
||||||
|
RECORDED_AT_OUTPATIENT="${VIGILCARE_RECORDED_AT_OUTPATIENT:-2026-06-25T11:00:00Z}"
|
||||||
|
|
||||||
|
PASS_COUNT=0
|
||||||
|
FAIL_COUNT=0
|
||||||
|
FAILED_TESTS=()
|
||||||
|
|
||||||
|
# Populated by promotion / alert tests for downstream checks.
|
||||||
|
SHARED_LIVE_OBS_ID=""
|
||||||
|
SHARED_BATCH_ID=""
|
||||||
|
SHARED_ALERT_ID=""
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '%s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
section() {
|
||||||
|
log ""
|
||||||
|
log "== $1 =="
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
PASS_COUNT=$((PASS_COUNT + 1))
|
||||||
|
log " PASS: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
FAILED_TESTS+=("$1")
|
||||||
|
log " FAIL: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
log "ERROR: required command not found: $cmd"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
new_uuid() {
|
||||||
|
if command -v uuidgen >/dev/null 2>&1; then
|
||||||
|
uuidgen
|
||||||
|
else
|
||||||
|
cat /proc/sys/kernel/random/uuid
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_service_running() {
|
||||||
|
local service="$1"
|
||||||
|
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
|
||||||
|
}
|
||||||
|
|
||||||
|
psql_available() {
|
||||||
|
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
|
||||||
|
compose_service_running postgres && return 0
|
||||||
|
command -v psql >/dev/null 2>&1 && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
psql_query() {
|
||||||
|
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if compose_service_running postgres; then
|
||||||
|
"${COMPOSE[@]}" exec -T postgres \
|
||||||
|
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
|
||||||
|
elif command -v psql >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
http_code() {
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}' "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_post() {
|
||||||
|
local url="$1"
|
||||||
|
local body="$2"
|
||||||
|
local token="${3:-}"
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
curl -sS -X POST "$url" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body"
|
||||||
|
else
|
||||||
|
curl -sS -X POST "$url" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
local username="$1"
|
||||||
|
local password="${2:-password}"
|
||||||
|
json_post "$API_URL/api/v1/auth/login" \
|
||||||
|
"{\"username\":\"$username\",\"password\":\"$password\"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_data_field() {
|
||||||
|
local json="$1"
|
||||||
|
local field="$2"
|
||||||
|
jq -er ".data.$field // empty" <<<"$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_error_code() {
|
||||||
|
local json="$1"
|
||||||
|
jq -er '.error.code // .extensions.code // empty' <<<"$json" 2>/dev/null ||
|
||||||
|
jq -er '.title // empty' <<<"$json" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_status_code() {
|
||||||
|
local json="$1"
|
||||||
|
jq -er '.statusCode // empty' <<<"$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_api_reachable() {
|
||||||
|
local code
|
||||||
|
code="$(http_code "$API_URL/swagger/index.html" || true)"
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
log "ERROR: API not reachable at $API_URL (HTTP $code)."
|
||||||
|
log "Start infrastructure with: docker compose up -d"
|
||||||
|
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
|
||||||
|
log "Start API with: dotnet run --project VigilCareRecordsAPI"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_potassium_threshold() {
|
||||||
|
if ! psql_available; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
psql_query "
|
||||||
|
INSERT INTO clinical.alert_thresholds
|
||||||
|
(id, observation_code, display_name, unit,
|
||||||
|
critical_low, warning_low, warning_high, critical_high, created_at)
|
||||||
|
SELECT gen_random_uuid(), 'POTASSIUM_MEQ_L', 'Serum Potassium', 'mEq/L',
|
||||||
|
2.5, 3.5, 5.0, 6.5, NOW()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM clinical.alert_thresholds
|
||||||
|
WHERE observation_code = 'POTASSIUM_MEQ_L'
|
||||||
|
);
|
||||||
|
" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Creates clinical.patients + clinical.encounters (active). Prints patient_id|encounter_id.
|
||||||
|
create_patient_and_encounter() {
|
||||||
|
local status="${1:-active}"
|
||||||
|
local patient_id encounter_id mrn
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
patient_id="$(new_uuid)"
|
||||||
|
encounter_id="$(new_uuid)"
|
||||||
|
# mrn is varchar(20); use a short unique prefix + uuid fragment
|
||||||
|
mrn="P6-$(echo "$patient_id" | tr -d '-' | cut -c1-13)"
|
||||||
|
|
||||||
|
psql_query "
|
||||||
|
INSERT INTO clinical.patients
|
||||||
|
(id, mrn, full_name, no_known_allergies, created_at, updated_at)
|
||||||
|
VALUES ('$patient_id', '$mrn', 'Phase 6 Patient', false, NOW(), NOW());
|
||||||
|
" >/dev/null
|
||||||
|
|
||||||
|
psql_query "
|
||||||
|
INSERT INTO clinical.encounters
|
||||||
|
(id, patient_id, department, room_bed, admission_reason, status,
|
||||||
|
admission_date, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
'$encounter_id', '$patient_id', 'Internal Medicine', 'IM-201A',
|
||||||
|
'Observation', '$status', NOW(), NOW(), NOW()
|
||||||
|
);
|
||||||
|
" >/dev/null
|
||||||
|
|
||||||
|
printf '%s|%s' "$patient_id" "$encounter_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Creates clinical.patients only. Prints patient_id.
|
||||||
|
create_patient() {
|
||||||
|
local patient_id mrn
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
patient_id="$(new_uuid)"
|
||||||
|
mrn="P6-$(echo "$patient_id" | tr -d '-' | cut -c1-13)"
|
||||||
|
|
||||||
|
psql_query "
|
||||||
|
INSERT INTO clinical.patients
|
||||||
|
(id, mrn, full_name, no_known_allergies, created_at, updated_at)
|
||||||
|
VALUES ('$patient_id', '$mrn', 'Phase 6 Patient', false, NOW(), NOW());
|
||||||
|
" >/dev/null
|
||||||
|
|
||||||
|
printf '%s' "$patient_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
live_capture_record() {
|
||||||
|
local token="$1"
|
||||||
|
local encounter_id="$2"
|
||||||
|
local body="$3"
|
||||||
|
json_post "$API_URL/api/v1/live-capture/encounters/$encounter_id/observations" \
|
||||||
|
"$body" "$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
live_capture_open_encounter() {
|
||||||
|
local token="$1"
|
||||||
|
local body="$2"
|
||||||
|
json_post "$API_URL/api/v1/live-capture/encounters" "$body" "$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
normal_observations_body() {
|
||||||
|
local password_confirm="${1:-password}"
|
||||||
|
jq -nc \
|
||||||
|
--arg recorded_at "$RECORDED_AT" \
|
||||||
|
--arg password "$password_confirm" \
|
||||||
|
'{
|
||||||
|
observations: [
|
||||||
|
{observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null},
|
||||||
|
{observationCode: "TEMP_C", value: 36.8, unit: "C", recordedAt: $recorded_at, note: null},
|
||||||
|
{observationCode: "SPO2", value: 98, unit: "%", recordedAt: $recorded_at, note: null}
|
||||||
|
],
|
||||||
|
clinicianAttestation: true,
|
||||||
|
passwordConfirm: $password
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
test_schema_alert_tables() {
|
||||||
|
section "1. Schema — clinical.alert_thresholds and clinical.clinical_alerts"
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: PostgreSQL not reachable"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local threshold_table alert_table
|
||||||
|
threshold_table="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'clinical' AND table_name = 'alert_thresholds';
|
||||||
|
")"
|
||||||
|
alert_table="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'clinical' AND table_name = 'clinical_alerts';
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$threshold_table" == "1" ]]; then
|
||||||
|
pass "clinical.alert_thresholds table exists"
|
||||||
|
else
|
||||||
|
fail "clinical.alert_thresholds table exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$alert_table" == "1" ]]; then
|
||||||
|
pass "clinical.clinical_alerts table exists"
|
||||||
|
else
|
||||||
|
fail "clinical.clinical_alerts table exists"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_swagger_live_capture_routes() {
|
||||||
|
section "2. API surface — live-capture routes in swagger"
|
||||||
|
|
||||||
|
local swagger_paths
|
||||||
|
swagger_paths="$(curl -sS "$API_URL/swagger/v1/swagger.json")"
|
||||||
|
|
||||||
|
if jq -e '.paths["/api/v1/live-capture/encounters/{encounterId}/observations"].post' \
|
||||||
|
<<<"$swagger_paths" >/dev/null; then
|
||||||
|
pass "POST /api/v1/live-capture/encounters/{encounterId}/observations documented"
|
||||||
|
else
|
||||||
|
fail "POST /api/v1/live-capture/encounters/{encounterId}/observations documented"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if jq -e '.paths["/api/v1/live-capture/encounters"].post' \
|
||||||
|
<<<"$swagger_paths" >/dev/null; then
|
||||||
|
pass "POST /api/v1/live-capture/encounters documented"
|
||||||
|
else
|
||||||
|
fail "POST /api/v1/live-capture/encounters documented"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_attestation_wrong_password() {
|
||||||
|
section "3. Attestation — wrong password returns 422 PASSWORD_CONFIRM_INVALID"
|
||||||
|
|
||||||
|
local clinician_token ids encounter_id body result http_code error_code
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient/encounter for wrong-password test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body="$(normal_observations_body wrong)"
|
||||||
|
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
|
||||||
|
http_code="$(extract_status_code "$result")"
|
||||||
|
error_code="$(extract_error_code "$result")"
|
||||||
|
|
||||||
|
if [[ "$http_code" == "422" && "$error_code" == "PASSWORD_CONFIRM_INVALID" ]]; then
|
||||||
|
pass "wrong password returns 422 PASSWORD_CONFIRM_INVALID"
|
||||||
|
else
|
||||||
|
fail "wrong password returns 422 PASSWORD_CONFIRM_INVALID (http=$http_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_attestation_non_clinician() {
|
||||||
|
section "4. Authorization — entry clerk returns 403 Forbidden"
|
||||||
|
|
||||||
|
local clerk_token ids encounter_id body http_result
|
||||||
|
|
||||||
|
clerk_token="$(extract_data_field "$(login entry1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient/encounter for non-clinician test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body="$(normal_observations_body)"
|
||||||
|
http_result="$(http_code -X POST \
|
||||||
|
"$API_URL/api/v1/live-capture/encounters/$encounter_id/observations" \
|
||||||
|
-H "Authorization: Bearer $clerk_token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body")"
|
||||||
|
|
||||||
|
if [[ "$http_result" == "403" ]]; then
|
||||||
|
pass "entry clerk receives 403 Forbidden"
|
||||||
|
else
|
||||||
|
fail "entry clerk receives 403 Forbidden (http=$http_result)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_attestation_false() {
|
||||||
|
section "5. Attestation — false attestation returns 422 ATTESTATION_REQUIRED"
|
||||||
|
|
||||||
|
local clinician_token ids encounter_id body result http_code error_code
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient/encounter for false-attestation test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body="$(jq -nc \
|
||||||
|
--arg recorded_at "$RECORDED_AT" \
|
||||||
|
'{
|
||||||
|
observations: [
|
||||||
|
{observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null}
|
||||||
|
],
|
||||||
|
clinicianAttestation: false,
|
||||||
|
passwordConfirm: "password"
|
||||||
|
}')"
|
||||||
|
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
|
||||||
|
http_code="$(extract_status_code "$result")"
|
||||||
|
error_code="$(extract_error_code "$result")"
|
||||||
|
|
||||||
|
if [[ "$http_code" == "422" && "$error_code" == "ATTESTATION_REQUIRED" ]]; then
|
||||||
|
pass "false attestation returns 422 ATTESTATION_REQUIRED"
|
||||||
|
else
|
||||||
|
fail "false attestation returns 422 ATTESTATION_REQUIRED (http=$http_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_normal_promotion() {
|
||||||
|
section "6. Synchronous promotion — normal vitals promoted with no alert"
|
||||||
|
|
||||||
|
local clinician_token ids encounter_id body result obs_count alert_count live_obs_id batch_id
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient/encounter for normal promotion test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body="$(normal_observations_body)"
|
||||||
|
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then
|
||||||
|
fail "normal vitals live capture returns success"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
obs_count="$(extract_data_field "$result" 'observations | length')"
|
||||||
|
alert_count="$(extract_data_field "$result" criticalAlertCount)"
|
||||||
|
live_obs_id="$(jq -er '.data.observations[0].liveObservationId' <<<"$result")"
|
||||||
|
batch_id="$(extract_data_field "$result" batchId)"
|
||||||
|
|
||||||
|
if [[ "$obs_count" == "3" && "$alert_count" == "0" ]]; then
|
||||||
|
pass "response has 3 observations and criticalAlertCount=0"
|
||||||
|
else
|
||||||
|
fail "response has 3 observations and criticalAlertCount=0 (obs=$obs_count alerts=$alert_count)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHARED_LIVE_OBS_ID="$live_obs_id"
|
||||||
|
SHARED_BATCH_ID="$batch_id"
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: DB checks for normal promotion"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local obs_source batch_status batch_track batch_attestation
|
||||||
|
obs_source="$(psql_query "
|
||||||
|
SELECT source FROM clinical.observations WHERE id = '$live_obs_id';
|
||||||
|
")"
|
||||||
|
batch_status="$(psql_query "
|
||||||
|
SELECT status FROM digitization_batches WHERE id = '$batch_id';
|
||||||
|
")"
|
||||||
|
batch_track="$(psql_query "
|
||||||
|
SELECT track FROM digitization_batches WHERE id = '$batch_id';
|
||||||
|
")"
|
||||||
|
batch_attestation="$(psql_query "
|
||||||
|
SELECT clinician_attestation FROM digitization_batches WHERE id = '$batch_id';
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$obs_source" == "live_capture" ]]; then
|
||||||
|
pass "clinical.observations.source is live_capture"
|
||||||
|
else
|
||||||
|
fail "clinical.observations.source is live_capture (got: ${obs_source:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$batch_status" == "PROMOTED" && "$batch_track" == "LIVE_CAPTURE" && "$batch_attestation" == "t" ]]; then
|
||||||
|
pass "digitization_batches: PROMOTED, LIVE_CAPTURE, clinician_attestation=true"
|
||||||
|
else
|
||||||
|
fail "digitization_batches state (status=$batch_status track=$batch_track attestation=$batch_attestation)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_critical_low_potassium() {
|
||||||
|
section "7. Critical alert — potassium 2.1 mEq/L fires CRITICAL_LOW synchronously"
|
||||||
|
|
||||||
|
seed_potassium_threshold
|
||||||
|
|
||||||
|
local clinician_token ids encounter_id body result alert_count severity bound threshold message alert_id
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient/encounter for critical-low test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body="$(jq -nc \
|
||||||
|
--arg recorded_at "$RECORDED_AT_CRITICAL" \
|
||||||
|
'{
|
||||||
|
observations: [
|
||||||
|
{
|
||||||
|
observationCode: "POTASSIUM_MEQ_L",
|
||||||
|
value: 2.1,
|
||||||
|
unit: "mEq/L",
|
||||||
|
recordedAt: $recorded_at,
|
||||||
|
note: "Bedside iSTAT result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
clinicianAttestation: true,
|
||||||
|
passwordConfirm: "password"
|
||||||
|
}')"
|
||||||
|
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then
|
||||||
|
fail "critical potassium live capture returns success"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
alert_count="$(extract_data_field "$result" criticalAlertCount)"
|
||||||
|
severity="$(jq -er '.data.observations[0].criticalAlert.severity' <<<"$result")"
|
||||||
|
bound="$(jq -er '.data.observations[0].criticalAlert.thresholdBound' <<<"$result")"
|
||||||
|
threshold="$(jq -er '.data.observations[0].criticalAlert.thresholdValue' <<<"$result")"
|
||||||
|
message="$(jq -er '.data.observations[0].criticalAlert.message' <<<"$result")"
|
||||||
|
alert_id="$(jq -er '.data.observations[0].criticalAlert.alertId' <<<"$result")"
|
||||||
|
|
||||||
|
SHARED_ALERT_ID="$alert_id"
|
||||||
|
|
||||||
|
if [[ "$alert_count" == "1" && "$severity" == "CRITICAL" && "$bound" == "CRITICAL_LOW" &&
|
||||||
|
"$threshold" == 2.5* && "$message" == *"below critical low"* ]]; then
|
||||||
|
pass "response includes inline CRITICAL_LOW alert for potassium 2.1"
|
||||||
|
else
|
||||||
|
fail "response includes inline CRITICAL_LOW alert (count=$alert_count severity=$severity bound=$bound)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: DB checks for critical alert"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local db_severity db_status db_details
|
||||||
|
db_severity="$(psql_query "
|
||||||
|
SELECT severity FROM clinical.clinical_alerts WHERE id = '$alert_id';
|
||||||
|
")"
|
||||||
|
db_status="$(psql_query "
|
||||||
|
SELECT status FROM clinical.clinical_alerts WHERE id = '$alert_id';
|
||||||
|
")"
|
||||||
|
db_details="$(psql_query "
|
||||||
|
SELECT details FROM clinical.clinical_alerts WHERE id = '$alert_id';
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$db_severity" == "CRITICAL" && "$db_status" == "OPEN" && "$db_details" == *"below critical low"* ]]; then
|
||||||
|
pass "clinical.clinical_alerts row committed (CRITICAL, OPEN)"
|
||||||
|
else
|
||||||
|
fail "clinical.clinical_alerts row (severity=$db_severity status=$db_status)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_critical_high_potassium() {
|
||||||
|
section "8. Critical alert — potassium 7.2 mEq/L fires CRITICAL_HIGH"
|
||||||
|
|
||||||
|
seed_potassium_threshold
|
||||||
|
|
||||||
|
local clinician_token ids encounter_id body result bound threshold message
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient/encounter for critical-high test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body="$(jq -nc \
|
||||||
|
--arg recorded_at "$RECORDED_AT_CRITICAL" \
|
||||||
|
'{
|
||||||
|
observations: [
|
||||||
|
{
|
||||||
|
observationCode: "POTASSIUM_MEQ_L",
|
||||||
|
value: 7.2,
|
||||||
|
unit: "mEq/L",
|
||||||
|
recordedAt: $recorded_at,
|
||||||
|
note: null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
clinicianAttestation: true,
|
||||||
|
passwordConfirm: "password"
|
||||||
|
}')"
|
||||||
|
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
|
||||||
|
|
||||||
|
bound="$(jq -er '.data.observations[0].criticalAlert.thresholdBound // empty' <<<"$result")"
|
||||||
|
threshold="$(jq -er '.data.observations[0].criticalAlert.thresholdValue // empty' <<<"$result")"
|
||||||
|
message="$(jq -er '.data.observations[0].criticalAlert.message // empty' <<<"$result")"
|
||||||
|
|
||||||
|
if [[ "$bound" == "CRITICAL_HIGH" && "$threshold" == 6.5* && "$message" == *"above critical high"* ]]; then
|
||||||
|
pass "response includes CRITICAL_HIGH alert for potassium 7.2"
|
||||||
|
else
|
||||||
|
fail "response includes CRITICAL_HIGH alert (bound=$bound threshold=$threshold)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_mixed_batch() {
|
||||||
|
section "9. Mixed batch — only critical observation gets alert"
|
||||||
|
|
||||||
|
seed_potassium_threshold
|
||||||
|
|
||||||
|
local clinician_token ids encounter_id body result alert_count hr_alert k_alert temp_alert
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient/encounter for mixed-batch test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body="$(jq -nc \
|
||||||
|
--arg recorded_at "$RECORDED_AT_CRITICAL" \
|
||||||
|
'{
|
||||||
|
observations: [
|
||||||
|
{observationCode: "HEART_RATE", value: 80, unit: "bpm", recordedAt: $recorded_at, note: null},
|
||||||
|
{observationCode: "POTASSIUM_MEQ_L", value: 2.1, unit: "mEq/L", recordedAt: $recorded_at, note: null},
|
||||||
|
{observationCode: "TEMP_C", value: 37.0, unit: "C", recordedAt: $recorded_at, note: null}
|
||||||
|
],
|
||||||
|
clinicianAttestation: true,
|
||||||
|
passwordConfirm: "password"
|
||||||
|
}')"
|
||||||
|
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
|
||||||
|
|
||||||
|
alert_count="$(extract_data_field "$result" criticalAlertCount)"
|
||||||
|
hr_alert="$(jq -er '.data.observations[0].criticalAlert // "null"' <<<"$result")"
|
||||||
|
k_alert="$(jq -er '.data.observations[1].criticalAlert.severity // empty' <<<"$result")"
|
||||||
|
temp_alert="$(jq -er '.data.observations[2].criticalAlert // "null"' <<<"$result")"
|
||||||
|
|
||||||
|
if [[ "$alert_count" == "1" && "$hr_alert" == "null" && "$k_alert" == "CRITICAL" && "$temp_alert" == "null" ]]; then
|
||||||
|
pass "mixed batch: single CRITICAL alert on potassium only"
|
||||||
|
else
|
||||||
|
fail "mixed batch alert shape (count=$alert_count hr=$hr_alert k=$k_alert temp=$temp_alert)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_outbox_events() {
|
||||||
|
section "10. Outbox — observation.recorded and alert.generated events"
|
||||||
|
|
||||||
|
if [[ -z "$SHARED_LIVE_OBS_ID" || -z "$SHARED_ALERT_ID" ]]; then
|
||||||
|
fail "outbox checks require normal promotion and critical-low tests first"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: outbox DB checks"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local obs_event_type alert_event_type
|
||||||
|
obs_event_type="$(psql_query "
|
||||||
|
SELECT event_type
|
||||||
|
FROM clinical.outbox_events
|
||||||
|
WHERE aggregate_id = '$SHARED_LIVE_OBS_ID'
|
||||||
|
LIMIT 1;
|
||||||
|
")"
|
||||||
|
alert_event_type="$(psql_query "
|
||||||
|
SELECT event_type
|
||||||
|
FROM clinical.outbox_events
|
||||||
|
WHERE aggregate_id = '$SHARED_ALERT_ID'
|
||||||
|
LIMIT 1;
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$obs_event_type" == "observation.recorded" ]]; then
|
||||||
|
pass "outbox event observation.recorded written for live observation"
|
||||||
|
else
|
||||||
|
fail "outbox event observation.recorded (got: ${obs_event_type:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$alert_event_type" == "alert.generated" ]]; then
|
||||||
|
pass "outbox event alert.generated written for clinical alert"
|
||||||
|
else
|
||||||
|
fail "outbox event alert.generated (got: ${alert_event_type:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local obs_payload_source
|
||||||
|
obs_payload_source="$(psql_query "
|
||||||
|
SELECT payload_json::text
|
||||||
|
FROM clinical.outbox_events
|
||||||
|
WHERE aggregate_id = '$SHARED_LIVE_OBS_ID'
|
||||||
|
LIMIT 1;
|
||||||
|
")"
|
||||||
|
if [[ "$obs_payload_source" == *"live_capture"* ]]; then
|
||||||
|
pass "observation outbox payload includes source live_capture"
|
||||||
|
else
|
||||||
|
fail "observation outbox payload includes source live_capture"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_open_encounter_with_vitals() {
|
||||||
|
section "11. Open encounter + vitals — outpatient workflow"
|
||||||
|
|
||||||
|
local clinician_token patient_id body result encounter_id obs_count department
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: open-encounter test requires DB for patient setup"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
patient_id="$(create_patient)" || {
|
||||||
|
fail "setup patient for open-encounter test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body="$(jq -nc \
|
||||||
|
--arg patient_id "$patient_id" \
|
||||||
|
--arg recorded_at "$RECORDED_AT_OUTPATIENT" \
|
||||||
|
'{
|
||||||
|
patientId: $patient_id,
|
||||||
|
department: "Outpatient Clinic",
|
||||||
|
roomBed: "OPD-3",
|
||||||
|
admissionReason: "Follow-up",
|
||||||
|
observations: [
|
||||||
|
{observationCode: "HEART_RATE", value: 68, unit: "bpm", recordedAt: $recorded_at, note: null},
|
||||||
|
{observationCode: "BP_SYSTOLIC", value: 120, unit: "mmHg", recordedAt: $recorded_at, note: null}
|
||||||
|
],
|
||||||
|
clinicianAttestation: true,
|
||||||
|
passwordConfirm: "password"
|
||||||
|
}')"
|
||||||
|
result="$(live_capture_open_encounter "$clinician_token" "$body")"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then
|
||||||
|
fail "open encounter with vitals returns success"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
encounter_id="$(extract_data_field "$result" encounterId)"
|
||||||
|
obs_count="$(extract_data_field "$result" 'observations | length')"
|
||||||
|
|
||||||
|
if [[ "$obs_count" == "2" && -n "$encounter_id" ]]; then
|
||||||
|
pass "open encounter response has encounterId and 2 observations"
|
||||||
|
else
|
||||||
|
fail "open encounter response (encounterId=$encounter_id obs=$obs_count)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
department="$(psql_query "
|
||||||
|
SELECT department FROM clinical.encounters WHERE id = '$encounter_id';
|
||||||
|
")"
|
||||||
|
if [[ "$department" == "Outpatient Clinic" ]]; then
|
||||||
|
pass "new encounter department is Outpatient Clinic"
|
||||||
|
else
|
||||||
|
fail "new encounter department is Outpatient Clinic (got: ${department:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_duplicate_active_encounter() {
|
||||||
|
section "12. Validation — duplicate active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS"
|
||||||
|
|
||||||
|
local clinician_token ids patient_id body result http_code error_code
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient with active encounter for duplicate test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patient_id="${ids%%|*}"
|
||||||
|
|
||||||
|
body="$(jq -nc \
|
||||||
|
--arg patient_id "$patient_id" \
|
||||||
|
--arg recorded_at "$RECORDED_AT" \
|
||||||
|
'{
|
||||||
|
patientId: $patient_id,
|
||||||
|
department: "Emergency Department",
|
||||||
|
roomBed: "ER-1",
|
||||||
|
admissionReason: "Chest pain",
|
||||||
|
observations: [
|
||||||
|
{observationCode: "HEART_RATE", value: 90, unit: "bpm", recordedAt: $recorded_at, note: null}
|
||||||
|
],
|
||||||
|
clinicianAttestation: true,
|
||||||
|
passwordConfirm: "password"
|
||||||
|
}')"
|
||||||
|
result="$(live_capture_open_encounter "$clinician_token" "$body")"
|
||||||
|
http_code="$(extract_status_code "$result")"
|
||||||
|
error_code="$(extract_error_code "$result")"
|
||||||
|
|
||||||
|
if [[ "$http_code" == "409" && "$error_code" == "ACTIVE_ENCOUNTER_EXISTS" ]]; then
|
||||||
|
pass "second active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS"
|
||||||
|
else
|
||||||
|
fail "second active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS (http=$http_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_discharged_encounter() {
|
||||||
|
section "13. Validation — discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE"
|
||||||
|
|
||||||
|
local clinician_token ids encounter_id body result http_code error_code
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter discharged)" || {
|
||||||
|
fail "setup discharged encounter"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body="$(jq -nc \
|
||||||
|
--arg recorded_at "$RECORDED_AT" \
|
||||||
|
'{
|
||||||
|
observations: [
|
||||||
|
{observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null}
|
||||||
|
],
|
||||||
|
clinicianAttestation: true,
|
||||||
|
passwordConfirm: "password"
|
||||||
|
}')"
|
||||||
|
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
|
||||||
|
http_code="$(extract_status_code "$result")"
|
||||||
|
error_code="$(extract_error_code "$result")"
|
||||||
|
|
||||||
|
if [[ "$http_code" == "409" && "$error_code" == "ENCOUNTER_NOT_ACTIVE" ]]; then
|
||||||
|
pass "discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE"
|
||||||
|
else
|
||||||
|
fail "discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE (http=$http_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_empty_observations() {
|
||||||
|
section "14. Validation — empty observations returns 422 EMPTY_OBSERVATIONS"
|
||||||
|
|
||||||
|
local clinician_token ids encounter_id body result http_code error_code
|
||||||
|
|
||||||
|
clinician_token="$(extract_data_field "$(login clinician1)" token)"
|
||||||
|
ids="$(create_patient_and_encounter)" || {
|
||||||
|
fail "setup patient/encounter for empty-observations test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encounter_id="${ids#*|}"
|
||||||
|
|
||||||
|
body='{"observations":[],"clinicianAttestation":true,"passwordConfirm":"password"}'
|
||||||
|
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
|
||||||
|
http_code="$(extract_status_code "$result")"
|
||||||
|
error_code="$(extract_error_code "$result")"
|
||||||
|
|
||||||
|
if [[ "$http_code" == "422" && "$error_code" == "EMPTY_OBSERVATIONS" ]]; then
|
||||||
|
pass "empty observations returns 422 EMPTY_OBSERVATIONS"
|
||||||
|
else
|
||||||
|
fail "empty observations returns 422 EMPTY_OBSERVATIONS (http=$http_code code=${error_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_digitization_events() {
|
||||||
|
section "15. Audit trail — live_capture_attested and promoted digitization events"
|
||||||
|
|
||||||
|
if [[ -z "$SHARED_BATCH_ID" ]]; then
|
||||||
|
fail "digitization event check requires normal promotion test first"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: digitization event DB checks"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local attested_count promoted_count
|
||||||
|
attested_count="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM digitization_events
|
||||||
|
WHERE batch_id = '$SHARED_BATCH_ID'
|
||||||
|
AND event_type = 'live_capture_attested';
|
||||||
|
")"
|
||||||
|
promoted_count="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM digitization_events
|
||||||
|
WHERE batch_id = '$SHARED_BATCH_ID'
|
||||||
|
AND event_type = 'promoted';
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$attested_count" == "1" && "$promoted_count" == "1" ]]; then
|
||||||
|
pass "batch has live_capture_attested and promoted digitization events"
|
||||||
|
else
|
||||||
|
fail "digitization events (attested=$attested_count promoted=$promoted_count)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_integration_tests() {
|
||||||
|
section "16. Integration tests — LiveCaptureIntegrationTests"
|
||||||
|
|
||||||
|
if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then
|
||||||
|
log " SKIP: VIGILCARE_SKIP_TEST_CHECKS=1"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v dotnet >/dev/null 2>&1; then
|
||||||
|
log " SKIP: dotnet not found"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local test_output test_exit
|
||||||
|
test_output="$(dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests" \
|
||||||
|
--filter "FullyQualifiedName~LiveCaptureIntegrationTests" \
|
||||||
|
--verbosity minimal 2>&1)"
|
||||||
|
test_exit=$?
|
||||||
|
|
||||||
|
if [[ "$test_exit" -eq 0 ]] && grep -q "Passed!" <<<"$test_output"; then
|
||||||
|
pass "LiveCaptureIntegrationTests pass (dotnet test)"
|
||||||
|
else
|
||||||
|
fail "LiveCaptureIntegrationTests pass (dotnet test)"
|
||||||
|
log "$test_output"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_cmd curl
|
||||||
|
require_cmd jq
|
||||||
|
require_cmd docker
|
||||||
|
|
||||||
|
log "VigilCare Records — Phase 6 verification"
|
||||||
|
log "API: $API_URL"
|
||||||
|
if compose_service_running postgres; then
|
||||||
|
log "PostgreSQL: docker compose exec (service: postgres)"
|
||||||
|
elif command -v psql >/dev/null 2>&1; then
|
||||||
|
log "PostgreSQL: host psql ($PG_HOST:$PG_PORT)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
assert_api_reachable
|
||||||
|
|
||||||
|
test_schema_alert_tables
|
||||||
|
test_swagger_live_capture_routes
|
||||||
|
test_attestation_wrong_password
|
||||||
|
test_attestation_non_clinician
|
||||||
|
test_attestation_false
|
||||||
|
test_normal_promotion
|
||||||
|
test_critical_low_potassium
|
||||||
|
test_critical_high_potassium
|
||||||
|
test_mixed_batch
|
||||||
|
test_outbox_events
|
||||||
|
test_open_encounter_with_vitals
|
||||||
|
test_duplicate_active_encounter
|
||||||
|
test_discharged_encounter
|
||||||
|
test_empty_observations
|
||||||
|
test_digitization_events
|
||||||
|
test_integration_tests
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
|
||||||
|
if (( FAIL_COUNT > 0 )); then
|
||||||
|
log "Failed checks:"
|
||||||
|
for item in "${FAILED_TESTS[@]}"; do
|
||||||
|
log " - $item"
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "All Phase 6 verification checks passed."
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user