using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; [ApiController] [Produces("application/json")] [Authorize] public class AlertQualityMetricsController : ControllerBase { private readonly IAlertQualityMetricsService _metrics; private readonly SimulationOptions _simulation; public AlertQualityMetricsController( IAlertQualityMetricsService metrics, IOptions simulation) { _metrics = metrics; _simulation = simulation.Value; } /// /// Returns alert quality metric snapshots for a time range, optionally filtered by alert type. /// [HttpGet("api/v1/alerts/quality-metrics")] [AuthorizePermission(ClinicalPermissions.AnalyticsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] public async Task List( [FromQuery] string? alertType, [FromQuery] DateTimeOffset? from, [FromQuery] DateTimeOffset? to) { AlertType? parsedType = null; if (!string.IsNullOrEmpty(alertType)) { try { parsedType = AlertTypeExtensions.FromDbString(alertType); } catch (ArgumentOutOfRangeException) { return BadRequest(ApiResponse.Fail( 400, "Invalid alert type filter.", "INVALID_ALERT_TYPE")); } } var periodEnd = to ?? DateTimeOffset.UtcNow; var periodStart = from ?? periodEnd.AddDays(-7); if (periodStart >= periodEnd) { return BadRequest(ApiResponse.Fail( 400, "'from' must be before 'to'.", "INVALID_DATE_RANGE")); } var items = await _metrics.ListAsync(parsedType, periodStart, periodEnd); return Ok(ApiResponse.Ok(new { periodStart, periodEnd, items })); } /// /// Returns aggregate alert quality rates across all alert types for a time range. /// [HttpGet("api/v1/alerts/quality-metrics/summary")] [AuthorizePermission(ClinicalPermissions.AnalyticsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task Summary( [FromQuery] DateTimeOffset? from, [FromQuery] DateTimeOffset? to) { var periodEnd = to ?? DateTimeOffset.UtcNow; var periodStart = from ?? periodEnd.AddDays(-7); var summary = await _metrics.GetSummaryAsync(periodStart, periodEnd); return Ok(ApiResponse.Ok(summary)); } /// /// Per-alert feedback rows with optional scenario attribution (when simulation is enabled). /// Filter with ?scenarioId= to limit to one simulated scenario. /// [HttpGet("api/v1/alerts/quality-metrics/feedback")] [AuthorizePermission(ClinicalPermissions.AnalyticsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] public async Task 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.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.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.Ok(new { periodStart, periodEnd, items = withoutAttribution, })); } }