using Microsoft.EntityFrameworkCore; public interface ISimulationPurgeService { Task GetSummaryAsync(CancellationToken ct); Task PurgeAsync(CancellationToken ct); } public record SimulationDataSummary( int SimulatedPatients, int Encounters, int Observations, int Alerts, int ActiveRuns); public record SimulationPurgeResult( int PatientsDeleted, int EncountersDeleted, int AlertsDeleted, int RunsCleared); /// /// Deletes all patients with IsSimulated = true and their dependents. /// Never deletes by encounter or date range — only the simulated-patient predicate. /// public sealed class SimulationPurgeService : ISimulationPurgeService { private readonly AppDbContext _db; private readonly ISimulationRunner _runner; private readonly IAuditService _audit; public SimulationPurgeService( AppDbContext db, ISimulationRunner runner, IAuditService audit) { _db = db; _runner = runner; _audit = audit; } public async Task GetSummaryAsync(CancellationToken ct) { var patientIds = await _db.Patients .Where(p => p.IsSimulated) .Select(p => p.Id) .ToListAsync(ct); if (patientIds.Count == 0) { return new SimulationDataSummary( 0, 0, 0, 0, _runner.ListRuns().Count(r => r.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running)); } var encounterIds = await _db.Encounters .Where(e => patientIds.Contains(e.PatientId)) .Select(e => e.Id) .ToListAsync(ct); var observations = encounterIds.Count == 0 ? 0 : await _db.Observations.CountAsync(o => encounterIds.Contains(o.EncounterId), ct); var alerts = await _db.ClinicalAlerts .CountAsync(a => patientIds.Contains(a.PatientId), ct); var activeRuns = _runner.ListRuns().Count(r => r.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running); return new SimulationDataSummary( patientIds.Count, encounterIds.Count, observations, alerts, activeRuns); } public async Task PurgeAsync(CancellationToken ct) { if (_runner.HasActiveRuns()) throw new ConflictException( "Cannot purge simulated data while runs are Pending or Running. Stop all runs first.", "SIMULATION_PURGE_ACTIVE_RUNS"); await using var tx = await _db.Database.BeginTransactionAsync(ct); var patientIds = await _db.Patients .Where(p => p.IsSimulated) .Select(p => p.Id) .ToListAsync(ct); if (patientIds.Count == 0) { var emptyRunsCleared = await ClearSimulationRunsAsync(ct); await tx.CommitAsync(ct); _runner.ClearRegistry(); var emptyResult = new SimulationPurgeResult(0, 0, 0, emptyRunsCleared); await WriteAuditAsync(emptyResult); return emptyResult; } var encounterIds = await _db.Encounters .Where(e => patientIds.Contains(e.PatientId)) .Select(e => e.Id) .ToListAsync(ct); var alertIds = await _db.ClinicalAlerts .Where(a => patientIds.Contains(a.PatientId)) .Select(a => a.Id) .ToListAsync(ct); var alertsDeleted = alertIds.Count; var encountersDeleted = encounterIds.Count; var patientsDeleted = patientIds.Count; // FK order — Restrict relationships require dependents first. if (encounterIds.Count > 0) { await _db.MedicationAdministrations .Where(m => encounterIds.Contains(m.EncounterId)) .ExecuteDeleteAsync(ct); var bundleIds = await _db.SepsisBundles .Where(b => encounterIds.Contains(b.EncounterId)) .Select(b => b.Id) .ToListAsync(ct); if (bundleIds.Count > 0) { await _db.SepsisBundleElements .Where(e => bundleIds.Contains(e.BundleId)) .ExecuteDeleteAsync(ct); await _db.SepsisBundles .Where(b => bundleIds.Contains(b.Id)) .ExecuteDeleteAsync(ct); } await _db.Orders .Where(o => encounterIds.Contains(o.EncounterId)) .ExecuteDeleteAsync(ct); await _db.ReconciliationAlerts .Where(r => r.EncounterId != null && encounterIds.Contains(r.EncounterId.Value)) .ExecuteDeleteAsync(ct); var encounterKeys = encounterIds.Select(id => id.ToString()).ToList(); await _db.OutboxEvents .Where(o => o.PartitionKey != null && encounterKeys.Contains(o.PartitionKey)) .ExecuteDeleteAsync(ct); await _db.Observations .Where(o => encounterIds.Contains(o.EncounterId)) .ExecuteDeleteAsync(ct); await _db.News2Scores .Where(s => encounterIds.Contains(s.EncounterId)) .ExecuteDeleteAsync(ct); await _db.SofaScores .Where(s => encounterIds.Contains(s.EncounterId)) .ExecuteDeleteAsync(ct); await _db.GcsScores .Where(s => encounterIds.Contains(s.EncounterId)) .ExecuteDeleteAsync(ct); await _db.QsofaEvaluations .Where(s => encounterIds.Contains(s.EncounterId)) .ExecuteDeleteAsync(ct); } if (alertIds.Count > 0) { // AlertFeedback cascades with alert, but delete explicitly for clarity. await _db.AlertFeedbacks .Where(f => alertIds.Contains(f.AlertId)) .ExecuteDeleteAsync(ct); await _db.ClinicalAlerts .Where(a => alertIds.Contains(a.Id)) .ExecuteDeleteAsync(ct); } // Window aggregates are not patient-scoped; clear them so the next tester // starts with a clean quality dashboard after a ward reset. await _db.AlertQualityMetrics.ExecuteDeleteAsync(ct); await _db.PhiAccessLogs .Where(l => l.PatientId != null && patientIds.Contains(l.PatientId.Value)) .ExecuteDeleteAsync(ct); var resourceIds = patientIds.Concat(encounterIds).ToList(); if (resourceIds.Count > 0) { await _db.ExternalResourceIdentifiers .Where(x => resourceIds.Contains(x.InternalId)) .ExecuteDeleteAsync(ct); } if (encounterIds.Count > 0) { await _db.Encounters .Where(e => encounterIds.Contains(e.Id)) .ExecuteDeleteAsync(ct); } await _db.Patients .Where(p => patientIds.Contains(p.Id)) .ExecuteDeleteAsync(ct); var runsCleared = await ClearSimulationRunsAsync(ct); await tx.CommitAsync(ct); _runner.ClearRegistry(); var result = new SimulationPurgeResult( patientsDeleted, encountersDeleted, alertsDeleted, runsCleared); await WriteAuditAsync(result); return result; } private async Task ClearSimulationRunsAsync(CancellationToken ct) { var count = await _db.SimulationRuns.CountAsync(ct); if (count > 0) await _db.SimulationRuns.ExecuteDeleteAsync(ct); return count; } private async Task WriteAuditAsync(SimulationPurgeResult result) { await _audit.WriteAsync( AuditAction.SimulationDataPurged, "SimulationData", Guid.NewGuid(), newValue: new { result.PatientsDeleted, result.EncountersDeleted, result.AlertsDeleted, result.RunsCleared, }); } }