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
@@ -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,