update docs and prep for frontend
This commit is contained in:
@@ -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 (0–3) 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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -36,6 +36,9 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Dashboard": {
|
||||
"CorsOrigins": [ "http://localhost:5173" ]
|
||||
},
|
||||
"Kafka": {
|
||||
"BootstrapServers": "localhost:9092",
|
||||
"Topics": {
|
||||
|
||||
Reference in New Issue
Block a user