110 lines
5.0 KiB
C#
110 lines
5.0 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
|
/// <summary>
|
|
/// Elasticsearch-backed analytics queries for population, trends, alerts, and patient search.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/v1/analytics")]
|
|
[Produces("application/json")]
|
|
[AuthorizePermission(ClinicalPermissions.AnalyticsRead)]
|
|
public class AnalyticsController : ControllerBase
|
|
{
|
|
private readonly IAnalyticsService _analytics;
|
|
|
|
public AnalyticsController(IAnalyticsService analytics) => _analytics = analytics;
|
|
|
|
/// <summary>
|
|
/// Counts distinct patients whose observations for a code exceed or fall below a threshold in a time window.
|
|
/// </summary>
|
|
/// <param name="code">Observation code (e.g. HEART_RATE).</param>
|
|
/// <param name="threshold">Numeric threshold value.</param>
|
|
/// <param name="direction">above or below.</param>
|
|
/// <param name="from">Optional start of recorded-at range.</param>
|
|
/// <param name="to">Optional end of recorded-at range.</param>
|
|
/// <returns>Cardinality of unique patients matching the criteria.</returns>
|
|
[HttpGet("population")]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> Population(
|
|
[FromQuery] string? code,
|
|
[FromQuery] decimal threshold,
|
|
[FromQuery] string direction, // "above" | "below"
|
|
[FromQuery] DateTimeOffset? from,
|
|
[FromQuery] DateTimeOffset? to)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
return BadRequest(ApiResponse<object>.Fail(400, "code is required.", "MISSING_CODE"));
|
|
|
|
var result = await _analytics.GetPopulationAsync(code, threshold, direction, from, to);
|
|
return Ok(ApiResponse<object>.Ok(result));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns hourly observation volume for an encounter and observation code.
|
|
/// </summary>
|
|
/// <param name="encounterId">Encounter id.</param>
|
|
/// <param name="code">Observation code.</param>
|
|
/// <param name="from">Optional start of recorded-at range.</param>
|
|
/// <param name="to">Optional end of recorded-at range.</param>
|
|
/// <returns>Hourly trend buckets with document counts.</returns>
|
|
[HttpGet("observations/trend")]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> ObservationTrend(
|
|
[FromQuery] Guid encounterId,
|
|
[FromQuery] string code,
|
|
[FromQuery] DateTimeOffset? from,
|
|
[FromQuery] DateTimeOffset? to)
|
|
{
|
|
var result = await _analytics.GetObservationTrendAsync(encounterId, code, from, to);
|
|
return Ok(ApiResponse<object>.Ok(result));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Summarizes alert volume by department over a time window.
|
|
/// </summary>
|
|
/// <param name="severity">Optional severity filter.</param>
|
|
/// <param name="department">Optional department filter.</param>
|
|
/// <param name="from">Optional start of triggered-at range.</param>
|
|
/// <param name="to">Optional end of triggered-at range.</param>
|
|
/// <returns>Department-level counts (severity breakdown omitted in current implementation).</returns>
|
|
[HttpGet("alerts/summary")]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> AlertSummary(
|
|
[FromQuery] string? severity,
|
|
[FromQuery] string? department,
|
|
[FromQuery] DateTimeOffset? from,
|
|
[FromQuery] DateTimeOffset? to)
|
|
{
|
|
var result = await _analytics.GetAlertSummaryAsync(severity, department, from, to);
|
|
return Ok(ApiResponse<object>.Ok(result));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Searches patient/encounter documents by MRN, name, department, or status.
|
|
/// </summary>
|
|
/// <param name="q">Free-text query (MRN, name, or department).</param>
|
|
/// <param name="department">Optional department filter.</param>
|
|
/// <param name="status">Optional encounter status filter.</param>
|
|
/// <param name="page">Page number (0-based).</param>
|
|
/// <param name="pageSize">Results per page.</param>
|
|
/// <returns>Matching patient/encounter documents.</returns>
|
|
[HttpGet("patients")]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status500InternalServerError)]
|
|
public async Task<IActionResult> PatientSearch(
|
|
[FromQuery] string? q,
|
|
[FromQuery] string? department,
|
|
[FromQuery] string? status,
|
|
[FromQuery] int page = 1,
|
|
[FromQuery] int pageSize = 20)
|
|
{
|
|
var result = await _analytics.SearchPatientsAsync(q, department, status, page, pageSize);
|
|
return Ok(ApiResponse<object>.Ok(result));
|
|
}
|
|
}
|