feature: Live Capture (Track B)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user