feature: Observation Ingest, Synchronous Alert Detection, and Alert Lifecycle

This commit is contained in:
voltsrage
2026-06-16 21:05:06 +08:00
parent 882d4af3e6
commit de603df151
26 changed files with 1471 additions and 5 deletions
@@ -0,0 +1,156 @@
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Clinical alert listing, acknowledgment, and resolution.
/// </summary>
[ApiController]
[Produces("application/json")]
public class AlertsController : ControllerBase
{
private readonly IAlertService _alerts;
public AlertsController(IAlertService alerts) => _alerts = alerts;
/// <summary>
/// Lists alerts for a single encounter with optional status filter.
/// </summary>
/// <param name="encounterId">Encounter id.</param>
/// <param name="status">Optional status filter (DB literal, e.g. OPEN).</param>
/// <param name="page">Page number (1-based).</param>
/// <param name="pageSize">Results per page.</param>
/// <returns>A paginated list of alerts for the encounter.</returns>
[HttpGet("api/v1/encounters/{encounterId:guid}/alerts")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ListByEncounter(
Guid encounterId,
[FromQuery] string? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
AlertStatus? parsedStatus = null;
if (!string.IsNullOrEmpty(status))
{
try
{
parsedStatus = AlertStatusExtensions.FromDbString(status);
}
catch (ArgumentOutOfRangeException)
{
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
}
}
var result = await _alerts.ListByEncounterAsync(encounterId, parsedStatus, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
}
/// <summary>
/// Lists alerts across all encounters with optional status, severity, and department filters.
/// </summary>
/// <param name="status">Optional status filter (DB literal, e.g. OPEN).</param>
/// <param name="severity">Optional severity filter (DB literal, e.g. CRITICAL).</param>
/// <param name="department">Optional department filter.</param>
/// <param name="page">Page number (1-based).</param>
/// <param name="pageSize">Results per page.</param>
/// <returns>A paginated list of alerts.</returns>
[HttpGet("api/v1/alerts")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ListGlobal(
[FromQuery] string? status,
[FromQuery] string? severity,
[FromQuery] string? department,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
AlertStatus? parsedStatus = null;
if (!string.IsNullOrEmpty(status))
{
try
{
parsedStatus = AlertStatusExtensions.FromDbString(status);
}
catch (ArgumentOutOfRangeException)
{
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
}
}
AlertSeverity? parsedSeverity = null;
if (!string.IsNullOrEmpty(severity))
{
try
{
parsedSeverity = AlertSeverityExtensions.FromDbString(severity);
}
catch (ArgumentOutOfRangeException)
{
return BadRequest(ApiResponse<object>.Fail(400, "Invalid severity filter.", "INVALID_SEVERITY"));
}
}
var result = await _alerts.ListGlobalAsync(parsedStatus, parsedSeverity, department, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
}
/// <summary>
/// Gets a single alert by id, including its encounter.
/// </summary>
/// <param name="id">Alert id.</param>
/// <returns>The alert record.</returns>
[HttpGet("api/v1/alerts/{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id)
{
var alert = await _alerts.GetByIdAsync(id);
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
}
/// <summary>
/// Acknowledges an open or escalated alert and emits an outbox event for downstream consumers.
/// </summary>
/// <param name="id">Alert id.</param>
/// <param name="req">Clinician id and optional note.</param>
/// <returns>The updated alert.</returns>
[HttpPost("api/v1/alerts/{id:guid}/acknowledge")]
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req)
{
var alert = await _alerts.AcknowledgeAsync(id, req);
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
}
/// <summary>
/// Resolves an acknowledged alert.
/// </summary>
/// <param name="id">Alert id.</param>
/// <returns>The updated alert.</returns>
[HttpPost("api/v1/alerts/{id:guid}/resolve")]
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Resolve(Guid id)
{
var alert = await _alerts.ResolveAsync(id);
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
}
}
@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Observation ingest and cursor-paginated history for an encounter.
/// </summary>
[ApiController]
[Route("api/v1/encounters/{encounterId:guid}/observations")]
[Produces("application/json")]
public class ObservationsController : ControllerBase
{
private readonly IObservationService _ingest;
private readonly IObservationQueryService _query;
public ObservationsController(IObservationService ingest, IObservationQueryService query)
{
_ingest = ingest;
_query = query;
}
/// <summary>
/// Ingests one to ten observations for an encounter in a single request.
/// </summary>
/// <param name="encounterId">Encounter id.</param>
/// <param name="req">Batch of observations to record.</param>
/// <returns>Per-observation ingest results, including any generated alerts.</returns>
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Ingest(Guid encounterId, [FromBody] BatchIngestRequest req)
{
if (req.Observations.Count == 0)
return BadRequest(ApiResponse<object>.Fail(400, "At least one observation is required.", "EMPTY_BATCH"));
if (req.Observations.Count > 10)
return BadRequest(ApiResponse<object>.Fail(400,
"Batch size cannot exceed 10 observations.", "BATCH_TOO_LARGE"));
var results = new List<object>();
foreach (var obs in req.Observations)
{
var result = await _ingest.IngestAsync(encounterId, obs);
results.Add(new
{
observation = result.Observation,
alertGenerated = result.AlertCreated is not null,
alertId = result.AlertCreated?.Id,
duplicate = result.IsDuplicate
});
}
return StatusCode(201, ApiResponse<object>.Created(
req.Observations.Count == 1 ? (object)results[0] : results));
}
/// <summary>
/// Returns cursor-paginated observation history for an encounter.
/// </summary>
/// <param name="encounterId">Encounter id.</param>
/// <param name="code">Optional observation code filter.</param>
/// <param name="from">Optional start of recorded-at range.</param>
/// <param name="to">Optional end of recorded-at range.</param>
/// <param name="limit">Maximum items per page.</param>
/// <param name="cursor">Opaque cursor from a previous page.</param>
/// <returns>A page of observations with an optional next cursor.</returns>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<IActionResult> History(
Guid encounterId,
[FromQuery] string? code,
[FromQuery] DateTimeOffset? from,
[FromQuery] DateTimeOffset? to,
[FromQuery] int limit = 50,
[FromQuery] string? cursor = null)
{
var page = await _query.GetHistoryAsync(encounterId, code, from, to, limit, cursor);
return Ok(ApiResponse<object>.Ok(new
{
items = page.Items,
nextCursor = page.NextCursor,
hasMore = page.HasMore
}));
}
}