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