Files
vigilcare-clinical/VigilCareClinicalAPI/Controllers/SimulationController.cs
T
voltsrage 24f45851e9
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 8m32s
feature: In-App Simulation Runner (Backend)
2026-08-06 01:52:53 +08:00

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);
}