update docs and prep for frontend

This commit is contained in:
voltsrage
2026-06-19 15:44:58 +08:00
parent abc781c9c0
commit 49973271e5
20 changed files with 1071 additions and 26 deletions
@@ -0,0 +1,6 @@
public class DashboardOptions
{
public const string Section = "Dashboard";
public string[] CorsOrigins { get; set; } = ["http://localhost:5173"];
}
@@ -13,6 +13,59 @@ public class EncountersController : ControllerBase
public EncountersController(IEncounterService encounters) => _encounters = encounters;
/// <summary>
/// Lists encounters for ward dashboards with denormalized clinical summary fields.
/// </summary>
/// <param name="status">Optional status filter (DB literal, e.g. ACTIVE).</param>
/// <param name="department">Optional department filter (DB literal, e.g. ICU).</param>
/// <param name="page">Page number (1-based).</param>
/// <param name="pageSize">Results per page.</param>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] string? department,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
EncounterStatus? parsedStatus = null;
if (!string.IsNullOrEmpty(status))
{
try
{
parsedStatus = EncounterStatusExtensions.FromDbString(status);
}
catch (ArgumentOutOfRangeException)
{
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
}
}
Department? parsedDepartment = null;
if (!string.IsNullOrEmpty(department))
{
try
{
parsedDepartment = DepartmentExtensions.FromDbString(department);
}
catch (ArgumentOutOfRangeException)
{
return BadRequest(ApiResponse<object>.Fail(400, "Invalid department filter.", "INVALID_DEPARTMENT"));
}
}
var result = await _encounters.ListAsync(parsedStatus, parsedDepartment, 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 an encounter with patient, recent observations, and open alerts.
/// </summary>
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// qSOFA scoring: current active criteria count per encounter (Redis-backed).
/// </summary>
[ApiController]
[Route("api/v1/encounters/{encounterId:guid}/qsofa")]
[Produces("application/json")]
public class QsofaController : ControllerBase
{
private readonly IQsofaService _qsofa;
public QsofaController(IQsofaService qsofa) => _qsofa = qsofa;
/// <summary>
/// Returns the current qSOFA active criteria count (03) for an encounter.
/// </summary>
[HttpGet("current")]
[ProducesResponseType(typeof(ApiResponse<QsofaCurrentResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(Guid encounterId)
{
var score = await _qsofa.GetCurrentAsync(encounterId);
return Ok(ApiResponse<QsofaCurrentResponse>.Ok(score));
}
}
@@ -0,0 +1,15 @@
public record WardEncounterSummary(
Guid EncounterId,
Guid PatientId,
string Mrn,
string FirstName,
string LastName,
string? RoomBed,
Department Department,
EncounterStatus Status,
int? News2Score,
string? News2RiskLevel,
int QsofaScore,
bool SepsisActive,
SepsisBundleComplianceStatus? SepsisBundleStatus,
int OpenAlertCount);
@@ -0,0 +1,8 @@
public record QsofaCurrentResponse(
int ActiveCriteria,
QsofaCriteriaState Criteria);
public record QsofaCriteriaState(
decimal? RespRate,
decimal? SystolicBp,
decimal? Avpu);
+20
View File
@@ -74,6 +74,23 @@ try
builder.Services.Configure<MedicationCorrelationOptions>(
builder.Configuration.GetSection(MedicationCorrelationOptions.SectionName));
builder.Services.Configure<DashboardOptions>(
builder.Configuration.GetSection(DashboardOptions.Section));
var dashboardOptions = builder.Configuration
.GetSection(DashboardOptions.Section)
.Get<DashboardOptions>() ?? new DashboardOptions();
builder.Services.AddCors(options =>
{
options.AddPolicy("Dashboard", policy =>
{
policy.WithOrigins(dashboardOptions.CorsOrigins)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddScoped<IPatientService, PatientService>();
builder.Services.AddScoped<IEncounterService, EncounterService>();
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
@@ -83,6 +100,7 @@ try
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
builder.Services.AddScoped<INews2Service, News2Service>();
builder.Services.AddScoped<IQsofaService, QsofaService>();
builder.Services.AddScoped<ISepsisBundleService, SepsisBundleService>();
builder.Services.AddScoped<SepsisAlertHandler>();
builder.Services.AddScoped<SirsDetector>();
@@ -185,6 +203,8 @@ try
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<ExceptionHandlerMiddleware>();
app.UseCors("Dashboard");
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
@@ -14,8 +14,13 @@ public class EncounterService : IEncounterService
};
private readonly AppDbContext _db;
private readonly IQsofaService _qsofa;
public EncounterService(AppDbContext db) => _db = db;
public EncounterService(AppDbContext db, IQsofaService qsofa)
{
_db = db;
_qsofa = qsofa;
}
public async Task<Encounter> GetByIdAsync(Guid id)
{
@@ -31,6 +36,88 @@ public class EncounterService : IEncounterService
return encounter;
}
public async Task<PagedResult<WardEncounterSummary>> ListAsync(
EncounterStatus? status, Department? department, int page, int pageSize)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Encounters
.AsNoTracking()
.Include(e => e.Patient)
.AsQueryable();
if (status.HasValue)
query = query.Where(e => e.Status == status.Value);
if (department.HasValue)
query = query.Where(e => e.Department == department.Value);
var total = await query.CountAsync();
var encounters = await query
.OrderByDescending(e => e.AdmittedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
if (encounters.Count == 0)
return new PagedResult<WardEncounterSummary>([], page, pageSize, total);
var encounterIds = encounters.Select(e => e.Id).ToList();
var news2ByEncounter = (await _db.News2Scores
.AsNoTracking()
.Where(s => encounterIds.Contains(s.EncounterId))
.OrderByDescending(s => s.CalculatedAt)
.ToListAsync())
.GroupBy(s => s.EncounterId)
.ToDictionary(g => g.Key, g => g.First());
var openAlertCounts = await _db.ClinicalAlerts
.AsNoTracking()
.Where(a => encounterIds.Contains(a.EncounterId) && a.Status == AlertStatus.Open)
.GroupBy(a => a.EncounterId)
.Select(g => new { EncounterId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.EncounterId, x => x.Count);
var bundlesByEncounter = (await _db.SepsisBundles
.AsNoTracking()
.Where(b => encounterIds.Contains(b.EncounterId))
.OrderByDescending(b => b.RecognizedAt)
.ToListAsync())
.GroupBy(b => b.EncounterId)
.ToDictionary(g => g.Key, g => g.First());
var summaries = new List<WardEncounterSummary>(encounters.Count);
foreach (var encounter in encounters)
{
news2ByEncounter.TryGetValue(encounter.Id, out var news2);
bundlesByEncounter.TryGetValue(encounter.Id, out var bundle);
openAlertCounts.TryGetValue(encounter.Id, out var openCount);
var qsofaScore = await _qsofa.GetActiveCriteriaCountAsync(encounter.Id);
summaries.Add(new WardEncounterSummary(
encounter.Id,
encounter.PatientId,
encounter.Patient.Mrn,
encounter.Patient.FirstName,
encounter.Patient.LastName,
encounter.RoomBed,
encounter.Department,
encounter.Status,
news2?.TotalScore,
news2?.RiskLevel,
qsofaScore,
bundle is not null && bundle.ComplianceStatus != SepsisBundleComplianceStatus.Compliant,
bundle?.ComplianceStatus,
openCount));
}
return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total);
}
public async Task<EncounterStatusTransitionResult> TransitionStatusAsync(
Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null)
{
@@ -1,6 +1,8 @@
public interface IEncounterService
{
Task<Encounter> GetByIdAsync(Guid id);
Task<PagedResult<WardEncounterSummary>> ListAsync(
EncounterStatus? status, Department? department, int page, int pageSize);
Task<EncounterStatusTransitionResult> TransitionStatusAsync(
Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null);
Task<object> GetTimelineAsync(Guid encounterId);
@@ -0,0 +1,5 @@
public interface IQsofaService
{
Task<QsofaCurrentResponse> GetCurrentAsync(Guid encounterId);
Task<int> GetActiveCriteriaCountAsync(Guid encounterId);
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class QsofaService : IQsofaService
{
private readonly AppDbContext _db;
private readonly IConnectionMultiplexer _redis;
public QsofaService(AppDbContext db, IConnectionMultiplexer redis)
{
_db = db;
_redis = redis;
}
public async Task<QsofaCurrentResponse> GetCurrentAsync(Guid encounterId)
{
await EnsureEncounterExistsAsync(encounterId);
var values = await ReadCriterionValuesAsync(encounterId);
return BuildResponse(values);
}
public async Task<int> GetActiveCriteriaCountAsync(Guid encounterId)
{
var values = await ReadCriterionValuesAsync(encounterId);
return QsofaCalculator.CountActiveCriteria(values);
}
private async Task EnsureEncounterExistsAsync(Guid encounterId)
{
var exists = await _db.Encounters.AnyAsync(e => e.Id == encounterId);
if (!exists)
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
}
private async Task<RedisValue[]> ReadCriterionValuesAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
return await cache.StringGetAsync(QsofaCalculator.AllCriterionKeys(encounterId));
}
private static QsofaCurrentResponse BuildResponse(RedisValue[] values)
{
return new QsofaCurrentResponse(
QsofaCalculator.CountActiveCriteria(values),
new QsofaCriteriaState(
ParseOptionalDecimal(values[0]),
ParseOptionalDecimal(values[1]),
ParseOptionalDecimal(values[2])));
}
private static decimal? ParseOptionalDecimal(RedisValue value) =>
value.HasValue ? decimal.Parse(value.ToString()) : null;
}
+3
View File
@@ -36,6 +36,9 @@
}
},
"AllowedHosts": "*",
"Dashboard": {
"CorsOrigins": [ "http://localhost:5173" ]
},
"Kafka": {
"BootstrapServers": "localhost:9092",
"Topics": {