feature: Live Capture (Track B)

This commit is contained in:
voltsrage
2026-06-27 04:23:51 +08:00
parent 01000a2489
commit 88e70b3dbe
30 changed files with 4547 additions and 23 deletions
+129 -17
View File
@@ -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 19 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 15.
Six phases from the project roadmap are implemented and verified. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 16.
| 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 |
@@ -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;
");
}
@@ -0,0 +1,630 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Database")]
public class LiveCaptureIntegrationTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private HttpClient _client = null!;
public LiveCaptureIntegrationTests(ApiFixture fixture) => _fixture = fixture;
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedAsync(db);
_client = _fixture.CreateClient();
}
public Task DisposeAsync() => Task.CompletedTask;
// ───────────────────────────────────────────────
// Happy path: normal observations, no alert
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_NormalValues_PromotesSynchronouslyWithNoAlert()
{
// Arrange — create patient and encounter via setup
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (patientId, encounterId) = await CreateTestPatientAndEncounterAsync();
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null),
new("TEMP_C", 36.8m, "C", DateTimeOffset.UtcNow, null),
new("SPO2", 98m, "%", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
Assert.True(body!.Success);
Assert.Equal(encounterId, body.Data!.EncounterId);
Assert.Equal(3, body.Data.Observations.Count);
Assert.Equal(0, body.Data.CriticalAlertCount);
// Verify all observations have live IDs
foreach (var obs in body.Data.Observations)
{
Assert.NotEqual(Guid.Empty, obs.LiveObservationId);
Assert.NotEqual(Guid.Empty, obs.DraftObservationId);
Assert.Null(obs.CriticalAlert);
}
// Verify batch exists in database with Promoted status
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var batch = await db.DigitizationBatches.FindAsync(body.Data.BatchId);
Assert.NotNull(batch);
Assert.Equal(BatchStatus.Promoted, batch!.Status);
Assert.Equal(BatchTrack.LiveCapture, batch.Track);
Assert.True(batch.ClinicianAttestation);
Assert.NotNull(batch.PromotedAt);
}
// ───────────────────────────────────────────────
// Critical path: alert fires BEFORE response returns
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_CriticalPotassium_AlertFiresBeforeResponseReturns()
{
// Arrange
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (patientId, encounterId) = await CreateTestPatientAndEncounterAsync();
// Seed a critical threshold for potassium:
// critical_low = 2.5, warning_low = 3.5, warning_high = 5.0, critical_high = 6.5
await SeedPotassiumThresholdAsync();
// Value of 2.1 is below critical_low of 2.5 — life-threatening
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", DateTimeOffset.UtcNow,
"Bedside iSTAT result")
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
// Assert — response contains the alert inline
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
Assert.True(body!.Success);
Assert.Equal(1, body.Data!.CriticalAlertCount);
Assert.Single(body.Data.Observations);
var obsResponse = body.Data.Observations[0];
Assert.NotNull(obsResponse.CriticalAlert);
Assert.Equal("CRITICAL", obsResponse.CriticalAlert!.Severity);
Assert.Equal("CRITICAL_LOW", obsResponse.CriticalAlert.ThresholdBound);
Assert.Equal(2.5m, obsResponse.CriticalAlert.ThresholdValue);
Assert.Contains("below critical low", obsResponse.CriticalAlert.Message);
// Verify the alert exists in the database BEFORE we read anything else —
// the alert was committed in the same transaction as the observation
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts.FindAsync(obsResponse.CriticalAlert.AlertId);
Assert.NotNull(alert);
Assert.Equal(AlertStatus.Open, alert!.Status);
Assert.Equal(AlertSeverity.Critical, alert.Severity);
Assert.Equal(AlertType.CriticalPotassiumMeqL, alert.AlertType);
Assert.Contains("below critical low", alert.Details);
// Verify outbox event for alert was written in the same transaction
var alertOutbox = await db.OutboxEvents
.Where(e => e.EventType == "alert.generated" &&
e.AggregateId == obsResponse.CriticalAlert.AlertId)
.FirstOrDefaultAsync();
Assert.NotNull(alertOutbox);
Assert.Contains(obsResponse.CriticalAlert.AlertId.ToString(), alertOutbox!.PayloadJson);
}
// ───────────────────────────────────────────────
// Critical high: value above critical_high threshold
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_CriticalHighPotassium_AlertWithCorrectBound()
{
// Arrange
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
await SeedPotassiumThresholdAsync();
// Value of 7.2 is above critical_high of 6.5
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("POTASSIUM_MEQ_L", 7.2m, "mEq/L", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
var alert = body!.Data!.Observations[0].CriticalAlert;
Assert.NotNull(alert);
Assert.Equal("CRITICAL_HIGH", alert!.ThresholdBound);
Assert.Equal(6.5m, alert.ThresholdValue);
Assert.Contains("above critical high", alert.Message);
}
// ───────────────────────────────────────────────
// Mixed batch: one critical, two normal
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_MixedBatch_OnlyCriticalObservationGetsAlert()
{
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
await SeedPotassiumThresholdAsync();
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 80m, "bpm", DateTimeOffset.UtcNow, null),
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", DateTimeOffset.UtcNow, null),
new("TEMP_C", 37.0m, "C", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
Assert.Equal(1, body!.Data!.CriticalAlertCount);
Assert.Null(body.Data.Observations[0].CriticalAlert); // heart rate — normal
Assert.NotNull(body.Data.Observations[1].CriticalAlert); // potassium — critical
Assert.Null(body.Data.Observations[2].CriticalAlert); // temp — normal
}
// ───────────────────────────────────────────────
// Open encounter + vitals in one request
// ───────────────────────────────────────────────
[Fact]
public async Task OpenEncounterWithVitals_CreatesEncounterAndPromotesObservations()
{
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var patientId = await CreateTestPatientAsync();
var request = new OpenEncounterWithVitalsRequest(
PatientId: patientId,
Department: "Outpatient Clinic",
RoomBed: "OPD-3",
AdmissionReason: "Follow-up consultation",
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 68m, "bpm", DateTimeOffset.UtcNow, null),
new("BP_SYSTOLIC", 120m, "mmHg", DateTimeOffset.UtcNow, null),
new("BP_DIASTOLIC", 80m, "mmHg", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
var response = await _client.PostAsJsonAsync(
"/api/v1/live-capture/encounters", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
Assert.True(body!.Success);
Assert.NotEqual(Guid.Empty, body.Data!.EncounterId);
Assert.Equal(3, body.Data.Observations.Count);
Assert.Equal(0, body.Data.CriticalAlertCount);
// Verify encounter was created in VigilCareClinical
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var encounter = await db.Encounters.FindAsync(body.Data.EncounterId);
Assert.NotNull(encounter);
Assert.Equal("active", encounter!.Status);
Assert.Equal(Department.OutpatientClinic, encounter.Department);
}
// ───────────────────────────────────────────────
// Authorization: non-clinician role rejected
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_NonClinicianRole_Returns403()
{
_client = await AuthHelper.LoginAsync(_fixture, "entry1");
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{Guid.NewGuid()}/observations", request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
// ───────────────────────────────────────────────
// Attestation: false attestation rejected
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_AttestationFalse_Returns422()
{
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: false, // Must be true
PasswordConfirm: "password"
);
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<object>>();
Assert.Equal("ATTESTATION_REQUIRED", body!.Error!.Code);
}
// ───────────────────────────────────────────────
// Attestation: wrong password rejected
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_WrongPassword_Returns422()
{
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "wrong-password" // Incorrect
);
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<object>>();
Assert.Equal("PASSWORD_CONFIRM_INVALID", body!.Error!.Code);
}
// ───────────────────────────────────────────────
// Edge case: discharged encounter rejected
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_DischargedEncounter_Returns409()
{
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (_, encounterId) = await CreateTestPatientAndDischargedEncounterAsync();
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<object>>();
Assert.Equal("ENCOUNTER_NOT_ACTIVE", body!.Error!.Code);
}
// ───────────────────────────────────────────────
// Edge case: duplicate active encounter
// ───────────────────────────────────────────────
[Fact]
public async Task OpenEncounterWithVitals_PatientHasActiveEncounter_Returns409()
{
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (patientId, _) = await CreateTestPatientAndEncounterAsync();
var request = new OpenEncounterWithVitalsRequest(
PatientId: patientId,
Department: Department.EmergencyDepartment.ToDbString(),
RoomBed: "ER-1",
AdmissionReason: "Chest pain",
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 90m, "bpm", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
var response = await _client.PostAsJsonAsync(
"/api/v1/live-capture/encounters", request);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<object>>();
Assert.Equal("ACTIVE_ENCOUNTER_EXISTS", body!.Error!.Code);
}
// ───────────────────────────────────────────────
// Edge case: empty observations rejected
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_EmptyList_Returns422()
{
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>(),
ClinicianAttestation: true,
PasswordConfirm: "password"
);
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<object>>();
Assert.Equal("EMPTY_OBSERVATIONS", body!.Error!.Code);
}
// ───────────────────────────────────────────────
// Audit trail: verify digitization events written
// ───────────────────────────────────────────────
[Fact]
public async Task RecordObservations_WritesAttestationAndPromotionEvents()
{
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
var request = new RecordObservationsRequest(
Observations: new List<LiveCaptureObservationRequest>
{
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
},
ClinicianAttestation: true,
PasswordConfirm: "password"
);
var response = await _client.PostAsJsonAsync(
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var events = await db.DigitizationEvents
.Where(e => e.BatchId == body!.Data!.BatchId)
.OrderBy(e => e.OccurredAt)
.ToListAsync();
Assert.Equal(2, events.Count);
Assert.Equal(DigitizationEventType.LiveCaptureAttested, events[0].EventType);
Assert.Equal(DigitizationEventType.Promoted, events[1].EventType);
// Verify metadata contains clinician info
Assert.Contains("clinicianName", events[0].MetadataJson!);
Assert.Contains("LIVE_CAPTURE", events[0].MetadataJson!);
}
// ───────────────────────────────────────────────
// Helpers
// ───────────────────────────────────────────────
private async Task<Guid> CreateTestPatientAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var patient = new Patient
{
Id = Guid.NewGuid(),
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
FullName = "Test Patient",
DateOfBirth = new DateOnly(1985, 3, 15),
Sex = "M",
BloodType = BloodType.OPos,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
await db.SaveChangesAsync();
return patient.Id;
}
private async Task<(Guid patientId, Guid encounterId)> CreateTestPatientAndEncounterAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var patient = new Patient
{
Id = Guid.NewGuid(),
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
FullName = "Test Patient",
DateOfBirth = new DateOnly(1985, 3, 15),
Sex = "M",
BloodType = BloodType.OPos,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
var encounter = new Encounter
{
Id = Guid.NewGuid(),
PatientId = patient.Id,
Department = Department.InternalMedicine,
RoomBed = "IM-201A",
AdmissionReason = "Observation",
Status = "active",
AdmissionDate = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
await db.SaveChangesAsync();
return (patient.Id, encounter.Id);
}
private async Task<(Guid patientId, Guid encounterId)> CreateTestPatientAndDischargedEncounterAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var patient = new Patient
{
Id = Guid.NewGuid(),
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
FullName = "Discharged Patient",
DateOfBirth = new DateOnly(1970, 7, 20),
Sex = "F",
BloodType = BloodType.ANeg,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
var encounter = new Encounter
{
Id = Guid.NewGuid(),
PatientId = patient.Id,
Department = Department.Surgery,
RoomBed = "SURG-105",
AdmissionReason = "Post-op recovery",
Status = "discharged",
AdmissionDate = DateTimeOffset.UtcNow.AddDays(-3),
CreatedAt = DateTimeOffset.UtcNow.AddDays(-3),
UpdatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
await db.SaveChangesAsync();
return (patient.Id, encounter.Id);
}
private async Task SeedPotassiumThresholdAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
// Only seed if not already present
var existing = await db.AlertThresholds
.FirstOrDefaultAsync(t => t.ObservationCode == "POTASSIUM_MEQ_L");
if (existing is null)
{
var threshold = new AlertThreshold
{
Id = Guid.NewGuid(),
ObservationCode = "POTASSIUM_MEQ_L",
DisplayName = "Serum Potassium",
Unit = "mEq/L",
CriticalLow = 2.5m,
WarningLow = 3.5m,
WarningHigh = 5.0m,
CriticalHigh = 6.5m,
CreatedAt = DateTimeOffset.UtcNow
};
db.AlertThresholds.Add(threshold);
await db.SaveChangesAsync();
// Pre-warm Redis cache with ThresholdCacheEntry (same shape as LiveCaptureService)
var cache = redis.GetDatabase();
await cache.StringSetAsync(
"threshold:POTASSIUM_MEQ_L",
JsonSerializer.Serialize(new ThresholdCacheEntry(
threshold.ObservationCode,
threshold.CriticalLow,
threshold.WarningLow,
threshold.WarningHigh,
threshold.CriticalHigh)),
TimeSpan.FromMinutes(30));
}
}
}
@@ -0,0 +1,88 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Track B live capture endpoints for credentialed clinicians.
/// Observations entered via these endpoints skip the verification queue
/// and are promoted synchronously to VigilCareClinical with full
/// critical value alerting.
/// </summary>
[ApiController]
[Route("api/v1/live-capture")]
[Produces("application/json")]
[Authorize(Roles = "CLINICIAN")]
public class LiveCaptureController : ControllerBase
{
private readonly ILiveCaptureService _liveCapture;
public LiveCaptureController(ILiveCaptureService liveCapture) =>
_liveCapture = liveCapture;
/// <summary>
/// Records observations against an existing VigilCareClinical encounter.
/// Requires clinician attestation and password re-confirmation.
/// Returns promoted observation IDs and any synchronous critical alerts.
/// </summary>
/// <remarks>
/// Track B workflow: no verification queue. Clinician attestation replaces
/// the dual-human gate used in Track A (backfill). Each observation is
/// promoted immediately and evaluated against critical alert thresholds
/// before this response returns.
///
/// Critical alerts are generated synchronously — a critical potassium
/// value will have an open ClinicalAlert row in the database before
/// the 201 response reaches the clinician's tablet.
/// </remarks>
[HttpPost("encounters/{encounterId:guid}/observations")]
[ProducesResponseType(typeof(ApiResponse<LiveCaptureResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> RecordObservations(
Guid encounterId,
[FromBody] RecordObservationsRequest request)
{
var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var result = await _liveCapture.RecordObservationsAsync(
encounterId, request, clinicianUserId);
return StatusCode(201, ApiResponse<LiveCaptureResponse>.Created(result));
}
/// <summary>
/// Opens a new encounter and records initial vitals in a single request.
/// Designed for outpatient workflows where the encounter does not yet
/// exist in VigilCareClinical.
/// </summary>
/// <remarks>
/// Creates the encounter with status "active", records all observations,
/// promotes immediately, and evaluates critical thresholds — all within
/// a single database transaction.
///
/// If the patient already has an active encounter, returns 409 with
/// ACTIVE_ENCOUNTER_EXISTS. Use the observation-only endpoint instead.
/// </remarks>
[HttpPost("encounters")]
[ProducesResponseType(typeof(ApiResponse<LiveCaptureResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> OpenEncounterWithVitals(
[FromBody] OpenEncounterWithVitalsRequest request)
{
var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var result = await _liveCapture.OpenEncounterWithVitalsAsync(
request, clinicianUserId);
return StatusCode(201, ApiResponse<LiveCaptureResponse>.Created(result));
}
}
+2
View File
@@ -18,6 +18,8 @@ public class AppDbContext : DbContext
public DbSet<Encounter> Encounters => Set<Encounter>();
public DbSet<Observation> Observations => Set<Observation>();
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
public DbSet<AlertThreshold> AlertThresholds => Set<AlertThreshold>();
public DbSet<ClinicalAlert> ClinicalAlerts => Set<ClinicalAlert>();
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class AlertThresholdConfiguration : IEntityTypeConfiguration<AlertThreshold>
{
public void Configure(EntityTypeBuilder<AlertThreshold> builder)
{
builder.ToTable("alert_thresholds", "clinical");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(t => t.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
builder.Property(t => t.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
builder.Property(t => t.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
builder.Property(t => t.CriticalLow).HasColumnName("critical_low").HasColumnType("decimal(10,3)");
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
builder.Property(t => t.SuppressionWindowMinutes).HasColumnName("suppression_window_minutes");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(t => t.ObservationCode).IsUnique();
}
}
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert>
{
public void Configure(EntityTypeBuilder<ClinicalAlert> builder)
{
builder.ToTable("clinical_alerts", "clinical", t =>
{
t.HasCheckConstraint("chk_clinical_alerts_severity",
"severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status",
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
t.HasCheckConstraint("chk_clinical_alerts_alert_type",
"alert_type IN (" +
"'SEPSIS_WARNING', " +
"'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', " +
"'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " +
"'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " +
"'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', " +
"'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', " +
"'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', " +
"'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', " +
"'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " +
"'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " +
"'WARNING_GLUCOSE_MG_DL', " +
"'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', " +
"'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', " +
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
"'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', " +
"'GCS_CRITICAL', 'GCS_WARNING', " +
"'SOFA_SEPSIS', 'SOFA_WARNING')");
});
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(a => a.EncounterId).HasColumnName("encounter_id");
builder.Property(a => a.PatientId).HasColumnName("patient_id");
builder.Property(a => a.ObservationId).HasColumnName("observation_id");
builder.Property(a => a.AlertType)
.HasColumnName("alert_type")
.HasMaxLength(50)
.HasConversion(
v => v.ToDbString(),
v => AlertTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(a => a.Severity)
.HasColumnName("severity")
.HasMaxLength(20)
.HasConversion(
v => v.ToDbString(),
v => AlertSeverityExtensions.FromDbString(v))
.IsRequired();
builder.Property(a => a.Details).HasColumnName("details").IsRequired();
builder.Property(a => a.ObservationCode).HasColumnName("observation_code").HasMaxLength(50);
builder.Property(a => a.Status)
.HasColumnName("status")
.HasMaxLength(20)
.HasConversion(
v => v.ToDbString(),
v => AlertStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'OPEN'")
.HasSentinel((AlertStatus)(-1));
builder.Property(a => a.AcknowledgedAt).HasColumnName("acknowledged_at");
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id");
builder.Property(a => a.SyncedFromGateway).HasColumnName("synced_from_gateway").HasDefaultValue(false);
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
builder.Property(a => a.FeedbackReceived).HasColumnName("feedback_received").HasDefaultValue(false);
builder.HasIndex(a => new { a.EncounterId, a.TriggeredAt });
builder.HasIndex(a => new { a.PatientId, a.TriggeredAt });
builder.HasIndex(a => new { a.Severity, a.TriggeredAt })
.HasFilter("status = 'OPEN'");
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
.HasFilter("status IN ('OPEN', 'ESCALATED')");
builder.HasIndex(a => a.ClientAlertId)
.IsUnique()
.HasFilter("client_alert_id IS NOT NULL");
}
}
@@ -0,0 +1,119 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class AddAlertThresholdsAndClinicalAlerts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "alert_thresholds",
schema: "clinical",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
critical_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
warning_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
warning_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
critical_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
suppression_window_minutes = table.Column<int>(type: "integer", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_alert_thresholds", x => x.id);
});
migrationBuilder.CreateTable(
name: "clinical_alerts",
schema: "clinical",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
observation_id = table.Column<Guid>(type: "uuid", nullable: true),
alert_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
severity = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
details = table.Column<string>(type: "text", nullable: false),
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'OPEN'"),
acknowledged_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
acknowledged_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
resolved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
triggered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
client_alert_id = table.Column<Guid>(type: "uuid", nullable: true),
synced_from_gateway = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
feedback_received = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_clinical_alerts", x => x.id);
table.CheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
table.CheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
table.CheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
migrationBuilder.CreateIndex(
name: "IX_alert_thresholds_observation_code",
schema: "clinical",
table: "alert_thresholds",
column: "observation_code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_client_alert_id",
schema: "clinical",
table: "clinical_alerts",
column: "client_alert_id",
unique: true,
filter: "client_alert_id IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_encounter_id_alert_type_observation_code",
schema: "clinical",
table: "clinical_alerts",
columns: new[] { "encounter_id", "alert_type", "observation_code" },
filter: "status IN ('OPEN', 'ESCALATED')");
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_encounter_id_triggered_at",
schema: "clinical",
table: "clinical_alerts",
columns: new[] { "encounter_id", "triggered_at" });
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_patient_id_triggered_at",
schema: "clinical",
table: "clinical_alerts",
columns: new[] { "patient_id", "triggered_at" });
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_severity_triggered_at",
schema: "clinical",
table: "clinical_alerts",
columns: new[] { "severity", "triggered_at" },
filter: "status = 'OPEN'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "alert_thresholds",
schema: "clinical");
migrationBuilder.DropTable(
name: "clinical_alerts",
schema: "clinical");
}
}
}
@@ -21,6 +21,66 @@ namespace VigilCareRecordsAPI.Data.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<int?>("SuppressionWindowMinutes")
.HasColumnType("integer")
.HasColumnName("suppression_window_minutes");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", "clinical");
});
modelBuilder.Entity("AuthAuditEvent", b =>
{
b.Property<Guid>("Id")
@@ -59,6 +119,117 @@ namespace VigilCareRecordsAPI.Data.Migrations
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<Guid?>("ClientAlertId")
.HasColumnType("uuid")
.HasColumnName("client_alert_id");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<bool>("FeedbackReceived")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("feedback_received");
b.Property<string>("ObservationCode")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<bool>("SyncedFromGateway")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("synced_from_gateway");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("ClientAlertId")
.IsUnique()
.HasFilter("client_alert_id IS NOT NULL");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.HasIndex("EncounterId", "AlertType", "ObservationCode")
.HasFilter("status IN ('OPEN', 'ESCALATED')");
b.ToTable("clinical_alerts", "clinical", t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
@@ -0,0 +1,13 @@
public class AlertThreshold
{
public Guid Id { get; set; }
public string ObservationCode { get; set; } = null!;
public string DisplayName { get; set; } = null!;
public string Unit { get; set; } = null!;
public decimal? CriticalLow { get; set; }
public decimal? WarningLow { get; set; }
public decimal? WarningHigh { get; set; }
public decimal? CriticalHigh { get; set; }
public int? SuppressionWindowMinutes { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,19 @@
public class ClinicalAlert
{
public Guid Id { get; set; }
public Guid EncounterId { get; set; }
public Guid PatientId { get; set; }
public Guid? ObservationId { get; set; }
public AlertType AlertType { get; set; }
public AlertSeverity Severity { get; set; }
public string Details { get; set; } = null!;
public string? ObservationCode { get; set; }
public AlertStatus Status { get; set; } = AlertStatus.Open;
public DateTimeOffset? AcknowledgedAt { get; set; }
public string? AcknowledgedBy { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
public DateTimeOffset TriggeredAt { get; set; }
public Guid? ClientAlertId { get; set; }
public bool SyncedFromGateway { get; set; }
public bool FeedbackReceived { get; set; }
}
@@ -0,0 +1,18 @@
public enum AlertSeverity { Warning, Critical }
public static class AlertSeverityExtensions
{
public static string ToDbString(this AlertSeverity s) => s switch
{
AlertSeverity.Warning => "WARNING",
AlertSeverity.Critical => "CRITICAL",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static AlertSeverity FromDbString(string v) => v switch
{
"WARNING" => AlertSeverity.Warning,
"CRITICAL" => AlertSeverity.Critical,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert severity: '{v}'")
};
}
@@ -0,0 +1,22 @@
public enum AlertStatus { Open, Acknowledged, Resolved, Escalated }
public static class AlertStatusExtensions
{
public static string ToDbString(this AlertStatus s) => s switch
{
AlertStatus.Open => "OPEN",
AlertStatus.Acknowledged => "ACKNOWLEDGED",
AlertStatus.Resolved => "RESOLVED",
AlertStatus.Escalated => "ESCALATED",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static AlertStatus FromDbString(string v) => v switch
{
"OPEN" => AlertStatus.Open,
"ACKNOWLEDGED" => AlertStatus.Acknowledged,
"RESOLVED" => AlertStatus.Resolved,
"ESCALATED" => AlertStatus.Escalated,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert status: '{v}'")
};
}
@@ -0,0 +1,225 @@
public enum AlertType
{
[Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")]
SepsisWarning,
CriticalHeartRate,
CriticalTempC,
CriticalPotassiumMeqL,
CriticalSpo2,
CriticalRespRate,
CriticalWbcKUl,
CriticalSystolicBp,
CriticalDiastolicBp,
CriticalLactateMmolL,
CriticalAvpu,
CriticalGlucoseMgDl,
// New — warning-level threshold alerts
WarningHeartRate,
WarningTempC,
WarningPotassiumMeqL,
WarningSpo2,
WarningRespRate,
WarningWbcKUl,
WarningSystolicBp,
WarningDiastolicBp,
WarningLactateMmolL,
WarningGlucoseMgDl,
News2Warning,
News2Emergency,
RapidDeterioration,
[Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")]
QsofaWarning,
QsofaScreen,
GcsCritical,
GcsWarning,
CriticalPao2MmHg,
WarningPao2MmHg,
CriticalPlateletKUl,
WarningPlateletKUl,
CriticalBilirubinMgDl,
WarningBilirubinMgDl,
CriticalCreatinineMgDl,
WarningCreatinineMgDl,
SofaSepsis,
SofaWarning,
}
public static class AlertTypeExtensions
{
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
public static string ToDbString(this AlertType t) => t switch
{
AlertType.SepsisWarning => "SEPSIS_WARNING",
AlertType.CriticalHeartRate => "CRITICAL_HEART_RATE",
AlertType.CriticalTempC => "CRITICAL_TEMP_C",
AlertType.CriticalPotassiumMeqL => "CRITICAL_POTASSIUM_MEQ_L",
AlertType.CriticalSpo2 => "CRITICAL_SPO2",
AlertType.CriticalRespRate => "CRITICAL_RESP_RATE",
AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL",
AlertType.CriticalSystolicBp => "CRITICAL_SYSTOLIC_BP",
AlertType.CriticalDiastolicBp => "CRITICAL_DIASTOLIC_BP",
AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L",
AlertType.CriticalAvpu => "CRITICAL_AVPU",
AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL",
AlertType.WarningHeartRate => "WARNING_HEART_RATE",
AlertType.WarningTempC => "WARNING_TEMP_C",
AlertType.WarningPotassiumMeqL => "WARNING_POTASSIUM_MEQ_L",
AlertType.WarningSpo2 => "WARNING_SPO2",
AlertType.WarningRespRate => "WARNING_RESP_RATE",
AlertType.WarningWbcKUl => "WARNING_WBC_K_UL",
AlertType.WarningSystolicBp => "WARNING_SYSTOLIC_BP",
AlertType.WarningDiastolicBp => "WARNING_DIASTOLIC_BP",
AlertType.WarningLactateMmolL => "WARNING_LACTATE_MMOL_L",
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
AlertType.News2Warning => "NEWS2_WARNING",
AlertType.News2Emergency => "NEWS2_EMERGENCY",
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
AlertType.QsofaWarning => "QSOFA_WARNING",
AlertType.GcsCritical => "GCS_CRITICAL",
AlertType.GcsWarning => "GCS_WARNING",
AlertType.CriticalPao2MmHg => "CRITICAL_PAO2_MMHG",
AlertType.WarningPao2MmHg => "WARNING_PAO2_MMHG",
AlertType.CriticalPlateletKUl => "CRITICAL_PLATELET_K_UL",
AlertType.WarningPlateletKUl => "WARNING_PLATELET_K_UL",
AlertType.CriticalBilirubinMgDl => "CRITICAL_BILIRUBIN_MG_DL",
AlertType.WarningBilirubinMgDl => "WARNING_BILIRUBIN_MG_DL",
AlertType.CriticalCreatinineMgDl => "CRITICAL_CREATININE_MG_DL",
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
AlertType.SofaSepsis => "SOFA_SEPSIS",
AlertType.SofaWarning => "SOFA_WARNING",
AlertType.QsofaScreen => "QSOFA_SCREEN",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
#pragma warning restore CS0618
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
public static AlertType FromDbString(string v) => v switch
{
"SEPSIS_WARNING" => AlertType.SepsisWarning,
"CRITICAL_HEART_RATE" => AlertType.CriticalHeartRate,
"CRITICAL_TEMP_C" => AlertType.CriticalTempC,
"CRITICAL_POTASSIUM_MEQ_L"=> AlertType.CriticalPotassiumMeqL,
"CRITICAL_SPO2" => AlertType.CriticalSpo2,
"CRITICAL_RESP_RATE" => AlertType.CriticalRespRate,
"CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl,
"CRITICAL_SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
"CRITICAL_DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
"CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
"CRITICAL_AVPU" => AlertType.CriticalAvpu,
"CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
"WARNING_HEART_RATE" => AlertType.WarningHeartRate,
"WARNING_TEMP_C" => AlertType.WarningTempC,
"WARNING_POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
"WARNING_SPO2" => AlertType.WarningSpo2,
"WARNING_RESP_RATE" => AlertType.WarningRespRate,
"WARNING_WBC_K_UL" => AlertType.WarningWbcKUl,
"WARNING_SYSTOLIC_BP" => AlertType.WarningSystolicBp,
"WARNING_DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
"WARNING_LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
"NEWS2_WARNING" => AlertType.News2Warning,
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
"QSOFA_WARNING" => AlertType.QsofaWarning,
"QSOFA_SCREEN" => AlertType.QsofaScreen,
"GCS_CRITICAL" => AlertType.GcsCritical,
"GCS_WARNING" => AlertType.GcsWarning,
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
"WARNING_PAO2_MMHG" => AlertType.WarningPao2MmHg,
"CRITICAL_PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
"WARNING_PLATELET_K_UL" => AlertType.WarningPlateletKUl,
"CRITICAL_BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
"WARNING_BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
"CRITICAL_CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
"WARNING_CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
"SOFA_SEPSIS" => AlertType.SofaSepsis,
"SOFA_WARNING" => AlertType.SofaWarning,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
};
#pragma warning restore CS0618
// Threshold alerts are derived from observation codes in alert_thresholds — not free-form strings.
public static AlertType CriticalFor(string observationCode) => observationCode switch
{
"HEART_RATE" => AlertType.CriticalHeartRate,
"TEMP_C" => AlertType.CriticalTempC,
"POTASSIUM_MEQ_L" => AlertType.CriticalPotassiumMeqL,
"SPO2" => AlertType.CriticalSpo2,
"RESP_RATE" => AlertType.CriticalRespRate,
"WBC_K_UL" => AlertType.CriticalWbcKUl,
"SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
"DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
"LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
"AVPU" => AlertType.CriticalAvpu,
"GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
"PAO2_MMHG" => AlertType.CriticalPao2MmHg,
"PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
"BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
"CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
_ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
};
public static AlertType WarningFor(string observationCode) => observationCode switch
{
"HEART_RATE" => AlertType.WarningHeartRate,
"TEMP_C" => AlertType.WarningTempC,
"POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
"SPO2" => AlertType.WarningSpo2,
"RESP_RATE" => AlertType.WarningRespRate,
"WBC_K_UL" => AlertType.WarningWbcKUl,
"SYSTOLIC_BP" => AlertType.WarningSystolicBp,
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
"PAO2_MMHG" => AlertType.WarningPao2MmHg,
"PLATELET_K_UL" => AlertType.WarningPlateletKUl,
"BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
"CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
_ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
};
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
public static bool IsSuppressible(this AlertType t) => t switch
{
AlertType.SepsisWarning or AlertType.News2Emergency => false,
AlertType.CriticalHeartRate or AlertType.CriticalTempC or AlertType.CriticalPotassiumMeqL
or AlertType.CriticalSpo2 or AlertType.CriticalRespRate or AlertType.CriticalWbcKUl
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
or AlertType.CriticalGlucoseMgDl => false,
AlertType.RapidDeterioration => false,
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
AlertType.SofaSepsis => false,
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
};
#pragma warning restore CS0618
public static string? ObservationCodeForWarning(this AlertType t) => t switch
{
AlertType.WarningHeartRate => "HEART_RATE",
AlertType.WarningTempC => "TEMP_C",
AlertType.WarningPotassiumMeqL => "POTASSIUM_MEQ_L",
AlertType.WarningSpo2 => "SPO2",
AlertType.WarningRespRate => "RESP_RATE",
AlertType.WarningWbcKUl => "WBC_K_UL",
AlertType.WarningSystolicBp => "SYSTOLIC_BP",
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
AlertType.WarningPao2MmHg => "PAO2_MMHG",
AlertType.WarningPlateletKUl => "PLATELET_K_UL",
AlertType.WarningBilirubinMgDl => "BILIRUBIN_MG_DL",
AlertType.WarningCreatinineMgDl => "CREATININE_MG_DL",
_ => null
};
}
@@ -0,0 +1,11 @@
/// <summary>
/// Internal result type for critical alert evaluation. Not exposed in API responses;
/// mapped to LiveCaptureCriticalAlert in the response builder.
/// </summary>
internal record CriticalAlertResult(
Guid Id,
string Severity,
string Message,
decimal ThresholdValue,
string ThresholdBound
);
@@ -0,0 +1,11 @@
/// <summary>
/// A critical alert that was generated synchronously during live capture promotion.
/// Returned inline so the clinician sees the alert before the HTTP response completes.
/// </summary>
public record LiveCaptureCriticalAlert(
Guid AlertId,
string Severity,
string Message,
decimal ThresholdValue,
string ThresholdBound
);
@@ -0,0 +1,10 @@
/// <summary>
/// A single observation entered at bedside by a credentialed clinician.
/// </summary>
public record LiveCaptureObservationRequest(
string ObservationCode,
decimal Value,
string Unit,
DateTimeOffset RecordedAt,
string? Note
);
@@ -0,0 +1,13 @@
/// <summary>
/// Response for a single promoted observation, including any synchronous
/// critical alert generated during promotion.
/// </summary>
public record LiveCaptureObservationResponse(
Guid DraftObservationId,
Guid LiveObservationId,
string ObservationCode,
decimal Value,
string Unit,
DateTimeOffset RecordedAt,
LiveCaptureCriticalAlert? CriticalAlert
);
@@ -0,0 +1,12 @@
/// <summary>
/// Full response for a live capture operation. Contains the batch ID,
/// the VigilCareClinical encounter ID, all promoted observations with
/// their live IDs, and any critical alerts generated synchronously.
/// </summary>
public record LiveCaptureResponse(
Guid BatchId,
Guid EncounterId,
IReadOnlyList<LiveCaptureObservationResponse> Observations,
int CriticalAlertCount,
DateTimeOffset PromotedAt
);
@@ -0,0 +1,13 @@
/// <summary>
/// Opens a new encounter and records initial vitals in a single request.
/// Used for outpatient workflows where the encounter does not yet exist.
/// </summary>
public record OpenEncounterWithVitalsRequest(
Guid PatientId,
string Department,
string? RoomBed,
string AdmissionReason,
List<LiveCaptureObservationRequest> Observations,
bool ClinicianAttestation,
string PasswordConfirm
);
@@ -0,0 +1,9 @@
/// <summary>
/// Records one or more observations against an existing VigilCareClinical encounter.
/// Requires clinician attestation and password re-confirmation.
/// </summary>
public record RecordObservationsRequest(
List<LiveCaptureObservationRequest> Observations,
bool ClinicianAttestation,
string PasswordConfirm
);
@@ -0,0 +1,6 @@
public record ThresholdCacheEntry(
string ObservationCode,
decimal? CriticalLow,
decimal? WarningLow,
decimal? WarningHigh,
decimal? CriticalHigh);
+2
View File
@@ -66,6 +66,8 @@ try
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
builder.Services.AddScoped<IDigitizationHistoryService, DigitizationHistoryService>();
builder.Services.AddScoped<IAttestationService, AttestationService>();
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
@@ -0,0 +1,55 @@
public class AttestationService : IAttestationService
{
private readonly AppDbContext _db;
private readonly ILogger<AttestationService> _logger;
public AttestationService(AppDbContext db, ILogger<AttestationService> logger)
{
_db = db;
_logger = logger;
}
public async Task<User> ValidateAttestationAsync(
Guid userId, bool clinicianAttestation, string passwordConfirm)
{
// 1. Attestation flag must be explicitly true
if (!clinicianAttestation)
throw new ValidationException(
"Clinician attestation is required for live capture.",
"ATTESTATION_REQUIRED");
// 2. Load user
var user = await _db.Users.FindAsync(userId);
if (user is null)
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
// 3. Role check — only Clinician role can use live capture
if (user.Role != UserRole.Clinician)
throw new ValidationException(
"Only users with the Clinician role can perform live capture.",
"CLINICIAN_ROLE_REQUIRED");
// 4. Account must be active
if (!user.IsActive)
throw new ConflictException(
"Account is disabled.", "ACCOUNT_DISABLED");
// 5. Password re-confirmation — prevents unattended sessions from
// submitting clinical data without the clinician present
if (string.IsNullOrWhiteSpace(passwordConfirm))
throw new ValidationException(
"Password re-confirmation is required.",
"PASSWORD_CONFIRM_REQUIRED");
if (!BCrypt.Net.BCrypt.Verify(passwordConfirm, user.PasswordHash))
throw new ValidationException(
"Password re-confirmation failed.",
"PASSWORD_CONFIRM_INVALID");
_logger.LogInformation(
"Clinician attestation validated for user {UserId} ({FullName})",
user.Id, user.FullName);
return user;
}
}
@@ -0,0 +1,12 @@
public interface IAttestationService
{
/// <summary>
/// Validates that the user is a credentialed clinician and that the
/// password re-confirm matches their stored hash. Throws on failure.
/// </summary>
/// <param name="userId">The authenticated user's ID from JWT claims.</param>
/// <param name="clinicianAttestation">Must be true; false throws ValidationException.</param>
/// <param name="passwordConfirm">Raw password for re-confirmation.</param>
/// <returns>The validated User entity.</returns>
Task<User> ValidateAttestationAsync(Guid userId, bool clinicianAttestation, string passwordConfirm);
}
@@ -0,0 +1,20 @@
public interface ILiveCaptureService
{
/// <summary>
/// Records observations against an existing VigilCareClinical encounter.
/// Validates clinician attestation, creates a live_capture batch, promotes
/// synchronously, and returns live observation IDs with any critical alerts.
/// </summary>
Task<LiveCaptureResponse> RecordObservationsAsync(
Guid encounterId,
RecordObservationsRequest request,
Guid clinicianUserId);
/// <summary>
/// Opens a new encounter in VigilCareClinical and records initial vitals
/// in a single atomic operation. Used for outpatient workflows.
/// </summary>
Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
OpenEncounterWithVitalsRequest request,
Guid clinicianUserId);
}
@@ -0,0 +1,481 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class LiveCaptureService : ILiveCaptureService
{
private readonly AppDbContext _db;
private readonly IAttestationService _attestation;
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<LiveCaptureService> _logger;
// Redis key prefix for cached alert thresholds (same as VigilCareClinical)
private const string ThresholdCachePrefix = "threshold:";
public LiveCaptureService(
AppDbContext db,
IAttestationService attestation,
IConnectionMultiplexer redis,
ILogger<LiveCaptureService> logger)
{
_db = db;
_attestation = attestation;
_redis = redis;
_logger = logger;
}
public async Task<LiveCaptureResponse> RecordObservationsAsync(
Guid encounterId, RecordObservationsRequest request, Guid clinicianUserId)
{
// 1. Validate attestation (role + password re-confirm)
var clinician = await _attestation.ValidateAttestationAsync(
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
// 2. Validate encounter exists and is active in VigilCareClinical
var encounter = await _db.Encounters.FindAsync(encounterId);
if (encounter is null)
throw new NotFoundException(
"Encounter not found in VigilCareClinical.", "ENCOUNTER_NOT_FOUND");
if (encounter.Status != "active")
throw new ConflictException(
"Observations can only be recorded against active encounters.",
"ENCOUNTER_NOT_ACTIVE");
// 3. Validate observations
if (request.Observations is null || request.Observations.Count == 0)
throw new ValidationException(
"At least one observation is required.", "EMPTY_OBSERVATIONS");
if (request.Observations.Count > 10)
throw new ValidationException(
"Maximum 10 observations per live capture request.",
"TOO_MANY_OBSERVATIONS");
foreach (var obs in request.Observations)
{
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
}
// 4. Execute synchronous promotion within a single transaction
return await PromoteSynchronouslyAsync(
encounter.Id, encounter.PatientId, clinician, request.Observations);
}
public async Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
OpenEncounterWithVitalsRequest request, Guid clinicianUserId)
{
// 1. Validate attestation
var clinician = await _attestation.ValidateAttestationAsync(
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
// 2. Validate patient exists
var patient = await _db.Patients.FindAsync(request.PatientId);
if (patient is null)
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
// 3. Validate observations
if (request.Observations is null || request.Observations.Count == 0)
throw new ValidationException(
"At least one observation is required.", "EMPTY_OBSERVATIONS");
if (request.Observations.Count > 10)
throw new ValidationException(
"Maximum 10 observations per live capture request.",
"TOO_MANY_OBSERVATIONS");
foreach (var obs in request.Observations)
{
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
}
// 4. Check no duplicate active encounter for this patient
var existingActive = await _db.Encounters.AnyAsync(e =>
e.PatientId == request.PatientId &&
e.Status == "active");
if (existingActive)
throw new ConflictException(
"Patient already has an active encounter. Record observations against the existing encounter.",
"ACTIVE_ENCOUNTER_EXISTS");
if (!DepartmentExtensions.TryFromDbString(request.Department, out var department))
throw new ValidationException(
$"Invalid department '{request.Department}'. Must be a recognized hospital department.",
"INVALID_DEPARTMENT");
// 5. Create the encounter in VigilCareClinical
var encounter = new Encounter
{
Id = Guid.NewGuid(),
PatientId = request.PatientId,
Department = department,
RoomBed = request.RoomBed,
AdmissionReason = request.AdmissionReason,
Status = "active",
AdmissionDate = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_db.Encounters.Add(encounter);
// 6. Promote observations synchronously
return await PromoteSynchronouslyAsync(
encounter.Id, request.PatientId, clinician, request.Observations);
}
/// <summary>
/// Core promotion logic shared by both endpoints. Creates the batch, draft
/// observations, live observations, evaluates critical thresholds, and writes
/// all audit and outbox events within a single database transaction.
/// </summary>
private async Task<LiveCaptureResponse> PromoteSynchronouslyAsync(
Guid encounterId, Guid patientId, User clinician,
List<LiveCaptureObservationRequest> observations)
{
var now = DateTimeOffset.UtcNow;
var batchId = Guid.NewGuid();
var observationResponses = new List<LiveCaptureObservationResponse>();
var criticalAlertCount = 0;
await using var transaction = await _db.Database.BeginTransactionAsync();
try
{
// --- Create the live_capture batch (already in terminal Promoted state) ---
var batch = new DigitizationBatch
{
Id = batchId,
Status = BatchStatus.Promoted,
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.LiveCapture,
PatientId = patientId,
DocumentRef = "live-capture", // No scanned document for Track B
DocumentSha256 = ComputeLiveCaptureHash(clinician.Id, encounterId, now),
EnableRetroactiveAlerts = false, // Not applicable — live capture always alerts
EnteredByUserId = clinician.Id,
VerifiedByUserId = clinician.Id, // Clinician attestation replaces verifier
ApprovedByUserId = clinician.Id,
ClinicianAttestation = true,
PromotedAt = now,
PromotionEncounterId = encounterId,
CreatedAt = now,
UpdatedAt = now
};
_db.DigitizationBatches.Add(batch);
// --- Write attestation and promotion events ---
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.LiveCaptureAttested,
ActorUserId = clinician.Id,
OccurredAt = now,
MetadataJson = JsonSerializer.Serialize(new
{
clinicianId = clinician.Id,
clinicianName = clinician.FullName,
encounterId,
observationCount = observations.Count,
track = "LIVE_CAPTURE"
})
});
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Promoted,
ActorUserId = clinician.Id,
OccurredAt = now.AddMilliseconds(1),
MetadataJson = JsonSerializer.Serialize(new
{
promotionType = "synchronous_live_capture",
encounterId
})
});
// --- Process each observation ---
var cache = _redis.GetDatabase();
foreach (var obs in observations)
{
var draftObsId = Guid.NewGuid();
var liveObsId = Guid.NewGuid();
// Create draft observation (audit trail)
var draftObservation = new DraftObservation
{
Id = draftObsId,
BatchId = batchId,
ObservationCode = obs.ObservationCode,
Value = obs.Value,
Unit = obs.Unit,
RecordedAt = obs.RecordedAt,
Note = obs.Note,
CreatedAt = now
};
_db.DraftObservations.Add(draftObservation);
// Create live observation in VigilCareClinical tables
var liveObservation = new Observation
{
Id = liveObsId,
EncounterId = encounterId,
PatientId = patientId,
ObservationCode = obs.ObservationCode,
Value = obs.Value,
Unit = obs.Unit,
RecordedAt = obs.RecordedAt,
Note = obs.Note,
Source = "live_capture",
SourceDraftObservationId = draftObsId,
SourceBatchId = batchId,
CreatedAt = now
};
_db.Observations.Add(liveObservation);
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
EventType = "observation.recorded",
AggregateType = "Observation",
AggregateId = liveObsId,
PayloadJson = JsonSerializer.Serialize(new
{
observationId = liveObsId,
encounterId,
patientId,
observationCode = obs.ObservationCode,
value = obs.Value,
unit = obs.Unit,
recordedAt = obs.RecordedAt,
source = "live_capture",
batchId
}),
CreatedAt = now,
ProcessedAt = null,
RetryCount = 0
});
// --- Synchronous critical value detection ---
var alert = await EvaluateCriticalThresholdAsync(
cache, liveObsId, encounterId, patientId,
obs.ObservationCode, obs.Value, obs.Unit, now);
if (alert is not null)
{
criticalAlertCount++;
observationResponses.Add(new LiveCaptureObservationResponse(
draftObsId, liveObsId,
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
new LiveCaptureCriticalAlert(
alert.Id,
alert.Severity,
alert.Message,
alert.ThresholdValue,
alert.ThresholdBound)));
}
else
{
observationResponses.Add(new LiveCaptureObservationResponse(
draftObsId, liveObsId,
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
null));
}
}
await _db.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInformation(
"Live capture batch {BatchId} promoted synchronously: " +
"{ObsCount} observations, {AlertCount} critical alerts, " +
"encounter {EncounterId}, clinician {ClinicianId}",
batchId, observations.Count, criticalAlertCount,
encounterId, clinician.Id);
return new LiveCaptureResponse(
batchId, encounterId, observationResponses,
criticalAlertCount, now);
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// <summary>
/// Evaluates a single observation against Redis-cached alert thresholds.
/// If the value breaches a CRITICAL bound, creates a ClinicalAlert row and
/// an outbox event within the current transaction (before SaveChanges).
///
/// Returns null if no critical threshold is breached.
/// Warning thresholds are handled asynchronously by the Kafka consumer —
/// the same split as VigilCareClinical Phase 2.
/// </summary>
private async Task<CriticalAlertResult?> EvaluateCriticalThresholdAsync(
StackExchange.Redis.IDatabase cache, Guid observationId, Guid encounterId, Guid patientId,
string observationCode, decimal value, string unit, DateTimeOffset now)
{
var threshold = await LoadThresholdAsync(cache, observationCode);
if (threshold is null)
return null;
var breach = GetCriticalBreach(value, threshold);
if (breach is null)
return null;
var (thresholdValue, thresholdBound) = breach.Value;
var details = BuildCriticalDetails(observationCode, value, unit, threshold, thresholdBound);
AlertType alertType;
try
{
alertType = AlertTypeExtensions.CriticalFor(observationCode);
}
catch (ArgumentOutOfRangeException)
{
_logger.LogWarning(
"No critical alert type configured for observation code {ObservationCode}",
observationCode);
return null;
}
var alertId = Guid.NewGuid();
var clinicalAlert = new ClinicalAlert
{
Id = alertId,
EncounterId = encounterId,
PatientId = patientId,
ObservationId = observationId,
ObservationCode = observationCode,
AlertType = alertType,
Severity = AlertSeverity.Critical,
Details = details,
Status = AlertStatus.Open,
TriggeredAt = now
};
_db.ClinicalAlerts.Add(clinicalAlert);
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
EventType = "alert.generated",
AggregateType = "ClinicalAlert",
AggregateId = alertId,
PayloadJson = JsonSerializer.Serialize(new
{
alertId,
encounterId,
patientId,
observationId,
observationCode,
alertType = alertType.ToDbString(),
severity = AlertSeverity.Critical.ToDbString(),
details,
triggeredValue = value,
thresholdValue,
thresholdBound,
source = "live_capture",
triggeredAt = now
}),
CreatedAt = now,
ProcessedAt = null,
RetryCount = 0
});
_logger.LogWarning(
"CRITICAL alert {AlertId} generated via live capture: " +
"{ObservationCode} = {Value} {Unit} ({ThresholdBound} = {ThresholdValue}), " +
"encounter {EncounterId}, patient {PatientId}",
alertId, observationCode, value, unit,
thresholdBound, thresholdValue, encounterId, patientId);
return new CriticalAlertResult(
alertId,
AlertSeverity.Critical.ToDbString(),
details,
thresholdValue,
thresholdBound);
}
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(
StackExchange.Redis.IDatabase cache, string observationCode)
{
var cacheKey = $"{ThresholdCachePrefix}{observationCode}";
var cached = await cache.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
var threshold = await _db.AlertThresholds
.AsNoTracking()
.FirstOrDefaultAsync(t => t.ObservationCode == observationCode);
if (threshold is null)
return null;
var entry = new ThresholdCacheEntry(
threshold.ObservationCode,
threshold.CriticalLow,
threshold.WarningLow,
threshold.WarningHigh,
threshold.CriticalHigh);
await cache.StringSetAsync(
cacheKey,
JsonSerializer.Serialize(entry),
TimeSpan.FromMinutes(30));
return entry;
}
private static (decimal ThresholdValue, string ThresholdBound)? GetCriticalBreach(
decimal value, ThresholdCacheEntry threshold)
{
if (threshold.CriticalLow.HasValue && value < threshold.CriticalLow.Value)
return (threshold.CriticalLow.Value, "CRITICAL_LOW");
if (threshold.CriticalHigh.HasValue && value > threshold.CriticalHigh.Value)
return (threshold.CriticalHigh.Value, "CRITICAL_HIGH");
return null;
}
private static string BuildCriticalDetails(
string observationCode, decimal value, string unit,
ThresholdCacheEntry threshold, string thresholdBound)
{
if (thresholdBound == "CRITICAL_LOW")
{
return $"{observationCode} value {value} {unit} is below critical low " +
$"threshold of {threshold.CriticalLow} {unit}";
}
return $"{observationCode} value {value} {unit} is above critical high " +
$"threshold of {threshold.CriticalHigh} {unit}";
}
/// <summary>
/// Computes a deterministic hash for live capture batches (no scanned document).
/// Uses clinician ID, encounter ID, and timestamp to generate uniqueness.
/// </summary>
private static string ComputeLiveCaptureHash(Guid clinicianId, Guid encounterId, DateTimeOffset timestamp)
{
var input = $"{clinicianId}:{encounterId}:{timestamp:O}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
+2
View File
@@ -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:
+943
View File
@@ -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 15 scripts):
# VIGILCARE_API_URL default: http://localhost:5217
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
# VIGILCARE_PG_HOST default: localhost
# VIGILCARE_PG_PORT default: 5437
# VIGILCARE_PG_DB default: vigilcare_records
# VIGILCARE_PG_USER default: postgres
# VIGILCARE_PG_PASSWORD default: password
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions
# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests
# VIGILCARE_RECORDED_AT default: 2026-06-25T10:00:00Z
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
COMPOSE=(docker compose -f "$COMPOSE_FILE")
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
PG_USER="${VIGILCARE_PG_USER:-postgres}"
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}"
RECORDED_AT="${VIGILCARE_RECORDED_AT:-2026-06-25T10:00:00Z}"
RECORDED_AT_CRITICAL="${VIGILCARE_RECORDED_AT_CRITICAL:-2026-06-25T10:05:00Z}"
RECORDED_AT_OUTPATIENT="${VIGILCARE_RECORDED_AT_OUTPATIENT:-2026-06-25T11:00:00Z}"
PASS_COUNT=0
FAIL_COUNT=0
FAILED_TESTS=()
# Populated by promotion / alert tests for downstream checks.
SHARED_LIVE_OBS_ID=""
SHARED_BATCH_ID=""
SHARED_ALERT_ID=""
log() {
printf '%s\n' "$*"
}
section() {
log ""
log "== $1 =="
}
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
log " PASS: $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
FAILED_TESTS+=("$1")
log " FAIL: $1"
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log "ERROR: required command not found: $cmd"
exit 1
fi
}
new_uuid() {
if command -v uuidgen >/dev/null 2>&1; then
uuidgen
else
cat /proc/sys/kernel/random/uuid
fi
}
compose_service_running() {
local service="$1"
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
}
psql_available() {
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
compose_service_running postgres && return 0
command -v psql >/dev/null 2>&1 && return 0
return 1
}
psql_query() {
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
return 1
fi
if compose_service_running postgres; then
"${COMPOSE[@]}" exec -T postgres \
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
elif command -v psql >/dev/null 2>&1; then
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
else
return 1
fi
}
http_code() {
curl -sS -o /dev/null -w '%{http_code}' "$@"
}
json_post() {
local url="$1"
local body="$2"
local token="${3:-}"
if [[ -n "$token" ]]; then
curl -sS -X POST "$url" \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d "$body"
else
curl -sS -X POST "$url" \
-H 'Content-Type: application/json' \
-d "$body"
fi
}
login() {
local username="$1"
local password="${2:-password}"
json_post "$API_URL/api/v1/auth/login" \
"{\"username\":\"$username\",\"password\":\"$password\"}"
}
extract_data_field() {
local json="$1"
local field="$2"
jq -er ".data.$field // empty" <<<"$json"
}
extract_error_code() {
local json="$1"
jq -er '.error.code // .extensions.code // empty' <<<"$json" 2>/dev/null ||
jq -er '.title // empty' <<<"$json" 2>/dev/null || true
}
extract_status_code() {
local json="$1"
jq -er '.statusCode // empty' <<<"$json"
}
assert_api_reachable() {
local code
code="$(http_code "$API_URL/swagger/index.html" || true)"
if [[ "$code" != "200" ]]; then
log "ERROR: API not reachable at $API_URL (HTTP $code)."
log "Start infrastructure with: docker compose up -d"
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
log "Start API with: dotnet run --project VigilCareRecordsAPI"
exit 1
fi
}
seed_potassium_threshold() {
if ! psql_available; then
return 1
fi
psql_query "
INSERT INTO clinical.alert_thresholds
(id, observation_code, display_name, unit,
critical_low, warning_low, warning_high, critical_high, created_at)
SELECT gen_random_uuid(), 'POTASSIUM_MEQ_L', 'Serum Potassium', 'mEq/L',
2.5, 3.5, 5.0, 6.5, NOW()
WHERE NOT EXISTS (
SELECT 1 FROM clinical.alert_thresholds
WHERE observation_code = 'POTASSIUM_MEQ_L'
);
" >/dev/null
}
# Creates clinical.patients + clinical.encounters (active). Prints patient_id|encounter_id.
create_patient_and_encounter() {
local status="${1:-active}"
local patient_id encounter_id mrn
if ! psql_available; then
return 1
fi
patient_id="$(new_uuid)"
encounter_id="$(new_uuid)"
# mrn is varchar(20); use a short unique prefix + uuid fragment
mrn="P6-$(echo "$patient_id" | tr -d '-' | cut -c1-13)"
psql_query "
INSERT INTO clinical.patients
(id, mrn, full_name, no_known_allergies, created_at, updated_at)
VALUES ('$patient_id', '$mrn', 'Phase 6 Patient', false, NOW(), NOW());
" >/dev/null
psql_query "
INSERT INTO clinical.encounters
(id, patient_id, department, room_bed, admission_reason, status,
admission_date, created_at, updated_at)
VALUES (
'$encounter_id', '$patient_id', 'Internal Medicine', 'IM-201A',
'Observation', '$status', NOW(), NOW(), NOW()
);
" >/dev/null
printf '%s|%s' "$patient_id" "$encounter_id"
}
# Creates clinical.patients only. Prints patient_id.
create_patient() {
local patient_id mrn
if ! psql_available; then
return 1
fi
patient_id="$(new_uuid)"
mrn="P6-$(echo "$patient_id" | tr -d '-' | cut -c1-13)"
psql_query "
INSERT INTO clinical.patients
(id, mrn, full_name, no_known_allergies, created_at, updated_at)
VALUES ('$patient_id', '$mrn', 'Phase 6 Patient', false, NOW(), NOW());
" >/dev/null
printf '%s' "$patient_id"
}
live_capture_record() {
local token="$1"
local encounter_id="$2"
local body="$3"
json_post "$API_URL/api/v1/live-capture/encounters/$encounter_id/observations" \
"$body" "$token"
}
live_capture_open_encounter() {
local token="$1"
local body="$2"
json_post "$API_URL/api/v1/live-capture/encounters" "$body" "$token"
}
normal_observations_body() {
local password_confirm="${1:-password}"
jq -nc \
--arg recorded_at "$RECORDED_AT" \
--arg password "$password_confirm" \
'{
observations: [
{observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null},
{observationCode: "TEMP_C", value: 36.8, unit: "C", recordedAt: $recorded_at, note: null},
{observationCode: "SPO2", value: 98, unit: "%", recordedAt: $recorded_at, note: null}
],
clinicianAttestation: true,
passwordConfirm: $password
}'
}
test_schema_alert_tables() {
section "1. Schema — clinical.alert_thresholds and clinical.clinical_alerts"
if ! psql_available; then
log " SKIP: PostgreSQL not reachable"
return
fi
local threshold_table alert_table
threshold_table="$(psql_query "
SELECT count(*)
FROM information_schema.tables
WHERE table_schema = 'clinical' AND table_name = 'alert_thresholds';
")"
alert_table="$(psql_query "
SELECT count(*)
FROM information_schema.tables
WHERE table_schema = 'clinical' AND table_name = 'clinical_alerts';
")"
if [[ "$threshold_table" == "1" ]]; then
pass "clinical.alert_thresholds table exists"
else
fail "clinical.alert_thresholds table exists"
fi
if [[ "$alert_table" == "1" ]]; then
pass "clinical.clinical_alerts table exists"
else
fail "clinical.clinical_alerts table exists"
fi
}
test_swagger_live_capture_routes() {
section "2. API surface — live-capture routes in swagger"
local swagger_paths
swagger_paths="$(curl -sS "$API_URL/swagger/v1/swagger.json")"
if jq -e '.paths["/api/v1/live-capture/encounters/{encounterId}/observations"].post' \
<<<"$swagger_paths" >/dev/null; then
pass "POST /api/v1/live-capture/encounters/{encounterId}/observations documented"
else
fail "POST /api/v1/live-capture/encounters/{encounterId}/observations documented"
fi
if jq -e '.paths["/api/v1/live-capture/encounters"].post' \
<<<"$swagger_paths" >/dev/null; then
pass "POST /api/v1/live-capture/encounters documented"
else
fail "POST /api/v1/live-capture/encounters documented"
fi
}
test_attestation_wrong_password() {
section "3. Attestation — wrong password returns 422 PASSWORD_CONFIRM_INVALID"
local clinician_token ids encounter_id body result http_code error_code
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient/encounter for wrong-password test"
return
}
encounter_id="${ids#*|}"
body="$(normal_observations_body wrong)"
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
http_code="$(extract_status_code "$result")"
error_code="$(extract_error_code "$result")"
if [[ "$http_code" == "422" && "$error_code" == "PASSWORD_CONFIRM_INVALID" ]]; then
pass "wrong password returns 422 PASSWORD_CONFIRM_INVALID"
else
fail "wrong password returns 422 PASSWORD_CONFIRM_INVALID (http=$http_code code=${error_code:-<none>})"
fi
}
test_attestation_non_clinician() {
section "4. Authorization — entry clerk returns 403 Forbidden"
local clerk_token ids encounter_id body http_result
clerk_token="$(extract_data_field "$(login entry1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient/encounter for non-clinician test"
return
}
encounter_id="${ids#*|}"
body="$(normal_observations_body)"
http_result="$(http_code -X POST \
"$API_URL/api/v1/live-capture/encounters/$encounter_id/observations" \
-H "Authorization: Bearer $clerk_token" \
-H 'Content-Type: application/json' \
-d "$body")"
if [[ "$http_result" == "403" ]]; then
pass "entry clerk receives 403 Forbidden"
else
fail "entry clerk receives 403 Forbidden (http=$http_result)"
fi
}
test_attestation_false() {
section "5. Attestation — false attestation returns 422 ATTESTATION_REQUIRED"
local clinician_token ids encounter_id body result http_code error_code
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient/encounter for false-attestation test"
return
}
encounter_id="${ids#*|}"
body="$(jq -nc \
--arg recorded_at "$RECORDED_AT" \
'{
observations: [
{observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null}
],
clinicianAttestation: false,
passwordConfirm: "password"
}')"
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
http_code="$(extract_status_code "$result")"
error_code="$(extract_error_code "$result")"
if [[ "$http_code" == "422" && "$error_code" == "ATTESTATION_REQUIRED" ]]; then
pass "false attestation returns 422 ATTESTATION_REQUIRED"
else
fail "false attestation returns 422 ATTESTATION_REQUIRED (http=$http_code code=${error_code:-<none>})"
fi
}
test_normal_promotion() {
section "6. Synchronous promotion — normal vitals promoted with no alert"
local clinician_token ids encounter_id body result obs_count alert_count live_obs_id batch_id
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient/encounter for normal promotion test"
return
}
encounter_id="${ids#*|}"
body="$(normal_observations_body)"
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then
fail "normal vitals live capture returns success"
return
fi
obs_count="$(extract_data_field "$result" 'observations | length')"
alert_count="$(extract_data_field "$result" criticalAlertCount)"
live_obs_id="$(jq -er '.data.observations[0].liveObservationId' <<<"$result")"
batch_id="$(extract_data_field "$result" batchId)"
if [[ "$obs_count" == "3" && "$alert_count" == "0" ]]; then
pass "response has 3 observations and criticalAlertCount=0"
else
fail "response has 3 observations and criticalAlertCount=0 (obs=$obs_count alerts=$alert_count)"
fi
SHARED_LIVE_OBS_ID="$live_obs_id"
SHARED_BATCH_ID="$batch_id"
if ! psql_available; then
log " SKIP: DB checks for normal promotion"
return
fi
local obs_source batch_status batch_track batch_attestation
obs_source="$(psql_query "
SELECT source FROM clinical.observations WHERE id = '$live_obs_id';
")"
batch_status="$(psql_query "
SELECT status FROM digitization_batches WHERE id = '$batch_id';
")"
batch_track="$(psql_query "
SELECT track FROM digitization_batches WHERE id = '$batch_id';
")"
batch_attestation="$(psql_query "
SELECT clinician_attestation FROM digitization_batches WHERE id = '$batch_id';
")"
if [[ "$obs_source" == "live_capture" ]]; then
pass "clinical.observations.source is live_capture"
else
fail "clinical.observations.source is live_capture (got: ${obs_source:-<none>})"
fi
if [[ "$batch_status" == "PROMOTED" && "$batch_track" == "LIVE_CAPTURE" && "$batch_attestation" == "t" ]]; then
pass "digitization_batches: PROMOTED, LIVE_CAPTURE, clinician_attestation=true"
else
fail "digitization_batches state (status=$batch_status track=$batch_track attestation=$batch_attestation)"
fi
}
test_critical_low_potassium() {
section "7. Critical alert — potassium 2.1 mEq/L fires CRITICAL_LOW synchronously"
seed_potassium_threshold
local clinician_token ids encounter_id body result alert_count severity bound threshold message alert_id
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient/encounter for critical-low test"
return
}
encounter_id="${ids#*|}"
body="$(jq -nc \
--arg recorded_at "$RECORDED_AT_CRITICAL" \
'{
observations: [
{
observationCode: "POTASSIUM_MEQ_L",
value: 2.1,
unit: "mEq/L",
recordedAt: $recorded_at,
note: "Bedside iSTAT result"
}
],
clinicianAttestation: true,
passwordConfirm: "password"
}')"
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then
fail "critical potassium live capture returns success"
return
fi
alert_count="$(extract_data_field "$result" criticalAlertCount)"
severity="$(jq -er '.data.observations[0].criticalAlert.severity' <<<"$result")"
bound="$(jq -er '.data.observations[0].criticalAlert.thresholdBound' <<<"$result")"
threshold="$(jq -er '.data.observations[0].criticalAlert.thresholdValue' <<<"$result")"
message="$(jq -er '.data.observations[0].criticalAlert.message' <<<"$result")"
alert_id="$(jq -er '.data.observations[0].criticalAlert.alertId' <<<"$result")"
SHARED_ALERT_ID="$alert_id"
if [[ "$alert_count" == "1" && "$severity" == "CRITICAL" && "$bound" == "CRITICAL_LOW" &&
"$threshold" == 2.5* && "$message" == *"below critical low"* ]]; then
pass "response includes inline CRITICAL_LOW alert for potassium 2.1"
else
fail "response includes inline CRITICAL_LOW alert (count=$alert_count severity=$severity bound=$bound)"
fi
if ! psql_available; then
log " SKIP: DB checks for critical alert"
return
fi
local db_severity db_status db_details
db_severity="$(psql_query "
SELECT severity FROM clinical.clinical_alerts WHERE id = '$alert_id';
")"
db_status="$(psql_query "
SELECT status FROM clinical.clinical_alerts WHERE id = '$alert_id';
")"
db_details="$(psql_query "
SELECT details FROM clinical.clinical_alerts WHERE id = '$alert_id';
")"
if [[ "$db_severity" == "CRITICAL" && "$db_status" == "OPEN" && "$db_details" == *"below critical low"* ]]; then
pass "clinical.clinical_alerts row committed (CRITICAL, OPEN)"
else
fail "clinical.clinical_alerts row (severity=$db_severity status=$db_status)"
fi
}
test_critical_high_potassium() {
section "8. Critical alert — potassium 7.2 mEq/L fires CRITICAL_HIGH"
seed_potassium_threshold
local clinician_token ids encounter_id body result bound threshold message
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient/encounter for critical-high test"
return
}
encounter_id="${ids#*|}"
body="$(jq -nc \
--arg recorded_at "$RECORDED_AT_CRITICAL" \
'{
observations: [
{
observationCode: "POTASSIUM_MEQ_L",
value: 7.2,
unit: "mEq/L",
recordedAt: $recorded_at,
note: null
}
],
clinicianAttestation: true,
passwordConfirm: "password"
}')"
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
bound="$(jq -er '.data.observations[0].criticalAlert.thresholdBound // empty' <<<"$result")"
threshold="$(jq -er '.data.observations[0].criticalAlert.thresholdValue // empty' <<<"$result")"
message="$(jq -er '.data.observations[0].criticalAlert.message // empty' <<<"$result")"
if [[ "$bound" == "CRITICAL_HIGH" && "$threshold" == 6.5* && "$message" == *"above critical high"* ]]; then
pass "response includes CRITICAL_HIGH alert for potassium 7.2"
else
fail "response includes CRITICAL_HIGH alert (bound=$bound threshold=$threshold)"
fi
}
test_mixed_batch() {
section "9. Mixed batch — only critical observation gets alert"
seed_potassium_threshold
local clinician_token ids encounter_id body result alert_count hr_alert k_alert temp_alert
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient/encounter for mixed-batch test"
return
}
encounter_id="${ids#*|}"
body="$(jq -nc \
--arg recorded_at "$RECORDED_AT_CRITICAL" \
'{
observations: [
{observationCode: "HEART_RATE", value: 80, unit: "bpm", recordedAt: $recorded_at, note: null},
{observationCode: "POTASSIUM_MEQ_L", value: 2.1, unit: "mEq/L", recordedAt: $recorded_at, note: null},
{observationCode: "TEMP_C", value: 37.0, unit: "C", recordedAt: $recorded_at, note: null}
],
clinicianAttestation: true,
passwordConfirm: "password"
}')"
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
alert_count="$(extract_data_field "$result" criticalAlertCount)"
hr_alert="$(jq -er '.data.observations[0].criticalAlert // "null"' <<<"$result")"
k_alert="$(jq -er '.data.observations[1].criticalAlert.severity // empty' <<<"$result")"
temp_alert="$(jq -er '.data.observations[2].criticalAlert // "null"' <<<"$result")"
if [[ "$alert_count" == "1" && "$hr_alert" == "null" && "$k_alert" == "CRITICAL" && "$temp_alert" == "null" ]]; then
pass "mixed batch: single CRITICAL alert on potassium only"
else
fail "mixed batch alert shape (count=$alert_count hr=$hr_alert k=$k_alert temp=$temp_alert)"
fi
}
test_outbox_events() {
section "10. Outbox — observation.recorded and alert.generated events"
if [[ -z "$SHARED_LIVE_OBS_ID" || -z "$SHARED_ALERT_ID" ]]; then
fail "outbox checks require normal promotion and critical-low tests first"
return
fi
if ! psql_available; then
log " SKIP: outbox DB checks"
return
fi
local obs_event_type alert_event_type
obs_event_type="$(psql_query "
SELECT event_type
FROM clinical.outbox_events
WHERE aggregate_id = '$SHARED_LIVE_OBS_ID'
LIMIT 1;
")"
alert_event_type="$(psql_query "
SELECT event_type
FROM clinical.outbox_events
WHERE aggregate_id = '$SHARED_ALERT_ID'
LIMIT 1;
")"
if [[ "$obs_event_type" == "observation.recorded" ]]; then
pass "outbox event observation.recorded written for live observation"
else
fail "outbox event observation.recorded (got: ${obs_event_type:-<none>})"
fi
if [[ "$alert_event_type" == "alert.generated" ]]; then
pass "outbox event alert.generated written for clinical alert"
else
fail "outbox event alert.generated (got: ${alert_event_type:-<none>})"
fi
local obs_payload_source
obs_payload_source="$(psql_query "
SELECT payload_json::text
FROM clinical.outbox_events
WHERE aggregate_id = '$SHARED_LIVE_OBS_ID'
LIMIT 1;
")"
if [[ "$obs_payload_source" == *"live_capture"* ]]; then
pass "observation outbox payload includes source live_capture"
else
fail "observation outbox payload includes source live_capture"
fi
}
test_open_encounter_with_vitals() {
section "11. Open encounter + vitals — outpatient workflow"
local clinician_token patient_id body result encounter_id obs_count department
if ! psql_available; then
log " SKIP: open-encounter test requires DB for patient setup"
return
fi
clinician_token="$(extract_data_field "$(login clinician1)" token)"
patient_id="$(create_patient)" || {
fail "setup patient for open-encounter test"
return
}
body="$(jq -nc \
--arg patient_id "$patient_id" \
--arg recorded_at "$RECORDED_AT_OUTPATIENT" \
'{
patientId: $patient_id,
department: "Outpatient Clinic",
roomBed: "OPD-3",
admissionReason: "Follow-up",
observations: [
{observationCode: "HEART_RATE", value: 68, unit: "bpm", recordedAt: $recorded_at, note: null},
{observationCode: "BP_SYSTOLIC", value: 120, unit: "mmHg", recordedAt: $recorded_at, note: null}
],
clinicianAttestation: true,
passwordConfirm: "password"
}')"
result="$(live_capture_open_encounter "$clinician_token" "$body")"
if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then
fail "open encounter with vitals returns success"
return
fi
encounter_id="$(extract_data_field "$result" encounterId)"
obs_count="$(extract_data_field "$result" 'observations | length')"
if [[ "$obs_count" == "2" && -n "$encounter_id" ]]; then
pass "open encounter response has encounterId and 2 observations"
else
fail "open encounter response (encounterId=$encounter_id obs=$obs_count)"
return
fi
department="$(psql_query "
SELECT department FROM clinical.encounters WHERE id = '$encounter_id';
")"
if [[ "$department" == "Outpatient Clinic" ]]; then
pass "new encounter department is Outpatient Clinic"
else
fail "new encounter department is Outpatient Clinic (got: ${department:-<none>})"
fi
}
test_duplicate_active_encounter() {
section "12. Validation — duplicate active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS"
local clinician_token ids patient_id body result http_code error_code
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient with active encounter for duplicate test"
return
}
patient_id="${ids%%|*}"
body="$(jq -nc \
--arg patient_id "$patient_id" \
--arg recorded_at "$RECORDED_AT" \
'{
patientId: $patient_id,
department: "Emergency Department",
roomBed: "ER-1",
admissionReason: "Chest pain",
observations: [
{observationCode: "HEART_RATE", value: 90, unit: "bpm", recordedAt: $recorded_at, note: null}
],
clinicianAttestation: true,
passwordConfirm: "password"
}')"
result="$(live_capture_open_encounter "$clinician_token" "$body")"
http_code="$(extract_status_code "$result")"
error_code="$(extract_error_code "$result")"
if [[ "$http_code" == "409" && "$error_code" == "ACTIVE_ENCOUNTER_EXISTS" ]]; then
pass "second active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS"
else
fail "second active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS (http=$http_code code=${error_code:-<none>})"
fi
}
test_discharged_encounter() {
section "13. Validation — discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE"
local clinician_token ids encounter_id body result http_code error_code
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter discharged)" || {
fail "setup discharged encounter"
return
}
encounter_id="${ids#*|}"
body="$(jq -nc \
--arg recorded_at "$RECORDED_AT" \
'{
observations: [
{observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null}
],
clinicianAttestation: true,
passwordConfirm: "password"
}')"
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
http_code="$(extract_status_code "$result")"
error_code="$(extract_error_code "$result")"
if [[ "$http_code" == "409" && "$error_code" == "ENCOUNTER_NOT_ACTIVE" ]]; then
pass "discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE"
else
fail "discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE (http=$http_code code=${error_code:-<none>})"
fi
}
test_empty_observations() {
section "14. Validation — empty observations returns 422 EMPTY_OBSERVATIONS"
local clinician_token ids encounter_id body result http_code error_code
clinician_token="$(extract_data_field "$(login clinician1)" token)"
ids="$(create_patient_and_encounter)" || {
fail "setup patient/encounter for empty-observations test"
return
}
encounter_id="${ids#*|}"
body='{"observations":[],"clinicianAttestation":true,"passwordConfirm":"password"}'
result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")"
http_code="$(extract_status_code "$result")"
error_code="$(extract_error_code "$result")"
if [[ "$http_code" == "422" && "$error_code" == "EMPTY_OBSERVATIONS" ]]; then
pass "empty observations returns 422 EMPTY_OBSERVATIONS"
else
fail "empty observations returns 422 EMPTY_OBSERVATIONS (http=$http_code code=${error_code:-<none>})"
fi
}
test_digitization_events() {
section "15. Audit trail — live_capture_attested and promoted digitization events"
if [[ -z "$SHARED_BATCH_ID" ]]; then
fail "digitization event check requires normal promotion test first"
return
fi
if ! psql_available; then
log " SKIP: digitization event DB checks"
return
fi
local attested_count promoted_count
attested_count="$(psql_query "
SELECT count(*)
FROM digitization_events
WHERE batch_id = '$SHARED_BATCH_ID'
AND event_type = 'live_capture_attested';
")"
promoted_count="$(psql_query "
SELECT count(*)
FROM digitization_events
WHERE batch_id = '$SHARED_BATCH_ID'
AND event_type = 'promoted';
")"
if [[ "$attested_count" == "1" && "$promoted_count" == "1" ]]; then
pass "batch has live_capture_attested and promoted digitization events"
else
fail "digitization events (attested=$attested_count promoted=$promoted_count)"
fi
}
test_integration_tests() {
section "16. Integration tests — LiveCaptureIntegrationTests"
if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then
log " SKIP: VIGILCARE_SKIP_TEST_CHECKS=1"
return
fi
if ! command -v dotnet >/dev/null 2>&1; then
log " SKIP: dotnet not found"
return
fi
local test_output test_exit
test_output="$(dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests" \
--filter "FullyQualifiedName~LiveCaptureIntegrationTests" \
--verbosity minimal 2>&1)"
test_exit=$?
if [[ "$test_exit" -eq 0 ]] && grep -q "Passed!" <<<"$test_output"; then
pass "LiveCaptureIntegrationTests pass (dotnet test)"
else
fail "LiveCaptureIntegrationTests pass (dotnet test)"
log "$test_output"
fi
}
main() {
require_cmd curl
require_cmd jq
require_cmd docker
log "VigilCare Records — Phase 6 verification"
log "API: $API_URL"
if compose_service_running postgres; then
log "PostgreSQL: docker compose exec (service: postgres)"
elif command -v psql >/dev/null 2>&1; then
log "PostgreSQL: host psql ($PG_HOST:$PG_PORT)"
fi
assert_api_reachable
test_schema_alert_tables
test_swagger_live_capture_routes
test_attestation_wrong_password
test_attestation_non_clinician
test_attestation_false
test_normal_promotion
test_critical_low_potassium
test_critical_high_potassium
test_mixed_batch
test_outbox_events
test_open_encounter_with_vitals
test_duplicate_active_encounter
test_discharged_encounter
test_empty_observations
test_digitization_events
test_integration_tests
log ""
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
if (( FAIL_COUNT > 0 )); then
log "Failed checks:"
for item in "${FAILED_TESTS[@]}"; do
log " - $item"
done
exit 1
fi
log "All Phase 6 verification checks passed."
}
main "$@"