using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; public class LiveCaptureService : ILiveCaptureService { private readonly AppDbContext _db; private readonly IAttestationService _attestation; private readonly IConnectionMultiplexer _redis; private readonly ILogger _logger; // Redis key prefix for cached alert thresholds (same as VigilCareClinical) private const string ThresholdCachePrefix = "threshold:"; public LiveCaptureService( AppDbContext db, IAttestationService attestation, IConnectionMultiplexer redis, ILogger logger) { _db = db; _attestation = attestation; _redis = redis; _logger = logger; } public async Task RecordObservationsAsync( Guid encounterId, RecordObservationsRequest request, Guid clinicianUserId) { // 1. Validate attestation (role + password re-confirm) var clinician = await _attestation.ValidateAttestationAsync( clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm); // 2. Validate encounter exists and is active in VigilCareClinical var encounter = await _db.Encounters.FindAsync(encounterId); if (encounter is null) throw new NotFoundException( "Encounter not found in VigilCareClinical.", "ENCOUNTER_NOT_FOUND"); if (encounter.Status != "active") throw new ConflictException( "Observations can only be recorded against active encounters.", "ENCOUNTER_NOT_ACTIVE"); // 3. Validate observations if (request.Observations is null || request.Observations.Count == 0) throw new ValidationException( "At least one observation is required.", "EMPTY_OBSERVATIONS"); if (request.Observations.Count > 10) throw new ValidationException( "Maximum 10 observations per live capture request.", "TOO_MANY_OBSERVATIONS"); foreach (var obs in request.Observations) { if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason)) throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); } // 4. Execute synchronous promotion within a single transaction return await PromoteSynchronouslyAsync( encounter.Id, encounter.PatientId, clinician, request.Observations); } public async Task OpenEncounterWithVitalsAsync( OpenEncounterWithVitalsRequest request, Guid clinicianUserId) { // 1. Validate attestation var clinician = await _attestation.ValidateAttestationAsync( clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm); // 2. Validate patient exists var patient = await _db.Patients.FindAsync(request.PatientId); if (patient is null) throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND"); // 3. Validate observations if (request.Observations is null || request.Observations.Count == 0) throw new ValidationException( "At least one observation is required.", "EMPTY_OBSERVATIONS"); if (request.Observations.Count > 10) throw new ValidationException( "Maximum 10 observations per live capture request.", "TOO_MANY_OBSERVATIONS"); foreach (var obs in request.Observations) { if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason)) throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); } // 4. Check no duplicate active encounter for this patient var existingActive = await _db.Encounters.AnyAsync(e => e.PatientId == request.PatientId && e.Status == "active"); if (existingActive) throw new ConflictException( "Patient already has an active encounter. Record observations against the existing encounter.", "ACTIVE_ENCOUNTER_EXISTS"); if (!DepartmentExtensions.TryFromDbString(request.Department, out var department)) throw new ValidationException( $"Invalid department '{request.Department}'. Must be a recognized hospital department.", "INVALID_DEPARTMENT"); // 5. Create the encounter in VigilCareClinical var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = request.PatientId, Department = department, RoomBed = request.RoomBed, AdmissionReason = request.AdmissionReason, Status = "active", AdmissionDate = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; _db.Encounters.Add(encounter); // 6. Promote observations synchronously return await PromoteSynchronouslyAsync( encounter.Id, request.PatientId, clinician, request.Observations); } /// /// Core promotion logic shared by both endpoints. Creates the batch, draft /// observations, live observations, evaluates critical thresholds, and writes /// all audit and outbox events within a single database transaction. /// private async Task PromoteSynchronouslyAsync( Guid encounterId, Guid patientId, User clinician, List observations) { var now = DateTimeOffset.UtcNow; var batchId = Guid.NewGuid(); var observationResponses = new List(); var criticalAlertCount = 0; await using var transaction = await _db.Database.BeginTransactionAsync(); try { // --- Create the live_capture batch (already in terminal Promoted state) --- var batch = new DigitizationBatch { Id = batchId, Status = BatchStatus.Promoted, BatchType = BatchType.VitalsSheet, Track = BatchTrack.LiveCapture, PatientId = patientId, DocumentRef = "live-capture", // No scanned document for Track B DocumentSha256 = ComputeLiveCaptureHash(clinician.Id, encounterId, now), EnableRetroactiveAlerts = false, // Not applicable — live capture always alerts EnteredByUserId = clinician.Id, VerifiedByUserId = clinician.Id, // Clinician attestation replaces verifier ApprovedByUserId = clinician.Id, ClinicianAttestation = true, PromotedAt = now, PromotionEncounterId = encounterId, CreatedAt = now, UpdatedAt = now }; _db.DigitizationBatches.Add(batch); // --- Write attestation and promotion events --- _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.LiveCaptureAttested, ActorUserId = clinician.Id, OccurredAt = now, MetadataJson = JsonSerializer.Serialize(new { clinicianId = clinician.Id, clinicianName = clinician.FullName, encounterId, observationCount = observations.Count, track = "LIVE_CAPTURE" }) }); _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.Promoted, ActorUserId = clinician.Id, OccurredAt = now.AddMilliseconds(1), MetadataJson = JsonSerializer.Serialize(new { promotionType = "synchronous_live_capture", encounterId }) }); // --- Process each observation --- var cache = _redis.GetDatabase(); foreach (var obs in observations) { var draftObsId = Guid.NewGuid(); var liveObsId = Guid.NewGuid(); // Create draft observation (audit trail) var draftObservation = new DraftObservation { Id = draftObsId, BatchId = batchId, ObservationCode = obs.ObservationCode, Value = obs.Value, Unit = obs.Unit, RecordedAt = obs.RecordedAt, Note = obs.Note, CreatedAt = now }; _db.DraftObservations.Add(draftObservation); // Create live observation in VigilCareClinical tables var liveObservation = new Observation { Id = liveObsId, EncounterId = encounterId, PatientId = patientId, ObservationCode = obs.ObservationCode, Value = obs.Value, Unit = obs.Unit, RecordedAt = obs.RecordedAt, Note = obs.Note, Source = "live_capture", SourceDraftObservationId = draftObsId, SourceBatchId = batchId, CreatedAt = now }; _db.Observations.Add(liveObservation); _db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), EventType = "observation.recorded", AggregateType = "Observation", AggregateId = liveObsId, PayloadJson = JsonSerializer.Serialize(new { observationId = liveObsId, encounterId, patientId, observationCode = obs.ObservationCode, value = obs.Value, unit = obs.Unit, recordedAt = obs.RecordedAt, source = "live_capture", batchId }), CreatedAt = now, ProcessedAt = null, RetryCount = 0 }); // --- Synchronous critical value detection --- var alert = await EvaluateCriticalThresholdAsync( cache, liveObsId, encounterId, patientId, obs.ObservationCode, obs.Value, obs.Unit, now); if (alert is not null) { criticalAlertCount++; observationResponses.Add(new LiveCaptureObservationResponse( draftObsId, liveObsId, obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt, new LiveCaptureCriticalAlert( alert.Id, alert.Severity, alert.Message, alert.ThresholdValue, alert.ThresholdBound))); } else { observationResponses.Add(new LiveCaptureObservationResponse( draftObsId, liveObsId, obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt, null)); } } await _db.SaveChangesAsync(); await transaction.CommitAsync(); _logger.LogInformation( "Live capture batch {BatchId} promoted synchronously: " + "{ObsCount} observations, {AlertCount} critical alerts, " + "encounter {EncounterId}, clinician {ClinicianId}", batchId, observations.Count, criticalAlertCount, encounterId, clinician.Id); return new LiveCaptureResponse( batchId, encounterId, observationResponses, criticalAlertCount, now); } catch { await transaction.RollbackAsync(); throw; } } /// /// Evaluates a single observation against Redis-cached alert thresholds. /// If the value breaches a CRITICAL bound, creates a ClinicalAlert row and /// an outbox event within the current transaction (before SaveChanges). /// /// Returns null if no critical threshold is breached. /// Warning thresholds are handled asynchronously by the Kafka consumer — /// the same split as VigilCareClinical Phase 2. /// private async Task EvaluateCriticalThresholdAsync( StackExchange.Redis.IDatabase cache, Guid observationId, Guid encounterId, Guid patientId, string observationCode, decimal value, string unit, DateTimeOffset now) { var threshold = await LoadThresholdAsync(cache, observationCode); if (threshold is null) return null; var breach = GetCriticalBreach(value, threshold); if (breach is null) return null; var (thresholdValue, thresholdBound) = breach.Value; var details = BuildCriticalDetails(observationCode, value, unit, threshold, thresholdBound); AlertType alertType; try { alertType = AlertTypeExtensions.CriticalFor(observationCode); } catch (ArgumentOutOfRangeException) { _logger.LogWarning( "No critical alert type configured for observation code {ObservationCode}", observationCode); return null; } var alertId = Guid.NewGuid(); var clinicalAlert = new ClinicalAlert { Id = alertId, EncounterId = encounterId, PatientId = patientId, ObservationId = observationId, ObservationCode = observationCode, AlertType = alertType, Severity = AlertSeverity.Critical, Details = details, Status = AlertStatus.Open, TriggeredAt = now }; _db.ClinicalAlerts.Add(clinicalAlert); _db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), EventType = "alert.generated", AggregateType = "ClinicalAlert", AggregateId = alertId, PayloadJson = JsonSerializer.Serialize(new { alertId, encounterId, patientId, observationId, observationCode, alertType = alertType.ToDbString(), severity = AlertSeverity.Critical.ToDbString(), details, triggeredValue = value, thresholdValue, thresholdBound, source = "live_capture", triggeredAt = now }), CreatedAt = now, ProcessedAt = null, RetryCount = 0 }); _logger.LogWarning( "CRITICAL alert {AlertId} generated via live capture: " + "{ObservationCode} = {Value} {Unit} ({ThresholdBound} = {ThresholdValue}), " + "encounter {EncounterId}, patient {PatientId}", alertId, observationCode, value, unit, thresholdBound, thresholdValue, encounterId, patientId); return new CriticalAlertResult( alertId, AlertSeverity.Critical.ToDbString(), details, thresholdValue, thresholdBound); } private async Task LoadThresholdAsync( StackExchange.Redis.IDatabase cache, string observationCode) { var cacheKey = $"{ThresholdCachePrefix}{observationCode}"; var cached = await cache.StringGetAsync(cacheKey); if (cached.HasValue) return JsonSerializer.Deserialize(cached!); var threshold = await _db.AlertThresholds .AsNoTracking() .FirstOrDefaultAsync(t => t.ObservationCode == observationCode); if (threshold is null) return null; var entry = new ThresholdCacheEntry( threshold.ObservationCode, threshold.CriticalLow, threshold.WarningLow, threshold.WarningHigh, threshold.CriticalHigh); await cache.StringSetAsync( cacheKey, JsonSerializer.Serialize(entry), TimeSpan.FromMinutes(30)); return entry; } private static (decimal ThresholdValue, string ThresholdBound)? GetCriticalBreach( decimal value, ThresholdCacheEntry threshold) { if (threshold.CriticalLow.HasValue && value < threshold.CriticalLow.Value) return (threshold.CriticalLow.Value, "CRITICAL_LOW"); if (threshold.CriticalHigh.HasValue && value > threshold.CriticalHigh.Value) return (threshold.CriticalHigh.Value, "CRITICAL_HIGH"); return null; } private static string BuildCriticalDetails( string observationCode, decimal value, string unit, ThresholdCacheEntry threshold, string thresholdBound) { if (thresholdBound == "CRITICAL_LOW") { return $"{observationCode} value {value} {unit} is below critical low " + $"threshold of {threshold.CriticalLow} {unit}"; } return $"{observationCode} value {value} {unit} is above critical high " + $"threshold of {threshold.CriticalHigh} {unit}"; } /// /// Computes a deterministic hash for live capture batches (no scanned document). /// Uses clinician ID, encounter ID, and timestamp to generate uniqueness. /// private static string ComputeLiveCaptureHash(Guid clinicianId, Guid encounterId, DateTimeOffset timestamp) { var input = $"{clinicianId}:{encounterId}:{timestamp:O}"; var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(bytes).ToLowerInvariant(); } }