From 88e70b3dbe68db70b7678fff80f87983503ce767 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sat, 27 Jun 2026 04:23:51 +0800 Subject: [PATCH] feature: Live Capture (Track B) --- README.md | 146 +- .../Helpers/DbResetHelper.cs | 13 +- .../LiveCaptureIntegrationTests.cs | 630 ++++++++ .../Controllers/LiveCaptureController.cs | 88 ++ VigilCareRecordsAPI/Data/AppDbContext.cs | 2 + .../Clinical/AlertThresholdConfiguration.cs | 23 + .../Clinical/ClinicalAlertConfiguration.cs | 81 + ...ertThresholdsAndClinicalAlerts.Designer.cs | 1400 +++++++++++++++++ ...425_AddAlertThresholdsAndClinicalAlerts.cs | 119 ++ .../Migrations/AppDbContextModelSnapshot.cs | 171 ++ .../Entities/Clinical/AlertThreshold.cs | 13 + .../Domain/Entities/Clinical/ClinicalAlert.cs | 19 + .../Domain/Enums/AlertSeverity.cs | 18 + .../Domain/Enums/AlertStatus.cs | 22 + VigilCareRecordsAPI/Domain/Enums/AlertType.cs | 225 +++ .../LiveCapture/CriticalAlertResult.cs | 11 + .../LiveCapture/LiveCaptureCriticalAlert.cs | 11 + .../LiveCaptureObservationRequest.cs | 10 + .../LiveCaptureObservationResponse.cs | 13 + .../LiveCapture/LiveCaptureResponse.cs | 12 + .../OpenEncounterWithVitalsRequest.cs | 13 + .../LiveCapture/RecordObservationsRequest.cs | 9 + .../LiveCapture/ThresholdCacheEntry.cs | 6 + VigilCareRecordsAPI/Program.cs | 2 + .../Services/AttestationService.cs | 55 + .../Interfaces/IAttestationService.cs | 12 + .../Interfaces/ILiveCaptureService.cs | 20 + .../Services/Interfaces/LiveCaptureService.cs | 481 ++++++ docker-compose.yml | 2 + ...-vigilcare-records-phase-6-verification.sh | 943 +++++++++++ 30 files changed, 4547 insertions(+), 23 deletions(-) create mode 100644 VigilCareRecordsAPI.Tests/LiveCaptureIntegrationTests.cs create mode 100644 VigilCareRecordsAPI/Controllers/LiveCaptureController.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/Clinical/AlertThresholdConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/Clinical/ClinicalAlertConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260626191425_AddAlertThresholdsAndClinicalAlerts.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260626191425_AddAlertThresholdsAndClinicalAlerts.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/Clinical/AlertThreshold.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/Clinical/ClinicalAlert.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/AlertSeverity.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/AlertStatus.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/AlertType.cs create mode 100644 VigilCareRecordsAPI/Models/Records/LiveCapture/CriticalAlertResult.cs create mode 100644 VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureCriticalAlert.cs create mode 100644 VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureObservationRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureObservationResponse.cs create mode 100644 VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureResponse.cs create mode 100644 VigilCareRecordsAPI/Models/Records/LiveCapture/OpenEncounterWithVitalsRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/LiveCapture/RecordObservationsRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/LiveCapture/ThresholdCacheEntry.cs create mode 100644 VigilCareRecordsAPI/Services/AttestationService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IAttestationService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/ILiveCaptureService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/LiveCaptureService.cs create mode 100755 scripts/run-vigilcare-records-phase-6-verification.sh diff --git a/README.md b/README.md index dc2faf4..217a30b 100644 --- a/README.md +++ b/README.md @@ -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. -**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 @@ -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 - **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 +- **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` - **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) @@ -85,6 +86,8 @@ HTTP request ├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries) ├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001) ├── 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) ├── PlausibilityValidator (per-code numeric range guard) ├── 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) | | 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) | | Authentication | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) | | Password hashing | BCrypt.Net-Next | @@ -142,6 +145,7 @@ VigilCareRecordsAPI/ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit │ ├── 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 │ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval ├── Domain/ @@ -150,7 +154,9 @@ VigilCareRecordsAPI/ │ │ │ ├── Patient.cs # Live patient record with MRN (promoted from draft) │ │ │ ├── Encounter.cs # Live encounter (promoted from draft) │ │ │ ├── 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 │ │ ├── Draft/ │ │ │ ├── DraftPatient.cs # Structured patient demographics from paper chart @@ -171,9 +177,12 @@ VigilCareRecordsAPI/ │ ├── UserRole.cs # IntakeClerk, DataEntryClerk, Verifier, ClinicalApprover, Clinician, Administrator │ ├── DigitizationEventType.cs # 18 event types covering full lifecycle + corrections + promotion retry │ ├── 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/ -│ ├── 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 │ ├── 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) @@ -184,6 +193,8 @@ VigilCareRecordsAPI/ │ ├── MrnGenerator.cs # PostgreSQL sequence-backed MRN generation (VCR-000001) │ ├── WorkQueueService.cs # Verification, entry, and clinical approval work queues │ ├── 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) ├── Configurations/ │ ├── JwtOptions.cs # Issuer, audience, signing key, access/refresh token expiration @@ -197,18 +208,19 @@ VigilCareRecordsAPI/ │ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest │ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest │ ├── WorkQueue/ # WorkQueueResponse, WorkQueueItemResponse +│ ├── LiveCapture/ # LiveCaptureResponse, RecordObservationsRequest, ThresholdCacheEntry, ... │ └── Common/ # PagedResult ├── Data/ │ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints │ ├── Configurations/ -│ │ ├── Clinical/ # PatientConfiguration, EncounterConfiguration, ObservationConfiguration, OutboxEventConfiguration, IdempotencyRecordConfiguration +│ │ ├── Clinical/ # PatientConfiguration, EncounterConfiguration, ObservationConfiguration, OutboxEventConfiguration, AlertThresholdConfiguration, ClinicalAlertConfiguration, IdempotencyRecordConfiguration │ │ ├── Draft/ # DraftPatientConfiguration, DraftEncounterConfiguration, DraftObservationConfiguration │ │ ├── LiveEncounterConfiguration.cs # live_encounters table mapping │ │ ├── LiveObservationConfiguration.cs # live_observations with supersession columns and partial index │ │ └── ... # DigitizationBatch, DigitizationEvent, ScannedDocument, User, RefreshToken, AuthAuditEvent configs │ ├── Seed/ │ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup -│ └── Migrations/ # InitialCreate through AddLiveObservationAndLiveEncounter +│ └── Migrations/ # InitialCreate through AddAlertThresholdsAndClinicalAlerts ├── Common/ │ ├── ApiResponse.cs # { success, statusCode, data, error } envelope │ └── Exceptions/ @@ -230,6 +242,7 @@ tests/ ├── VerificationTests.cs # Verification, rejection, separation of duties, clinical approval routing ├── PromotionTests.cs # Approval, atomic promotion, idempotency, separation of duties, retroactive alerts, patient dedup ├── CorrectionSupersessionTests.cs # Correction batch supersession, validation guards, patient digitization history + ├── LiveCaptureIntegrationTests.cs # Track B attestation, synchronous promotion, critical alerts, open encounter workflow ├── Fixtures/ │ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers │ └── 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-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-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/ ├── 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) -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 @@ -376,7 +390,7 @@ docker compose up -d |---|---|---| | PostgreSQL 16 | 5437 | Database: `vigilcare_records`, user: `postgres`, password: `password` | | 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` | ### 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 | | `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 | +| `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 @@ -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-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-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 | | 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 @@ -759,7 +841,7 @@ uploadedAt DateTimeOffset ``` id Guid PK 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 occurredAt DateTimeOffset metadataJson string? optional JSON (field checks, rejection reason, assigned-to) @@ -838,8 +920,8 @@ createdAt DateTimeOffset ``` id Guid PK -eventType string e.g. observation.created -aggregateType string e.g. Observation +eventType string e.g. observation.created | observation.recorded | alert.generated +aggregateType string e.g. Observation | ClinicalAlert aggregateId Guid FK → the created entity payloadJson string full event payload for downstream consumers createdAt DateTimeOffset @@ -847,6 +929,36 @@ processedAt DateTimeOffset? set when consumed 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 ``` @@ -922,7 +1034,7 @@ Response shape: ## 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 | |---|---|---| @@ -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 | | 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 | -| 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 | | 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Planned | | 9 | Seed data, E2E verification script, clinical scenario documentation | Planned | diff --git a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs index 4cc6319..44f301a 100644 --- a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs +++ b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs @@ -8,12 +8,13 @@ public static class DbResetHelper { await db.Database.ExecuteSqlRawAsync(@" TRUNCATE TABLE live_observations, live_encounters, - clinical.outbox_events, clinical.observations, - clinical.encounters, clinical.patients, - idempotency_records, - digitization_events, draft_observations, - draft_encounters, draft_patients, - scanned_documents, digitization_batches, users + clinical.clinical_alerts, clinical.alert_thresholds, + clinical.outbox_events, clinical.observations, + clinical.encounters, clinical.patients, + idempotency_records, + digitization_events, draft_observations, + draft_encounters, draft_patients, + scanned_documents, digitization_batches, users RESTART IDENTITY CASCADE; "); } diff --git a/VigilCareRecordsAPI.Tests/LiveCaptureIntegrationTests.cs b/VigilCareRecordsAPI.Tests/LiveCaptureIntegrationTests.cs new file mode 100644 index 0000000..f4dd543 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/LiveCaptureIntegrationTests.cs @@ -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(); + 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 + { + 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>(); + 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(); + 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 + { + 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>(); + 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(); + + 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 + { + 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>(); + + 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 + { + 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>(); + + 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 + { + 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>(); + + 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(); + 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 + { + 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 + { + 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>(); + 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 + { + 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>(); + 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 + { + 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>(); + 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 + { + 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>(); + 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(), + 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>(); + 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 + { + 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>(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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 CreateTestPatientAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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(); + + 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(); + + 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(); + var redis = scope.ServiceProvider.GetRequiredService(); + + // 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)); + } + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/LiveCaptureController.cs b/VigilCareRecordsAPI/Controllers/LiveCaptureController.cs new file mode 100644 index 0000000..55949d7 --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/LiveCaptureController.cs @@ -0,0 +1,88 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +/// +/// 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. +/// +[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; + + /// + /// Records observations against an existing VigilCareClinical encounter. + /// Requires clinician attestation and password re-confirmation. + /// Returns promoted observation IDs and any synchronous critical alerts. + /// + /// + /// 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. + /// + [HttpPost("encounters/{encounterId:guid}/observations")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task 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.Created(result)); + } + + /// + /// 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. + /// + /// + /// 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. + /// + [HttpPost("encounters")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task OpenEncounterWithVitals( + [FromBody] OpenEncounterWithVitalsRequest request) + { + var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var result = await _liveCapture.OpenEncounterWithVitalsAsync( + request, clinicianUserId); + + return StatusCode(201, ApiResponse.Created(result)); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/AppDbContext.cs b/VigilCareRecordsAPI/Data/AppDbContext.cs index 95a63ab..a80b5f4 100644 --- a/VigilCareRecordsAPI/Data/AppDbContext.cs +++ b/VigilCareRecordsAPI/Data/AppDbContext.cs @@ -18,6 +18,8 @@ public class AppDbContext : DbContext public DbSet Encounters => Set(); public DbSet Observations => Set(); public DbSet OutboxEvents => Set(); + public DbSet AlertThresholds => Set(); + public DbSet ClinicalAlerts => Set(); public DbSet LiveEncounters => Set(); public DbSet LiveObservations => Set(); diff --git a/VigilCareRecordsAPI/Data/Configurations/Clinical/AlertThresholdConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Clinical/AlertThresholdConfiguration.cs new file mode 100644 index 0000000..ccca5bf --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/Clinical/AlertThresholdConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class AlertThresholdConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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(); + } +} diff --git a/VigilCareRecordsAPI/Data/Configurations/Clinical/ClinicalAlertConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/Clinical/ClinicalAlertConfiguration.cs new file mode 100644 index 0000000..c813dc5 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/Clinical/ClinicalAlertConfiguration.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ClinicalAlertConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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"); + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260626191425_AddAlertThresholdsAndClinicalAlerts.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260626191425_AddAlertThresholdsAndClinicalAlerts.Designer.cs new file mode 100644 index 0000000..ec62ede --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260626191425_AddAlertThresholdsAndClinicalAlerts.Designer.cs @@ -0,0 +1,1400 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260626191425_AddAlertThresholdsAndClinicalAlerts")] + partial class AddAlertThresholdsAndClinicalAlerts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", "clinical"); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("ClientAlertId") + .HasColumnType("uuid") + .HasColumnName("client_alert_id"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("FeedbackReceived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("feedback_received"); + + b.Property("ObservationCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("SyncedFromGateway") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("synced_from_gateway"); + + b.Property("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 => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status") + .HasDatabaseName("ix_encounters_patient_status"); + + b.ToTable("encounters", "clinical", t => + { + t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("http_status_code"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("OperationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("operation_name"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_body_json"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_idempotency_records_expires_at"); + + b.HasIndex("IdempotencyKey", "OperationName") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status"); + + b.ToTable("live_encounters", null, t => + { + t.HasCheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("LiveObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsSuperseded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_superseded"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SupersededAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_at"); + + b.Property("SupersededByBatchId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_batch_id"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IsSuperseded") + .HasFilter("is_superseded = true"); + + b.HasIndex("SourceBatchId"); + + b.HasIndex("EncounterId", "ObservationCode"); + + b.ToTable("live_observations", (string)null); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SourceDraftObservationId") + .HasColumnType("uuid") + .HasColumnName("source_draft_observation_id"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("PatientId"); + + b.HasIndex("SourceBatchId") + .HasDatabaseName("ix_observations_source_batch") + .HasFilter("source_batch_id IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode") + .HasDatabaseName("ix_observations_encounter_code"); + + b.ToTable("observations", "clinical"); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_outbox_events_unprocessed") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", "clinical"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique() + .HasDatabaseName("ix_patients_mrn"); + + b.ToTable("patients", "clinical", t => + { + t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.HasOne("User", "ApprovedByUser") + .WithMany() + .HasForeignKey("ApprovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "EnteredByUser") + .WithMany() + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "VerifiedByUser") + .WithMany() + .HasForeignKey("VerifiedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovedByUser"); + + b.Navigation("EnteredByUser"); + + b.Navigation("VerifiedByUser"); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.HasOne("User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany("Events") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftEncounter") + .HasForeignKey("DraftEncounter", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany("DraftObservations") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftPatient") + .HasForeignKey("DraftPatient", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.HasOne("Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("LiveObservation", b => + { + b.HasOne("LiveEncounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("Document") + .HasForeignKey("ScannedDocument", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Navigation("Document"); + + b.Navigation("DraftEncounter"); + + b.Navigation("DraftObservations"); + + b.Navigation("DraftPatient"); + + b.Navigation("Events"); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.Navigation("Observations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260626191425_AddAlertThresholdsAndClinicalAlerts.cs b/VigilCareRecordsAPI/Data/Migrations/20260626191425_AddAlertThresholdsAndClinicalAlerts.cs new file mode 100644 index 0000000..a62c11d --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260626191425_AddAlertThresholdsAndClinicalAlerts.cs @@ -0,0 +1,119 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class AddAlertThresholdsAndClinicalAlerts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "alert_thresholds", + schema: "clinical", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + observation_code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + unit = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + critical_low = table.Column(type: "numeric(10,3)", nullable: true), + warning_low = table.Column(type: "numeric(10,3)", nullable: true), + warning_high = table.Column(type: "numeric(10,3)", nullable: true), + critical_high = table.Column(type: "numeric(10,3)", nullable: true), + suppression_window_minutes = table.Column(type: "integer", nullable: true), + created_at = table.Column(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(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + encounter_id = table.Column(type: "uuid", nullable: false), + patient_id = table.Column(type: "uuid", nullable: false), + observation_id = table.Column(type: "uuid", nullable: true), + alert_type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + severity = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + details = table.Column(type: "text", nullable: false), + observation_code = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'OPEN'"), + acknowledged_at = table.Column(type: "timestamp with time zone", nullable: true), + acknowledged_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + resolved_at = table.Column(type: "timestamp with time zone", nullable: true), + triggered_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + client_alert_id = table.Column(type: "uuid", nullable: true), + synced_from_gateway = table.Column(type: "boolean", nullable: false, defaultValue: false), + feedback_received = table.Column(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'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "alert_thresholds", + schema: "clinical"); + + migrationBuilder.DropTable( + name: "clinical_alerts", + schema: "clinical"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs index 4574230..523ea9e 100644 --- a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs @@ -21,6 +21,66 @@ namespace VigilCareRecordsAPI.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", "clinical"); + }); + modelBuilder.Entity("AuthAuditEvent", b => { b.Property("Id") @@ -59,6 +119,117 @@ namespace VigilCareRecordsAPI.Data.Migrations }); }); + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("ClientAlertId") + .HasColumnType("uuid") + .HasColumnName("client_alert_id"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("FeedbackReceived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("feedback_received"); + + b.Property("ObservationCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("SyncedFromGateway") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("synced_from_gateway"); + + b.Property("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 => { b.Property("Id") diff --git a/VigilCareRecordsAPI/Domain/Entities/Clinical/AlertThreshold.cs b/VigilCareRecordsAPI/Domain/Entities/Clinical/AlertThreshold.cs new file mode 100644 index 0000000..d534e11 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/Clinical/AlertThreshold.cs @@ -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; } +} diff --git a/VigilCareRecordsAPI/Domain/Entities/Clinical/ClinicalAlert.cs b/VigilCareRecordsAPI/Domain/Entities/Clinical/ClinicalAlert.cs new file mode 100644 index 0000000..b0c171b --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/Clinical/ClinicalAlert.cs @@ -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; } +} diff --git a/VigilCareRecordsAPI/Domain/Enums/AlertSeverity.cs b/VigilCareRecordsAPI/Domain/Enums/AlertSeverity.cs new file mode 100644 index 0000000..1fca037 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/AlertSeverity.cs @@ -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}'") + }; +} diff --git a/VigilCareRecordsAPI/Domain/Enums/AlertStatus.cs b/VigilCareRecordsAPI/Domain/Enums/AlertStatus.cs new file mode 100644 index 0000000..0e6f642 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/AlertStatus.cs @@ -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}'") + }; +} diff --git a/VigilCareRecordsAPI/Domain/Enums/AlertType.cs b/VigilCareRecordsAPI/Domain/Enums/AlertType.cs new file mode 100644 index 0000000..a14ba1e --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/AlertType.cs @@ -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 + }; +} diff --git a/VigilCareRecordsAPI/Models/Records/LiveCapture/CriticalAlertResult.cs b/VigilCareRecordsAPI/Models/Records/LiveCapture/CriticalAlertResult.cs new file mode 100644 index 0000000..329b28d --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/LiveCapture/CriticalAlertResult.cs @@ -0,0 +1,11 @@ +/// +/// Internal result type for critical alert evaluation. Not exposed in API responses; +/// mapped to LiveCaptureCriticalAlert in the response builder. +/// +internal record CriticalAlertResult( + Guid Id, + string Severity, + string Message, + decimal ThresholdValue, + string ThresholdBound +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureCriticalAlert.cs b/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureCriticalAlert.cs new file mode 100644 index 0000000..0c1f9be --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureCriticalAlert.cs @@ -0,0 +1,11 @@ +/// +/// A critical alert that was generated synchronously during live capture promotion. +/// Returned inline so the clinician sees the alert before the HTTP response completes. +/// +public record LiveCaptureCriticalAlert( + Guid AlertId, + string Severity, + string Message, + decimal ThresholdValue, + string ThresholdBound +); diff --git a/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureObservationRequest.cs b/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureObservationRequest.cs new file mode 100644 index 0000000..f464ff6 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureObservationRequest.cs @@ -0,0 +1,10 @@ +/// +/// A single observation entered at bedside by a credentialed clinician. +/// +public record LiveCaptureObservationRequest( + string ObservationCode, + decimal Value, + string Unit, + DateTimeOffset RecordedAt, + string? Note +); diff --git a/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureObservationResponse.cs b/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureObservationResponse.cs new file mode 100644 index 0000000..3c27833 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureObservationResponse.cs @@ -0,0 +1,13 @@ +/// +/// Response for a single promoted observation, including any synchronous +/// critical alert generated during promotion. +/// +public record LiveCaptureObservationResponse( + Guid DraftObservationId, + Guid LiveObservationId, + string ObservationCode, + decimal Value, + string Unit, + DateTimeOffset RecordedAt, + LiveCaptureCriticalAlert? CriticalAlert +); diff --git a/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureResponse.cs b/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureResponse.cs new file mode 100644 index 0000000..4793311 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/LiveCapture/LiveCaptureResponse.cs @@ -0,0 +1,12 @@ +/// +/// 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. +/// +public record LiveCaptureResponse( + Guid BatchId, + Guid EncounterId, + IReadOnlyList Observations, + int CriticalAlertCount, + DateTimeOffset PromotedAt +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/LiveCapture/OpenEncounterWithVitalsRequest.cs b/VigilCareRecordsAPI/Models/Records/LiveCapture/OpenEncounterWithVitalsRequest.cs new file mode 100644 index 0000000..dd7a6a9 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/LiveCapture/OpenEncounterWithVitalsRequest.cs @@ -0,0 +1,13 @@ +/// +/// Opens a new encounter and records initial vitals in a single request. +/// Used for outpatient workflows where the encounter does not yet exist. +/// +public record OpenEncounterWithVitalsRequest( + Guid PatientId, + string Department, + string? RoomBed, + string AdmissionReason, + List Observations, + bool ClinicianAttestation, + string PasswordConfirm +); diff --git a/VigilCareRecordsAPI/Models/Records/LiveCapture/RecordObservationsRequest.cs b/VigilCareRecordsAPI/Models/Records/LiveCapture/RecordObservationsRequest.cs new file mode 100644 index 0000000..16a5c48 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/LiveCapture/RecordObservationsRequest.cs @@ -0,0 +1,9 @@ +/// +/// Records one or more observations against an existing VigilCareClinical encounter. +/// Requires clinician attestation and password re-confirmation. +/// +public record RecordObservationsRequest( + List Observations, + bool ClinicianAttestation, + string PasswordConfirm +); diff --git a/VigilCareRecordsAPI/Models/Records/LiveCapture/ThresholdCacheEntry.cs b/VigilCareRecordsAPI/Models/Records/LiveCapture/ThresholdCacheEntry.cs new file mode 100644 index 0000000..652f9b9 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/LiveCapture/ThresholdCacheEntry.cs @@ -0,0 +1,6 @@ +public record ThresholdCacheEntry( + string ObservationCode, + decimal? CriticalLow, + decimal? WarningLow, + decimal? WarningHigh, + decimal? CriticalHigh); diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index 59fbb2a..cbca9aa 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -66,6 +66,8 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); diff --git a/VigilCareRecordsAPI/Services/AttestationService.cs b/VigilCareRecordsAPI/Services/AttestationService.cs new file mode 100644 index 0000000..817ff77 --- /dev/null +++ b/VigilCareRecordsAPI/Services/AttestationService.cs @@ -0,0 +1,55 @@ +public class AttestationService : IAttestationService +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public AttestationService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task 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; + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IAttestationService.cs b/VigilCareRecordsAPI/Services/Interfaces/IAttestationService.cs new file mode 100644 index 0000000..ba97f38 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IAttestationService.cs @@ -0,0 +1,12 @@ +public interface IAttestationService +{ + /// + /// Validates that the user is a credentialed clinician and that the + /// password re-confirm matches their stored hash. Throws on failure. + /// + /// The authenticated user's ID from JWT claims. + /// Must be true; false throws ValidationException. + /// Raw password for re-confirmation. + /// The validated User entity. + Task ValidateAttestationAsync(Guid userId, bool clinicianAttestation, string passwordConfirm); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/ILiveCaptureService.cs b/VigilCareRecordsAPI/Services/Interfaces/ILiveCaptureService.cs new file mode 100644 index 0000000..7d51734 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/ILiveCaptureService.cs @@ -0,0 +1,20 @@ +public interface ILiveCaptureService +{ + /// + /// 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. + /// + Task RecordObservationsAsync( + Guid encounterId, + RecordObservationsRequest request, + Guid clinicianUserId); + + /// + /// Opens a new encounter in VigilCareClinical and records initial vitals + /// in a single atomic operation. Used for outpatient workflows. + /// + Task OpenEncounterWithVitalsAsync( + OpenEncounterWithVitalsRequest request, + Guid clinicianUserId); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/LiveCaptureService.cs b/VigilCareRecordsAPI/Services/Interfaces/LiveCaptureService.cs new file mode 100644 index 0000000..9133bb7 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/LiveCaptureService.cs @@ -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 _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 logger) + { + _db = db; + _attestation = attestation; + _redis = redis; + _logger = logger; + } + + public async Task 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 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); + } + + /// + /// 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. + /// + private async Task PromoteSynchronouslyAsync( + Guid encounterId, Guid patientId, User clinician, + List observations) + { + var now = DateTimeOffset.UtcNow; + var batchId = Guid.NewGuid(); + var observationResponses = new List(); + 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; + } + } + + /// + /// 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. + /// + private async Task 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 LoadThresholdAsync( + StackExchange.Redis.IDatabase cache, string observationCode) + { + var cacheKey = $"{ThresholdCachePrefix}{observationCode}"; + var cached = await cache.StringGetAsync(cacheKey); + if (cached.HasValue) + return JsonSerializer.Deserialize(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}"; + } + + /// + /// Computes a deterministic hash for live capture batches (no scanned document). + /// Uses clinician ID, encounter ID, and timestamp to generate uniqueness. + /// + 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(); + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 030a13c..4470e80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ services: image: datalust/seq:latest environment: ACCEPT_EULA: "Y" + # Required on first run for Seq 2025+ (CreateAdminUser migration) + SEQ_FIRSTRUN_ADMINPASSWORD: seqadmin ports: - "5346:80" volumes: diff --git a/scripts/run-vigilcare-records-phase-6-verification.sh b/scripts/run-vigilcare-records-phase-6-verification.sh new file mode 100755 index 0000000..69b4dec --- /dev/null +++ b/scripts/run-vigilcare-records-phase-6-verification.sh @@ -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: /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:-})" + 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:-})" + 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:-})" + 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:-})" + 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:-})" + 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:-})" + 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:-})" + 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:-})" + 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:-})" + 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 "$@"