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 --- 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"); // --- Begin atomic transaction --- await using var transaction = await _db.Database.BeginTransactionAsync(); try { var now = DateTimeOffset.UtcNow; // === Step 1: Create or update Patient === var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now); // === Step 2: Create or match Encounter === var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now); // === Step 3: Insert each DraftObservation as live Observation === var (observationIds, outboxCount) = await PromoteObservationsAsync( batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now); // === Step 4: Update batch status to Promoted === batch.Status = BatchStatus.Promoted; batch.ApprovedByUserId = approverUserId; batch.PromotedAt = now; batch.PromotionEncounterId = encounter.Id; batch.EnableRetroactiveAlerts = enableRetroactiveAlerts; batch.UpdatedAt = now; // === Step 5: Write DigitizationEvent === _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.Promoted, ActorUserId = approverUserId, OccurredAt = now, MetadataJson = JsonSerializer.Serialize(new { patientId = patient.Id, mrn = patient.Mrn, encounterId = encounter.Id, observationCount = observationIds.Length, outboxEventsWritten = outboxCount, enableRetroactiveAlerts, track = batch.Track.ToDbString() }) }); // === 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 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); } }