feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public interface ISimulationPurgeService
|
||||
{
|
||||
Task<SimulationDataSummary> GetSummaryAsync(CancellationToken ct);
|
||||
Task<SimulationPurgeResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all patients with <c>IsSimulated = true</c> and their dependents.
|
||||
/// Never deletes by encounter or date range — only the simulated-patient predicate.
|
||||
/// </summary>
|
||||
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<SimulationDataSummary> 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<SimulationPurgeResult> 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<int> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user