using System.Diagnostics; using System.Text.Json; using Microsoft.EntityFrameworkCore; public class PromotionService : IPromotionService { private readonly AppDbContext _db; private readonly IIdempotencyService _idempotency; private readonly IMrnGenerator _mrnGenerator; private readonly ILogger _logger; public PromotionService( AppDbContext db, IIdempotencyService idempotency, IMrnGenerator mrnGenerator, ILogger logger) { _db = db; _idempotency = idempotency; _mrnGenerator = mrnGenerator; _logger = logger; } public async Task ApproveAndPromoteAsync( Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey) { // --- Idempotency check (before transaction) --- if (!string.IsNullOrWhiteSpace(idempotencyKey)) { var existing = await _idempotency.GetExistingAsync(idempotencyKey, "batch_promote"); if (existing is not null) { _logger.LogInformation( "Idempotent replay for batch {BatchId} with key {Key}", batchId, idempotencyKey); return JsonSerializer.Deserialize( existing.ResponseBodyJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; } } // --- Start timing the promotion --- var stopwatch = Stopwatch.StartNew(); try { // --- Load batch with all draft data --- var batch = await _db.DigitizationBatches .Include(b => b.DraftPatient) .Include(b => b.DraftEncounter) .Include(b => b.DraftObservations) .FirstOrDefaultAsync(b => b.Id == batchId); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); // --- Status validation --- if (batch.Status != BatchStatus.Verified && batch.Status != BatchStatus.AwaitingClinicalApproval) throw new ConflictException( $"Batch must be in 'verified' or 'awaiting_clinical_approval' status to approve. Current status: '{batch.Status.ToDbString()}'.", "ILLEGAL_STATUS_TRANSITION"); // --- Separation of duties: approver cannot be the entry clerk --- if (batch.EnteredByUserId == approverUserId) throw new ConflictException( "Separation of duties: the entry clerk cannot approve their own batch.", "SEPARATION_OF_DUTIES_VIOLATION"); // --- Also cannot be the verifier --- if (batch.VerifiedByUserId == approverUserId) throw new ConflictException( "Separation of duties: the verifier cannot also approve the same batch.", "SEPARATION_OF_DUTIES_VIOLATION"); // --- Validate draft data completeness --- ValidateDraftDataForPromotion(batch); // --- Begin atomic transaction --- await using var transaction = await _db.Database.BeginTransactionAsync(); try { var now = DateTimeOffset.UtcNow; batch.ApprovedByUserId = approverUserId; var core = await ExecutePromotionCoreAsync( batch, approverUserId, enableRetroactiveAlerts, now); // === Store idempotency record (within same transaction) === var result = new PromotionResultResponse( BatchId: batchId, Status: BatchStatus.Promoted.ToDbString(), PatientId: core.Patient.Id, Mrn: core.Patient.Mrn, EncounterId: core.Encounter.Id, ObservationIds: core.ObservationIds, PromotedAt: now, OutboxEventsWritten: core.OutboxCount ); if (!string.IsNullOrWhiteSpace(idempotencyKey)) { await _idempotency.SaveAsync( idempotencyKey, "batch_promote", batchId, 200, result, TimeSpan.FromHours(24)); } // === Commit === await _db.SaveChangesAsync(); await transaction.CommitAsync(); stopwatch.Stop(); DiagnosticsMetrics.PromotionDuration.Observe(stopwatch.Elapsed.TotalSeconds); _logger.LogInformation( "Batch {BatchId} promoted in {ElapsedMs}ms: Patient {PatientId} (MRN {Mrn}), " + "Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events", batchId, stopwatch.ElapsedMilliseconds, core.Patient.Id, core.Patient.Mrn, core.Encounter.Id, core.ObservationIds.Length, core.OutboxCount); return result; } catch { await transaction.RollbackAsync(); throw; } } catch { stopwatch.Stop(); throw; } } public async Task GetPromotionResultAsync(Guid batchId) { var batch = await _db.DigitizationBatches .FirstOrDefaultAsync(b => b.Id == batchId); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); if (batch.Status != BatchStatus.Promoted) throw new ConflictException( $"Batch is not promoted. Current status: '{batch.Status.ToDbString()}'.", "BATCH_NOT_PROMOTED"); // Retrieve the live patient by looking up the encounter var encounter = await _db.Encounters .Include(e => e.Patient) .FirstOrDefaultAsync(e => e.Id == batch.PromotionEncounterId); if (encounter is null) throw new NotFoundException( "Promotion encounter not found. Data may be inconsistent.", "PROMOTION_ENCOUNTER_NOT_FOUND"); var observationIds = await _db.Observations .Where(o => o.SourceBatchId == batchId) .Select(o => o.Id) .ToArrayAsync(); return new PromotionResultResponse( BatchId: batchId, Status: batch.Status.ToDbString(), PatientId: encounter.PatientId, Mrn: encounter.Patient.Mrn, EncounterId: encounter.Id, ObservationIds: observationIds, PromotedAt: batch.PromotedAt!.Value, OutboxEventsWritten: 0 // Historical count not stored; use event metadata ); } // ────────────────────────────────────────────────── // Private helpers // ────────────────────────────────────────────────── private record PromotionCoreResult( Patient Patient, Encounter Encounter, Guid[] ObservationIds, int OutboxCount, SupersessionResult? Supersession); private static void ValidateDraftDataForPromotion(DigitizationBatch batch) { if (!batch.SupersedesBatchId.HasValue) { if (batch.DraftPatient is null) throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT"); if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName)) throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT"); } else if (!batch.PatientId.HasValue) { throw new ValidationException( "Correction batch has no linked patient.", "MISSING_PATIENT"); } } private async Task ExecutePromotionCoreAsync( DigitizationBatch batch, Guid actorUserId, bool enableRetroactiveAlerts, DateTimeOffset now) { // === Step 1: Create or update Patient === Patient patient; if (batch.SupersedesBatchId.HasValue) { patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == batch.PatientId!.Value) ?? throw new NotFoundException( $"Patient {batch.PatientId!.Value} not found.", "PATIENT_NOT_FOUND"); } else { patient = await CreateOrUpdatePatientAsync(batch.DraftPatient!, now); } // === Step 2: Create or match Encounter + LiveEncounter === var encounter = await ResolveClinicalEncounterAsync(batch, patient.Id, now); await EnsureLiveEncounterAsync(encounter, now); // === Step 3: Insert clinical Observations + OutboxEvents === var (observationIds, outboxCount) = await PromoteObservationsAsync( batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now); // === Step 4: Mirror to LiveObservations === PromoteLiveObservationsAsync(batch, patient.Id, encounter.Id, now); // === Step 5: Handle supersession for correction batches === SupersessionResult? supersessionResult = null; if (batch.SupersedesBatchId.HasValue) { supersessionResult = await SupersedeOriginalBatchAsync( batch.SupersedesBatchId.Value, batch.Id, actorUserId, now); } // === Step 6: Update batch status to Promoted === batch.Status = BatchStatus.Promoted; batch.PatientId = patient.Id; batch.PromotedAt = now; batch.PromotionEncounterId = encounter.Id; batch.EnableRetroactiveAlerts = enableRetroactiveAlerts; batch.UpdatedAt = now; // === Step 7: Write DigitizationEvent === var promotionMetadata = new Dictionary { ["patientId"] = patient.Id, ["mrn"] = patient.Mrn, ["encounterId"] = encounter.Id, ["observationCount"] = observationIds.Length, ["outboxEventsWritten"] = outboxCount, ["enableRetroactiveAlerts"] = enableRetroactiveAlerts, ["track"] = batch.Track.ToDbString(), ["isCorrection"] = batch.SupersedesBatchId.HasValue }; if (supersessionResult is not null) { promotionMetadata["supersession"] = new { originalBatchId = supersessionResult.OriginalBatchId, observationsSuperseded = supersessionResult.ObservationsSuperseded, observationsReplaced = supersessionResult.ObservationsReplaced }; } _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch.Id, EventType = batch.SupersedesBatchId.HasValue ? DigitizationEventType.CorrectionPromoted : DigitizationEventType.Promoted, ActorUserId = actorUserId, OccurredAt = now, MetadataJson = JsonSerializer.Serialize(promotionMetadata) }); return new PromotionCoreResult(patient, encounter, observationIds, outboxCount, supersessionResult); } private static string NormalizePatientName(string name) => string.Join(" ", name.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) .ToUpperInvariant(); private async Task CreateOrUpdatePatientAsync(DraftPatient draft, DateTimeOffset now) { var normalizedName = NormalizePatientName(draft.FullName!); Patient? existing = null; if (draft.DateOfBirth.HasValue) { existing = await _db.Patients .FirstOrDefaultAsync(p => EF.Functions.ILike(p.FullName, normalizedName) && p.DateOfBirth == draft.DateOfBirth); } if (existing is not null) { existing.Sex = draft.Sex ?? existing.Sex; existing.BloodType = draft.BloodType ?? existing.BloodType; existing.EmergencyContact = draft.EmergencyContact ?? existing.EmergencyContact; existing.AllergiesJson = draft.AllergiesJson ?? existing.AllergiesJson; existing.NoKnownAllergies = draft.NoKnownAllergies || existing.NoKnownAllergies; existing.UpdatedAt = now; _logger.LogInformation( "Matched existing patient {PatientId} (MRN {Mrn}) by normalized name + DOB", existing.Id, existing.Mrn); return existing; } // No exact normalized match — check for fuzzy near-misses on same DOB if (draft.DateOfBirth.HasValue) { var sameDobPatients = await _db.Patients .Where(p => p.DateOfBirth == draft.DateOfBirth) .Select(p => new { p.Id, p.Mrn, p.FullName }) .ToListAsync(); foreach (var candidate in sameDobPatients) { var distance = LevenshteinDistance( normalizedName, NormalizePatientName(candidate.FullName)); if (distance > 0 && distance <= 3) { _logger.LogWarning( "Fuzzy patient match: new name '{NewName}' is {Distance} edits from " + "existing patient {PatientId} (MRN {Mrn}, name '{ExistingName}') with same DOB. " + "Creating new patient — consider merging if these are the same person", normalizedName, distance, candidate.Id, candidate.Mrn, candidate.FullName); } } } var mrn = await _mrnGenerator.GenerateNextMrnAsync(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = mrn, FullName = normalizedName, DateOfBirth = draft.DateOfBirth, Sex = draft.Sex, BloodType = draft.BloodType, EmergencyContact = draft.EmergencyContact, AllergiesJson = draft.AllergiesJson, NoKnownAllergies = draft.NoKnownAllergies, CreatedAt = now, UpdatedAt = now }; _db.Patients.Add(patient); _logger.LogInformation( "Created new patient {PatientId} with MRN {Mrn}", patient.Id, mrn); return patient; } private static int LevenshteinDistance(string a, string b) { if (a.Length == 0) return b.Length; if (b.Length == 0) return a.Length; var prev = new int[b.Length + 1]; var curr = new int[b.Length + 1]; for (var j = 0; j <= b.Length; j++) prev[j] = j; for (var i = 1; i <= a.Length; i++) { curr[0] = i; for (var j = 1; j <= b.Length; j++) { var cost = a[i - 1] == b[j - 1] ? 0 : 1; curr[j] = Math.Min( Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); } (prev, curr) = (curr, prev); } return prev[b.Length]; } private async Task ResolveClinicalEncounterAsync( DigitizationBatch batch, Guid patientId, DateTimeOffset now) { if (batch.SupersedesBatchId.HasValue) { var originalBatch = await _db.DigitizationBatches .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value); if (originalBatch?.PromotionEncounterId is not Guid encounterId) throw new ValidationException( "Original batch has no promotion encounter for correction reuse.", "MISSING_ORIGINAL_ENCOUNTER"); var encounter = await _db.Encounters.FirstOrDefaultAsync(e => e.Id == encounterId); if (encounter is null) throw new NotFoundException( "Original promotion encounter not found.", "PROMOTION_ENCOUNTER_NOT_FOUND"); _logger.LogInformation( "Correction batch {CorrectionBatchId} reusing clinical encounter {EncounterId} " + "from original batch {OriginalBatchId}", batch.Id, encounter.Id, originalBatch.Id); return encounter; } return await CreateOrMatchEncounterAsync(batch, patientId, now); } private async Task EnsureLiveEncounterAsync(Encounter encounter, DateTimeOffset now) { if (await _db.LiveEncounters.AnyAsync(e => e.Id == encounter.Id)) return; _db.LiveEncounters.Add(new LiveEncounter { Id = encounter.Id, PatientId = encounter.PatientId, AdmissionDate = encounter.AdmissionDate ?? now, Department = encounter.Department, RoomBed = encounter.RoomBed, AdmissionReason = encounter.AdmissionReason, DischargeDiagnosis = encounter.DischargeDiagnosis, Status = encounter.Status, CreatedAt = now }); } private void PromoteLiveObservationsAsync( DigitizationBatch batch, Guid patientId, Guid encounterId, DateTimeOffset now) { foreach (var draft in batch.DraftObservations) { _db.LiveObservations.Add(new LiveObservation { Id = Guid.NewGuid(), EncounterId = encounterId, PatientId = patientId, SourceBatchId = batch.Id, ObservationCode = draft.ObservationCode, Value = draft.Value, Unit = draft.Unit, RecordedAt = draft.RecordedAt, Note = draft.Note, CreatedAt = now, IsSuperseded = false, SupersededByBatchId = null, SupersededAt = null }); } } private async Task CreateOrMatchEncounterAsync( DigitizationBatch batch, Guid patientId, DateTimeOffset now) { // If the batch has a draft encounter, try to match an active encounter // for the same patient in the same department if (batch.DraftEncounter is not null) { var existingEncounter = await _db.Encounters .FirstOrDefaultAsync(e => e.PatientId == patientId && e.Department == batch.DraftEncounter.Department && e.Status == "active" && e.SourceBatchId != batch.Id); if (existingEncounter is not null) { _logger.LogInformation( "Matched existing active encounter {EncounterId} for patient {PatientId}", existingEncounter.Id, patientId); return existingEncounter; } } // Determine encounter status from draft var encounterStatus = batch.DraftEncounter?.Status ?? "active"; if (batch.DraftEncounter?.DischargeDiagnosis is not null) { encounterStatus = "discharged"; } var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patientId, AdmissionDate = batch.DraftEncounter?.AdmissionDate, Department = batch.DraftEncounter?.Department, RoomBed = batch.DraftEncounter?.RoomBed, AdmissionReason = batch.DraftEncounter?.AdmissionReason, DischargeDiagnosis = batch.DraftEncounter?.DischargeDiagnosis, Status = encounterStatus, SourceBatchId = batch.Id, CreatedAt = now, UpdatedAt = now }; _db.Encounters.Add(encounter); _logger.LogInformation( "Created encounter {EncounterId} for patient {PatientId} with status '{Status}'", encounter.Id, patientId, encounterStatus); return encounter; } private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync( DigitizationBatch batch, Guid patientId, Guid encounterId, bool enableRetroactiveAlerts, DateTimeOffset now) { var observationIds = new List(); var outboxCount = 0; // Determine the source label based on batch track var source = batch.Track == BatchTrack.LiveCapture ? "live_capture" : "digitization_backfill"; // Determine whether outbox events should be written for this batch var shouldAlert = batch.Track == BatchTrack.LiveCapture || enableRetroactiveAlerts; foreach (var draft in batch.DraftObservations) { var observationId = Guid.NewGuid(); var observation = new Observation { Id = observationId, EncounterId = encounterId, PatientId = patientId, ObservationCode = draft.ObservationCode, Value = draft.Value, Unit = draft.Unit, RecordedAt = draft.RecordedAt, Note = draft.Note, Source = source, SourceDraftObservationId = draft.Id, SourceBatchId = batch.Id, CreatedAt = now }; _db.Observations.Add(observation); observationIds.Add(observationId); // Write outbox event only if alerting is enabled for this batch if (shouldAlert) { _db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), EventType = "observation.created", AggregateType = "Observation", AggregateId = observationId, PayloadJson = JsonSerializer.Serialize(new { observationId, encounterId, patientId, observationCode = draft.ObservationCode, value = draft.Value, unit = draft.Unit, recordedAt = draft.RecordedAt, source, batchId = batch.Id, track = batch.Track.ToDbString() }), CreatedAt = now, ProcessedAt = null, RetryCount = 0 }); outboxCount++; } } _logger.LogInformation( "Promoted {Count} observations for batch {BatchId}, " + "{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})", observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString()); return (observationIds.ToArray(), outboxCount); } public async Task PromoteAsync(Guid batchId, Guid actorUserId) { var stopwatch = Stopwatch.StartNew(); try { await using var transaction = await _db.Database.BeginTransactionAsync(); try { var batch = await _db.DigitizationBatches .Include(b => b.DraftPatient) .Include(b => b.DraftEncounter) .Include(b => b.DraftObservations) .FirstOrDefaultAsync(b => b.Id == batchId); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); if (batch.Status != BatchStatus.Approved) throw new ConflictException( $"Only approved batches can be promoted. Batch is in '{batch.Status.ToDbString()}' status.", "ILLEGAL_STATUS_TRANSITION"); ValidateDraftDataForPromotion(batch); var now = DateTimeOffset.UtcNow; var enableRetroactiveAlerts = batch.EnableRetroactiveAlerts; var core = await ExecutePromotionCoreAsync( batch, actorUserId, enableRetroactiveAlerts, now); await _db.SaveChangesAsync(); await transaction.CommitAsync(); stopwatch.Stop(); DiagnosticsMetrics.PromotionDuration.Observe(stopwatch.Elapsed.TotalSeconds); _logger.LogInformation( "Batch {BatchId} promoted via retry in {ElapsedMs}ms: Patient {PatientId} (MRN {Mrn}), " + "Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events", batchId, stopwatch.ElapsedMilliseconds, core.Patient.Id, core.Patient.Mrn, core.Encounter.Id, core.ObservationIds.Length, core.OutboxCount); return new PromotionResult( batch.Id, core.Patient.Id, core.Patient.Mrn, core.Encounter.Id, core.ObservationIds, core.ObservationIds.Length, core.OutboxCount, batch.SupersedesBatchId.HasValue, core.Supersession); } catch { await transaction.RollbackAsync(); throw; } } catch { stopwatch.Stop(); throw; } } /// /// Marks all live observations sourced from the original batch as superseded. /// Records an audit event on the original batch documenting the supersession. /// private async Task SupersedeOriginalBatchAsync( Guid originalBatchId, Guid correctionBatchId, Guid actorUserId, DateTimeOffset now) { // Load all live observations that came from the original erroneous batch var originalObservations = await _db.LiveObservations .Where(o => o.SourceBatchId == originalBatchId && !o.IsSuperseded) .ToListAsync(); if (originalObservations.Count == 0) { _logger.LogWarning( "Supersession: no active live observations found for original batch {OriginalBatchId}", originalBatchId); } // Mark each observation as superseded — never delete foreach (var obs in originalObservations) { obs.IsSuperseded = true; obs.SupersededByBatchId = correctionBatchId; obs.SupersededAt = now; } // Count the correction batch's draft observations for the replacement count var replacementCount = await _db.DigitizationBatches .Where(b => b.Id == correctionBatchId) .SelectMany(b => b.DraftObservations) .CountAsync(); // Record supersession event on the original batch's audit trail _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = originalBatchId, EventType = DigitizationEventType.Superseded, ActorUserId = actorUserId, OccurredAt = now, MetadataJson = JsonSerializer.Serialize(new { supersededByBatchId = correctionBatchId, observationsSuperseded = originalObservations.Count, replacementObservations = replacementCount, reason = "Correction batch promoted — original observations marked superseded" }) }); return new SupersessionResult( originalBatchId, originalObservations.Count, replacementCount, now); } }