feature: In-App Simulation Runner (Backend)
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
/// <summary>
|
||||
/// In-app scenario catalogue and run control for clinical testing sessions.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/simulation")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class SimulationController : ControllerBase
|
||||
{
|
||||
private readonly SimulationOptions _options;
|
||||
private readonly ISimulationRunner? _runner;
|
||||
private readonly IScenarioCatalog? _catalog;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public SimulationController(
|
||||
IOptions<SimulationOptions> options,
|
||||
IServiceProvider services,
|
||||
ICurrentUserService currentUser,
|
||||
IAuditService audit)
|
||||
{
|
||||
_options = options.Value;
|
||||
_runner = services.GetService<ISimulationRunner>();
|
||||
_catalog = services.GetService<IScenarioCatalog>();
|
||||
_currentUser = currentUser;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feature-detect simulation availability without requiring simulation:run.
|
||||
/// Returns enabled=false when the feature is off (never 404).
|
||||
/// </summary>
|
||||
[HttpGet("config")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SimulationConfigResponse>), StatusCodes.Status200OK)]
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return Ok(ApiResponse<SimulationConfigResponse>.Ok(new SimulationConfigResponse(Enabled: false)));
|
||||
|
||||
return Ok(ApiResponse<SimulationConfigResponse>.Ok(new SimulationConfigResponse(
|
||||
Enabled: true,
|
||||
MaxSpeed: _options.MaxSpeed,
|
||||
MaxConcurrentRuns: _options.MaxConcurrentRuns)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists available scenario files from the configured scenario directory.
|
||||
/// </summary>
|
||||
[HttpGet("scenarios")]
|
||||
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<ScenarioSummaryResponse>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public IActionResult ListScenarios()
|
||||
{
|
||||
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))
|
||||
.ToList();
|
||||
return Ok(ApiResponse<List<ScenarioSummaryResponse>>.Ok(items));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a background scenario replay.
|
||||
/// </summary>
|
||||
[HttpPost("runs")]
|
||||
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> StartRun(
|
||||
[FromBody] StartSimulationRunRequest req, CancellationToken ct)
|
||||
{
|
||||
EnsureEnabled();
|
||||
|
||||
var userId = _currentUser.UserId?.ToString()
|
||||
?? throw new ValidationException("Authenticated user id is required.", "SIMULATION_USER_REQUIRED");
|
||||
|
||||
var state = await _runner!.StartAsync(req.ScenarioId, req.Speed, userId, ct);
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.SimulationRunStarted,
|
||||
"SimulationRun",
|
||||
state.RunId,
|
||||
newValue: new
|
||||
{
|
||||
state.ScenarioId,
|
||||
state.ScenarioName,
|
||||
state.Speed,
|
||||
StartedBy = userId,
|
||||
});
|
||||
|
||||
return StatusCode(201, ApiResponse<SimulationRunResponse>.Created(ToResponse(state)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists active and recent simulation runs from the in-memory registry.
|
||||
/// </summary>
|
||||
[HttpGet("runs")]
|
||||
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<SimulationRunResponse>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public IActionResult ListRuns()
|
||||
{
|
||||
EnsureEnabled();
|
||||
var items = _runner!.ListRuns().Select(ToResponse).ToList();
|
||||
return Ok(ApiResponse<List<SimulationRunResponse>>.Ok(items));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets one simulation run by id.
|
||||
/// </summary>
|
||||
[HttpGet("runs/{runId:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetRun(Guid runId)
|
||||
{
|
||||
EnsureEnabled();
|
||||
var state = _runner!.GetRun(runId)
|
||||
?? throw new NotFoundException($"Simulation run '{runId}' was not found.");
|
||||
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops an in-flight run. Idempotent — stopping a finished run succeeds as a no-op.
|
||||
/// </summary>
|
||||
[HttpPost("runs/{runId:guid}/stop")]
|
||||
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> StopRun(Guid runId)
|
||||
{
|
||||
EnsureEnabled();
|
||||
|
||||
if (!_runner!.Cancel(runId))
|
||||
throw new NotFoundException($"Simulation run '{runId}' was not found.");
|
||||
|
||||
var state = _runner.GetRun(runId)
|
||||
?? throw new NotFoundException($"Simulation run '{runId}' was not found.");
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.SimulationRunStopped,
|
||||
"SimulationRun",
|
||||
runId,
|
||||
newValue: new
|
||||
{
|
||||
state.ScenarioId,
|
||||
state.Status,
|
||||
StoppedBy = _currentUser.UserId?.ToString(),
|
||||
});
|
||||
|
||||
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
|
||||
}
|
||||
|
||||
private void EnsureEnabled()
|
||||
{
|
||||
if (!_options.Enabled || _runner is null || _catalog is null)
|
||||
throw new NotFoundException("Simulation endpoints are not available.");
|
||||
}
|
||||
|
||||
private static SimulationRunResponse ToResponse(SimulationRunState state) =>
|
||||
new(
|
||||
state.RunId,
|
||||
state.ScenarioId,
|
||||
state.ScenarioName,
|
||||
state.Status.ToDbString(),
|
||||
state.Speed,
|
||||
state.PatientId,
|
||||
state.EncounterId,
|
||||
state.PatientDisplayName,
|
||||
state.StartedAt,
|
||||
state.ElapsedRealSeconds,
|
||||
state.LastOffsetMinutes,
|
||||
state.TotalOffsetMinutes,
|
||||
state.ProgressPercent,
|
||||
state.ObservationsSent,
|
||||
state.MedicationsSent,
|
||||
state.OrdersPlaced,
|
||||
state.FailureReason);
|
||||
}
|
||||
Reference in New Issue
Block a user