feature: Schema, Migrations, Core CRUD, and Redis Threshold Cache

This commit is contained in:
voltsrage
2026-06-16 17:59:16 +08:00
commit 882d4af3e6
63 changed files with 3923 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Patient> Patients => Set<Patient>();
public DbSet<Encounter> Encounters => Set<Encounter>();
public DbSet<AlertThreshold> AlertThresholds => Set<AlertThreshold>();
public DbSet<Observation> Observations => Set<Observation>();
public DbSet<ClinicalAlert> ClinicalAlerts => Set<ClinicalAlert>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
public DbSet<ReconciliationAlert> ReconciliationAlerts => Set<ReconciliationAlert>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class AlertThresholdConfiguration : IEntityTypeConfiguration<AlertThreshold>
{
public void Configure(EntityTypeBuilder<AlertThreshold> 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.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(t => t.ObservationCode).IsUnique();
}
}
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert>
{
public void Configure(EntityTypeBuilder<ClinicalAlert> 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')");
});
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.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.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'");
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class EncounterConfiguration : IEntityTypeConfiguration<Encounter>
{
public void Configure(EntityTypeBuilder<Encounter> 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')");
});
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).IsRequired();
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.DischargedAt).HasColumnName("discharged_at");
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
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,44 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ObservationConfiguration : IEntityTypeConfiguration<Observation>
{
public void Configure(EntityTypeBuilder<Observation> 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);
// Partial unique index — only non-null idempotency keys are checked for uniqueness.
// Devices that do not send a key are not subject to deduplication.
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,38 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("orders", t =>
{
t.HasCheckConstraint("chk_orders_order_type",
"order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
});
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.OrderType)
.HasColumnName("order_type")
.HasMaxLength(20)
.HasConversion(
v => v.ToDbString(),
v => OrderTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(o => o.Description).HasColumnName("description").IsRequired();
builder.Property(o => o.OrderedBy).HasColumnName("ordered_by").HasMaxLength(200).IsRequired();
builder.Property(o => o.Status).HasColumnName("status").HasMaxLength(20).HasDefaultValue("pending");
builder.Property(o => o.OrderedAt).HasColumnName("ordered_at").HasDefaultValueSql("NOW()");
builder.Property(o => o.ResultedAt).HasColumnName("resulted_at");
builder.HasOne(o => o.Encounter)
.WithMany(e => e.Orders)
.HasForeignKey(o => o.EncounterId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(o => new { o.EncounterId, o.OrderedAt });
builder.HasIndex(o => new { o.Status, o.OrderedAt })
.HasFilter("status IN ('pending', 'in_progress')");
}
}
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class OutboxEventConfiguration : IEntityTypeConfiguration<OutboxEvent>
{
public void Configure(EntityTypeBuilder<OutboxEvent> builder)
{
builder.ToTable("outbox_events");
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(o => o.Topic).HasColumnName("topic").HasMaxLength(200).IsRequired();
builder.Property(o => o.Payload).HasColumnName("payload").HasColumnType("jsonb").IsRequired();
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(o => o.ProcessedAt).HasColumnName("processed_at");
builder.HasIndex(o => o.CreatedAt)
.HasFilter("processed_at IS NULL");
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class PatientConfiguration : IEntityTypeConfiguration<Patient>
{
public void Configure(EntityTypeBuilder<Patient> 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.Status).HasColumnName("status").HasMaxLength(20).HasDefaultValue("active");
builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
// MRN uses exact-match unique index — MRN lookups are always equality checks,
// never LIKE/ILIKE. A B-tree unique index satisfies O(log n) point lookup.
// Name search uses ILIKE on first_name/last_name — a prefix scan, not an
// equality match — so no index here; full ILIKE is intentionally unindexed
// at this scale (pg_trgm GIN would be warranted at >500k patients).
builder.HasIndex(p => p.Mrn).IsUnique();
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ReconciliationAlertConfiguration : IEntityTypeConfiguration<ReconciliationAlert>
{
public void Configure(EntityTypeBuilder<ReconciliationAlert> builder)
{
builder.ToTable("reconciliation_alerts", t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type",
"check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
builder.HasKey(r => r.Id);
builder.Property(r => r.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(r => r.CheckType)
.HasColumnName("check_type")
.HasMaxLength(50)
.HasConversion(
v => v.ToDbString(),
v => ReconciliationCheckTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(r => r.EncounterId).HasColumnName("encounter_id");
builder.Property(r => r.PatientId).HasColumnName("patient_id");
builder.Property(r => r.Details).HasColumnName("details").IsRequired();
builder.Property(r => r.ResolvedAt).HasColumnName("resolved_at");
builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(r => new { r.CheckType, r.EncounterId })
.HasFilter("resolved_at IS NULL");
}
}
@@ -0,0 +1,104 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public static class DataSeeder
{
public static async Task SeedAsync(AppDbContext db, IConnectionMultiplexer redis)
{
if (await db.Patients.AnyAsync()) return;
// Two patients
var patient1 = new Patient
{
Id = Guid.NewGuid(), Mrn = "MRN-000001", FirstName = "Jane", LastName = "Smith",
DateOfBirth = new DateOnly(1975, 4, 12), Gender = "F",
CreatedAt = DateTimeOffset.UtcNow
};
var patient2 = new Patient
{
Id = Guid.NewGuid(), Mrn = "MRN-000002", FirstName = "Robert", LastName = "Chen",
DateOfBirth = new DateOnly(1962, 9, 3), Gender = "M",
CreatedAt = DateTimeOffset.UtcNow
};
db.Patients.AddRange(patient1, patient2);
// One active inpatient encounter per patient
var encounter1 = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient1.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = "ICU",
AttendingPhysician = "Dr. Osei", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-6),
CreatedAt = DateTimeOffset.UtcNow.AddHours(-6)
};
var encounter2 = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient2.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = "General Medicine",
AttendingPhysician = "Dr. Patel", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-12),
CreatedAt = DateTimeOffset.UtcNow.AddHours(-12)
};
db.Encounters.AddRange(encounter1, encounter2);
// Four alert thresholds
var thresholds = new List<AlertThreshold>
{
new() {
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", DisplayName = "Heart Rate",
Unit = "bpm", CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "TEMP_C", DisplayName = "Body Temperature",
Unit = "°C", CriticalLow = 35.0m, WarningLow = 36.0m, WarningHigh = 38.3m, CriticalHigh = 40.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "POTASSIUM_MEQ_L", DisplayName = "Serum Potassium",
Unit = "mEq/L", CriticalLow = 2.5m, WarningLow = 3.5m, WarningHigh = 5.0m, CriticalHigh = 6.5m,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "SPO2", DisplayName = "Oxygen Saturation",
Unit = "%", CriticalLow = 88, WarningLow = 92, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
}
};
db.AlertThresholds.AddRange(thresholds);
// Observations spanning normal, warning, and critical ranges for encounter1
var now = DateTimeOffset.UtcNow;
var observations = new List<Observation>
{
// Normal heart rate
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "HEART_RATE",
Value = 78, Unit = "bpm", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-30), CreatedAt = now.AddMinutes(-30) },
// Warning heart rate
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "HEART_RATE",
Value = 104, Unit = "bpm", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-15), CreatedAt = now.AddMinutes(-15) },
// Critical potassium (low)
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "POTASSIUM_MEQ_L",
Value = 2.3m, Unit = "mEq/L", Source = ObservationSource.Lab, RecordedAt = now.AddMinutes(-10), CreatedAt = now.AddMinutes(-10) },
// Normal SpO2
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SPO2",
Value = 97, Unit = "%", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-5), CreatedAt = now.AddMinutes(-5) },
// Normal temp for encounter2
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "TEMP_C",
Value = 37.1m, Unit = "°C", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-20), CreatedAt = now.AddMinutes(-20) }
};
db.Observations.AddRange(observations);
await db.SaveChangesAsync();
// Populate Redis cache with the seeded thresholds
var cache = redis.GetDatabase();
foreach (var t in thresholds)
{
var json = JsonSerializer.Serialize(new
{
t.ObservationCode, t.CriticalLow, t.WarningLow, t.WarningHigh, t.CriticalHigh
});
await cache.StringSetAsync($"threshold:{t.ObservationCode}", json);
}
}
}