fix:
No SOFA trend chart or organ-system timeline No GCS trend chart or component history No qSOFA history view
This commit is contained in:
@@ -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!;
|
||||
}
|
||||
+1705
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user