195 lines
7.2 KiB
C#
195 lines
7.2 KiB
C#
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);
|
|
}
|