No SOFA trend chart or organ-system timeline
No GCS trend chart or component history
No qSOFA history view
This commit is contained in:
voltsrage
2026-06-23 18:00:43 +08:00
parent 729c19830c
commit 7751d6df06
30 changed files with 3866 additions and 41 deletions
@@ -27,8 +27,33 @@ public class GcsController : ControllerBase
if (score is null)
return Ok(ApiResponse<GcsScoreResponse?>.Ok(null));
return Ok(ApiResponse<GcsScoreResponse>.Ok(new GcsScoreResponse(
score.EyeScore, score.VerbalScore, score.MotorScore,
score.TotalScore, score.Classification, score.CalculatedAt)));
return Ok(ApiResponse<GcsScoreResponse>.Ok(MapResponse(score)));
}
/// <summary>
/// Returns cursor-paginated GCS score history for an encounter.
/// </summary>
/// <param name="encounterId">Encounter id.</param>
/// <param name="limit">Maximum items per page.</param>
/// <param name="cursor">Opaque cursor from a previous page.</param>
/// <returns>A page of GCS scores with an optional next cursor.</returns>
[HttpGet("history")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<IActionResult> History(
Guid encounterId,
[FromQuery] int limit = 20,
[FromQuery] string? cursor = null)
{
var page = await _gcs.GetHistoryAsync(encounterId, limit, cursor);
return Ok(ApiResponse<object>.Ok(new
{
items = page.Items.Select(MapResponse),
nextCursor = page.NextCursor,
hasMore = page.HasMore
}));
}
private static GcsScoreResponse MapResponse(GcsScore score) =>
new(score.EyeScore, score.VerbalScore, score.MotorScore,
score.TotalScore, score.Classification, score.CalculatedAt);
}
@@ -39,7 +39,11 @@ public class QsofaController : ControllerBase
var page = await _qsofa.GetHistoryAsync(encounterId, limit, cursor);
return Ok(ApiResponse<object>.Ok(new
{
items = page.Items,
items = page.Items.Select(e => new QsofaHistoryResponse(
e.ActiveCriteria,
QsofaCalculator.ParseCriteriaState(e),
e.ScreenAlertFired,
e.EvaluatedAt)),
nextCursor = page.NextCursor,
hasMore = page.HasMore
}));
@@ -25,6 +25,7 @@ public class AppDbContext : DbContext
public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>();
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
public DbSet<QsofaEvaluation> QsofaEvaluations => Set<QsofaEvaluation>();
public DbSet<ExternalResourceIdentifier> ExternalResourceIdentifiers => Set<ExternalResourceIdentifier>();
public DbSet<ClinicalUser> ClinicalUsers => Set<ClinicalUser>();
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class QsofaEvaluationConfiguration : IEntityTypeConfiguration<QsofaEvaluation>
{
public void Configure(EntityTypeBuilder<QsofaEvaluation> builder)
{
builder.ToTable("qsofa_evaluations", t =>
{
t.HasCheckConstraint("chk_qsofa_evaluations_active_criteria",
"active_criteria >= 0 AND active_criteria <= 3");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(e => e.EncounterId).HasColumnName("encounter_id").IsRequired();
builder.Property(e => e.PatientId).HasColumnName("patient_id").IsRequired();
builder.Property(e => e.ActiveCriteria).HasColumnName("active_criteria").IsRequired();
builder.Property(e => e.RespRate).HasColumnName("resp_rate");
builder.Property(e => e.SystolicBp).HasColumnName("systolic_bp");
builder.Property(e => e.Avpu).HasColumnName("avpu");
builder.Property(e => e.ScreenAlertFired).HasColumnName("screen_alert_fired").IsRequired();
builder.Property(e => e.EvaluatedAt).HasColumnName("evaluated_at").IsRequired();
builder.Property(e => e.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(e => e.Encounter)
.WithMany()
.HasForeignKey(e => e.EncounterId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.EncounterId, e.EvaluatedAt });
}
}
@@ -0,0 +1,15 @@
public class QsofaEvaluation
{
public Guid Id { get; set; }
public Guid EncounterId { get; set; }
public Guid PatientId { get; set; }
public int ActiveCriteria { get; set; }
public decimal? RespRate { get; set; }
public decimal? SystolicBp { get; set; }
public decimal? Avpu { get; set; }
public bool ScreenAlertFired { get; set; }
public DateTimeOffset EvaluatedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Encounter Encounter { get; set; } = null!;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddQsofaEvaluations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "qsofa_evaluations",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
active_criteria = table.Column<int>(type: "integer", nullable: false),
resp_rate = table.Column<decimal>(type: "numeric", nullable: true),
systolic_bp = table.Column<decimal>(type: "numeric", nullable: true),
avpu = table.Column<decimal>(type: "numeric", nullable: true),
screen_alert_fired = table.Column<bool>(type: "boolean", nullable: false),
evaluated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qsofa_evaluations", x => x.id);
table.CheckConstraint("chk_qsofa_evaluations_active_criteria", "active_criteria >= 0 AND active_criteria <= 3");
table.ForeignKey(
name: "FK_qsofa_evaluations_encounters_encounter_id",
column: x => x.encounter_id,
principalTable: "encounters",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_qsofa_evaluations_encounter_id_evaluated_at",
table: "qsofa_evaluations",
columns: new[] { "encounter_id", "evaluated_at" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "qsofa_evaluations");
}
}
}
@@ -1122,6 +1122,60 @@ namespace VigilCareClinicalAPI.Migrations
b.ToTable("phi_access_logs", (string)null);
});
modelBuilder.Entity("QsofaEvaluation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int>("ActiveCriteria")
.HasColumnType("integer")
.HasColumnName("active_criteria");
b.Property<decimal?>("Avpu")
.HasColumnType("numeric")
.HasColumnName("avpu");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<DateTimeOffset>("EvaluatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("evaluated_at");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<decimal?>("RespRate")
.HasColumnType("numeric")
.HasColumnName("resp_rate");
b.Property<bool>("ScreenAlertFired")
.HasColumnType("boolean")
.HasColumnName("screen_alert_fired");
b.Property<decimal?>("SystolicBp")
.HasColumnType("numeric")
.HasColumnName("systolic_bp");
b.HasKey("Id");
b.HasIndex("EncounterId", "EvaluatedAt");
b.ToTable("qsofa_evaluations", null, t =>
{
t.HasCheckConstraint("chk_qsofa_evaluations_active_criteria", "active_criteria >= 0 AND active_criteria <= 3");
});
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
@@ -1527,6 +1581,17 @@ namespace VigilCareClinicalAPI.Migrations
b.Navigation("Encounter");
});
modelBuilder.Entity("QsofaEvaluation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
@@ -0,0 +1,22 @@
using System.Text;
using System.Text.Json;
public record GcsScoreCursor(DateTimeOffset CalculatedAt, Guid Id)
{
public string Encode()
{
var json = JsonSerializer.Serialize(this);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
public static GcsScoreCursor? Decode(string? encoded)
{
if (string.IsNullOrEmpty(encoded)) return null;
try
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
return JsonSerializer.Deserialize<GcsScoreCursor>(json);
}
catch { return null; }
}
}
@@ -0,0 +1,22 @@
using System.Text;
using System.Text.Json;
public record QsofaEvaluationCursor(DateTimeOffset EvaluatedAt, Guid Id)
{
public string Encode()
{
var json = JsonSerializer.Serialize(this);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
public static QsofaEvaluationCursor? Decode(string? encoded)
{
if (string.IsNullOrEmpty(encoded)) return null;
try
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
return JsonSerializer.Deserialize<QsofaEvaluationCursor>(json);
}
catch { return null; }
}
}
@@ -0,0 +1,5 @@
public record QsofaHistoryResponse(
int ActiveCriteria,
QsofaCriteriaState Criteria,
bool ScreenAlertFired,
DateTimeOffset EvaluatedAt);
@@ -34,4 +34,16 @@ public static class QsofaCalculator
public static int CountActiveCriteria(RedisValue[] values) =>
values.Count(v => v.HasValue);
public static QsofaCriteriaState ParseCriteriaState(RedisValue[] values) =>
new(
ParseOptionalDecimal(values.ElementAtOrDefault(0)),
ParseOptionalDecimal(values.ElementAtOrDefault(1)),
ParseOptionalDecimal(values.ElementAtOrDefault(2)));
public static QsofaCriteriaState ParseCriteriaState(QsofaEvaluation evaluation) =>
new(evaluation.RespRate, evaluation.SystolicBp, evaluation.Avpu);
private static decimal? ParseOptionalDecimal(RedisValue value) =>
value.HasValue ? decimal.Parse(value.ToString()!) : null;
}
+52 -1
View File
@@ -83,13 +83,64 @@ public class QsofaDetector
var values = await cache.StringGetAsync(allKeys);
var activeCount = QsofaCalculator.CountActiveCriteria(values);
var created = activeCount >= 2
&& await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct);
await PersistEvaluationIfChangedAsync(
encounterId, patientId, activeCount, values, created, ct);
if (activeCount < 2)
return QsofaResult.InsufficientCriteria(activeCount);
var created = await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct);
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
}
private async Task PersistEvaluationIfChangedAsync(
Guid encounterId,
Guid patientId,
int activeCount,
RedisValue[] values,
bool screenAlertFired,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var last = await db.QsofaEvaluations
.AsNoTracking()
.Where(e => e.EncounterId == encounterId)
.OrderByDescending(e => e.EvaluatedAt)
.ThenByDescending(e => e.Id)
.Select(e => new { e.ActiveCriteria, e.ScreenAlertFired })
.FirstOrDefaultAsync(ct);
if (last is not null
&& last.ActiveCriteria == activeCount
&& !screenAlertFired)
{
return;
}
var criteria = QsofaCalculator.ParseCriteriaState(values);
var evaluatedAt = DateTimeOffset.UtcNow;
db.QsofaEvaluations.Add(new QsofaEvaluation
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
ActiveCriteria = activeCount,
RespRate = criteria.RespRate,
SystolicBp = criteria.SystolicBp,
Avpu = criteria.Avpu,
ScreenAlertFired = screenAlertFired,
EvaluatedAt = evaluatedAt,
CreatedAt = evaluatedAt,
});
await db.SaveChangesAsync(ct);
}
private async Task<bool> TryCreateScreenAlertAsync(
Guid encounterId,
Guid patientId,
@@ -14,4 +14,37 @@ public class GcsService : IGcsService
.OrderByDescending(s => s.CalculatedAt)
.FirstOrDefaultAsync();
}
public async Task<CursorPage<GcsScore>> GetHistoryAsync(
Guid encounterId, int limit, string? cursorToken)
{
limit = Math.Clamp(limit, 1, 100);
var cursor = GcsScoreCursor.Decode(cursorToken);
var query = _db.GcsScores
.AsNoTracking()
.Where(s => s.EncounterId == encounterId);
if (cursor is not null)
{
query = query.Where(s =>
s.CalculatedAt < cursor.CalculatedAt ||
(s.CalculatedAt == cursor.CalculatedAt && s.Id.CompareTo(cursor.Id) < 0));
}
var items = await query
.OrderByDescending(s => s.CalculatedAt)
.ThenByDescending(s => s.Id)
.Take(limit + 1)
.ToListAsync();
var hasMore = items.Count > limit;
if (hasMore) items.RemoveAt(limit);
var nextCursor = hasMore
? new GcsScoreCursor(items[^1].CalculatedAt, items[^1].Id).Encode()
: null;
return new CursorPage<GcsScore>(items, nextCursor, hasMore);
}
}
@@ -1,4 +1,5 @@
public interface IGcsService
{
Task<GcsScore?> GetCurrentAsync(Guid encounterId);
Task<CursorPage<GcsScore>> GetHistoryAsync(Guid encounterId, int limit, string? cursorToken);
}
@@ -2,5 +2,5 @@ public interface IQsofaService
{
Task<QsofaCurrentResponse> GetCurrentAsync(Guid encounterId);
Task<int> GetActiveCriteriaCountAsync(Guid encounterId);
Task<CursorPage<ClinicalAlert>> GetHistoryAsync(Guid encounterId, int limit, string? cursor);
Task<CursorPage<QsofaEvaluation>> GetHistoryAsync(Guid encounterId, int limit, string? cursor);
}
+20 -17
View File
@@ -26,28 +26,37 @@ public class QsofaService : IQsofaService
return QsofaCalculator.CountActiveCriteria(values);
}
public async Task<CursorPage<ClinicalAlert>> GetHistoryAsync(
Guid encounterId, int limit, string? cursor)
public async Task<CursorPage<QsofaEvaluation>> GetHistoryAsync(
Guid encounterId, int limit, string? cursorToken)
{
limit = Math.Clamp(limit, 1, 100);
var cursor = QsofaEvaluationCursor.Decode(cursorToken);
var query = _db.ClinicalAlerts
var query = _db.QsofaEvaluations
.AsNoTracking()
.Where(a => a.EncounterId == encounterId
#pragma warning disable CS0618 // Include legacy QSOFA_WARNING rows in history
&& (a.AlertType == AlertType.QsofaScreen || a.AlertType == AlertType.QsofaWarning));
#pragma warning restore CS0618
.Where(e => e.EncounterId == encounterId);
if (cursor is not null)
{
query = query.Where(e =>
e.EvaluatedAt < cursor.EvaluatedAt ||
(e.EvaluatedAt == cursor.EvaluatedAt && e.Id.CompareTo(cursor.Id) < 0));
}
var items = await query
.OrderByDescending(a => a.TriggeredAt)
.ThenByDescending(a => a.Id)
.OrderByDescending(e => e.EvaluatedAt)
.ThenByDescending(e => e.Id)
.Take(limit + 1)
.ToListAsync();
var hasMore = items.Count > limit;
if (hasMore) items.RemoveAt(limit);
return new CursorPage<ClinicalAlert>(items, null, hasMore);
var nextCursor = hasMore
? new QsofaEvaluationCursor(items[^1].EvaluatedAt, items[^1].Id).Encode()
: null;
return new CursorPage<QsofaEvaluation>(items, nextCursor, hasMore);
}
private async Task EnsureEncounterExistsAsync(Guid encounterId)
@@ -67,12 +76,6 @@ public class QsofaService : IQsofaService
{
return new QsofaCurrentResponse(
QsofaCalculator.CountActiveCriteria(values),
new QsofaCriteriaState(
ParseOptionalDecimal(values[0]),
ParseOptionalDecimal(values[1]),
ParseOptionalDecimal(values[2])));
QsofaCalculator.ParseCriteriaState(values));
}
private static decimal? ParseOptionalDecimal(RedisValue value) =>
value.HasValue ? decimal.Parse(value.ToString()) : null;
}