481 lines
18 KiB
C#
481 lines
18 KiB
C#
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();
|
|
}
|
|
} |