From 3eb937ef984e3af51a93a1d097425827c2117a48 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sun, 21 Jun 2026 16:45:09 +0800 Subject: [PATCH] fix: Trend alert matching uses fragile LIKE pattern --- .../ClinicalAlertConfiguration.cs | 3 + .../Domains/Entities/ClinicalAlert.cs | 1 + ...083919_AddAlertObservationCode.Designer.cs | 1269 +++++++++++++++++ .../20260621083919_AddAlertObservationCode.cs | 52 + .../Migrations/AppDbContextModelSnapshot.cs | 8 + VigilCareClinicalAPI/Trend/TrendDetector.cs | 12 +- 6 files changed, 1339 insertions(+), 6 deletions(-) create mode 100644 VigilCareClinicalAPI/Migrations/20260621083919_AddAlertObservationCode.Designer.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260621083919_AddAlertObservationCode.cs diff --git a/VigilCareClinicalAPI/Data/Configurations/ClinicalAlertConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/ClinicalAlertConfiguration.cs index 88e2863..f743225 100644 --- a/VigilCareClinicalAPI/Data/Configurations/ClinicalAlertConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/ClinicalAlertConfiguration.cs @@ -51,6 +51,7 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration 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) @@ -73,5 +74,7 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration 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')"); } } diff --git a/VigilCareClinicalAPI/Domains/Entities/ClinicalAlert.cs b/VigilCareClinicalAPI/Domains/Entities/ClinicalAlert.cs index 44332df..9ff5c70 100644 --- a/VigilCareClinicalAPI/Domains/Entities/ClinicalAlert.cs +++ b/VigilCareClinicalAPI/Domains/Entities/ClinicalAlert.cs @@ -7,6 +7,7 @@ public class ClinicalAlert 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; } diff --git a/VigilCareClinicalAPI/Migrations/20260621083919_AddAlertObservationCode.Designer.cs b/VigilCareClinicalAPI/Migrations/20260621083919_AddAlertObservationCode.Designer.cs new file mode 100644 index 0000000..25ff96a --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260621083919_AddAlertObservationCode.Designer.cs @@ -0,0 +1,1269 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260621083919_AddAlertObservationCode")] + partial class AddAlertObservationCode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + 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", null, 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("ClinicalAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entity_type"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("NewValueJson") + .HasColumnType("jsonb") + .HasColumnName("new_value_json"); + + b.Property("PreviousValueJson") + .HasColumnType("jsonb") + .HasColumnName("previous_value_json"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UserDisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("UserId"); + + b.ToTable("clinical_audit_logs", (string)null); + }); + + modelBuilder.Entity("ClinicalUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("clinical_users", (string)null); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("ExternalResourceIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalId") + .HasColumnType("uuid") + .HasColumnName("internal_id"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("resource_type"); + + b.Property("System") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("system"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("ResourceType", "InternalId"); + + b.HasIndex("ResourceType", "System", "Value") + .IsUnique(); + + b.ToTable("external_resource_identifiers", (string)null); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("classification"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("EyeScore") + .HasColumnType("integer") + .HasColumnName("eye_score"); + + b.Property("MotorScore") + .HasColumnType("integer") + .HasColumnName("motor_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.Property("VerbalScore") + .HasColumnType("integer") + .HasColumnName("verbal_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("gcs_scores", null, t => + { + t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')"); + }); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + + modelBuilder.Entity("News2Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("ConsciousnessScore") + .HasColumnType("integer") + .HasColumnName("consciousness_score"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("HasSingleParamThree") + .HasColumnType("boolean") + .HasColumnName("has_single_param_three"); + + b.Property("HeartRateScore") + .HasColumnType("integer") + .HasColumnName("heart_rate_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRateScore") + .HasColumnType("integer") + .HasColumnName("resp_rate_score"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("risk_level"); + + b.Property("Spo2Score") + .HasColumnType("integer") + .HasColumnName("spo2_score"); + + b.Property("SupplementalO2Score") + .HasColumnType("integer") + .HasColumnName("supplemental_o2_score"); + + b.Property("SystolicBpScore") + .HasColumnType("integer") + .HasColumnName("systolic_bp_score"); + + b.Property("TemperatureScore") + .HasColumnType("integer") + .HasColumnName("temperature_score"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.HasIndex("PatientId", "CalculatedAt"); + + b.ToTable("news2_scores", null, t => + { + t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ComplianceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("compliance_status") + .HasDefaultValueSql("'IN_PROGRESS'"); + + b.Property("DeadlineAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deadline_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("RecognizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recognized_at"); + + b.Property("TriggeringAlertId") + .HasColumnType("uuid") + .HasColumnName("triggering_alert_id"); + + b.Property("TriggeringAlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("triggering_alert_type"); + + b.HasKey("Id"); + + b.HasIndex("ComplianceStatus"); + + b.HasIndex("TriggeringAlertId"); + + b.HasIndex("EncounterId", "RecognizedAt"); + + b.ToTable("sepsis_bundles", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')"); + }); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ElementType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("element_type"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("BundleId", "ElementType") + .IsUnique(); + + b.ToTable("sepsis_bundle_elements", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')"); + + t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')"); + }); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("CardiovascularScore") + .HasColumnType("integer") + .HasColumnName("cardiovascular_score"); + + b.Property("CnsScore") + .HasColumnType("integer") + .HasColumnName("cns_score"); + + b.Property("CoagulationScore") + .HasColumnType("integer") + .HasColumnName("coagulation_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeltaFromBaseline") + .HasColumnType("integer") + .HasColumnName("delta_from_baseline"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsBaseline") + .HasColumnType("boolean") + .HasColumnName("is_baseline"); + + b.Property("LiverScore") + .HasColumnType("integer") + .HasColumnName("liver_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RenalScore") + .HasColumnType("integer") + .HasColumnName("renal_score"); + + b.Property("RespiratoryScore") + .HasColumnType("integer") + .HasColumnName("respiratory_score"); + + b.Property("StalenessFlags") + .HasColumnType("jsonb") + .HasColumnName("staleness_flags"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId") + .HasDatabaseName("idx_sofa_scores_baseline") + .HasFilter("is_baseline = true"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("sofa_scores", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("News2Score", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalAlert", "TriggeringAlert") + .WithMany() + .HasForeignKey("TriggeringAlertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("TriggeringAlert"); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.HasOne("SepsisBundle", "Bundle") + .WithMany("Elements") + .HasForeignKey("BundleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Bundle"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260621083919_AddAlertObservationCode.cs b/VigilCareClinicalAPI/Migrations/20260621083919_AddAlertObservationCode.cs new file mode 100644 index 0000000..7d41aa8 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260621083919_AddAlertObservationCode.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddAlertObservationCode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "observation_code", + table: "clinical_alerts", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.Sql(@" + UPDATE clinical_alerts + SET observation_code = CASE + WHEN details LIKE '%HEART_RATE%' THEN 'HEART_RATE' + WHEN details LIKE '%RESP_RATE%' THEN 'RESP_RATE' + WHEN details LIKE '%SYSTOLIC_BP%' THEN 'SYSTOLIC_BP' + WHEN details LIKE '%TEMP_C%' THEN 'TEMP_C' + WHEN details LIKE '%SPO2%' THEN 'SPO2' + END + WHERE alert_type = 'RAPID_DETERIORATION' + AND observation_code IS NULL; + "); + + migrationBuilder.CreateIndex( + name: "IX_clinical_alerts_encounter_id_alert_type_observation_code", + table: "clinical_alerts", + columns: new[] { "encounter_id", "alert_type", "observation_code" }, + filter: "status IN ('OPEN', 'ESCALATED')"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_clinical_alerts_encounter_id_alert_type_observation_code", + table: "clinical_alerts"); + + migrationBuilder.DropColumn( + name: "observation_code", + table: "clinical_alerts"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index b423f3e..6868b2b 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -113,6 +113,11 @@ namespace VigilCareClinicalAPI.Migrations .HasColumnType("uuid") .HasColumnName("encounter_id"); + b.Property("ObservationCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + b.Property("ObservationId") .HasColumnType("uuid") .HasColumnName("observation_id"); @@ -154,6 +159,9 @@ namespace VigilCareClinicalAPI.Migrations b.HasIndex("Severity", "TriggeredAt") .HasFilter("status = 'OPEN'"); + b.HasIndex("EncounterId", "AlertType", "ObservationCode") + .HasFilter("status IN ('OPEN', 'ESCALATED')"); + b.ToTable("clinical_alerts", null, 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')"); diff --git a/VigilCareClinicalAPI/Trend/TrendDetector.cs b/VigilCareClinicalAPI/Trend/TrendDetector.cs index e185594..f6c187a 100644 --- a/VigilCareClinicalAPI/Trend/TrendDetector.cs +++ b/VigilCareClinicalAPI/Trend/TrendDetector.cs @@ -101,15 +101,15 @@ public class TrendDetector var affected = await db.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO clinical_alerts - (id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at) + (id, encounter_id, patient_id, alert_type, severity, details, observation_code, status, triggered_at) SELECT {alertId}, {encounterId}, {patientId}, - 'RAPID_DETERIORATION', 'WARNING', {fullDetails}, 'OPEN', {triggeredAt} + 'RAPID_DETERIORATION', 'WARNING', {fullDetails}, {observationCode}, 'OPEN', {triggeredAt} WHERE NOT EXISTS ( SELECT 1 FROM clinical_alerts - WHERE encounter_id = {encounterId} - AND alert_type = 'RAPID_DETERIORATION' - AND details LIKE {$"%{observationCode}%"} - AND status IN ('OPEN', 'ESCALATED') + WHERE encounter_id = {encounterId} + AND alert_type = 'RAPID_DETERIORATION' + AND observation_code = {observationCode} + AND status IN ('OPEN', 'ESCALATED') ) """, ct);