feature: Self-Service Clinical Testing Sessions
CI / backend (push) Successful in 8m52s
CI / frontend (push) Failing after 1m39s

This commit is contained in:
voltsrage
2026-08-06 04:03:04 +08:00
parent 1d28880920
commit 80b009fd23
41 changed files with 4922 additions and 123 deletions
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
[ApiController]
[Produces("application/json")]
@@ -7,9 +8,15 @@ using Microsoft.AspNetCore.Mvc;
public class AlertQualityMetricsController : ControllerBase
{
private readonly IAlertQualityMetricsService _metrics;
private readonly SimulationOptions _simulation;
public AlertQualityMetricsController(IAlertQualityMetricsService metrics) =>
public AlertQualityMetricsController(
IAlertQualityMetricsService metrics,
IOptions<SimulationOptions> simulation)
{
_metrics = metrics;
_simulation = simulation.Value;
}
/// <summary>
/// Returns alert quality metric snapshots for a time range, optionally filtered by alert type.
@@ -70,4 +77,67 @@ public class AlertQualityMetricsController : ControllerBase
var summary = await _metrics.GetSummaryAsync(periodStart, periodEnd);
return Ok(ApiResponse<AlertQualitySummaryResponse>.Ok(summary));
}
}
/// <summary>
/// Per-alert feedback rows with optional scenario attribution (when simulation is enabled).
/// Filter with <c>?scenarioId=</c> to limit to one simulated scenario.
/// </summary>
[HttpGet("api/v1/alerts/quality-metrics/feedback")]
[AuthorizePermission(ClinicalPermissions.AnalyticsRead)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ListFeedback(
[FromQuery] string? scenarioId,
[FromQuery] DateTimeOffset? from,
[FromQuery] DateTimeOffset? to)
{
var periodEnd = to ?? DateTimeOffset.UtcNow;
var periodStart = from ?? periodEnd.AddDays(-7);
if (periodStart >= periodEnd)
{
return BadRequest(ApiResponse<object>.Fail(
400, "'from' must be before 'to'.", "INVALID_DATE_RANGE"));
}
// Scenario filter only applies when simulation attribution is available.
var filterScenario = _simulation.Enabled ? scenarioId : null;
var rows = await _metrics.ListFeedbackAsync(periodStart, periodEnd, filterScenario);
if (_simulation.Enabled)
{
var withAttribution = rows.Select(r => new
{
id = r.Id,
alertId = r.AlertId,
alertType = r.AlertType,
feedbackType = r.FeedbackType,
comment = r.Comment,
createdAt = r.CreatedAt,
scenarioId = r.ScenarioId,
sessionId = r.SessionId,
});
return Ok(ApiResponse<object>.Ok(new
{
periodStart,
periodEnd,
items = withAttribution,
}));
}
var withoutAttribution = rows.Select(r => new
{
id = r.Id,
alertId = r.AlertId,
alertType = r.AlertType,
feedbackType = r.FeedbackType,
comment = r.Comment,
createdAt = r.CreatedAt,
});
return Ok(ApiResponse<object>.Ok(new
{
periodStart,
periodEnd,
items = withoutAttribution,
}));
}
}
@@ -14,6 +14,8 @@ public class SimulationController : ControllerBase
private readonly SimulationOptions _options;
private readonly ISimulationRunner? _runner;
private readonly IScenarioCatalog? _catalog;
private readonly ISessionCatalog? _sessions;
private readonly ISimulationPurgeService? _purge;
private readonly ICurrentUserService _currentUser;
private readonly IAuditService _audit;
@@ -26,6 +28,8 @@ public class SimulationController : ControllerBase
_options = options.Value;
_runner = services.GetService<ISimulationRunner>();
_catalog = services.GetService<IScenarioCatalog>();
_sessions = services.GetService<ISessionCatalog>();
_purge = services.GetService<ISimulationPurgeService>();
_currentUser = currentUser;
_audit = audit;
}
@@ -59,19 +63,75 @@ public class SimulationController : ControllerBase
{
EnsureEnabled();
var items = _catalog!.ListScenarios()
.Select(s => new ScenarioSummaryResponse(
s.Scenario.Id,
s.Scenario.Name,
s.Scenario.Description,
s.Scenario.DurationMinutes,
s.Scenario.Tags,
s.Encounter.Department,
s.Events.Count,
s.ExpectedOutcomes?.Count ?? 0))
.Select(ToScenarioSummary)
.ToList();
return Ok(ApiResponse<List<ScenarioSummaryResponse>>.Ok(items));
}
/// <summary>
/// Lists session presets with resolved scenario summaries.
/// </summary>
[HttpGet("sessions")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<List<SessionPresetResponse>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public IActionResult ListSessions()
{
EnsureEnabled();
var items = _sessions!.ListSessions()
.Select(preset => new SessionPresetResponse(
preset.Id,
preset.Name,
preset.Goal,
preset.EstimatedMinutes,
preset.DefaultSpeed,
preset.Scenarios
.Select(id => _catalog!.GetById(id))
.Where(s => s is not null)
.Select(s => ToScenarioSummary(s!))
.ToList()))
.ToList();
return Ok(ApiResponse<List<SessionPresetResponse>>.Ok(items));
}
/// <summary>
/// Starts every scenario in a session preset (all-or-nothing admission, staggered launch).
/// </summary>
[HttpPost("sessions/{sessionId}/start")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<SimulationSessionResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> StartSession(
string sessionId,
[FromBody] StartSimulationSessionRequest? req,
CancellationToken ct)
{
EnsureEnabled();
var userId = _currentUser.UserId?.ToString()
?? throw new ValidationException("Authenticated user id is required.", "SIMULATION_USER_REQUIRED");
var state = await _runner!.StartSessionAsync(sessionId, req?.Speed, userId, ct);
await _audit.WriteAsync(
AuditAction.SimulationRunStarted,
"SimulationSession",
Guid.NewGuid(),
newValue: new
{
state.SessionId,
state.Name,
state.RunIds,
Speed = req?.Speed,
StartedBy = userId,
});
return StatusCode(201, ApiResponse<SimulationSessionResponse>.Created(
new SimulationSessionResponse(state.SessionId, state.Name, state.StartedAt, state.RunIds)));
}
/// <summary>
/// Starts a background scenario replay.
/// </summary>
@@ -166,12 +226,64 @@ public class SimulationController : ControllerBase
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
}
/// <summary>
/// Counts simulated patients and dependents currently on the ward.
/// </summary>
[HttpGet("data/summary")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<SimulationDataSummaryResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDataSummary(CancellationToken ct)
{
EnsureEnabled();
var summary = await _purge!.GetSummaryAsync(ct);
return Ok(ApiResponse<SimulationDataSummaryResponse>.Ok(
new SimulationDataSummaryResponse(
summary.SimulatedPatients,
summary.Encounters,
summary.Observations,
summary.Alerts,
summary.ActiveRuns)));
}
/// <summary>
/// Purges all simulated patients and dependents. Refuses while runs are active.
/// </summary>
[HttpDelete("data")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<SimulationPurgeResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> PurgeData(CancellationToken ct)
{
EnsureEnabled();
var result = await _purge!.PurgeAsync(ct);
return Ok(ApiResponse<SimulationPurgeResponse>.Ok(
new SimulationPurgeResponse(
result.PatientsDeleted,
result.EncountersDeleted,
result.AlertsDeleted,
result.RunsCleared)));
}
private void EnsureEnabled()
{
if (!_options.Enabled || _runner is null || _catalog is null)
if (!_options.Enabled || _runner is null || _catalog is null
|| _sessions is null || _purge is null)
throw new NotFoundException("Simulation endpoints are not available.");
}
private static ScenarioSummaryResponse ToScenarioSummary(VigilCare.Simulation.ScenarioFile s) =>
new(
s.Scenario.Id,
s.Scenario.Name,
s.Scenario.Description,
s.Scenario.DurationMinutes,
s.Scenario.Tags,
s.Encounter.Department,
s.Events.Count,
s.ExpectedOutcomes?.Count ?? 0);
private static SimulationRunResponse ToResponse(SimulationRunState state) =>
new(
state.RunId,
@@ -181,6 +293,7 @@ public class SimulationController : ControllerBase
state.Speed,
state.PatientId,
state.EncounterId,
state.SessionId,
state.PatientDisplayName,
state.StartedAt,
state.ElapsedRealSeconds,
@@ -17,6 +17,7 @@ public class SimulationRunConfiguration : IEntityTypeConfiguration<SimulationRun
v => SimulationRunStatusExtensions.FromDbString(v));
builder.Property(r => r.PatientId).HasColumnName("patient_id");
builder.Property(r => r.EncounterId).HasColumnName("encounter_id");
builder.Property(r => r.SessionId).HasColumnName("session_id").HasMaxLength(100);
builder.Property(r => r.StartedByUserId).HasColumnName("started_by_user_id").HasMaxLength(100).IsRequired();
builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.CompletedAt).HasColumnName("completed_at");
@@ -7,6 +7,8 @@ public class SimulationRun
public SimulationRunStatus Status { get; set; }
public Guid? PatientId { get; set; }
public Guid? EncounterId { get; set; }
/// <summary>Session preset id when started via a Phase 38 session; null for single-scenario starts.</summary>
public string? SessionId { get; set; }
public string StartedByUserId { get; set; } = null!;
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
@@ -16,6 +16,7 @@ public enum AuditAction
TokenRefreshed,
SimulationRunStarted,
SimulationRunStopped,
SimulationDataPurged,
}
public static class AuditActionExtensions
@@ -38,6 +39,7 @@ public static class AuditActionExtensions
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
AuditAction.SimulationRunStarted => "SIMULATION_RUN_STARTED",
AuditAction.SimulationRunStopped => "SIMULATION_RUN_STOPPED",
AuditAction.SimulationDataPurged => "SIMULATION_DATA_PURGED",
_ => throw new ArgumentOutOfRangeException(nameof(a))
};
@@ -59,6 +61,7 @@ public static class AuditActionExtensions
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
"SIMULATION_RUN_STARTED" => AuditAction.SimulationRunStarted,
"SIMULATION_RUN_STOPPED" => AuditAction.SimulationRunStopped,
"SIMULATION_DATA_PURGED" => AuditAction.SimulationDataPurged,
_ => throw new ArgumentOutOfRangeException(nameof(v))
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSimulationSessionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "session_id",
table: "simulation_runs",
type: "character varying(100)",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "session_id",
table: "simulation_runs");
}
}
}
@@ -1587,6 +1587,11 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnType("character varying(200)")
.HasColumnName("scenario_name");
b.Property<string>("SessionId")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("session_id");
b.Property<double>("Speed")
.HasColumnType("double precision")
.HasColumnName("speed");
@@ -0,0 +1,9 @@
public record AlertFeedbackQualityRow(
Guid Id,
Guid AlertId,
string AlertType,
string FeedbackType,
string? Comment,
DateTimeOffset CreatedAt,
string? ScenarioId,
string? SessionId);
@@ -15,6 +15,35 @@ public record ScenarioSummaryResponse(
public record StartSimulationRunRequest(string ScenarioId, double Speed = 60);
public record StartSimulationSessionRequest(double? Speed = null);
public record SessionPresetResponse(
string Id,
string Name,
string Goal,
int EstimatedMinutes,
double DefaultSpeed,
IReadOnlyList<ScenarioSummaryResponse> Scenarios);
public record SimulationSessionResponse(
string SessionId,
string Name,
DateTimeOffset StartedAt,
IReadOnlyList<Guid> RunIds);
public record SimulationDataSummaryResponse(
int SimulatedPatients,
int Encounters,
int Observations,
int Alerts,
int ActiveRuns);
public record SimulationPurgeResponse(
int PatientsDeleted,
int EncountersDeleted,
int AlertsDeleted,
int RunsCleared);
public record SimulationRunResponse(
Guid RunId,
string ScenarioId,
@@ -23,6 +52,7 @@ public record SimulationRunResponse(
double Speed,
Guid? PatientId,
Guid? EncounterId,
string? SessionId,
string PatientDisplayName,
DateTimeOffset StartedAt,
double ElapsedRealSeconds,
+2
View File
@@ -179,9 +179,11 @@ try
c.BaseAddress = new Uri(simulationOptions.LoopbackBaseUrl));
builder.Services.AddSingleton<ISimulationClientFactory, SimulationClientFactory>();
builder.Services.AddSingleton<IScenarioCatalog, ScenarioCatalog>();
builder.Services.AddSingleton<ISessionCatalog, SessionCatalog>();
builder.Services.AddSingleton<SimulationRunner>();
builder.Services.AddSingleton<ISimulationRunner>(sp => sp.GetRequiredService<SimulationRunner>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<SimulationRunner>());
builder.Services.AddScoped<ISimulationPurgeService, SimulationPurgeService>();
}
builder.Services.AddCors(options =>
@@ -63,6 +63,51 @@ public class AlertQualityMetricsService : IAlertQualityMetricsService
: weightedResolveSeconds / snapshots.Sum(s => s.ResolvedCount));
}
public async Task<IReadOnlyList<AlertFeedbackQualityRow>> ListFeedbackAsync(
DateTimeOffset periodStart, DateTimeOffset periodEnd, string? scenarioId)
{
// Left-join ClinicalAlert → SimulationRun on EncounterId for attribution.
var query =
from f in _db.AlertFeedbacks.AsNoTracking()
join a in _db.ClinicalAlerts.AsNoTracking() on f.AlertId equals a.Id
join r in _db.SimulationRuns.AsNoTracking()
on a.EncounterId equals r.EncounterId into runs
from r in runs.DefaultIfEmpty()
where f.CreatedAt >= periodStart && f.CreatedAt <= periodEnd
select new { Feedback = f, Alert = a, Run = r };
if (!string.IsNullOrWhiteSpace(scenarioId))
{
var needle = scenarioId.ToLowerInvariant();
query = query.Where(x =>
x.Run != null
&& x.Run.ScenarioId != null
&& x.Run.ScenarioId.ToLower() == needle);
}
var rows = await query
.OrderByDescending(x => x.Feedback.CreatedAt)
.ToListAsync();
// Distinct by feedback id in case multiple SimulationRun rows share an EncounterId.
return rows
.GroupBy(x => x.Feedback.Id)
.Select(g =>
{
var x = g.First();
return new AlertFeedbackQualityRow(
x.Feedback.Id,
x.Alert.Id,
x.Alert.AlertType.ToDbString(),
x.Feedback.FeedbackType.ToDbString(),
x.Feedback.Comment,
x.Feedback.CreatedAt,
x.Run?.ScenarioId,
x.Run?.SessionId);
})
.ToList();
}
private static AlertQualityMetricResponse Map(AlertQualityMetric m) =>
new(
m.Id,
@@ -84,4 +129,4 @@ public class AlertQualityMetricsService : IAlertQualityMetricsService
m.AvgSecondsToAcknowledge,
m.AvgSecondsToResolution,
m.ComputedAt);
}
}
@@ -5,4 +5,12 @@ public interface IAlertQualityMetricsService
Task<AlertQualitySummaryResponse> GetSummaryAsync(
DateTimeOffset from, DateTimeOffset to);
}
/// <summary>
/// Per-alert feedback rows for analytics export / by-scenario breakdown.
/// When <paramref name="scenarioId"/> is set, only rows attributed to that
/// simulation scenario are returned (join via SimulationRun.EncounterId).
/// </summary>
Task<IReadOnlyList<AlertFeedbackQualityRow>> ListFeedbackAsync(
DateTimeOffset periodStart, DateTimeOffset periodEnd, string? scenarioId);
}
@@ -44,8 +44,12 @@ public sealed class ScenarioCatalog : IScenarioCatalog
}
var disk = Directory.EnumerateFiles(_directory, "*.json")
.Where(p => !string.Equals(
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
.Where(p =>
{
var name = Path.GetFileName(p);
return !string.Equals(name, "schema.json", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(name, "sessions.json", StringComparison.OrdinalIgnoreCase);
})
.Select(p => (Path: p, LastWriteUtc: File.GetLastWriteTimeUtc(p)))
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
.ToList();
@@ -0,0 +1,139 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
public interface ISessionCatalog
{
IReadOnlyList<SessionPreset> ListSessions();
SessionPreset? GetById(string sessionId);
}
public sealed record SessionPreset(
string Id,
string Name,
string Goal,
int EstimatedMinutes,
double DefaultSpeed,
IReadOnlyList<string> Scenarios);
/// <summary>
/// Loads <c>sessions.json</c> from the scenario directory (or its parent) and
/// validates every referenced scenario id against <see cref="IScenarioCatalog"/>
/// at construction — a typo fails loudly at startup.
/// </summary>
public sealed class SessionCatalog : ISessionCatalog
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly IReadOnlyList<SessionPreset> _sessions;
public SessionCatalog(IOptions<SimulationOptions> options, IScenarioCatalog scenarios)
{
var directory = options.Value.ScenarioDirectory;
var path = ResolveManifestPath(directory)
?? throw new InvalidOperationException(
$"Simulation session manifest not found. Expected sessions.json in or beside " +
$"ScenarioDirectory '{Path.GetFullPath(directory)}'.");
_sessions = LoadAndValidate(path, scenarios);
}
public IReadOnlyList<SessionPreset> ListSessions() => _sessions;
public SessionPreset? GetById(string sessionId) =>
_sessions.FirstOrDefault(s =>
string.Equals(s.Id, sessionId, StringComparison.OrdinalIgnoreCase));
internal static string? ResolveManifestPath(string scenarioDirectory)
{
var inDir = Path.Combine(scenarioDirectory, "sessions.json");
if (File.Exists(inDir))
return inDir;
var parent = Directory.GetParent(Path.GetFullPath(scenarioDirectory))?.FullName;
if (parent is not null)
{
var beside = Path.Combine(parent, "sessions.json");
if (File.Exists(beside))
return beside;
}
return null;
}
internal static IReadOnlyList<SessionPreset> LoadAndValidate(
string path, IScenarioCatalog scenarios)
{
SessionsDocument doc;
try
{
var json = File.ReadAllText(path);
doc = JsonSerializer.Deserialize<SessionsDocument>(json, JsonOptions)
?? throw new InvalidOperationException(
$"Session manifest '{path}' deserialized to null.");
}
catch (Exception ex) when (ex is not InvalidOperationException)
{
throw new InvalidOperationException(
$"Failed to load simulation session manifest '{path}': {ex.Message}", ex);
}
if (doc.Sessions is null || doc.Sessions.Count == 0)
throw new InvalidOperationException(
$"Session manifest '{path}' contains no sessions.");
var presets = new List<SessionPreset>(doc.Sessions.Count);
var missing = new List<string>();
foreach (var entry in doc.Sessions)
{
if (string.IsNullOrWhiteSpace(entry.Id))
throw new InvalidOperationException(
$"Session manifest '{path}' has a session with an empty id.");
if (entry.Scenarios is null || entry.Scenarios.Count == 0)
throw new InvalidOperationException(
$"Session '{entry.Id}' in '{path}' lists no scenarios.");
foreach (var scenarioId in entry.Scenarios)
{
if (scenarios.GetById(scenarioId) is null)
missing.Add($"{entry.Id} → {scenarioId}");
}
presets.Add(new SessionPreset(
entry.Id,
entry.Name ?? entry.Id,
entry.Goal ?? string.Empty,
entry.EstimatedMinutes,
entry.DefaultSpeed <= 0 ? 60 : entry.DefaultSpeed,
entry.Scenarios.AsReadOnly()));
}
if (missing.Count > 0)
{
throw new InvalidOperationException(
"Session manifest references unknown scenario id(s): " +
string.Join("; ", missing));
}
return presets;
}
private sealed class SessionsDocument
{
public List<SessionEntry>? Sessions { get; set; }
}
private sealed class SessionEntry
{
public string Id { get; set; } = null!;
public string? Name { get; set; }
public string? Goal { get; set; }
public int EstimatedMinutes { get; set; }
public double DefaultSpeed { get; set; }
public List<string>? Scenarios { get; set; }
}
}
@@ -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,
});
}
}
@@ -7,6 +7,7 @@ public sealed class SimulationRunState
public string ScenarioId { get; init; } = null!;
public string ScenarioName { get; init; } = null!;
public double Speed { get; init; }
public string? SessionId { get; init; }
public string StartedByUserId { get; init; } = null!;
public DateTimeOffset StartedAt { get; init; }
public double TotalOffsetMinutes { get; init; }
@@ -105,6 +106,7 @@ public sealed class SimulationRunState
ScenarioId = ScenarioId,
ScenarioName = ScenarioName,
Speed = Speed,
SessionId = SessionId,
StartedByUserId = StartedByUserId,
StartedAt = StartedAt,
TotalOffsetMinutes = TotalOffsetMinutes,
@@ -8,15 +8,23 @@ public interface ISimulationRunner
IReadOnlyList<SimulationRunState> ListRuns();
SimulationRunState? GetRun(Guid runId);
Task<SimulationRunState> StartAsync(
string scenarioId, double speed, string startedByUserId, CancellationToken ct);
string scenarioId, double speed, string startedByUserId, CancellationToken ct,
string? sessionId = null);
Task<SimulationSessionState> StartSessionAsync(
string sessionId, double? speed, string userId, CancellationToken ct);
bool Cancel(Guid runId);
bool HasActiveRuns();
void ClearRegistry();
}
public sealed class SimulationRunner : ISimulationRunner, IHostedService
{
private static readonly TimeSpan SessionStagger = TimeSpan.FromMilliseconds(500);
private readonly ConcurrentDictionary<Guid, RunContext> _runs = new();
private readonly ISimulationClientFactory _clientFactory;
private readonly IScenarioCatalog _catalog;
private readonly ISessionCatalog _sessions;
private readonly IServiceScopeFactory _scopeFactory;
private readonly SimulationOptions _options;
private readonly ILogger<SimulationRunner> _logger;
@@ -24,12 +32,14 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
public SimulationRunner(
ISimulationClientFactory clientFactory,
IScenarioCatalog catalog,
ISessionCatalog sessions,
IServiceScopeFactory scopeFactory,
IOptions<SimulationOptions> options,
ILogger<SimulationRunner> logger)
{
_clientFactory = clientFactory;
_catalog = catalog;
_sessions = sessions;
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
@@ -44,8 +54,15 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
public SimulationRunState? GetRun(Guid runId) =>
_runs.TryGetValue(runId, out var ctx) ? ctx.State.Snapshot() : null;
public bool HasActiveRuns() =>
_runs.Values.Any(c =>
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
public void ClearRegistry() => _runs.Clear();
public async Task<SimulationRunState> StartAsync(
string scenarioId, double speed, string startedByUserId, CancellationToken ct)
string scenarioId, double speed, string startedByUserId, CancellationToken ct,
string? sessionId = null)
{
if (!_options.Enabled)
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
@@ -68,34 +85,77 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
$"Maximum concurrent simulation runs ({_options.MaxConcurrentRuns}) reached.",
"SIMULATION_CONCURRENCY_LIMIT");
var totalOffset = scenario.Events.Count == 0
? 0
: scenario.Events.Max(e => e.OffsetMinutes);
return await RegisterAndStartAsync(scenario, speed, startedByUserId, sessionId, ct);
}
var runId = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
var state = new SimulationRunState
public async Task<SimulationSessionState> StartSessionAsync(
string sessionId, double? speed, string userId, CancellationToken ct)
{
if (!_options.Enabled)
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
if (string.IsNullOrWhiteSpace(sessionId))
throw new ValidationException("sessionId is required.", "SIMULATION_SESSION_REQUIRED");
var preset = _sessions.GetById(sessionId)
?? throw new ValidationException(
$"Unknown session '{sessionId}'.", "SIMULATION_SESSION_UNKNOWN");
var resolvedSpeed = speed ?? preset.DefaultSpeed;
if (resolvedSpeed <= 0 || resolvedSpeed > _options.MaxSpeed)
throw new ValidationException(
$"speed must be > 0 and <= {_options.MaxSpeed}.", "SIMULATION_SPEED_INVALID");
// Resolve every scenario up front so a typo never half-starts a session.
var scenarios = new List<ScenarioFile>(preset.Scenarios.Count);
foreach (var id in preset.Scenarios)
{
RunId = runId,
ScenarioId = scenario.Scenario.Id,
ScenarioName = scenario.Scenario.Name,
Speed = speed,
StartedByUserId = startedByUserId,
var scenario = _catalog.GetById(id)
?? throw new ValidationException(
$"Unknown scenario '{id}' in session '{preset.Id}'.",
"SIMULATION_SCENARIO_UNKNOWN");
scenarios.Add(scenario);
}
// All-or-nothing admission — check capacity before starting anything.
var activeCount = _runs.Values.Count(c =>
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
if (activeCount + scenarios.Count > _options.MaxConcurrentRuns)
throw new ConflictException(
$"Session '{preset.Id}' needs {scenarios.Count} runs but only " +
$"{_options.MaxConcurrentRuns - activeCount} slot(s) remain " +
$"(max {_options.MaxConcurrentRuns}).",
"SIMULATION_CONCURRENCY_LIMIT");
var startedAt = DateTimeOffset.UtcNow;
var started = new List<SimulationRunState>(scenarios.Count);
try
{
for (var i = 0; i < scenarios.Count; i++)
{
if (i > 0)
await Task.Delay(SessionStagger, ct);
var state = await RegisterAndStartAsync(
scenarios[i], resolvedSpeed, userId, preset.Id, ct);
started.Add(state);
}
}
catch
{
foreach (var run in started)
Cancel(run.RunId);
throw;
}
return new SimulationSessionState
{
SessionId = preset.Id,
Name = preset.Name,
StartedAt = startedAt,
TotalOffsetMinutes = totalOffset,
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
RunIds = started.Select(s => s.RunId).ToList(),
};
await PersistNewRunAsync(state, ct);
var cts = new CancellationTokenSource();
var ctx = new RunContext(state, cts, scenario);
if (!_runs.TryAdd(runId, ctx))
throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED");
_ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None);
return state.Snapshot();
}
public bool Cancel(Guid runId)
@@ -131,6 +191,44 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
}
}
private async Task<SimulationRunState> RegisterAndStartAsync(
ScenarioFile scenario,
double speed,
string startedByUserId,
string? sessionId,
CancellationToken ct)
{
var totalOffset = scenario.Events.Count == 0
? 0
: scenario.Events.Max(e => e.OffsetMinutes);
var runId = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
var state = new SimulationRunState
{
RunId = runId,
ScenarioId = scenario.Scenario.Id,
ScenarioName = scenario.Scenario.Name,
Speed = speed,
SessionId = sessionId,
StartedByUserId = startedByUserId,
StartedAt = startedAt,
TotalOffsetMinutes = totalOffset,
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
};
await PersistNewRunAsync(state, ct);
var cts = new CancellationTokenSource();
var ctx = new RunContext(state, cts, scenario);
if (!_runs.TryAdd(runId, ctx))
throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED");
_ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None);
return state.Snapshot();
}
private async Task ExecuteAsync(RunContext ctx, CancellationToken _)
{
var runId = ctx.State.RunId;
@@ -198,6 +296,7 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
ScenarioName = state.ScenarioName,
Speed = state.Speed,
Status = SimulationRunStatus.Pending,
SessionId = state.SessionId,
StartedByUserId = state.StartedByUserId,
StartedAt = state.StartedAt,
TotalOffsetMinutes = state.TotalOffsetMinutes,
@@ -221,6 +320,7 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
row.Status = status;
row.PatientId = state.PatientId;
row.EncounterId = state.EncounterId;
row.SessionId = state.SessionId;
row.ObservationsSent = state.ObservationsSent;
row.MedicationsSent = state.MedicationsSent;
row.OrdersPlaced = state.OrdersPlaced;
@@ -0,0 +1,7 @@
public sealed class SimulationSessionState
{
public string SessionId { get; init; } = null!;
public string Name { get; init; } = null!;
public DateTimeOffset StartedAt { get; init; }
public IReadOnlyList<Guid> RunIds { get; init; } = Array.Empty<Guid>();
}