feature: Ward Gateway Service (Local-First Clinical Path)

This commit is contained in:
voltsrage
2026-06-23 16:45:38 +08:00
parent d8e142fffe
commit 1bf8359097
100 changed files with 5474 additions and 4 deletions
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Authorize]
public class AlertsController : ControllerBase
{
private readonly ILocalAlertService _alerts;
public AlertsController(ILocalAlertService alerts) => _alerts = alerts;
[HttpGet("api/v1/encounters/{encounterId:guid}/alerts")]
public async Task<IActionResult> ListByEncounter(
Guid encounterId,
[FromQuery] string? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
if (!string.IsNullOrEmpty(status) && !TryParseStatus(status, out _))
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
var result = await _alerts.ListAsync(encounterId, status, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
}
[HttpGet("api/v1/alerts")]
public async Task<IActionResult> ListGlobal(
[FromQuery] string? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
if (!string.IsNullOrEmpty(status) && !TryParseStatus(status, out _))
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
var result = await _alerts.ListAsync(null, status, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
}
[HttpPost("api/v1/alerts/{id:guid}/acknowledge")]
public async Task<IActionResult> Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req)
{
var clinicianId = User.Identity?.Name ?? "unknown";
var alert = await _alerts.AcknowledgeAsync(id, req, clinicianId);
return Ok(ApiResponse<LocalClinicalAlert>.Ok(alert));
}
[HttpPost("api/v1/alerts/{id:guid}/resolve")]
public async Task<IActionResult> Resolve(Guid id)
{
var alert = await _alerts.ResolveAsync(id);
return Ok(ApiResponse<LocalClinicalAlert>.Ok(alert));
}
private static bool TryParseStatus(string status, out AlertStatus _)
{
try
{
_ = AlertStatusExtensions.FromDbString(status);
return true;
}
catch (ArgumentOutOfRangeException)
{
_ = default;
return false;
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Stubs for central-only scoring and bundle endpoints — returns 503 during degraded mode.
/// </summary>
[ApiController]
[Route("api/v1/encounters/{encounterId:guid}")]
[Authorize]
public class CentralRequiredController : ControllerBase
{
[HttpGet("news2")]
public IActionResult News2History(Guid encounterId) =>
CentralRequired("NEWS2 history requires central connection.");
[HttpGet("news2/current")]
public IActionResult News2Current(Guid encounterId) =>
CentralRequired("NEWS2 current score requires central connection.");
[HttpGet("qsofa/current")]
public IActionResult QsofaCurrent(Guid encounterId) =>
CentralRequired("qSOFA current score requires central connection.");
[HttpGet("sofa/current")]
public IActionResult SofaCurrent(Guid encounterId) =>
CentralRequired("SOFA current score requires central connection.");
[HttpGet("sepsis-bundle/current")]
public IActionResult SepsisBundleCurrent(Guid encounterId) =>
CentralRequired("Sepsis bundle status requires central connection.");
private static IActionResult CentralRequired(string message) =>
new ObjectResult(ApiResponse<object>.Fail(503, message, "CENTRAL_REQUIRED"))
{
StatusCode = StatusCodes.Status503ServiceUnavailable
};
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Authorize]
public class EncountersController : ControllerBase
{
private readonly IEncounterReadService _encounters;
public EncountersController(IEncounterReadService encounters) => _encounters = encounters;
[HttpGet("api/v1/encounters")]
public async Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] string? department)
{
if (status is not null and not "ACTIVE")
return BadRequest(ApiResponse<object>.Fail(400, "Gateway supports ACTIVE only.", "INVALID_STATUS"));
if (!string.IsNullOrEmpty(department) && !TryParseDepartment(department, out _))
return BadRequest(ApiResponse<object>.Fail(400, "Invalid department filter.", "INVALID_DEPARTMENT"));
var items = await _encounters.ListActiveAsync(department);
return Ok(ApiResponse<object>.Ok(new { items }));
}
[HttpGet("api/v1/encounters/{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var detail = await _encounters.GetByIdAsync(id);
if (detail is null)
return NotFound(ApiResponse<object>.Fail(404, "Encounter not found.", "ENCOUNTER_NOT_FOUND"));
return Ok(ApiResponse<EncounterDetail>.Ok(detail));
}
private static bool TryParseDepartment(string department, out Department _)
{
try
{
_ = DepartmentExtensions.FromDbString(department);
return true;
}
catch (ArgumentOutOfRangeException)
{
_ = default;
return false;
}
}
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/encounters/{encounterId:guid}/observations")]
[Authorize]
public class ObservationsController : ControllerBase
{
private readonly ILocalObservationService _observations;
private readonly IObservationQueryService _query;
public ObservationsController(
ILocalObservationService observations,
IObservationQueryService query)
{
_observations = observations;
_query = query;
}
[HttpPost]
public async Task<IActionResult> Ingest(Guid encounterId, [FromBody] IngestObservationRequest req)
{
var result = await _observations.IngestAsync(encounterId, req);
if (result.IsDuplicate)
return Ok(ApiResponse<object>.Ok(new { duplicate = true, result.Observation }));
return StatusCode(201, ApiResponse<object>.Created(new
{
observation = result.Observation,
alert = result.Alert
}));
}
[HttpGet]
public async Task<IActionResult> History(
Guid encounterId,
[FromQuery] string? code,
[FromQuery] DateTimeOffset? from,
[FromQuery] DateTimeOffset? to,
[FromQuery] int limit = 20,
[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
}));
}
}