feature: Ward Gateway Service (Local-First Clinical Path)

This commit is contained in:
voltsrage
2026-06-23 16:45:38 +08:00
parent d8e142fffe
commit 1bf8359097
100 changed files with 5474 additions and 4 deletions
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class BufferedSyncItemConfiguration : IEntityTypeConfiguration<BufferedSyncItem>
{
public void Configure(EntityTypeBuilder<BufferedSyncItem> builder)
{
builder.ToTable("buffered_sync_items", t =>
{
t.HasCheckConstraint("chk_buffered_sync_items_item_type",
"item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
});
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(b => b.ItemType)
.HasColumnName("item_type")
.HasMaxLength(32)
.HasConversion(
v => v.ToDbString(),
v => BufferedSyncItemTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(b => b.Payload).HasColumnName("payload").HasColumnType("jsonb").IsRequired();
builder.Property(b => b.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(200).IsRequired();
builder.Property(b => b.EncounterId).HasColumnName("encounter_id");
builder.Property(b => b.RecordedAt).HasColumnName("recorded_at");
builder.Property(b => b.Synced).HasColumnName("synced").HasDefaultValue(false);
builder.Property(b => b.SyncedAt).HasColumnName("synced_at");
builder.Property(b => b.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(b => b.IdempotencyKey)
.IsUnique()
.HasFilter("NOT synced");
builder.HasIndex(b => b.CreatedAt)
.HasFilter("NOT synced");
}
}
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class GatewaySyncStateConfiguration : IEntityTypeConfiguration<GatewaySyncState>
{
public void Configure(EntityTypeBuilder<GatewaySyncState> builder)
{
builder.ToTable("gateway_sync_state");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(s => s.LastEncounterSyncAt).HasColumnName("last_encounter_sync_at");
builder.Property(s => s.InitialSyncCompleted)
.HasColumnName("initial_sync_completed")
.HasDefaultValue(false);
}
}
@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class LocalClinicalAlertConfiguration : IEntityTypeConfiguration<LocalClinicalAlert>
{
public void Configure(EntityTypeBuilder<LocalClinicalAlert> builder)
{
builder.ToTable("clinical_alerts", 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").IsRequired();
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
builder.HasOne(a => a.Encounter)
.WithMany(e => e.Alerts)
.HasForeignKey(a => a.EncounterId)
.OnDelete(DeleteBehavior.Restrict);
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();
}
}
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class LocalObservationConfiguration : IEntityTypeConfiguration<LocalObservation>
{
public void Configure(EntityTypeBuilder<LocalObservation> builder)
{
builder.ToTable("observations", t =>
{
t.HasCheckConstraint("chk_observations_source",
"source IN ('MANUAL', 'DEVICE', 'LAB')");
});
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(o => o.EncounterId).HasColumnName("encounter_id");
builder.Property(o => o.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired();
builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
builder.Property(o => o.Source)
.HasColumnName("source")
.HasMaxLength(20)
.HasConversion(
v => v.ToDbString(),
v => ObservationSourceExtensions.FromDbString(v))
.HasDefaultValueSql("'MANUAL'")
.HasSentinel((ObservationSource)(-1));
builder.Property(o => o.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(100);
builder.Property(o => o.RecordedAt).HasColumnName("recorded_at");
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasOne(o => o.Encounter)
.WithMany(e => e.Observations)
.HasForeignKey(o => o.EncounterId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(o => o.IdempotencyKey)
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
builder.HasIndex(o => new { o.EncounterId, o.ObservationCode, o.RecordedAt });
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ReplicaAlertThresholdConfiguration : IEntityTypeConfiguration<ReplicaAlertThreshold>
{
public void Configure(EntityTypeBuilder<ReplicaAlertThreshold> builder)
{
builder.ToTable("alert_thresholds");
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.SyncedAt).HasColumnName("synced_at");
builder.HasIndex(t => t.ObservationCode).IsUnique();
}
}
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ReplicaEncounterConfiguration : IEntityTypeConfiguration<ReplicaEncounter>
{
public void Configure(EntityTypeBuilder<ReplicaEncounter> builder)
{
builder.ToTable("encounters", t =>
{
t.HasCheckConstraint("chk_encounters_encounter_type",
"encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status",
"status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
t.HasCheckConstraint("chk_encounters_department",
"department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(e => e.PatientId).HasColumnName("patient_id");
builder.Property(e => e.EncounterType)
.HasColumnName("encounter_type")
.HasMaxLength(20)
.HasConversion(
v => v.ToDbString(),
v => EncounterTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(e => e.Status)
.HasColumnName("status")
.HasMaxLength(20)
.HasConversion(
v => v.ToDbString(),
v => EncounterStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'SCHEDULED'")
.HasSentinel((EncounterStatus)(-1));
builder.Property(e => e.Department)
.HasColumnName("department")
.HasMaxLength(100)
.HasConversion(
v => v.ToDbString(),
v => DepartmentExtensions.FromDbString(v))
.IsRequired();
builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(20);
builder.Property(e => e.AttendingPhysician).HasColumnName("attending_physician").HasMaxLength(200).IsRequired();
builder.Property(e => e.AdmittedAt).HasColumnName("admitted_at").HasDefaultValueSql("NOW()");
builder.Property(e => e.SyncedAt).HasColumnName("synced_at");
builder.HasOne(e => e.Patient)
.WithMany(p => p.Encounters)
.HasForeignKey(e => e.PatientId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.PatientId, e.AdmittedAt });
builder.HasIndex(e => new { e.Status, e.AdmittedAt })
.HasFilter("status = 'ACTIVE'");
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ReplicaPatientConfiguration : IEntityTypeConfiguration<ReplicaPatient>
{
public void Configure(EntityTypeBuilder<ReplicaPatient> builder)
{
builder.ToTable("patients");
builder.HasKey(p => p.Id);
builder.Property(p => p.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(p => p.Mrn).HasColumnName("mrn").HasMaxLength(20).IsRequired();
builder.Property(p => p.FirstName).HasColumnName("first_name").HasMaxLength(100).IsRequired();
builder.Property(p => p.LastName).HasColumnName("last_name").HasMaxLength(100).IsRequired();
builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth");
builder.Property(p => p.Gender).HasColumnName("gender").HasMaxLength(10).IsRequired();
builder.Property(p => p.SyncedAt).HasColumnName("synced_at");
builder.HasIndex(p => p.Mrn).IsUnique();
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class SyncOutboxEntryConfiguration : IEntityTypeConfiguration<SyncOutboxEntry>
{
public void Configure(EntityTypeBuilder<SyncOutboxEntry> builder)
{
builder.ToTable("sync_outbox");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(e => e.BatchId).HasColumnName("batch_id");
builder.Property(e => e.Status)
.HasColumnName("status")
.HasMaxLength(16)
.HasConversion(
v => v.ToDbString(),
v => SyncOutboxStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'PENDING'")
.HasSentinel((SyncOutboxStatus)(-1));
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(e => e.SubmittedAt).HasColumnName("submitted_at");
}
}
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
public class GatewayDbContext : DbContext
{
public GatewayDbContext(DbContextOptions<GatewayDbContext> opts) : base(opts) { }
public DbSet<ReplicaPatient> Patients => Set<ReplicaPatient>();
public DbSet<ReplicaEncounter> Encounters => Set<ReplicaEncounter>();
public DbSet<ReplicaAlertThreshold> AlertThresholds => Set<ReplicaAlertThreshold>();
public DbSet<LocalObservation> Observations => Set<LocalObservation>();
public DbSet<LocalClinicalAlert> ClinicalAlerts => Set<LocalClinicalAlert>();
public DbSet<BufferedSyncItem> BufferedSyncItems => Set<BufferedSyncItem>();
public DbSet<SyncOutboxEntry> SyncOutbox => Set<SyncOutboxEntry>();
public DbSet<GatewaySyncState> SyncState => Set<GatewaySyncState>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(GatewayDbContext).Assembly);
}