feature: Live Capture (Track B)
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Track B live capture endpoints for credentialed clinicians.
|
||||
/// Observations entered via these endpoints skip the verification queue
|
||||
/// and are promoted synchronously to VigilCareClinical with full
|
||||
/// critical value alerting.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/live-capture")]
|
||||
[Produces("application/json")]
|
||||
[Authorize(Roles = "CLINICIAN")]
|
||||
public class LiveCaptureController : ControllerBase
|
||||
{
|
||||
private readonly ILiveCaptureService _liveCapture;
|
||||
|
||||
public LiveCaptureController(ILiveCaptureService liveCapture) =>
|
||||
_liveCapture = liveCapture;
|
||||
|
||||
/// <summary>
|
||||
/// Records observations against an existing VigilCareClinical encounter.
|
||||
/// Requires clinician attestation and password re-confirmation.
|
||||
/// Returns promoted observation IDs and any synchronous critical alerts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Track B workflow: no verification queue. Clinician attestation replaces
|
||||
/// the dual-human gate used in Track A (backfill). Each observation is
|
||||
/// promoted immediately and evaluated against critical alert thresholds
|
||||
/// before this response returns.
|
||||
///
|
||||
/// Critical alerts are generated synchronously — a critical potassium
|
||||
/// value will have an open ClinicalAlert row in the database before
|
||||
/// the 201 response reaches the clinician's tablet.
|
||||
/// </remarks>
|
||||
[HttpPost("encounters/{encounterId:guid}/observations")]
|
||||
[ProducesResponseType(typeof(ApiResponse<LiveCaptureResponse>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> RecordObservations(
|
||||
Guid encounterId,
|
||||
[FromBody] RecordObservationsRequest request)
|
||||
{
|
||||
var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var result = await _liveCapture.RecordObservationsAsync(
|
||||
encounterId, request, clinicianUserId);
|
||||
|
||||
return StatusCode(201, ApiResponse<LiveCaptureResponse>.Created(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new encounter and records initial vitals in a single request.
|
||||
/// Designed for outpatient workflows where the encounter does not yet
|
||||
/// exist in VigilCareClinical.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creates the encounter with status "active", records all observations,
|
||||
/// promotes immediately, and evaluates critical thresholds — all within
|
||||
/// a single database transaction.
|
||||
///
|
||||
/// If the patient already has an active encounter, returns 409 with
|
||||
/// ACTIVE_ENCOUNTER_EXISTS. Use the observation-only endpoint instead.
|
||||
/// </remarks>
|
||||
[HttpPost("encounters")]
|
||||
[ProducesResponseType(typeof(ApiResponse<LiveCaptureResponse>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> OpenEncounterWithVitals(
|
||||
[FromBody] OpenEncounterWithVitalsRequest request)
|
||||
{
|
||||
var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var result = await _liveCapture.OpenEncounterWithVitalsAsync(
|
||||
request, clinicianUserId);
|
||||
|
||||
return StatusCode(201, ApiResponse<LiveCaptureResponse>.Created(result));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<Encounter> Encounters => Set<Encounter>();
|
||||
public DbSet<Observation> Observations => Set<Observation>();
|
||||
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
||||
public DbSet<AlertThreshold> AlertThresholds => Set<AlertThreshold>();
|
||||
public DbSet<ClinicalAlert> ClinicalAlerts => Set<ClinicalAlert>();
|
||||
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
|
||||
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class AlertThresholdConfiguration : IEntityTypeConfiguration<AlertThreshold>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AlertThreshold> builder)
|
||||
{
|
||||
builder.ToTable("alert_thresholds", "clinical");
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(t => t.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||
builder.Property(t => t.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
|
||||
builder.Property(t => t.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||
builder.Property(t => t.CriticalLow).HasColumnName("critical_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.SuppressionWindowMinutes).HasColumnName("suppression_window_minutes");
|
||||
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasIndex(t => t.ObservationCode).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ClinicalAlert> builder)
|
||||
{
|
||||
builder.ToTable("clinical_alerts", "clinical", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity",
|
||||
"severity IN ('WARNING', 'CRITICAL')");
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status",
|
||||
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type",
|
||||
"alert_type IN (" +
|
||||
"'SEPSIS_WARNING', " +
|
||||
"'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', " +
|
||||
"'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " +
|
||||
"'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " +
|
||||
"'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', " +
|
||||
"'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', " +
|
||||
"'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', " +
|
||||
"'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', " +
|
||||
"'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " +
|
||||
"'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " +
|
||||
"'WARNING_GLUCOSE_MG_DL', " +
|
||||
"'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', " +
|
||||
"'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', " +
|
||||
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
|
||||
"'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', " +
|
||||
"'GCS_CRITICAL', 'GCS_WARNING', " +
|
||||
"'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
});
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(a => a.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(a => a.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(a => a.ObservationId).HasColumnName("observation_id");
|
||||
builder.Property(a => a.AlertType)
|
||||
.HasColumnName("alert_type")
|
||||
.HasMaxLength(50)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(a => a.Severity)
|
||||
.HasColumnName("severity")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertSeverityExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(a => a.Details).HasColumnName("details").IsRequired();
|
||||
builder.Property(a => a.ObservationCode).HasColumnName("observation_code").HasMaxLength(50);
|
||||
builder.Property(a => a.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'OPEN'")
|
||||
.HasSentinel((AlertStatus)(-1));
|
||||
builder.Property(a => a.AcknowledgedAt).HasColumnName("acknowledged_at");
|
||||
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
|
||||
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
|
||||
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id");
|
||||
builder.Property(a => a.SyncedFromGateway).HasColumnName("synced_from_gateway").HasDefaultValue(false);
|
||||
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(a => a.FeedbackReceived).HasColumnName("feedback_received").HasDefaultValue(false);
|
||||
|
||||
builder.HasIndex(a => new { a.EncounterId, a.TriggeredAt });
|
||||
builder.HasIndex(a => new { a.PatientId, a.TriggeredAt });
|
||||
builder.HasIndex(a => new { a.Severity, a.TriggeredAt })
|
||||
.HasFilter("status = 'OPEN'");
|
||||
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
|
||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||
builder.HasIndex(a => a.ClientAlertId)
|
||||
.IsUnique()
|
||||
.HasFilter("client_alert_id IS NOT NULL");
|
||||
}
|
||||
}
|
||||
Generated
+1400
File diff suppressed because it is too large
Load Diff
+119
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAlertThresholdsAndClinicalAlerts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "alert_thresholds",
|
||||
schema: "clinical",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
critical_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
warning_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
warning_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
critical_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
suppression_window_minutes = table.Column<int>(type: "integer", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_alert_thresholds", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "clinical_alerts",
|
||||
schema: "clinical",
|
||||
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),
|
||||
observation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
alert_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
severity = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
details = table.Column<string>(type: "text", nullable: false),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'OPEN'"),
|
||||
acknowledged_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
acknowledged_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
resolved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
triggered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
client_alert_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
synced_from_gateway = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
feedback_received = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_clinical_alerts", x => x.id);
|
||||
table.CheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
table.CheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
table.CheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_thresholds_observation_code",
|
||||
schema: "clinical",
|
||||
table: "alert_thresholds",
|
||||
column: "observation_code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_client_alert_id",
|
||||
schema: "clinical",
|
||||
table: "clinical_alerts",
|
||||
column: "client_alert_id",
|
||||
unique: true,
|
||||
filter: "client_alert_id IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_encounter_id_alert_type_observation_code",
|
||||
schema: "clinical",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "encounter_id", "alert_type", "observation_code" },
|
||||
filter: "status IN ('OPEN', 'ESCALATED')");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_encounter_id_triggered_at",
|
||||
schema: "clinical",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "encounter_id", "triggered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_patient_id_triggered_at",
|
||||
schema: "clinical",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "patient_id", "triggered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_severity_triggered_at",
|
||||
schema: "clinical",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "severity", "triggered_at" },
|
||||
filter: "status = 'OPEN'");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "alert_thresholds",
|
||||
schema: "clinical");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "clinical_alerts",
|
||||
schema: "clinical");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,66 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlertThreshold", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<decimal?>("CriticalHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_high");
|
||||
|
||||
b.Property<decimal?>("CriticalLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_low");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<int?>("SuppressionWindowMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("suppression_window_minutes");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal?>("WarningHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_high");
|
||||
|
||||
b.Property<decimal?>("WarningLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_low");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ObservationCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("alert_thresholds", "clinical");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AuthAuditEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -59,6 +119,117 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("AcknowledgedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("acknowledged_at");
|
||||
|
||||
b.Property<string>("AcknowledgedBy")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("acknowledged_by");
|
||||
|
||||
b.Property<string>("AlertType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("alert_type");
|
||||
|
||||
b.Property<Guid?>("ClientAlertId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("client_alert_id");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("FeedbackReceived")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("feedback_received");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<Guid?>("ObservationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("observation_id");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'OPEN'");
|
||||
|
||||
b.Property<bool>("SyncedFromGateway")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("synced_from_gateway");
|
||||
|
||||
b.Property<DateTimeOffset>("TriggeredAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("triggered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientAlertId")
|
||||
.IsUnique()
|
||||
.HasFilter("client_alert_id IS NOT NULL");
|
||||
|
||||
b.HasIndex("EncounterId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("PatientId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("Severity", "TriggeredAt")
|
||||
.HasFilter("status = 'OPEN'");
|
||||
|
||||
b.HasIndex("EncounterId", "AlertType", "ObservationCode")
|
||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||
|
||||
b.ToTable("clinical_alerts", "clinical", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DigitizationBatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
public class AlertThreshold
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public string Unit { get; set; } = null!;
|
||||
public decimal? CriticalLow { get; set; }
|
||||
public decimal? WarningLow { get; set; }
|
||||
public decimal? WarningHigh { get; set; }
|
||||
public decimal? CriticalHigh { get; set; }
|
||||
public int? SuppressionWindowMinutes { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
public class ClinicalAlert
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public Guid? ObservationId { get; set; }
|
||||
public AlertType AlertType { get; set; }
|
||||
public AlertSeverity Severity { get; set; }
|
||||
public string Details { get; set; } = null!;
|
||||
public string? ObservationCode { get; set; }
|
||||
public AlertStatus Status { get; set; } = AlertStatus.Open;
|
||||
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
public DateTimeOffset TriggeredAt { get; set; }
|
||||
public Guid? ClientAlertId { get; set; }
|
||||
public bool SyncedFromGateway { get; set; }
|
||||
public bool FeedbackReceived { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
public enum AlertSeverity { Warning, Critical }
|
||||
|
||||
public static class AlertSeverityExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertSeverity s) => s switch
|
||||
{
|
||||
AlertSeverity.Warning => "WARNING",
|
||||
AlertSeverity.Critical => "CRITICAL",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static AlertSeverity FromDbString(string v) => v switch
|
||||
{
|
||||
"WARNING" => AlertSeverity.Warning,
|
||||
"CRITICAL" => AlertSeverity.Critical,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert severity: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum AlertStatus { Open, Acknowledged, Resolved, Escalated }
|
||||
|
||||
public static class AlertStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertStatus s) => s switch
|
||||
{
|
||||
AlertStatus.Open => "OPEN",
|
||||
AlertStatus.Acknowledged => "ACKNOWLEDGED",
|
||||
AlertStatus.Resolved => "RESOLVED",
|
||||
AlertStatus.Escalated => "ESCALATED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static AlertStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"OPEN" => AlertStatus.Open,
|
||||
"ACKNOWLEDGED" => AlertStatus.Acknowledged,
|
||||
"RESOLVED" => AlertStatus.Resolved,
|
||||
"ESCALATED" => AlertStatus.Escalated,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
public enum AlertType
|
||||
{
|
||||
[Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")]
|
||||
SepsisWarning,
|
||||
CriticalHeartRate,
|
||||
CriticalTempC,
|
||||
CriticalPotassiumMeqL,
|
||||
CriticalSpo2,
|
||||
CriticalRespRate,
|
||||
CriticalWbcKUl,
|
||||
CriticalSystolicBp,
|
||||
CriticalDiastolicBp,
|
||||
CriticalLactateMmolL,
|
||||
CriticalAvpu,
|
||||
CriticalGlucoseMgDl,
|
||||
|
||||
// New — warning-level threshold alerts
|
||||
WarningHeartRate,
|
||||
WarningTempC,
|
||||
WarningPotassiumMeqL,
|
||||
WarningSpo2,
|
||||
WarningRespRate,
|
||||
WarningWbcKUl,
|
||||
WarningSystolicBp,
|
||||
WarningDiastolicBp,
|
||||
WarningLactateMmolL,
|
||||
WarningGlucoseMgDl,
|
||||
|
||||
News2Warning,
|
||||
News2Emergency,
|
||||
|
||||
RapidDeterioration,
|
||||
|
||||
[Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")]
|
||||
QsofaWarning,
|
||||
|
||||
QsofaScreen,
|
||||
|
||||
GcsCritical,
|
||||
GcsWarning,
|
||||
|
||||
CriticalPao2MmHg,
|
||||
WarningPao2MmHg,
|
||||
CriticalPlateletKUl,
|
||||
WarningPlateletKUl,
|
||||
CriticalBilirubinMgDl,
|
||||
WarningBilirubinMgDl,
|
||||
CriticalCreatinineMgDl,
|
||||
WarningCreatinineMgDl,
|
||||
|
||||
SofaSepsis,
|
||||
SofaWarning,
|
||||
}
|
||||
|
||||
public static class AlertTypeExtensions
|
||||
{
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static string ToDbString(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.SepsisWarning => "SEPSIS_WARNING",
|
||||
AlertType.CriticalHeartRate => "CRITICAL_HEART_RATE",
|
||||
AlertType.CriticalTempC => "CRITICAL_TEMP_C",
|
||||
AlertType.CriticalPotassiumMeqL => "CRITICAL_POTASSIUM_MEQ_L",
|
||||
AlertType.CriticalSpo2 => "CRITICAL_SPO2",
|
||||
AlertType.CriticalRespRate => "CRITICAL_RESP_RATE",
|
||||
AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL",
|
||||
AlertType.CriticalSystolicBp => "CRITICAL_SYSTOLIC_BP",
|
||||
AlertType.CriticalDiastolicBp => "CRITICAL_DIASTOLIC_BP",
|
||||
AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L",
|
||||
AlertType.CriticalAvpu => "CRITICAL_AVPU",
|
||||
AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL",
|
||||
AlertType.WarningHeartRate => "WARNING_HEART_RATE",
|
||||
AlertType.WarningTempC => "WARNING_TEMP_C",
|
||||
AlertType.WarningPotassiumMeqL => "WARNING_POTASSIUM_MEQ_L",
|
||||
AlertType.WarningSpo2 => "WARNING_SPO2",
|
||||
AlertType.WarningRespRate => "WARNING_RESP_RATE",
|
||||
AlertType.WarningWbcKUl => "WARNING_WBC_K_UL",
|
||||
AlertType.WarningSystolicBp => "WARNING_SYSTOLIC_BP",
|
||||
AlertType.WarningDiastolicBp => "WARNING_DIASTOLIC_BP",
|
||||
AlertType.WarningLactateMmolL => "WARNING_LACTATE_MMOL_L",
|
||||
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
|
||||
AlertType.News2Warning => "NEWS2_WARNING",
|
||||
AlertType.News2Emergency => "NEWS2_EMERGENCY",
|
||||
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
|
||||
AlertType.QsofaWarning => "QSOFA_WARNING",
|
||||
AlertType.GcsCritical => "GCS_CRITICAL",
|
||||
AlertType.GcsWarning => "GCS_WARNING",
|
||||
AlertType.CriticalPao2MmHg => "CRITICAL_PAO2_MMHG",
|
||||
AlertType.WarningPao2MmHg => "WARNING_PAO2_MMHG",
|
||||
AlertType.CriticalPlateletKUl => "CRITICAL_PLATELET_K_UL",
|
||||
AlertType.WarningPlateletKUl => "WARNING_PLATELET_K_UL",
|
||||
AlertType.CriticalBilirubinMgDl => "CRITICAL_BILIRUBIN_MG_DL",
|
||||
AlertType.WarningBilirubinMgDl => "WARNING_BILIRUBIN_MG_DL",
|
||||
AlertType.CriticalCreatinineMgDl => "CRITICAL_CREATININE_MG_DL",
|
||||
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
|
||||
AlertType.SofaSepsis => "SOFA_SEPSIS",
|
||||
AlertType.SofaWarning => "SOFA_WARNING",
|
||||
AlertType.QsofaScreen => "QSOFA_SCREEN",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static AlertType FromDbString(string v) => v switch
|
||||
{
|
||||
"SEPSIS_WARNING" => AlertType.SepsisWarning,
|
||||
"CRITICAL_HEART_RATE" => AlertType.CriticalHeartRate,
|
||||
"CRITICAL_TEMP_C" => AlertType.CriticalTempC,
|
||||
"CRITICAL_POTASSIUM_MEQ_L"=> AlertType.CriticalPotassiumMeqL,
|
||||
"CRITICAL_SPO2" => AlertType.CriticalSpo2,
|
||||
"CRITICAL_RESP_RATE" => AlertType.CriticalRespRate,
|
||||
"CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||
"CRITICAL_SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
|
||||
"CRITICAL_DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
|
||||
"CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||
"CRITICAL_AVPU" => AlertType.CriticalAvpu,
|
||||
"CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||
"WARNING_HEART_RATE" => AlertType.WarningHeartRate,
|
||||
"WARNING_TEMP_C" => AlertType.WarningTempC,
|
||||
"WARNING_POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||
"WARNING_SPO2" => AlertType.WarningSpo2,
|
||||
"WARNING_RESP_RATE" => AlertType.WarningRespRate,
|
||||
"WARNING_WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||
"WARNING_SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||
"WARNING_DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||
"WARNING_LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||
"NEWS2_WARNING" => AlertType.News2Warning,
|
||||
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
|
||||
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
|
||||
"QSOFA_WARNING" => AlertType.QsofaWarning,
|
||||
"QSOFA_SCREEN" => AlertType.QsofaScreen,
|
||||
"GCS_CRITICAL" => AlertType.GcsCritical,
|
||||
"GCS_WARNING" => AlertType.GcsWarning,
|
||||
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
|
||||
"WARNING_PAO2_MMHG" => AlertType.WarningPao2MmHg,
|
||||
"CRITICAL_PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
|
||||
"WARNING_PLATELET_K_UL" => AlertType.WarningPlateletKUl,
|
||||
"CRITICAL_BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
|
||||
"WARNING_BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
|
||||
"CRITICAL_CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
|
||||
"WARNING_CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
|
||||
"SOFA_SEPSIS" => AlertType.SofaSepsis,
|
||||
"SOFA_WARNING" => AlertType.SofaWarning,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// Threshold alerts are derived from observation codes in alert_thresholds — not free-form strings.
|
||||
public static AlertType CriticalFor(string observationCode) => observationCode switch
|
||||
{
|
||||
"HEART_RATE" => AlertType.CriticalHeartRate,
|
||||
"TEMP_C" => AlertType.CriticalTempC,
|
||||
"POTASSIUM_MEQ_L" => AlertType.CriticalPotassiumMeqL,
|
||||
"SPO2" => AlertType.CriticalSpo2,
|
||||
"RESP_RATE" => AlertType.CriticalRespRate,
|
||||
"WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||
"SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
|
||||
"DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
|
||||
"LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||
"AVPU" => AlertType.CriticalAvpu,
|
||||
"GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||
"PAO2_MMHG" => AlertType.CriticalPao2MmHg,
|
||||
"PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
|
||||
"BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
|
||||
"CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
|
||||
};
|
||||
|
||||
public static AlertType WarningFor(string observationCode) => observationCode switch
|
||||
{
|
||||
"HEART_RATE" => AlertType.WarningHeartRate,
|
||||
"TEMP_C" => AlertType.WarningTempC,
|
||||
"POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||
"SPO2" => AlertType.WarningSpo2,
|
||||
"RESP_RATE" => AlertType.WarningRespRate,
|
||||
"WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||
"SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||
"PAO2_MMHG" => AlertType.WarningPao2MmHg,
|
||||
"PLATELET_K_UL" => AlertType.WarningPlateletKUl,
|
||||
"BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
|
||||
"CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
|
||||
};
|
||||
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static bool IsSuppressible(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.SepsisWarning or AlertType.News2Emergency => false,
|
||||
AlertType.CriticalHeartRate or AlertType.CriticalTempC or AlertType.CriticalPotassiumMeqL
|
||||
or AlertType.CriticalSpo2 or AlertType.CriticalRespRate or AlertType.CriticalWbcKUl
|
||||
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
|
||||
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
|
||||
or AlertType.CriticalGlucoseMgDl => false,
|
||||
AlertType.RapidDeterioration => false,
|
||||
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
|
||||
AlertType.SofaSepsis => false,
|
||||
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
public static string? ObservationCodeForWarning(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.WarningHeartRate => "HEART_RATE",
|
||||
AlertType.WarningTempC => "TEMP_C",
|
||||
AlertType.WarningPotassiumMeqL => "POTASSIUM_MEQ_L",
|
||||
AlertType.WarningSpo2 => "SPO2",
|
||||
AlertType.WarningRespRate => "RESP_RATE",
|
||||
AlertType.WarningWbcKUl => "WBC_K_UL",
|
||||
AlertType.WarningSystolicBp => "SYSTOLIC_BP",
|
||||
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
|
||||
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
|
||||
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
|
||||
AlertType.WarningPao2MmHg => "PAO2_MMHG",
|
||||
AlertType.WarningPlateletKUl => "PLATELET_K_UL",
|
||||
AlertType.WarningBilirubinMgDl => "BILIRUBIN_MG_DL",
|
||||
AlertType.WarningCreatinineMgDl => "CREATININE_MG_DL",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// <summary>
|
||||
/// Internal result type for critical alert evaluation. Not exposed in API responses;
|
||||
/// mapped to LiveCaptureCriticalAlert in the response builder.
|
||||
/// </summary>
|
||||
internal record CriticalAlertResult(
|
||||
Guid Id,
|
||||
string Severity,
|
||||
string Message,
|
||||
decimal ThresholdValue,
|
||||
string ThresholdBound
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
/// <summary>
|
||||
/// A critical alert that was generated synchronously during live capture promotion.
|
||||
/// Returned inline so the clinician sees the alert before the HTTP response completes.
|
||||
/// </summary>
|
||||
public record LiveCaptureCriticalAlert(
|
||||
Guid AlertId,
|
||||
string Severity,
|
||||
string Message,
|
||||
decimal ThresholdValue,
|
||||
string ThresholdBound
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
/// <summary>
|
||||
/// A single observation entered at bedside by a credentialed clinician.
|
||||
/// </summary>
|
||||
public record LiveCaptureObservationRequest(
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? Note
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
/// <summary>
|
||||
/// Response for a single promoted observation, including any synchronous
|
||||
/// critical alert generated during promotion.
|
||||
/// </summary>
|
||||
public record LiveCaptureObservationResponse(
|
||||
Guid DraftObservationId,
|
||||
Guid LiveObservationId,
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
DateTimeOffset RecordedAt,
|
||||
LiveCaptureCriticalAlert? CriticalAlert
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
/// <summary>
|
||||
/// Full response for a live capture operation. Contains the batch ID,
|
||||
/// the VigilCareClinical encounter ID, all promoted observations with
|
||||
/// their live IDs, and any critical alerts generated synchronously.
|
||||
/// </summary>
|
||||
public record LiveCaptureResponse(
|
||||
Guid BatchId,
|
||||
Guid EncounterId,
|
||||
IReadOnlyList<LiveCaptureObservationResponse> Observations,
|
||||
int CriticalAlertCount,
|
||||
DateTimeOffset PromotedAt
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
/// <summary>
|
||||
/// Opens a new encounter and records initial vitals in a single request.
|
||||
/// Used for outpatient workflows where the encounter does not yet exist.
|
||||
/// </summary>
|
||||
public record OpenEncounterWithVitalsRequest(
|
||||
Guid PatientId,
|
||||
string Department,
|
||||
string? RoomBed,
|
||||
string AdmissionReason,
|
||||
List<LiveCaptureObservationRequest> Observations,
|
||||
bool ClinicianAttestation,
|
||||
string PasswordConfirm
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <summary>
|
||||
/// Records one or more observations against an existing VigilCareClinical encounter.
|
||||
/// Requires clinician attestation and password re-confirmation.
|
||||
/// </summary>
|
||||
public record RecordObservationsRequest(
|
||||
List<LiveCaptureObservationRequest> Observations,
|
||||
bool ClinicianAttestation,
|
||||
string PasswordConfirm
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
public record ThresholdCacheEntry(
|
||||
string ObservationCode,
|
||||
decimal? CriticalLow,
|
||||
decimal? WarningLow,
|
||||
decimal? WarningHigh,
|
||||
decimal? CriticalHigh);
|
||||
@@ -66,6 +66,8 @@ try
|
||||
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
||||
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
||||
builder.Services.AddScoped<IDigitizationHistoryService, DigitizationHistoryService>();
|
||||
builder.Services.AddScoped<IAttestationService, AttestationService>();
|
||||
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
public class AttestationService : IAttestationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<AttestationService> _logger;
|
||||
|
||||
public AttestationService(AppDbContext db, ILogger<AttestationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<User> ValidateAttestationAsync(
|
||||
Guid userId, bool clinicianAttestation, string passwordConfirm)
|
||||
{
|
||||
// 1. Attestation flag must be explicitly true
|
||||
if (!clinicianAttestation)
|
||||
throw new ValidationException(
|
||||
"Clinician attestation is required for live capture.",
|
||||
"ATTESTATION_REQUIRED");
|
||||
|
||||
// 2. Load user
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user is null)
|
||||
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
|
||||
|
||||
// 3. Role check — only Clinician role can use live capture
|
||||
if (user.Role != UserRole.Clinician)
|
||||
throw new ValidationException(
|
||||
"Only users with the Clinician role can perform live capture.",
|
||||
"CLINICIAN_ROLE_REQUIRED");
|
||||
|
||||
// 4. Account must be active
|
||||
if (!user.IsActive)
|
||||
throw new ConflictException(
|
||||
"Account is disabled.", "ACCOUNT_DISABLED");
|
||||
|
||||
// 5. Password re-confirmation — prevents unattended sessions from
|
||||
// submitting clinical data without the clinician present
|
||||
if (string.IsNullOrWhiteSpace(passwordConfirm))
|
||||
throw new ValidationException(
|
||||
"Password re-confirmation is required.",
|
||||
"PASSWORD_CONFIRM_REQUIRED");
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(passwordConfirm, user.PasswordHash))
|
||||
throw new ValidationException(
|
||||
"Password re-confirmation failed.",
|
||||
"PASSWORD_CONFIRM_INVALID");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Clinician attestation validated for user {UserId} ({FullName})",
|
||||
user.Id, user.FullName);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
public interface IAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that the user is a credentialed clinician and that the
|
||||
/// password re-confirm matches their stored hash. Throws on failure.
|
||||
/// </summary>
|
||||
/// <param name="userId">The authenticated user's ID from JWT claims.</param>
|
||||
/// <param name="clinicianAttestation">Must be true; false throws ValidationException.</param>
|
||||
/// <param name="passwordConfirm">Raw password for re-confirmation.</param>
|
||||
/// <returns>The validated User entity.</returns>
|
||||
Task<User> ValidateAttestationAsync(Guid userId, bool clinicianAttestation, string passwordConfirm);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public interface ILiveCaptureService
|
||||
{
|
||||
/// <summary>
|
||||
/// Records observations against an existing VigilCareClinical encounter.
|
||||
/// Validates clinician attestation, creates a live_capture batch, promotes
|
||||
/// synchronously, and returns live observation IDs with any critical alerts.
|
||||
/// </summary>
|
||||
Task<LiveCaptureResponse> RecordObservationsAsync(
|
||||
Guid encounterId,
|
||||
RecordObservationsRequest request,
|
||||
Guid clinicianUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new encounter in VigilCareClinical and records initial vitals
|
||||
/// in a single atomic operation. Used for outpatient workflows.
|
||||
/// </summary>
|
||||
Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
|
||||
OpenEncounterWithVitalsRequest request,
|
||||
Guid clinicianUserId);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class LiveCaptureService : ILiveCaptureService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IAttestationService _attestation;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ILogger<LiveCaptureService> _logger;
|
||||
|
||||
// Redis key prefix for cached alert thresholds (same as VigilCareClinical)
|
||||
private const string ThresholdCachePrefix = "threshold:";
|
||||
|
||||
public LiveCaptureService(
|
||||
AppDbContext db,
|
||||
IAttestationService attestation,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<LiveCaptureService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_attestation = attestation;
|
||||
_redis = redis;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<LiveCaptureResponse> RecordObservationsAsync(
|
||||
Guid encounterId, RecordObservationsRequest request, Guid clinicianUserId)
|
||||
{
|
||||
// 1. Validate attestation (role + password re-confirm)
|
||||
var clinician = await _attestation.ValidateAttestationAsync(
|
||||
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
|
||||
|
||||
// 2. Validate encounter exists and is active in VigilCareClinical
|
||||
var encounter = await _db.Encounters.FindAsync(encounterId);
|
||||
if (encounter is null)
|
||||
throw new NotFoundException(
|
||||
"Encounter not found in VigilCareClinical.", "ENCOUNTER_NOT_FOUND");
|
||||
|
||||
if (encounter.Status != "active")
|
||||
throw new ConflictException(
|
||||
"Observations can only be recorded against active encounters.",
|
||||
"ENCOUNTER_NOT_ACTIVE");
|
||||
|
||||
// 3. Validate observations
|
||||
if (request.Observations is null || request.Observations.Count == 0)
|
||||
throw new ValidationException(
|
||||
"At least one observation is required.", "EMPTY_OBSERVATIONS");
|
||||
|
||||
if (request.Observations.Count > 10)
|
||||
throw new ValidationException(
|
||||
"Maximum 10 observations per live capture request.",
|
||||
"TOO_MANY_OBSERVATIONS");
|
||||
|
||||
foreach (var obs in request.Observations)
|
||||
{
|
||||
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
|
||||
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
||||
}
|
||||
|
||||
// 4. Execute synchronous promotion within a single transaction
|
||||
return await PromoteSynchronouslyAsync(
|
||||
encounter.Id, encounter.PatientId, clinician, request.Observations);
|
||||
}
|
||||
|
||||
public async Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
|
||||
OpenEncounterWithVitalsRequest request, Guid clinicianUserId)
|
||||
{
|
||||
// 1. Validate attestation
|
||||
var clinician = await _attestation.ValidateAttestationAsync(
|
||||
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
|
||||
|
||||
// 2. Validate patient exists
|
||||
var patient = await _db.Patients.FindAsync(request.PatientId);
|
||||
if (patient is null)
|
||||
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
|
||||
// 3. Validate observations
|
||||
if (request.Observations is null || request.Observations.Count == 0)
|
||||
throw new ValidationException(
|
||||
"At least one observation is required.", "EMPTY_OBSERVATIONS");
|
||||
|
||||
if (request.Observations.Count > 10)
|
||||
throw new ValidationException(
|
||||
"Maximum 10 observations per live capture request.",
|
||||
"TOO_MANY_OBSERVATIONS");
|
||||
|
||||
foreach (var obs in request.Observations)
|
||||
{
|
||||
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
|
||||
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
||||
}
|
||||
|
||||
// 4. Check no duplicate active encounter for this patient
|
||||
var existingActive = await _db.Encounters.AnyAsync(e =>
|
||||
e.PatientId == request.PatientId &&
|
||||
e.Status == "active");
|
||||
|
||||
if (existingActive)
|
||||
throw new ConflictException(
|
||||
"Patient already has an active encounter. Record observations against the existing encounter.",
|
||||
"ACTIVE_ENCOUNTER_EXISTS");
|
||||
|
||||
if (!DepartmentExtensions.TryFromDbString(request.Department, out var department))
|
||||
throw new ValidationException(
|
||||
$"Invalid department '{request.Department}'. Must be a recognized hospital department.",
|
||||
"INVALID_DEPARTMENT");
|
||||
|
||||
// 5. Create the encounter in VigilCareClinical
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = request.PatientId,
|
||||
Department = department,
|
||||
RoomBed = request.RoomBed,
|
||||
AdmissionReason = request.AdmissionReason,
|
||||
Status = "active",
|
||||
AdmissionDate = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_db.Encounters.Add(encounter);
|
||||
|
||||
// 6. Promote observations synchronously
|
||||
return await PromoteSynchronouslyAsync(
|
||||
encounter.Id, request.PatientId, clinician, request.Observations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core promotion logic shared by both endpoints. Creates the batch, draft
|
||||
/// observations, live observations, evaluates critical thresholds, and writes
|
||||
/// all audit and outbox events within a single database transaction.
|
||||
/// </summary>
|
||||
private async Task<LiveCaptureResponse> PromoteSynchronouslyAsync(
|
||||
Guid encounterId, Guid patientId, User clinician,
|
||||
List<LiveCaptureObservationRequest> observations)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var batchId = Guid.NewGuid();
|
||||
var observationResponses = new List<LiveCaptureObservationResponse>();
|
||||
var criticalAlertCount = 0;
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// --- Create the live_capture batch (already in terminal Promoted state) ---
|
||||
var batch = new DigitizationBatch
|
||||
{
|
||||
Id = batchId,
|
||||
Status = BatchStatus.Promoted,
|
||||
BatchType = BatchType.VitalsSheet,
|
||||
Track = BatchTrack.LiveCapture,
|
||||
PatientId = patientId,
|
||||
DocumentRef = "live-capture", // No scanned document for Track B
|
||||
DocumentSha256 = ComputeLiveCaptureHash(clinician.Id, encounterId, now),
|
||||
EnableRetroactiveAlerts = false, // Not applicable — live capture always alerts
|
||||
EnteredByUserId = clinician.Id,
|
||||
VerifiedByUserId = clinician.Id, // Clinician attestation replaces verifier
|
||||
ApprovedByUserId = clinician.Id,
|
||||
ClinicianAttestation = true,
|
||||
PromotedAt = now,
|
||||
PromotionEncounterId = encounterId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_db.DigitizationBatches.Add(batch);
|
||||
|
||||
// --- Write attestation and promotion events ---
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.LiveCaptureAttested,
|
||||
ActorUserId = clinician.Id,
|
||||
OccurredAt = now,
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
clinicianId = clinician.Id,
|
||||
clinicianName = clinician.FullName,
|
||||
encounterId,
|
||||
observationCount = observations.Count,
|
||||
track = "LIVE_CAPTURE"
|
||||
})
|
||||
});
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.Promoted,
|
||||
ActorUserId = clinician.Id,
|
||||
OccurredAt = now.AddMilliseconds(1),
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
promotionType = "synchronous_live_capture",
|
||||
encounterId
|
||||
})
|
||||
});
|
||||
|
||||
// --- Process each observation ---
|
||||
var cache = _redis.GetDatabase();
|
||||
|
||||
foreach (var obs in observations)
|
||||
{
|
||||
var draftObsId = Guid.NewGuid();
|
||||
var liveObsId = Guid.NewGuid();
|
||||
|
||||
// Create draft observation (audit trail)
|
||||
var draftObservation = new DraftObservation
|
||||
{
|
||||
Id = draftObsId,
|
||||
BatchId = batchId,
|
||||
ObservationCode = obs.ObservationCode,
|
||||
Value = obs.Value,
|
||||
Unit = obs.Unit,
|
||||
RecordedAt = obs.RecordedAt,
|
||||
Note = obs.Note,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_db.DraftObservations.Add(draftObservation);
|
||||
|
||||
// Create live observation in VigilCareClinical tables
|
||||
var liveObservation = new Observation
|
||||
{
|
||||
Id = liveObsId,
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
ObservationCode = obs.ObservationCode,
|
||||
Value = obs.Value,
|
||||
Unit = obs.Unit,
|
||||
RecordedAt = obs.RecordedAt,
|
||||
Note = obs.Note,
|
||||
Source = "live_capture",
|
||||
SourceDraftObservationId = draftObsId,
|
||||
SourceBatchId = batchId,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_db.Observations.Add(liveObservation);
|
||||
|
||||
_db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EventType = "observation.recorded",
|
||||
AggregateType = "Observation",
|
||||
AggregateId = liveObsId,
|
||||
PayloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
observationId = liveObsId,
|
||||
encounterId,
|
||||
patientId,
|
||||
observationCode = obs.ObservationCode,
|
||||
value = obs.Value,
|
||||
unit = obs.Unit,
|
||||
recordedAt = obs.RecordedAt,
|
||||
source = "live_capture",
|
||||
batchId
|
||||
}),
|
||||
CreatedAt = now,
|
||||
ProcessedAt = null,
|
||||
RetryCount = 0
|
||||
});
|
||||
|
||||
// --- Synchronous critical value detection ---
|
||||
var alert = await EvaluateCriticalThresholdAsync(
|
||||
cache, liveObsId, encounterId, patientId,
|
||||
obs.ObservationCode, obs.Value, obs.Unit, now);
|
||||
|
||||
if (alert is not null)
|
||||
{
|
||||
criticalAlertCount++;
|
||||
|
||||
observationResponses.Add(new LiveCaptureObservationResponse(
|
||||
draftObsId, liveObsId,
|
||||
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
|
||||
new LiveCaptureCriticalAlert(
|
||||
alert.Id,
|
||||
alert.Severity,
|
||||
alert.Message,
|
||||
alert.ThresholdValue,
|
||||
alert.ThresholdBound)));
|
||||
}
|
||||
else
|
||||
{
|
||||
observationResponses.Add(new LiveCaptureObservationResponse(
|
||||
draftObsId, liveObsId,
|
||||
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
|
||||
null));
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Live capture batch {BatchId} promoted synchronously: " +
|
||||
"{ObsCount} observations, {AlertCount} critical alerts, " +
|
||||
"encounter {EncounterId}, clinician {ClinicianId}",
|
||||
batchId, observations.Count, criticalAlertCount,
|
||||
encounterId, clinician.Id);
|
||||
|
||||
return new LiveCaptureResponse(
|
||||
batchId, encounterId, observationResponses,
|
||||
criticalAlertCount, now);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a single observation against Redis-cached alert thresholds.
|
||||
/// If the value breaches a CRITICAL bound, creates a ClinicalAlert row and
|
||||
/// an outbox event within the current transaction (before SaveChanges).
|
||||
///
|
||||
/// Returns null if no critical threshold is breached.
|
||||
/// Warning thresholds are handled asynchronously by the Kafka consumer —
|
||||
/// the same split as VigilCareClinical Phase 2.
|
||||
/// </summary>
|
||||
private async Task<CriticalAlertResult?> EvaluateCriticalThresholdAsync(
|
||||
StackExchange.Redis.IDatabase cache, Guid observationId, Guid encounterId, Guid patientId,
|
||||
string observationCode, decimal value, string unit, DateTimeOffset now)
|
||||
{
|
||||
var threshold = await LoadThresholdAsync(cache, observationCode);
|
||||
if (threshold is null)
|
||||
return null;
|
||||
|
||||
var breach = GetCriticalBreach(value, threshold);
|
||||
if (breach is null)
|
||||
return null;
|
||||
|
||||
var (thresholdValue, thresholdBound) = breach.Value;
|
||||
var details = BuildCriticalDetails(observationCode, value, unit, threshold, thresholdBound);
|
||||
|
||||
AlertType alertType;
|
||||
try
|
||||
{
|
||||
alertType = AlertTypeExtensions.CriticalFor(observationCode);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No critical alert type configured for observation code {ObservationCode}",
|
||||
observationCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var alertId = Guid.NewGuid();
|
||||
var clinicalAlert = new ClinicalAlert
|
||||
{
|
||||
Id = alertId,
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
ObservationId = observationId,
|
||||
ObservationCode = observationCode,
|
||||
AlertType = alertType,
|
||||
Severity = AlertSeverity.Critical,
|
||||
Details = details,
|
||||
Status = AlertStatus.Open,
|
||||
TriggeredAt = now
|
||||
};
|
||||
|
||||
_db.ClinicalAlerts.Add(clinicalAlert);
|
||||
|
||||
_db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EventType = "alert.generated",
|
||||
AggregateType = "ClinicalAlert",
|
||||
AggregateId = alertId,
|
||||
PayloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
alertId,
|
||||
encounterId,
|
||||
patientId,
|
||||
observationId,
|
||||
observationCode,
|
||||
alertType = alertType.ToDbString(),
|
||||
severity = AlertSeverity.Critical.ToDbString(),
|
||||
details,
|
||||
triggeredValue = value,
|
||||
thresholdValue,
|
||||
thresholdBound,
|
||||
source = "live_capture",
|
||||
triggeredAt = now
|
||||
}),
|
||||
CreatedAt = now,
|
||||
ProcessedAt = null,
|
||||
RetryCount = 0
|
||||
});
|
||||
|
||||
_logger.LogWarning(
|
||||
"CRITICAL alert {AlertId} generated via live capture: " +
|
||||
"{ObservationCode} = {Value} {Unit} ({ThresholdBound} = {ThresholdValue}), " +
|
||||
"encounter {EncounterId}, patient {PatientId}",
|
||||
alertId, observationCode, value, unit,
|
||||
thresholdBound, thresholdValue, encounterId, patientId);
|
||||
|
||||
return new CriticalAlertResult(
|
||||
alertId,
|
||||
AlertSeverity.Critical.ToDbString(),
|
||||
details,
|
||||
thresholdValue,
|
||||
thresholdBound);
|
||||
}
|
||||
|
||||
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(
|
||||
StackExchange.Redis.IDatabase cache, string observationCode)
|
||||
{
|
||||
var cacheKey = $"{ThresholdCachePrefix}{observationCode}";
|
||||
var cached = await cache.StringGetAsync(cacheKey);
|
||||
if (cached.HasValue)
|
||||
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
|
||||
|
||||
var threshold = await _db.AlertThresholds
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.ObservationCode == observationCode);
|
||||
|
||||
if (threshold is null)
|
||||
return null;
|
||||
|
||||
var entry = new ThresholdCacheEntry(
|
||||
threshold.ObservationCode,
|
||||
threshold.CriticalLow,
|
||||
threshold.WarningLow,
|
||||
threshold.WarningHigh,
|
||||
threshold.CriticalHigh);
|
||||
|
||||
await cache.StringSetAsync(
|
||||
cacheKey,
|
||||
JsonSerializer.Serialize(entry),
|
||||
TimeSpan.FromMinutes(30));
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static (decimal ThresholdValue, string ThresholdBound)? GetCriticalBreach(
|
||||
decimal value, ThresholdCacheEntry threshold)
|
||||
{
|
||||
if (threshold.CriticalLow.HasValue && value < threshold.CriticalLow.Value)
|
||||
return (threshold.CriticalLow.Value, "CRITICAL_LOW");
|
||||
|
||||
if (threshold.CriticalHigh.HasValue && value > threshold.CriticalHigh.Value)
|
||||
return (threshold.CriticalHigh.Value, "CRITICAL_HIGH");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildCriticalDetails(
|
||||
string observationCode, decimal value, string unit,
|
||||
ThresholdCacheEntry threshold, string thresholdBound)
|
||||
{
|
||||
if (thresholdBound == "CRITICAL_LOW")
|
||||
{
|
||||
return $"{observationCode} value {value} {unit} is below critical low " +
|
||||
$"threshold of {threshold.CriticalLow} {unit}";
|
||||
}
|
||||
|
||||
return $"{observationCode} value {value} {unit} is above critical high " +
|
||||
$"threshold of {threshold.CriticalHigh} {unit}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic hash for live capture batches (no scanned document).
|
||||
/// Uses clinician ID, encounter ID, and timestamp to generate uniqueness.
|
||||
/// </summary>
|
||||
private static string ComputeLiveCaptureHash(Guid clinicianId, Guid encounterId, DateTimeOffset timestamp)
|
||||
{
|
||||
var input = $"{clinicianId}:{encounterId}:{timestamp:O}";
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user