feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user