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 })!; } } // --- 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 (corrections reuse the linked live patient) --- 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"); } // --- Begin atomic transaction --- await using var transaction = await _db.Database.BeginTransactionAsync(); try { var now = DateTimeOffset.UtcNow; // === 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 (reuse original encounter for corrections) === var encounter = await ResolveClinicalEncounterAsync(batch, patient.Id, now); await EnsureLiveEncounterAsync(encounter, now); // === Step 3: Insert each DraftObservation as live Observation === var (observationIds, outboxCount) = await PromoteObservationsAsync( batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now); // === Step 3b: Mirror to live_observations for supersession / digitization history === PromoteLiveObservationsAsync(batch, patient.Id, encounter.Id, now); SupersessionResult? supersessionResult = null; if (batch.SupersedesBatchId.HasValue) { supersessionResult = await SupersedeOriginalBatchAsync( batch.SupersedesBatchId.Value, batchId, approverUserId, now); } // === Step 4: Update batch status to Promoted === batch.Status = BatchStatus.Promoted; batch.ApprovedByUserId = approverUserId; batch.PatientId = patient.Id; batch.PromotedAt = now; batch.PromotionEncounterId = encounter.Id; batch.EnableRetroactiveAlerts = enableRetroactiveAlerts; batch.UpdatedAt = now; // === Step 5: 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 = batchId, EventType = batch.SupersedesBatchId.HasValue ? DigitizationEventType.CorrectionPromoted : DigitizationEventType.Promoted, ActorUserId = approverUserId, OccurredAt = now, MetadataJson = JsonSerializer.Serialize(promotionMetadata) }); // === Step 6: Store idempotency record (within same transaction) === var result = new PromotionResultResponse( BatchId: batchId, Status: BatchStatus.Promoted.ToDbString(), PatientId: patient.Id, Mrn: patient.Mrn, EncounterId: encounter.Id, ObservationIds: observationIds, PromotedAt: now, OutboxEventsWritten: outboxCount ); if (!string.IsNullOrWhiteSpace(idempotencyKey)) { await _idempotency.SaveAsync( idempotencyKey, "batch_promote", batchId, 200, result, TimeSpan.FromHours(24)); } // === Step 7: Commit === await _db.SaveChangesAsync(); await transaction.CommitAsync(); _logger.LogInformation( "Batch {BatchId} promoted: Patient {PatientId} (MRN {Mrn}), " + "Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events", batchId, patient.Id, patient.Mrn, encounter.Id, observationIds.Length, outboxCount); return result; } catch { await transaction.RollbackAsync(); 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 async Task CreateOrUpdatePatientAsync(DraftPatient draft, DateTimeOffset now) { // Attempt to match existing patient by name + DOB (simple dedup) Patient? existing = null; if (draft.DateOfBirth.HasValue) { existing = await _db.Patients .FirstOrDefaultAsync(p => p.FullName == draft.FullName && p.DateOfBirth == draft.DateOfBirth); } if (existing is not null) { // Update fields that may have new information 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 name + DOB", existing.Id, existing.Mrn); return existing; } // Create new patient with generated MRN var mrn = await _mrnGenerator.GenerateNextMrnAsync(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = mrn, FullName = draft.FullName!, 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 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) { await using var transaction = await _db.Database.BeginTransactionAsync(); try { // Load the 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"); if (batch.Status != BatchStatus.Approved) throw new ConflictException( $"Only approved batches can be promoted. Batch is in '{batch.Status.ToDbString()}' status.", "ILLEGAL_STATUS_TRANSITION"); // Resolve or create the live encounter var encounterId = await ResolveEncounterAsync(batch); // Promote draft observations to live observations var now = DateTimeOffset.UtcNow; var liveObservations = batch.DraftObservations.Select(draft => new LiveObservation { Id = Guid.NewGuid(), EncounterId = encounterId, PatientId = batch.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 }).ToList(); _db.LiveObservations.AddRange(liveObservations); // Handle supersession if this is a correction batch SupersessionResult? supersessionResult = null; if (batch.SupersedesBatchId.HasValue) { supersessionResult = await SupersedeOriginalBatchAsync( batch.SupersedesBatchId.Value, batch.Id, actorUserId, now); } // Update batch status to Promoted batch.Status = BatchStatus.Promoted; batch.PromotedAt = now; batch.PromotionEncounterId = encounterId; batch.UpdatedAt = now; // Record promotion event on the correction batch var promotionMetadata = new Dictionary { ["encounterId"] = encounterId, ["observationsPromoted"] = liveObservations.Count, ["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) }); await _db.SaveChangesAsync(); await transaction.CommitAsync(); _logger.LogInformation( "Batch {BatchId} promoted (correction={IsCorrection}, " + "observations={ObservationCount}, superseded={SupersededCount})", batchId, batch.SupersedesBatchId.HasValue, liveObservations.Count, supersessionResult?.ObservationsSuperseded ?? 0); return new PromotionResult( batch.Id, encounterId, liveObservations.Count, batch.SupersedesBatchId.HasValue, supersessionResult); } catch { await transaction.RollbackAsync(); 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); } /// /// Resolves the live encounter for promotion. For correction batches, reuses the /// encounter from the original batch to maintain continuity. For new batches, /// creates or resolves the encounter from draft data. /// private async Task ResolveEncounterAsync(DigitizationBatch batch) { // For correction batches, reuse the encounter from the original promoted batch if (batch.SupersedesBatchId.HasValue) { var originalBatch = await _db.DigitizationBatches .FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value); if (originalBatch?.PromotionEncounterId.HasValue == true) { _logger.LogInformation( "Correction batch {CorrectionBatchId} reusing encounter {EncounterId} " + "from original batch {OriginalBatchId}", batch.Id, originalBatch.PromotionEncounterId.Value, originalBatch.Id); return originalBatch.PromotionEncounterId.Value; } } // For non-correction batches, create or find the encounter from draft data if (batch.DraftEncounter is null) throw new ValidationException( "Batch has no draft encounter data for promotion.", "MISSING_ENCOUNTER_DATA"); var encounter = new LiveEncounter { Id = Guid.NewGuid(), PatientId = batch.PatientId ?? throw new ValidationException("Patient ID is required for promotion.", "MISSING_PATIENT_ID"), AdmissionDate = batch.DraftEncounter.AdmissionDate ?? throw new ValidationException("Admission date is required.", "MISSING_ADMISSION_DATE"), Department = batch.DraftEncounter.Department, RoomBed = batch.DraftEncounter.RoomBed, AdmissionReason = batch.DraftEncounter.AdmissionReason, DischargeDiagnosis = batch.DraftEncounter.DischargeDiagnosis, Status = batch.DraftEncounter.Status ?? "active", CreatedAt = DateTimeOffset.UtcNow }; _db.LiveEncounters.Add(encounter); return encounter.Id; } }