feature:
Full SOFA Score: Data Layer + Scoring Engine Glasgow Coma Scale: Data Layer + Scoring Engine
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using Confluent.Kafka;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class GcsScoringService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly KafkaOptions _kafkaOptions;
|
||||
private readonly ILogger<GcsScoringService> _logger;
|
||||
|
||||
public GcsScoringService(
|
||||
IServiceProvider services,
|
||||
IOptions<KafkaOptions> kafkaOptions,
|
||||
ILogger<GcsScoringService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_kafkaOptions = kafkaOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var config = new ConsumerConfig
|
||||
{
|
||||
BootstrapServers = _kafkaOptions.BootstrapServers,
|
||||
GroupId = "gcs-scoring",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
_logger.LogInformation("GcsScoringService started — consumer group: gcs-scoring");
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
ConsumeResult<string, string>? result = null;
|
||||
try
|
||||
{
|
||||
result = consumer.Consume(stoppingToken);
|
||||
|
||||
var evt = JsonSerializer.Deserialize<News2ObservationEvent>(
|
||||
result.Message.Value,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<GcsDetector>();
|
||||
|
||||
var outcome = await detector.ProcessObservationAsync(
|
||||
evt.EncounterId,
|
||||
evt.PatientId,
|
||||
evt.ObservationCode,
|
||||
evt.Value,
|
||||
stoppingToken);
|
||||
|
||||
if (outcome.Outcome == GcsOutcome.ScoreComputed)
|
||||
_logger.LogInformation(
|
||||
"GCS scored via consumer — encounter={Id} total={Total} class={Class}",
|
||||
evt.EncounterId, outcome.TotalScore, outcome.Classification);
|
||||
|
||||
consumer.Commit(result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"GcsScoringService failed on topic={Topic} offset={Offset} — not committing",
|
||||
result?.Topic, result?.Offset.Value);
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
consumer.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public class KafkaTopicProvisioner : IHostedService
|
||||
_options.Topics.AlertAcknowledged,
|
||||
_options.Topics.EncounterStatusChanged,
|
||||
_options.Topics.SepsisBundleCreated,
|
||||
_options.Topics.SepsisBundleUpdated
|
||||
_options.Topics.SepsisBundleUpdated,
|
||||
_options.Topics.GcsScored
|
||||
};
|
||||
|
||||
var specs = topicNames.Select(name => new TopicSpecification
|
||||
@@ -44,7 +45,9 @@ public class KafkaTopicProvisioner : IHostedService
|
||||
}
|
||||
catch (CreateTopicsException ex)
|
||||
{
|
||||
var errors = ex.Results.Where(r => r.Error.Code != ErrorCode.TopicAlreadyExists).ToList();
|
||||
var errors = ex.Results
|
||||
.Where(r => r.Error.Code is not (ErrorCode.NoError or ErrorCode.TopicAlreadyExists))
|
||||
.ToList();
|
||||
if (errors.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to create Kafka topics: {string.Join(", ", errors.Select(e => e.Error.Reason))}");
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json;
|
||||
using Confluent.Kafka;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class SofaScoringService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly KafkaOptions _kafkaOptions;
|
||||
private readonly ILogger<SofaScoringService> _logger;
|
||||
|
||||
public SofaScoringService(
|
||||
IServiceProvider services,
|
||||
IOptions<KafkaOptions> kafkaOptions,
|
||||
ILogger<SofaScoringService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_kafkaOptions = kafkaOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var config = new ConsumerConfig
|
||||
{
|
||||
BootstrapServers = _kafkaOptions.BootstrapServers,
|
||||
GroupId = "sofa-scoring",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(new[]
|
||||
{
|
||||
_kafkaOptions.Topics.ObservationRecorded,
|
||||
_kafkaOptions.Topics.GcsScored
|
||||
});
|
||||
|
||||
_logger.LogInformation("SofaScoringService started — consumer group: sofa-scoring");
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
ConsumeResult<string, string>? result = null;
|
||||
try
|
||||
{
|
||||
result = consumer.Consume(stoppingToken);
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
|
||||
|
||||
if (result.Topic == _kafkaOptions.Topics.GcsScored)
|
||||
{
|
||||
var gcsEvt = JsonSerializer.Deserialize<GcsScoredEvent>(
|
||||
result.Message.Value,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var outcome = await detector.ProcessGcsScoredAsync(
|
||||
gcsEvt.EncounterId, gcsEvt.PatientId, stoppingToken);
|
||||
|
||||
if (outcome.Outcome == SofaOutcome.EncounterNotFound)
|
||||
_logger.LogWarning(
|
||||
"Skipping stale gcs.scored event — encounter={Id} offset={Offset}",
|
||||
gcsEvt.EncounterId, result.Offset.Value);
|
||||
else if (outcome.Outcome == SofaOutcome.ScoreComputed)
|
||||
_logger.LogInformation(
|
||||
"SOFA re-scored via gcs.scored — encounter={Id} total={Total}",
|
||||
gcsEvt.EncounterId, outcome.Score!.Total);
|
||||
}
|
||||
else
|
||||
{
|
||||
var evt = JsonSerializer.Deserialize<News2ObservationEvent>(
|
||||
result.Message.Value,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var outcome = await detector.ProcessObservationAsync(
|
||||
evt.EncounterId,
|
||||
evt.PatientId,
|
||||
evt.ObservationCode,
|
||||
evt.Value,
|
||||
DateTimeOffset.UtcNow,
|
||||
stoppingToken);
|
||||
|
||||
if (outcome.Outcome == SofaOutcome.EncounterNotFound)
|
||||
_logger.LogWarning(
|
||||
"Skipping stale observation.recorded event — encounter={Id} code={Code} offset={Offset}",
|
||||
evt.EncounterId, evt.ObservationCode, result.Offset.Value);
|
||||
else if (outcome.Outcome == SofaOutcome.ScoreComputed)
|
||||
_logger.LogInformation(
|
||||
"SOFA scored via consumer — encounter={Id} total={Total}",
|
||||
evt.EncounterId, outcome.Score!.Total);
|
||||
}
|
||||
|
||||
consumer.Commit(result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"SofaScoringService failed on topic={Topic} offset={Offset} — not committing",
|
||||
result?.Topic, result?.Offset.Value);
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
consumer.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record GcsScoredEvent(Guid EncounterId, Guid PatientId, int TotalScore);
|
||||
@@ -6,4 +6,5 @@ public class KafkaTopicOptions
|
||||
public string EncounterStatusChanged { get; set; } = "encounter.status.changed";
|
||||
public string SepsisBundleCreated { get; set; } = "sepsis.bundle.created";
|
||||
public string SepsisBundleUpdated { get; set; } = "sepsis.bundle.updated";
|
||||
public string GcsScored { get; set; } = "gcs.scored";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public class SofaOptions
|
||||
{
|
||||
public int LabStalenessHours { get; set; } = 24;
|
||||
public int LabWarningHours { get; set; } = 12;
|
||||
public bool UseSpO2FiO2Fallback { get; set; } = true;
|
||||
public int VasopressorWindowHours { get; set; } = 1;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/gcs")]
|
||||
[Produces("application/json")]
|
||||
public class GcsController : ControllerBase
|
||||
{
|
||||
private readonly IGcsService _gcs;
|
||||
|
||||
public GcsController(IGcsService gcs) => _gcs = gcs;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<GcsScoreResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Current(Guid encounterId)
|
||||
{
|
||||
var score = await _gcs.GetCurrentAsync(encounterId);
|
||||
if (score is null)
|
||||
return NotFound(ApiResponse<object>.Fail(
|
||||
404, "No GCS score computed for this encounter.", "NO_GCS_SCORE"));
|
||||
|
||||
return Ok(ApiResponse<GcsScoreResponse>.Ok(new GcsScoreResponse(
|
||||
score.EyeScore, score.VerbalScore, score.MotorScore,
|
||||
score.TotalScore, score.Classification, score.CalculatedAt)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/sofa")]
|
||||
[Produces("application/json")]
|
||||
public class SofaController : ControllerBase
|
||||
{
|
||||
private readonly ISofaService _sofa;
|
||||
|
||||
public SofaController(ISofaService sofa) => _sofa = sofa;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<SofaScoreResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Current(Guid encounterId)
|
||||
{
|
||||
var score = await _sofa.GetCurrentAsync(encounterId);
|
||||
if (score is null)
|
||||
return NotFound(ApiResponse<object>.Fail(
|
||||
404, "No SOFA score computed for this encounter.", "NO_SOFA_SCORE"));
|
||||
|
||||
return Ok(ApiResponse<SofaScoreResponse>.Ok(MapResponse(score)));
|
||||
}
|
||||
|
||||
[HttpGet("history")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> History(
|
||||
Guid encounterId,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? cursor = null)
|
||||
{
|
||||
var page = await _sofa.GetHistoryAsync(encounterId, limit, cursor);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = page.Items.Select(MapResponse),
|
||||
nextCursor = page.NextCursor,
|
||||
hasMore = page.HasMore
|
||||
}));
|
||||
}
|
||||
|
||||
private static SofaScoreResponse MapResponse(SofaScore score)
|
||||
{
|
||||
SofaStalenessInfo? staleness = null;
|
||||
if (!string.IsNullOrEmpty(score.StalenessFlags))
|
||||
{
|
||||
var flags = JsonSerializer.Deserialize<SofaStalenessFlags>(
|
||||
score.StalenessFlags,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (flags is not null)
|
||||
{
|
||||
staleness = new SofaStalenessInfo(
|
||||
flags.StaleComponents, flags.MissingComponents, flags.UsedSpO2Fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return new SofaScoreResponse(
|
||||
score.TotalScore,
|
||||
score.RespiratoryScore, score.CoagulationScore, score.LiverScore,
|
||||
score.CardiovascularScore, score.CnsScore, score.RenalScore,
|
||||
score.IsBaseline, score.DeltaFromBaseline,
|
||||
staleness, score.CalculatedAt);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<SepsisBundle> SepsisBundles => Set<SepsisBundle>();
|
||||
public DbSet<SepsisBundleElement> SepsisBundleElements => Set<SepsisBundleElement>();
|
||||
public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>();
|
||||
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
|
||||
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -12,8 +12,19 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
|
||||
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')");
|
||||
"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', " +
|
||||
"'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', " +
|
||||
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
|
||||
"'RAPID_DETERIORATION', 'QSOFA_WARNING', " +
|
||||
"'GCS_CRITICAL', 'GCS_WARNING')");
|
||||
});
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class GcsScoreConfiguration : IEntityTypeConfiguration<GcsScore>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GcsScore> builder)
|
||||
{
|
||||
builder.ToTable("gcs_scores", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_gcs_scores_classification",
|
||||
"classification IN ('MILD', 'MODERATE', 'SEVERE')");
|
||||
});
|
||||
builder.HasKey(g => g.Id);
|
||||
builder.Property(g => g.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(g => g.EncounterId).HasColumnName("encounter_id").IsRequired();
|
||||
builder.Property(g => g.PatientId).HasColumnName("patient_id").IsRequired();
|
||||
builder.Property(g => g.EyeScore).HasColumnName("eye_score").IsRequired();
|
||||
builder.Property(g => g.VerbalScore).HasColumnName("verbal_score").IsRequired();
|
||||
builder.Property(g => g.MotorScore).HasColumnName("motor_score").IsRequired();
|
||||
builder.Property(g => g.TotalScore).HasColumnName("total_score").IsRequired();
|
||||
builder.Property(g => g.Classification).HasColumnName("classification").HasMaxLength(16).IsRequired();
|
||||
builder.Property(g => g.CalculatedAt).HasColumnName("calculated_at").IsRequired();
|
||||
builder.Property(g => g.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
|
||||
builder.HasOne(g => g.Encounter)
|
||||
.WithMany()
|
||||
.HasForeignKey(g => g.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(g => new { g.EncounterId, g.CalculatedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class SofaScoreConfiguration : IEntityTypeConfiguration<SofaScore>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SofaScore> builder)
|
||||
{
|
||||
builder.ToTable("sofa_scores");
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(s => s.EncounterId).HasColumnName("encounter_id").IsRequired();
|
||||
builder.Property(s => s.PatientId).HasColumnName("patient_id").IsRequired();
|
||||
builder.Property(s => s.TotalScore).HasColumnName("total_score").IsRequired();
|
||||
builder.Property(s => s.RespiratoryScore).HasColumnName("respiratory_score").IsRequired();
|
||||
builder.Property(s => s.CoagulationScore).HasColumnName("coagulation_score").IsRequired();
|
||||
builder.Property(s => s.LiverScore).HasColumnName("liver_score").IsRequired();
|
||||
builder.Property(s => s.CardiovascularScore).HasColumnName("cardiovascular_score").IsRequired();
|
||||
builder.Property(s => s.CnsScore).HasColumnName("cns_score").IsRequired();
|
||||
builder.Property(s => s.RenalScore).HasColumnName("renal_score").IsRequired();
|
||||
builder.Property(s => s.IsBaseline).HasColumnName("is_baseline").IsRequired();
|
||||
builder.Property(s => s.DeltaFromBaseline).HasColumnName("delta_from_baseline");
|
||||
builder.Property(s => s.StalenessFlags).HasColumnName("staleness_flags").HasColumnType("jsonb");
|
||||
builder.Property(s => s.CalculatedAt).HasColumnName("calculated_at").IsRequired();
|
||||
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Encounter)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(s => new { s.EncounterId, s.CalculatedAt });
|
||||
builder.HasIndex(s => s.EncounterId)
|
||||
.HasFilter("is_baseline = true")
|
||||
.HasDatabaseName("idx_sofa_scores_baseline");
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,70 @@ public static class DataSeeder
|
||||
CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
// GCS components — registered for ingestion; thresholds are null (alerting is on computed total in GcsDetector)
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "GCS_EYE",
|
||||
DisplayName = "GCS Eye Response", Unit = "score",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "GCS_VERBAL",
|
||||
DisplayName = "GCS Verbal Response", Unit = "score",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "GCS_MOTOR",
|
||||
DisplayName = "GCS Motor Response", Unit = "score",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "PAO2_MMHG",
|
||||
DisplayName = "Partial Pressure O2 (Arterial)", Unit = "mmHg",
|
||||
CriticalLow = 60m, WarningLow = 80m, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "FIO2_PCT",
|
||||
DisplayName = "Fraction of Inspired O2", Unit = "%",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "PLATELET_K_UL",
|
||||
DisplayName = "Platelet Count", Unit = "k/µL",
|
||||
CriticalLow = 20m, WarningLow = 50m, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "BILIRUBIN_MG_DL",
|
||||
DisplayName = "Total Bilirubin", Unit = "mg/dL",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 6.0m,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "CREATININE_MG_DL",
|
||||
DisplayName = "Serum Creatinine", Unit = "mg/dL",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 3.5m,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(), ObservationCode = "URINE_OUTPUT_ML_H",
|
||||
DisplayName = "Urine Output", Unit = "mL/h",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
};
|
||||
db.AlertThresholds.AddRange(thresholds);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
public class GcsScore
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public int EyeScore { get; set; }
|
||||
public int VerbalScore { get; set; }
|
||||
public int MotorScore { get; set; }
|
||||
public int TotalScore { get; set; }
|
||||
public string Classification { get; set; } = null!;
|
||||
public DateTimeOffset CalculatedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public Encounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public class SofaScore
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public int TotalScore { get; set; }
|
||||
public int RespiratoryScore { get; set; }
|
||||
public int CoagulationScore { get; set; }
|
||||
public int LiverScore { get; set; }
|
||||
public int CardiovascularScore { get; set; }
|
||||
public int CnsScore { get; set; }
|
||||
public int RenalScore { get; set; }
|
||||
public bool IsBaseline { get; set; }
|
||||
public int? DeltaFromBaseline { get; set; }
|
||||
public string? StalenessFlags { get; set; }
|
||||
public DateTimeOffset CalculatedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public Encounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -31,6 +31,21 @@ public enum AlertType
|
||||
RapidDeterioration,
|
||||
|
||||
QsofaWarning,
|
||||
|
||||
GcsCritical,
|
||||
GcsWarning,
|
||||
|
||||
CriticalPao2MmHg,
|
||||
WarningPao2MmHg,
|
||||
CriticalPlateletKUl,
|
||||
WarningPlateletKUl,
|
||||
CriticalBilirubinMgDl,
|
||||
WarningBilirubinMgDl,
|
||||
CriticalCreatinineMgDl,
|
||||
WarningCreatinineMgDl,
|
||||
|
||||
SofaSepsis,
|
||||
SofaWarning,
|
||||
}
|
||||
|
||||
public static class AlertTypeExtensions
|
||||
@@ -63,6 +78,18 @@ public static class AlertTypeExtensions
|
||||
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",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
@@ -94,6 +121,18 @@ public static class AlertTypeExtensions
|
||||
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
|
||||
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
|
||||
"QSOFA_WARNING" => AlertType.QsofaWarning,
|
||||
"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}'")
|
||||
};
|
||||
|
||||
@@ -111,6 +150,10 @@ public static class AlertTypeExtensions
|
||||
"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}'")
|
||||
};
|
||||
@@ -127,6 +170,10 @@ public static class AlertTypeExtensions
|
||||
"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}'")
|
||||
};
|
||||
@@ -139,7 +186,9 @@ public static class AlertTypeExtensions
|
||||
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
|
||||
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
|
||||
or AlertType.CriticalGlucoseMgDl => false,
|
||||
AlertType.RapidDeterioration => false, // trajectory alerts are never suppressed
|
||||
AlertType.RapidDeterioration => false,
|
||||
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
|
||||
AlertType.SofaSepsis => false,
|
||||
_ => true // all Warning* types, News2Warning, and QsofaWarning
|
||||
};
|
||||
|
||||
@@ -155,6 +204,10 @@ public static class AlertTypeExtensions
|
||||
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,6 @@
|
||||
public enum GcsOutcome
|
||||
{
|
||||
NotGcsCode,
|
||||
IncompleteComponents,
|
||||
ScoreComputed
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public enum SofaOutcome
|
||||
{
|
||||
NotSofaTrigger,
|
||||
EncounterNotFound,
|
||||
ScoreComputed
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public enum SofaValueStatus
|
||||
{
|
||||
Current,
|
||||
Stale,
|
||||
Expired
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
public static class GcsCalculator
|
||||
{
|
||||
public static readonly IReadOnlyList<string> ComponentCodes = new[]
|
||||
{
|
||||
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR"
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> ComponentCodeSet =
|
||||
new HashSet<string>(ComponentCodes);
|
||||
|
||||
public static bool IsGcsCode(string observationCode) =>
|
||||
ComponentCodeSet.Contains(observationCode);
|
||||
|
||||
public static RedisKey[] AllComponentKeys(Guid encounterId) =>
|
||||
ComponentCodes
|
||||
.Select(code => (RedisKey)$"gcs:{encounterId}:{code}")
|
||||
.ToArray();
|
||||
|
||||
public static string ComponentKey(Guid encounterId, string code) =>
|
||||
$"gcs:{encounterId}:{code}";
|
||||
|
||||
// Compute total from three components. Returns null if any component missing.
|
||||
public static int? ComputeTotal(decimal? eye, decimal? verbal, decimal? motor)
|
||||
{
|
||||
if (eye is null || verbal is null || motor is null)
|
||||
return null;
|
||||
return (int)(eye.Value + verbal.Value + motor.Value);
|
||||
}
|
||||
|
||||
// GCS severity classification
|
||||
public static string ClassifyGcs(int total) => total switch
|
||||
{
|
||||
<= 8 => "SEVERE", // Coma
|
||||
<= 12 => "MODERATE",
|
||||
_ => "MILD" // 13-15
|
||||
};
|
||||
|
||||
// Map GCS total to NEWS2 consciousness score (replaces AVPU mapping)
|
||||
public static int ToNews2ConsciousnessScore(int gcsTotal) => gcsTotal switch
|
||||
{
|
||||
15 => 0, // Fully alert — equivalent to AVPU=Alert
|
||||
_ => 3 // Any deficit — equivalent to AVPU=Voice/Pain/Unresponsive
|
||||
};
|
||||
|
||||
// Map GCS total to qSOFA altered mentation criterion
|
||||
public static bool MeetsQsofaAlteredMentation(int gcsTotal) =>
|
||||
gcsTotal < 15;
|
||||
|
||||
// Map GCS total to SOFA CNS score (used in Phase 26)
|
||||
public static int ToSofaCnsScore(int gcsTotal) => gcsTotal switch
|
||||
{
|
||||
15 => 0,
|
||||
>= 13 => 1, // 13-14
|
||||
>= 10 => 2, // 10-12
|
||||
>= 6 => 3, // 6-9
|
||||
_ => 4 // < 6
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class GcsDetector
|
||||
{
|
||||
private const int GcsTtlSeconds = 14400; // 4 hours — same as NEWS2
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
private readonly ILogger<GcsDetector> _logger;
|
||||
|
||||
public GcsDetector(
|
||||
IConnectionMultiplexer redis,
|
||||
IServiceProvider services,
|
||||
ClinicalMetrics metrics,
|
||||
ILogger<GcsDetector> logger)
|
||||
{
|
||||
_redis = redis;
|
||||
_services = services;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GcsResult> ProcessObservationAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
string observationCode,
|
||||
decimal value,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!GcsCalculator.IsGcsCode(observationCode))
|
||||
return GcsResult.NotGcsCode;
|
||||
|
||||
var cache = _redis.GetDatabase();
|
||||
|
||||
await cache.StringSetAsync(
|
||||
GcsCalculator.ComponentKey(encounterId, observationCode),
|
||||
value.ToString(CultureInfo.InvariantCulture),
|
||||
TimeSpan.FromSeconds(GcsTtlSeconds));
|
||||
|
||||
var allKeys = GcsCalculator.AllComponentKeys(encounterId);
|
||||
var allValues = await cache.StringGetAsync(allKeys);
|
||||
|
||||
if (allValues.Any(v => !v.HasValue))
|
||||
{
|
||||
var present = allValues.Count(v => v.HasValue);
|
||||
_logger.LogDebug(
|
||||
"GCS incomplete for encounter {Id}: {Present}/3 components present",
|
||||
encounterId, present);
|
||||
return GcsResult.IncompleteComponents(present);
|
||||
}
|
||||
|
||||
var eye = decimal.Parse(allValues[0]!, CultureInfo.InvariantCulture);
|
||||
var verbal = decimal.Parse(allValues[1]!, CultureInfo.InvariantCulture);
|
||||
var motor = decimal.Parse(allValues[2]!, CultureInfo.InvariantCulture);
|
||||
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
|
||||
var classification = GcsCalculator.ClassifyGcs(total);
|
||||
var calculatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await PersistScoreAsync(
|
||||
encounterId, patientId, (int)eye, (int)verbal, (int)motor,
|
||||
total, classification, calculatedAt, ct);
|
||||
|
||||
var alertCreated = false;
|
||||
if (total <= 8)
|
||||
{
|
||||
alertCreated = await TryCreateAlertAsync(
|
||||
encounterId, patientId, AlertType.GcsCritical, AlertSeverity.Critical,
|
||||
eye, verbal, motor, total, classification, ct);
|
||||
}
|
||||
else if (total <= 12)
|
||||
{
|
||||
alertCreated = await TryCreateAlertAsync(
|
||||
encounterId, patientId, AlertType.GcsWarning, AlertSeverity.Warning,
|
||||
eye, verbal, motor, total, classification, ct);
|
||||
}
|
||||
|
||||
await PublishScoredEventAsync(
|
||||
encounterId, patientId, eye, verbal, motor, total, classification, calculatedAt, ct);
|
||||
|
||||
// Re-evaluate qSOFA altered mentation from the computed GCS total (Step 6)
|
||||
using (var scope = _services.CreateScope())
|
||||
{
|
||||
var qsofa = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
|
||||
await qsofa.SyncAlteredMentationAsync(encounterId, patientId, ct);
|
||||
}
|
||||
|
||||
_metrics.GcsScoresTotal.WithLabels(classification).Inc();
|
||||
|
||||
_logger.LogInformation(
|
||||
"GCS score {Total} ({Classification}) for encounter {Id} — E={Eye} V={Verbal} M={Motor}",
|
||||
total, classification, encounterId, eye, verbal, motor);
|
||||
|
||||
return new GcsResult(GcsOutcome.ScoreComputed, total, classification, alertCreated, 3);
|
||||
}
|
||||
|
||||
private async Task PersistScoreAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
int eye, int verbal, int motor,
|
||||
int total, string classification,
|
||||
DateTimeOffset calculatedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
db.GcsScores.Add(new GcsScore
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
EyeScore = eye,
|
||||
VerbalScore = verbal,
|
||||
MotorScore = motor,
|
||||
TotalScore = total,
|
||||
Classification = classification,
|
||||
CalculatedAt = calculatedAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<bool> TryCreateAlertAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
AlertType alertType, AlertSeverity severity,
|
||||
decimal eye, decimal verbal, decimal motor,
|
||||
int total, string classification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (alertType == AlertType.GcsWarning)
|
||||
{
|
||||
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
|
||||
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
|
||||
{
|
||||
_logger.LogDebug("GCS_WARNING suppressed for encounter {Id}", encounterId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var alertId = Guid.NewGuid();
|
||||
var triggeredAt = DateTimeOffset.UtcNow;
|
||||
var details =
|
||||
$"GCS total {total} ({classification}): E={eye}, V={verbal}, M={motor}.";
|
||||
|
||||
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
|
||||
INSERT INTO clinical_alerts
|
||||
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
|
||||
SELECT {alertId}, {encounterId}, {patientId},
|
||||
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM clinical_alerts
|
||||
WHERE encounter_id = {encounterId}
|
||||
AND alert_type = {alertType.ToDbString()}
|
||||
AND status IN ('OPEN', 'ESCALATED')
|
||||
)
|
||||
""", ct);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Topic = "alert.generated",
|
||||
Payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
alertId,
|
||||
encounterId,
|
||||
patientId,
|
||||
alertType = alertType.ToDbString(),
|
||||
severity = severity.ToDbString(),
|
||||
details,
|
||||
triggeredAt,
|
||||
gcsTotal = total,
|
||||
gcsClassification = classification,
|
||||
partitionKey = encounterId.ToString()
|
||||
}),
|
||||
PartitionKey = encounterId.ToString(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
_metrics.ClinicalAlertsTotal
|
||||
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task PublishScoredEventAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
decimal eye, decimal verbal, decimal motor,
|
||||
int total, string classification,
|
||||
DateTimeOffset calculatedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Topic = "gcs.scored",
|
||||
Payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
encounterId,
|
||||
patientId,
|
||||
eyeScore = (int)eye,
|
||||
verbalScore = (int)verbal,
|
||||
motorScore = (int)motor,
|
||||
totalScore = total,
|
||||
classification,
|
||||
calculatedAt,
|
||||
partitionKey = encounterId.ToString()
|
||||
}),
|
||||
PartitionKey = encounterId.ToString(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
// <auto-generated />
|
||||
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("20260620153118_AddGcsScores")]
|
||||
partial class AddGcsScores
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<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", (string)null);
|
||||
});
|
||||
|
||||
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<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
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<DateTimeOffset>("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.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', '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', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_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("Encounter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("AdmissionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("admission_reason");
|
||||
|
||||
b.Property<DateTimeOffset>("AdmittedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("admitted_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("AttendingPhysician")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("attending_physician");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
b.Property<string>("DischargeDiagnosis")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("discharge_diagnosis");
|
||||
|
||||
b.Property<DateTimeOffset?>("DischargedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("discharged_at");
|
||||
|
||||
b.Property<string>("EncounterType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("encounter_type");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("RoomBed")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("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("GcsScore", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<string>("Classification")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("classification");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<int>("EyeScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("eye_score");
|
||||
|
||||
b.Property<int>("MotorScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("motor_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("TotalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_score");
|
||||
|
||||
b.Property<int>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("AdministeredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("administered_at");
|
||||
|
||||
b.Property<string>("AdministeredBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("administered_by");
|
||||
|
||||
b.Property<decimal>("Dose")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("numeric(10,4)")
|
||||
.HasColumnName("dose");
|
||||
|
||||
b.Property<string>("DoseUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("dose_unit");
|
||||
|
||||
b.Property<string>("DrugName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("drug_name");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<int>("ConsciousnessScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("consciousness_score");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("HasSingleParamThree")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_single_param_three");
|
||||
|
||||
b.Property<int>("HeartRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("heart_rate_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("RespRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("resp_rate_score");
|
||||
|
||||
b.Property<string>("RiskLevel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("risk_level");
|
||||
|
||||
b.Property<int>("Spo2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("spo2_score");
|
||||
|
||||
b.Property<int>("SupplementalO2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("supplemental_o2_score");
|
||||
|
||||
b.Property<int>("SystolicBpScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("systolic_bp_score");
|
||||
|
||||
b.Property<int>("TemperatureScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("temperature_score");
|
||||
|
||||
b.Property<int>("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<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<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("source")
|
||||
.HasDefaultValueSql("'MANUAL'");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("OrderType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("order_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OrderedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ordered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("OrderedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("ordered_by");
|
||||
|
||||
b.Property<string>("ResultSummary")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("result_summary");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResultedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resulted_at");
|
||||
|
||||
b.Property<string>("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<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<string>("PartitionKey")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("partition_key");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Allergies")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("allergies");
|
||||
|
||||
b.Property<string>("BloodType")
|
||||
.HasMaxLength(5)
|
||||
.HasColumnType("character varying(5)")
|
||||
.HasColumnName("blood_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateOnly>("DateOfBirth")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date_of_birth");
|
||||
|
||||
b.Property<string>("EmergencyContactName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("emergency_contact_name");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("emergency_contact_phone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("CheckType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("check_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid?>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<Guid?>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<string>("ComplianceStatus")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("compliance_status")
|
||||
.HasDefaultValueSql("'IN_PROGRESS'");
|
||||
|
||||
b.Property<DateTimeOffset>("DeadlineAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deadline_at");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("RecognizedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recognized_at");
|
||||
|
||||
b.Property<Guid>("TriggeringAlertId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("triggering_alert_id");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid>("BundleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("bundle_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<string>("ElementType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)")
|
||||
.HasColumnName("element_type");
|
||||
|
||||
b.Property<Guid?>("OrderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("order_id");
|
||||
|
||||
b.Property<string>("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("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("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGcsScores : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "gcs_scores",
|
||||
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),
|
||||
eye_score = table.Column<int>(type: "integer", nullable: false),
|
||||
verbal_score = table.Column<int>(type: "integer", nullable: false),
|
||||
motor_score = table.Column<int>(type: "integer", nullable: false),
|
||||
total_score = table.Column<int>(type: "integer", nullable: false),
|
||||
classification = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
calculated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_gcs_scores", x => x.id);
|
||||
table.CheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
|
||||
table.ForeignKey(
|
||||
name: "FK_gcs_scores_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_gcs_scores_encounter_id_calculated_at",
|
||||
table: "gcs_scores",
|
||||
columns: new[] { "encounter_id", "calculated_at" });
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
|
||||
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
|
||||
CHECK (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',
|
||||
'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',
|
||||
'NEWS2_WARNING', 'NEWS2_EMERGENCY',
|
||||
'RAPID_DETERIORATION', 'QSOFA_WARNING',
|
||||
'GCS_CRITICAL', 'GCS_WARNING'
|
||||
));
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "gcs_scores");
|
||||
}
|
||||
}
|
||||
}
|
||||
+999
@@ -0,0 +1,999 @@
|
||||
// <auto-generated />
|
||||
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("20260620161423_AddSofaObservationAlertTypes")]
|
||||
partial class AddSofaObservationAlertTypes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<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", (string)null);
|
||||
});
|
||||
|
||||
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<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
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<DateTimeOffset>("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.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', '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', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_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("Encounter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("AdmissionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("admission_reason");
|
||||
|
||||
b.Property<DateTimeOffset>("AdmittedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("admitted_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("AttendingPhysician")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("attending_physician");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
b.Property<string>("DischargeDiagnosis")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("discharge_diagnosis");
|
||||
|
||||
b.Property<DateTimeOffset?>("DischargedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("discharged_at");
|
||||
|
||||
b.Property<string>("EncounterType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("encounter_type");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("RoomBed")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("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("GcsScore", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<string>("Classification")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("classification");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<int>("EyeScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("eye_score");
|
||||
|
||||
b.Property<int>("MotorScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("motor_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("TotalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_score");
|
||||
|
||||
b.Property<int>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("AdministeredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("administered_at");
|
||||
|
||||
b.Property<string>("AdministeredBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("administered_by");
|
||||
|
||||
b.Property<decimal>("Dose")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("numeric(10,4)")
|
||||
.HasColumnName("dose");
|
||||
|
||||
b.Property<string>("DoseUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("dose_unit");
|
||||
|
||||
b.Property<string>("DrugName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("drug_name");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<int>("ConsciousnessScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("consciousness_score");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("HasSingleParamThree")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_single_param_three");
|
||||
|
||||
b.Property<int>("HeartRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("heart_rate_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("RespRateScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("resp_rate_score");
|
||||
|
||||
b.Property<string>("RiskLevel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("risk_level");
|
||||
|
||||
b.Property<int>("Spo2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("spo2_score");
|
||||
|
||||
b.Property<int>("SupplementalO2Score")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("supplemental_o2_score");
|
||||
|
||||
b.Property<int>("SystolicBpScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("systolic_bp_score");
|
||||
|
||||
b.Property<int>("TemperatureScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("temperature_score");
|
||||
|
||||
b.Property<int>("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<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<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("source")
|
||||
.HasDefaultValueSql("'MANUAL'");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("OrderType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("order_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OrderedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ordered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("OrderedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("ordered_by");
|
||||
|
||||
b.Property<string>("ResultSummary")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("result_summary");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResultedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resulted_at");
|
||||
|
||||
b.Property<string>("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<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<string>("PartitionKey")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("partition_key");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Allergies")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("allergies");
|
||||
|
||||
b.Property<string>("BloodType")
|
||||
.HasMaxLength(5)
|
||||
.HasColumnType("character varying(5)")
|
||||
.HasColumnName("blood_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateOnly>("DateOfBirth")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date_of_birth");
|
||||
|
||||
b.Property<string>("EmergencyContactName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("emergency_contact_name");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("emergency_contact_phone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("CheckType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("check_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid?>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<Guid?>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<string>("ComplianceStatus")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("compliance_status")
|
||||
.HasDefaultValueSql("'IN_PROGRESS'");
|
||||
|
||||
b.Property<DateTimeOffset>("DeadlineAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deadline_at");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("RecognizedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recognized_at");
|
||||
|
||||
b.Property<Guid>("TriggeringAlertId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("triggering_alert_id");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid>("BundleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("bundle_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<string>("ElementType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)")
|
||||
.HasColumnName("element_type");
|
||||
|
||||
b.Property<Guid?>("OrderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("order_id");
|
||||
|
||||
b.Property<string>("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("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("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSofaObservationAlertTypes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
|
||||
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
|
||||
CHECK (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',
|
||||
'GCS_CRITICAL', 'GCS_WARNING'
|
||||
));
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSofaScores : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sofa_scores",
|
||||
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),
|
||||
total_score = table.Column<int>(type: "integer", nullable: false),
|
||||
respiratory_score = table.Column<int>(type: "integer", nullable: false),
|
||||
coagulation_score = table.Column<int>(type: "integer", nullable: false),
|
||||
liver_score = table.Column<int>(type: "integer", nullable: false),
|
||||
cardiovascular_score = table.Column<int>(type: "integer", nullable: false),
|
||||
cns_score = table.Column<int>(type: "integer", nullable: false),
|
||||
renal_score = table.Column<int>(type: "integer", nullable: false),
|
||||
is_baseline = table.Column<bool>(type: "boolean", nullable: false),
|
||||
delta_from_baseline = table.Column<int>(type: "integer", nullable: true),
|
||||
staleness_flags = table.Column<string>(type: "jsonb", nullable: true),
|
||||
calculated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sofa_scores", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_sofa_scores_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_sofa_scores_baseline",
|
||||
table: "sofa_scores",
|
||||
column: "encounter_id",
|
||||
filter: "is_baseline = true");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sofa_scores_encounter_id_calculated_at",
|
||||
table: "sofa_scores",
|
||||
columns: new[] { "encounter_id", "calculated_at" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "sofa_scores");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1085
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSofaAlertTypes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
|
||||
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
|
||||
CHECK (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',
|
||||
'GCS_CRITICAL', 'GCS_WARNING',
|
||||
'SOFA_SEPSIS', 'SOFA_WARNING'
|
||||
));
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
|
||||
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')");
|
||||
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', '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', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_WARNING')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
|
||||
@@ -250,6 +250,62 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GcsScore", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<string>("Classification")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("classification");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<int>("EyeScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("eye_score");
|
||||
|
||||
b.Property<int>("MotorScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("motor_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("TotalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_score");
|
||||
|
||||
b.Property<int>("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<Guid>("Id")
|
||||
@@ -785,6 +841,81 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SofaScore", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CalculatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("calculated_at");
|
||||
|
||||
b.Property<int>("CardiovascularScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("cardiovascular_score");
|
||||
|
||||
b.Property<int>("CnsScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("cns_score");
|
||||
|
||||
b.Property<int>("CoagulationScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("coagulation_score");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int?>("DeltaFromBaseline")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("delta_from_baseline");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("IsBaseline")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_baseline");
|
||||
|
||||
b.Property<int>("LiverScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("liver_score");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<int>("RenalScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("renal_score");
|
||||
|
||||
b.Property<int>("RespiratoryScore")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("respiratory_score");
|
||||
|
||||
b.Property<string>("StalenessFlags")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("staleness_flags");
|
||||
|
||||
b.Property<int>("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")
|
||||
@@ -807,6 +938,17 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
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")
|
||||
@@ -905,6 +1047,17 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
public record GcsResult(
|
||||
GcsOutcome Outcome,
|
||||
int? TotalScore = null,
|
||||
string? Classification = null,
|
||||
bool AlertCreated = false,
|
||||
int PresentComponents = 0)
|
||||
{
|
||||
public static readonly GcsResult NotGcsCode = new(GcsOutcome.NotGcsCode);
|
||||
|
||||
public static GcsResult IncompleteComponents(int presentCount) =>
|
||||
new(GcsOutcome.IncompleteComponents, PresentComponents: presentCount);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public record GcsScoreResponse(
|
||||
int EyeScore,
|
||||
int VerbalScore,
|
||||
int MotorScore,
|
||||
int TotalScore,
|
||||
string Classification,
|
||||
DateTimeOffset CalculatedAt);
|
||||
@@ -0,0 +1 @@
|
||||
public record SofaCachedValue(decimal Value, DateTimeOffset RecordedAt);
|
||||
@@ -0,0 +1,3 @@
|
||||
public record SofaResult(
|
||||
int Total, int Respiratory, int Coagulation, int Liver,
|
||||
int Cardiovascular, int Cns, int Renal);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record SofaScoreResponse(
|
||||
int TotalScore,
|
||||
int RespiratoryScore, int CoagulationScore, int LiverScore,
|
||||
int CardiovascularScore, int CnsScore, int RenalScore,
|
||||
bool IsBaseline, int? DeltaFromBaseline,
|
||||
SofaStalenessInfo? Staleness,
|
||||
DateTimeOffset CalculatedAt);
|
||||
@@ -0,0 +1,10 @@
|
||||
public record SofaScoringResult(
|
||||
SofaOutcome Outcome,
|
||||
SofaResult? Score = null,
|
||||
bool IsBaseline = false,
|
||||
int? DeltaFromBaseline = null,
|
||||
bool AlertCreated = false)
|
||||
{
|
||||
public static readonly SofaScoringResult NotSofaTrigger = new(SofaOutcome.NotSofaTrigger);
|
||||
public static readonly SofaScoringResult EncounterNotFound = new(SofaOutcome.EncounterNotFound);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public record SofaStalenessFlags(
|
||||
IReadOnlyList<string> StaleComponents,
|
||||
IReadOnlyList<string> MissingComponents,
|
||||
bool UsedSpO2Fallback);
|
||||
@@ -0,0 +1,4 @@
|
||||
public record SofaStalenessInfo(
|
||||
IReadOnlyList<string> StaleComponents,
|
||||
IReadOnlyList<string> MissingComponents,
|
||||
bool UsedSpO2Fallback);
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using System.Text.Json;
|
||||
|
||||
public class SofaVasopressorResolver
|
||||
{
|
||||
private static readonly HashSet<string> VasopressorDrugs = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"DOPAMINE", "DOBUTAMINE", "EPINEPHRINE", "NOREPINEPHRINE",
|
||||
"VASOPRESSIN", "PHENYLEPHRINE"
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly SofaOptions _options;
|
||||
|
||||
public SofaVasopressorResolver(
|
||||
AppDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
IOptions<SofaOptions> options)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public static string VasopressorCacheKey(Guid encounterId) =>
|
||||
$"sofa:{encounterId}:vasopressor";
|
||||
|
||||
public async Task CacheFromAdministrationAsync(MedicationAdministration med, CancellationToken ct)
|
||||
{
|
||||
if (!VasopressorDrugs.Contains(med.DrugName)) return;
|
||||
|
||||
var info = new VasopressorInfo(med.DrugName, NormalizeDose(med.DrugName, med.Dose, med.DoseUnit));
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
info.DrugName,
|
||||
info.DoseUgKgMin,
|
||||
med.AdministeredAt
|
||||
}, JsonOptions);
|
||||
|
||||
await _redis.GetDatabase().StringSetAsync(
|
||||
VasopressorCacheKey(med.EncounterId),
|
||||
json,
|
||||
TimeSpan.FromHours(_options.VasopressorWindowHours));
|
||||
}
|
||||
|
||||
public async Task<VasopressorInfo?> GetActiveVasopressorAsync(
|
||||
Guid encounterId, CancellationToken ct)
|
||||
{
|
||||
var cached = await _redis.GetDatabase()
|
||||
.StringGetAsync(VasopressorCacheKey(encounterId));
|
||||
if (cached.HasValue)
|
||||
{
|
||||
using var doc = JsonDocument.Parse((string)cached!);
|
||||
var root = doc.RootElement;
|
||||
return new VasopressorInfo(
|
||||
root.GetProperty("drugName").GetString()!,
|
||||
root.GetProperty("doseUgKgMin").GetDecimal());
|
||||
}
|
||||
|
||||
var since = DateTimeOffset.UtcNow.AddHours(-_options.VasopressorWindowHours);
|
||||
var med = await _db.MedicationAdministrations
|
||||
.AsNoTracking()
|
||||
.Where(m => m.EncounterId == encounterId
|
||||
&& VasopressorDrugs.Contains(m.DrugName)
|
||||
&& m.AdministeredAt >= since)
|
||||
.OrderByDescending(m => m.AdministeredAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (med is null) return null;
|
||||
return new VasopressorInfo(med.DrugName, NormalizeDose(med.DrugName, med.Dose, med.DoseUnit));
|
||||
}
|
||||
|
||||
private static decimal NormalizeDose(string drug, decimal dose, string unit) =>
|
||||
unit.ToLowerInvariant() switch
|
||||
{
|
||||
"mcg/kg/min" or "µg/kg/min" => dose,
|
||||
"mcg/min" or "µg/min" => dose / 70m,
|
||||
"mg/hr" => dose * 1000m / 60m / 70m,
|
||||
_ => dose
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
public record VasopressorInfo(string DrugName, decimal DoseUgKgMin);
|
||||
@@ -9,18 +9,15 @@ public static class News2Calculator
|
||||
};
|
||||
|
||||
public static RedisKey[] AllParameterKeys(Guid encounterId) =>
|
||||
ParameterCodes
|
||||
.Select(code => (RedisKey)$"news2:{encounterId}:{code}")
|
||||
.ToArray();
|
||||
ParameterCodes
|
||||
.Select(code => (RedisKey)$"news2:{encounterId}:{code}")
|
||||
.ToArray();
|
||||
|
||||
public static string ParameterKey(Guid encounterId, string code) =>
|
||||
$"news2:{encounterId}:{code}";
|
||||
|
||||
public static bool IsNews2Code(string observationCode) =>
|
||||
ParameterCodes.Contains(observationCode);
|
||||
|
||||
// --- Individual parameter scoring ---
|
||||
// Each method returns 0-3 per the official NEWS2 scoring table.
|
||||
ParameterCodes.Contains(observationCode) || GcsCalculator.IsGcsCode(observationCode);
|
||||
|
||||
public static int ScoreRespRate(decimal value) => value switch
|
||||
{
|
||||
@@ -28,16 +25,15 @@ public static class News2Calculator
|
||||
<= 11 => 1,
|
||||
<= 20 => 0,
|
||||
<= 24 => 2,
|
||||
_ => 3 // >= 25
|
||||
_ => 3
|
||||
};
|
||||
|
||||
// Scale 1 (standard). Scale 2 (hypercapnic respiratory failure) is not implemented.
|
||||
public static int ScoreSpo2(decimal value) => value switch
|
||||
{
|
||||
<= 91 => 3,
|
||||
<= 93 => 2,
|
||||
<= 95 => 1,
|
||||
_ => 0 // >= 96
|
||||
_ => 0
|
||||
};
|
||||
|
||||
public static int ScoreSystolicBp(decimal value) => value switch
|
||||
@@ -46,7 +42,7 @@ public static class News2Calculator
|
||||
<= 100 => 2,
|
||||
<= 110 => 1,
|
||||
<= 219 => 0,
|
||||
_ => 3 // >= 220
|
||||
_ => 3
|
||||
};
|
||||
|
||||
public static int ScoreHeartRate(decimal value) => value switch
|
||||
@@ -56,30 +52,30 @@ public static class News2Calculator
|
||||
<= 90 => 0,
|
||||
<= 110 => 1,
|
||||
<= 130 => 2,
|
||||
_ => 3 // >= 131
|
||||
_ => 3
|
||||
};
|
||||
|
||||
// AVPU: Alert=0, Voice/Pain/Unresponsive=3 (any non-Alert scores 3)
|
||||
public static int ScoreConsciousness(decimal value) => value switch
|
||||
{
|
||||
0 => 0, // Alert
|
||||
_ => 3 // Voice (1), Pain (2), Unresponsive (3)
|
||||
0 => 0,
|
||||
_ => 3
|
||||
};
|
||||
|
||||
public static int ScoreConsciousnessFromGcs(int gcsTotal) =>
|
||||
GcsCalculator.ToNews2ConsciousnessScore(gcsTotal);
|
||||
|
||||
public static int ScoreTemperature(decimal value) => value switch
|
||||
{
|
||||
<= 35.0m => 3,
|
||||
<= 36.0m => 1,
|
||||
<= 38.0m => 0,
|
||||
<= 39.0m => 1,
|
||||
_ => 2 // >= 39.1
|
||||
_ => 2
|
||||
};
|
||||
|
||||
// 0 = room air, 1 = on supplemental oxygen
|
||||
public static int ScoreSupplementalO2(decimal value) =>
|
||||
value >= 1 ? 2 : 0;
|
||||
|
||||
// Dispatch to the correct scoring function by observation code.
|
||||
public static int ScoreParameter(string observationCode, decimal value) =>
|
||||
observationCode switch
|
||||
{
|
||||
@@ -93,13 +89,12 @@ public static class News2Calculator
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(observationCode))
|
||||
};
|
||||
|
||||
// Determine risk level from total score and single-param-3 flag.
|
||||
public static string DetermineRiskLevel(int totalScore, bool hasSingleParamThree) =>
|
||||
totalScore switch
|
||||
{
|
||||
>= 7 => "HIGH",
|
||||
>= 5 => "MEDIUM",
|
||||
_ when hasSingleParamThree => "LOW_MEDIUM",
|
||||
_ => "LOW"
|
||||
>= 7 => "HIGH",
|
||||
>= 5 => "MEDIUM",
|
||||
_ when hasSingleParamThree => "LOW_MEDIUM",
|
||||
_ => "LOW"
|
||||
};
|
||||
}
|
||||
@@ -40,30 +40,51 @@ public class News2Detector
|
||||
return News2Result.NotNews2Code;
|
||||
|
||||
using var timer = _metrics.News2ScoringDuration.NewTimer();
|
||||
|
||||
var cache = _redis.GetDatabase();
|
||||
|
||||
// Compute the individual score and store in Redis
|
||||
var individualScore = News2Calculator.ScoreParameter(observationCode, value);
|
||||
var paramData = JsonSerializer.Serialize(new
|
||||
// GCS components are cached by GcsDetector — trigger re-score only
|
||||
if (!GcsCalculator.IsGcsCode(observationCode))
|
||||
{
|
||||
value,
|
||||
score = individualScore,
|
||||
recordedAt = DateTimeOffset.UtcNow
|
||||
}, CachedParamJsonOptions);
|
||||
await cache.StringSetAsync(
|
||||
News2Calculator.ParameterKey(encounterId, observationCode),
|
||||
paramData,
|
||||
TimeSpan.FromSeconds(News2TtlSeconds));
|
||||
var individualScore = News2Calculator.ScoreParameter(observationCode, value);
|
||||
var paramData = JsonSerializer.Serialize(new
|
||||
{
|
||||
value,
|
||||
score = individualScore,
|
||||
recordedAt = DateTimeOffset.UtcNow
|
||||
}, CachedParamJsonOptions);
|
||||
await cache.StringSetAsync(
|
||||
News2Calculator.ParameterKey(encounterId, observationCode),
|
||||
paramData,
|
||||
TimeSpan.FromSeconds(News2TtlSeconds));
|
||||
}
|
||||
|
||||
// Fetch all 7 parameter keys in one MGET round-trip
|
||||
return await TryComputeScoreAsync(encounterId, patientId, ct);
|
||||
}
|
||||
|
||||
private async Task<News2Result> TryComputeScoreAsync(
|
||||
Guid encounterId, Guid patientId, CancellationToken ct)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var allKeys = News2Calculator.AllParameterKeys(encounterId);
|
||||
var allValues = await cache.StringGetAsync(allKeys);
|
||||
|
||||
// Check completeness — all 7 must be present
|
||||
var scores = new int?[7];
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
if (i == 4) // consciousness — GCS-first, AVPU-fallback
|
||||
{
|
||||
scores[4] = await ResolveConsciousnessScoreAsync(encounterId);
|
||||
if (scores[4] is null)
|
||||
{
|
||||
var present = allValues.Count(v => v.HasValue) + 0;
|
||||
_logger.LogDebug(
|
||||
"NEWS2 incomplete for encounter {Id}: consciousness missing ({Present}/7 present)",
|
||||
encounterId, present);
|
||||
return News2Result.IncompleteParameters(present);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!allValues[i].HasValue)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
@@ -77,23 +98,15 @@ public class News2Detector
|
||||
scores[i] = cached?.Score;
|
||||
}
|
||||
|
||||
// All 7 present — compute aggregate
|
||||
var paramScores = scores.Select(s => s!.Value).ToArray();
|
||||
var totalScore = paramScores.Sum();
|
||||
var hasSingleParamThree = paramScores.Any(s => s == 3);
|
||||
var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree);
|
||||
|
||||
// Persist the score to PostgreSQL
|
||||
var scoreId = await PersistScoreAsync(
|
||||
await PersistScoreAsync(
|
||||
encounterId, patientId, totalScore, riskLevel,
|
||||
paramScores, hasSingleParamThree, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"NEWS2 score {Score} ({Risk}) for encounter {Id} — components: {Components}",
|
||||
totalScore, riskLevel, encounterId,
|
||||
string.Join(",", News2Calculator.ParameterCodes.Zip(paramScores, (c, s) => $"{c}={s}")));
|
||||
|
||||
// Create alert if warranted
|
||||
var alertCreated = false;
|
||||
if (riskLevel == "HIGH")
|
||||
{
|
||||
@@ -112,6 +125,29 @@ public class News2Detector
|
||||
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
|
||||
}
|
||||
|
||||
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
|
||||
|
||||
if (gcsValues.All(v => v.HasValue))
|
||||
{
|
||||
var eye = decimal.Parse(gcsValues[0]!);
|
||||
var verbal = decimal.Parse(gcsValues[1]!);
|
||||
var motor = decimal.Parse(gcsValues[2]!);
|
||||
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
|
||||
return News2Calculator.ScoreConsciousnessFromGcs(total);
|
||||
}
|
||||
|
||||
var avpuVal = await cache.StringGetAsync(
|
||||
News2Calculator.ParameterKey(encounterId, "AVPU"));
|
||||
if (!avpuVal.HasValue)
|
||||
return null;
|
||||
|
||||
var cached = JsonSerializer.Deserialize<News2CachedParam>(avpuVal!, CachedParamJsonOptions);
|
||||
return cached?.Score;
|
||||
}
|
||||
|
||||
private async Task<Guid> PersistScoreAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
int totalScore, string riskLevel,
|
||||
|
||||
@@ -55,6 +55,16 @@ public sealed class ClinicalMetrics
|
||||
"Sepsis bundle compliance outcomes.",
|
||||
labelNames: new[] { "status" });
|
||||
|
||||
public readonly Counter GcsScoresTotal = Metrics.CreateCounter(
|
||||
"gcs_scores_total",
|
||||
"GCS scores computed, labeled by classification.",
|
||||
labelNames: new[] { "classification" });
|
||||
|
||||
public readonly Counter SofaScoresTotal = Metrics.CreateCounter(
|
||||
"sofa_scores_total",
|
||||
"SOFA scores computed, labeled by whether a delta alert was created.",
|
||||
labelNames: new[] { "has_delta_alert" });
|
||||
|
||||
// --- Histograms ---
|
||||
|
||||
// Measures the full ingest transaction: Redis cache lookup + alert evaluation +
|
||||
@@ -84,6 +94,14 @@ public sealed class ClinicalMetrics
|
||||
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
|
||||
});
|
||||
|
||||
public readonly Histogram SofaScoringDuration = Metrics.CreateHistogram(
|
||||
"sofa_scoring_duration_seconds",
|
||||
"SOFA scoring computation time",
|
||||
new HistogramConfiguration
|
||||
{
|
||||
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
|
||||
});
|
||||
|
||||
// --- Gauges (set by background collectors, not incremented inline) ---
|
||||
|
||||
// The most clinically significant panel. A non-zero value means a patient's
|
||||
|
||||
@@ -77,6 +77,8 @@ try
|
||||
builder.Services.Configure<DashboardOptions>(
|
||||
builder.Configuration.GetSection(DashboardOptions.Section));
|
||||
|
||||
builder.Services.Configure<SofaOptions>(builder.Configuration.GetSection("Sofa"));
|
||||
|
||||
var dashboardOptions = builder.Configuration
|
||||
.GetSection(DashboardOptions.Section)
|
||||
.Get<DashboardOptions>() ?? new DashboardOptions();
|
||||
@@ -115,6 +117,12 @@ try
|
||||
builder.Services.AddSingleton<IAlertSuppressionService, AlertSuppressionService>();
|
||||
builder.Services.AddScoped<IMedicationService, MedicationService>();
|
||||
builder.Services.AddScoped<MedicationCorrelationHelper>();
|
||||
builder.Services.AddScoped<GcsDetector>();
|
||||
builder.Services.AddScoped<IGcsService, GcsService>();
|
||||
builder.Services.AddScoped<SofaLabCache>();
|
||||
builder.Services.AddScoped<SofaVasopressorResolver>();
|
||||
builder.Services.AddScoped<SofaDetector>();
|
||||
builder.Services.AddScoped<ISofaService, SofaService>();
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||
@@ -136,7 +144,8 @@ try
|
||||
builder.Services.AddHostedService<News2ScoringService>();
|
||||
builder.Services.AddHostedService<TrendAnalyzerService>();
|
||||
builder.Services.AddHostedService<SepsisBundleMonitorService>();
|
||||
|
||||
builder.Services.AddHostedService<GcsScoringService>();
|
||||
builder.Services.AddHostedService<SofaScoringService>();
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(opts =>
|
||||
|
||||
@@ -10,19 +10,18 @@ public static class QsofaCalculator
|
||||
public static readonly IReadOnlySet<string> QsofaCodeSet =
|
||||
new HashSet<string>(QsofaCodes);
|
||||
|
||||
// qSOFA criteria (Sepsis-3 consensus):
|
||||
// - Respiratory rate ≥ 22 breaths/min
|
||||
// - Systolic blood pressure ≤ 100 mmHg
|
||||
// - Altered mentation: AVPU score ≥ 1 (any non-Alert state)
|
||||
public static bool MeetsCriterion(string observationCode, decimal value) =>
|
||||
observationCode switch
|
||||
{
|
||||
"RESP_RATE" => value >= 22m,
|
||||
"SYSTOLIC_BP" => value <= 100m,
|
||||
"AVPU" => value >= 1m,
|
||||
_ => false
|
||||
_ => false
|
||||
};
|
||||
|
||||
public static bool MeetsGcsAlteredMentation(int gcsTotal) =>
|
||||
GcsCalculator.MeetsQsofaAlteredMentation(gcsTotal);
|
||||
|
||||
public static string CriterionKey(Guid encounterId, string code) =>
|
||||
$"qsofa:{encounterId}:{code}";
|
||||
|
||||
|
||||
@@ -68,6 +68,44 @@ public class QsofaDetector
|
||||
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
|
||||
}
|
||||
|
||||
public async Task<QsofaResult> SyncAlteredMentationAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var avpuKey = QsofaCalculator.CriterionKey(encounterId, "AVPU");
|
||||
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
|
||||
|
||||
if (gcsValues.All(v => v.HasValue))
|
||||
{
|
||||
var eye = decimal.Parse(gcsValues[0]!);
|
||||
var verbal = decimal.Parse(gcsValues[1]!);
|
||||
var motor = decimal.Parse(gcsValues[2]!);
|
||||
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
|
||||
|
||||
if (QsofaCalculator.MeetsGcsAlteredMentation(total))
|
||||
{
|
||||
await cache.StringSetAsync(
|
||||
avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds));
|
||||
}
|
||||
else
|
||||
{
|
||||
await cache.KeyDeleteAsync(avpuKey);
|
||||
}
|
||||
}
|
||||
|
||||
var allKeys = QsofaCalculator.AllCriterionKeys(encounterId);
|
||||
var values = await cache.StringGetAsync(allKeys);
|
||||
var activeCount = QsofaCalculator.CountActiveCriteria(values);
|
||||
|
||||
if (activeCount < 2)
|
||||
return QsofaResult.InsufficientCriteria(activeCount);
|
||||
|
||||
var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, values, ct);
|
||||
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
|
||||
}
|
||||
|
||||
private async Task<bool> TryCreateAlertAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class GcsService : IGcsService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public GcsService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<GcsScore?> GetCurrentAsync(Guid encounterId)
|
||||
{
|
||||
return await _db.GcsScores
|
||||
.AsNoTracking()
|
||||
.Where(s => s.EncounterId == encounterId)
|
||||
.OrderByDescending(s => s.CalculatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public interface IGcsService
|
||||
{
|
||||
Task<GcsScore?> GetCurrentAsync(Guid encounterId);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public interface ISofaService
|
||||
{
|
||||
Task<SofaScore?> GetCurrentAsync(Guid encounterId);
|
||||
Task<SofaScore?> GetBaselineAsync(Guid encounterId);
|
||||
Task<CursorPage<SofaScore>> GetHistoryAsync(
|
||||
Guid encounterId, int limit, string? cursorToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public static class MapCalculator
|
||||
{
|
||||
// Returns MAP in mmHg from systolic and diastolic BP.
|
||||
public static decimal Calculate(decimal systolicBp, decimal diastolicBp) =>
|
||||
Math.Round(diastolicBp + (systolicBp - diastolicBp) / 3m, 1);
|
||||
}
|
||||
@@ -17,6 +17,15 @@ public static class PlausibilityValidator
|
||||
["LACTATE_MMOL_L"] = (0.1m, 30),
|
||||
["AVPU"] = (0, 3),
|
||||
["SUPPLEMENTAL_O2"] = (0, 1),
|
||||
["GCS_EYE"] = (1, 4),
|
||||
["GCS_VERBAL"] = (1, 5),
|
||||
["GCS_MOTOR"] = (1, 6),
|
||||
["PAO2_MMHG"] = (20, 600),
|
||||
["FIO2_PCT"] = (21, 100),
|
||||
["PLATELET_K_UL"] = (1, 1500),
|
||||
["BILIRUBIN_MG_DL"] = (0.1m, 50),
|
||||
["CREATININE_MG_DL"] = (0.1m, 20),
|
||||
["URINE_OUTPUT_ML_H"] = (0, 500),
|
||||
};
|
||||
|
||||
public static bool IsPlausible(string observationCode, decimal value, out string? reason)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class SofaService : ISofaService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public SofaService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<SofaScore?> GetCurrentAsync(Guid encounterId)
|
||||
{
|
||||
return await _db.SofaScores
|
||||
.AsNoTracking()
|
||||
.Where(s => s.EncounterId == encounterId)
|
||||
.OrderByDescending(s => s.CalculatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<SofaScore?> GetBaselineAsync(Guid encounterId)
|
||||
{
|
||||
return await _db.SofaScores
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.EncounterId == encounterId && s.IsBaseline);
|
||||
}
|
||||
|
||||
public async Task<CursorPage<SofaScore>> GetHistoryAsync(
|
||||
Guid encounterId, int limit, string? cursorToken)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
var query = _db.SofaScores
|
||||
.AsNoTracking()
|
||||
.Where(s => s.EncounterId == encounterId);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(s => s.CalculatedAt)
|
||||
.ThenByDescending(s => s.Id)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync();
|
||||
|
||||
var hasMore = items.Count > limit;
|
||||
if (hasMore) items.RemoveAt(limit);
|
||||
|
||||
return new CursorPage<SofaScore>(items, null, hasMore);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
public static class SofaCalculator
|
||||
{
|
||||
public static readonly IReadOnlyList<string> SofaObservationCodes = new[]
|
||||
{
|
||||
"PAO2_MMHG", "FIO2_PCT", "PLATELET_K_UL", "BILIRUBIN_MG_DL",
|
||||
"CREATININE_MG_DL", "URINE_OUTPUT_ML_H",
|
||||
"SYSTOLIC_BP", "DIASTOLIC_BP", "SPO2", "SUPPLEMENTAL_O2"
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> SofaCodeSet =
|
||||
new HashSet<string>(SofaObservationCodes);
|
||||
|
||||
public static bool IsSofaCode(string observationCode) =>
|
||||
SofaCodeSet.Contains(observationCode);
|
||||
|
||||
// GCS components and gcs.scored also trigger SOFA re-score (CNS organ system)
|
||||
public static bool TriggersRescore(string observationCode) =>
|
||||
IsSofaCode(observationCode) || GcsCalculator.IsGcsCode(observationCode);
|
||||
|
||||
public static string CacheKey(Guid encounterId, string code) =>
|
||||
$"sofa:{encounterId}:{code}";
|
||||
|
||||
// --- 1. Respiratory: PaO2/FiO2 ratio ---
|
||||
public static int ScoreRespiratory(decimal? pao2, decimal? fio2, bool onMechanicalVent)
|
||||
{
|
||||
if (pao2 is null || fio2 is null || fio2 == 0) return 0;
|
||||
var ratio = pao2.Value / (fio2.Value / 100m);
|
||||
return ratio switch
|
||||
{
|
||||
>= 400 when onMechanicalVent => 0,
|
||||
>= 400 => 0,
|
||||
>= 300 => 1,
|
||||
>= 200 => 2,
|
||||
>= 100 when onMechanicalVent => 3,
|
||||
>= 100 => 2,
|
||||
_ when onMechanicalVent => 4,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
// SpO2/FiO2 proxy when PaO2 unavailable (Rice et al. 2007)
|
||||
public static int ScoreRespiratoryFromSpo2(decimal? spo2, decimal? fio2, bool onMechanicalVent)
|
||||
{
|
||||
if (spo2 is null || fio2 is null || fio2 == 0) return 0;
|
||||
var sfRatio = spo2.Value / (fio2.Value / 100m);
|
||||
return sfRatio switch
|
||||
{
|
||||
>= 315 => 0,
|
||||
>= 235 => 1,
|
||||
>= 150 => 2,
|
||||
>= 67 when onMechanicalVent => 3,
|
||||
>= 67 => 2,
|
||||
_ when onMechanicalVent => 4,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
// --- 2. Coagulation: Platelet count (k/µL) ---
|
||||
public static int ScoreCoagulation(decimal? platelets)
|
||||
{
|
||||
if (platelets is null) return 0;
|
||||
return platelets.Value switch
|
||||
{
|
||||
>= 150 => 0,
|
||||
>= 100 => 1,
|
||||
>= 50 => 2,
|
||||
>= 20 => 3,
|
||||
_ => 4
|
||||
};
|
||||
}
|
||||
|
||||
// --- 3. Liver: Bilirubin (mg/dL) ---
|
||||
public static int ScoreLiver(decimal? bilirubin)
|
||||
{
|
||||
if (bilirubin is null) return 0;
|
||||
return bilirubin.Value switch
|
||||
{
|
||||
< 1.2m => 0,
|
||||
< 2.0m => 1,
|
||||
< 6.0m => 2,
|
||||
< 12.0m => 3,
|
||||
_ => 4
|
||||
};
|
||||
}
|
||||
|
||||
// --- 4. Cardiovascular: MAP and vasopressor dose ---
|
||||
public static int ScoreCardiovascular(decimal? map, VasopressorInfo? vasopressor)
|
||||
{
|
||||
if (vasopressor is not null)
|
||||
{
|
||||
return vasopressor.DrugName.ToUpperInvariant() switch
|
||||
{
|
||||
"DOPAMINE" when vasopressor.DoseUgKgMin > 15m => 4,
|
||||
"EPINEPHRINE" when vasopressor.DoseUgKgMin > 0.1m => 4,
|
||||
"NOREPINEPHRINE" when vasopressor.DoseUgKgMin > 0.1m => 4,
|
||||
"DOPAMINE" when vasopressor.DoseUgKgMin > 5m => 3,
|
||||
"EPINEPHRINE" => 3,
|
||||
"NOREPINEPHRINE" => 3,
|
||||
"DOPAMINE" => 2,
|
||||
"DOBUTAMINE" => 2,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
|
||||
if (map is null) return 0;
|
||||
return map.Value < 70m ? 1 : 0;
|
||||
}
|
||||
|
||||
// --- 5. CNS: Glasgow Coma Scale (from Phase 25) ---
|
||||
public static int ScoreCns(int? gcsTotal) =>
|
||||
GcsCalculator.ToSofaCnsScore(gcsTotal ?? 15);
|
||||
|
||||
// --- 6. Renal: Creatinine (mg/dL) or urine output (mL/day) ---
|
||||
public static int ScoreRenal(decimal? creatinine, decimal? urineOutputMlPerDay)
|
||||
{
|
||||
var creatScore = creatinine switch
|
||||
{
|
||||
null => 0,
|
||||
< 1.2m => 0,
|
||||
< 2.0m => 1,
|
||||
< 3.5m => 2,
|
||||
< 5.0m => 3,
|
||||
_ => 4
|
||||
};
|
||||
|
||||
var urineScore = urineOutputMlPerDay switch
|
||||
{
|
||||
null => 0,
|
||||
< 200m => 4,
|
||||
< 500m => 3,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
return Math.Max(creatScore, urineScore);
|
||||
}
|
||||
|
||||
public static SofaResult ComputeTotal(
|
||||
int respiratory, int coagulation, int liver,
|
||||
int cardiovascular, int cns, int renal) =>
|
||||
new(
|
||||
Total: respiratory + coagulation + liver + cardiovascular + cns + renal,
|
||||
Respiratory: respiratory,
|
||||
Coagulation: coagulation,
|
||||
Liver: liver,
|
||||
Cardiovascular: cardiovascular,
|
||||
Cns: cns,
|
||||
Renal: renal);
|
||||
|
||||
// Count organ systems with component data for baseline eligibility
|
||||
public static int CountPopulatedOrganSystems(
|
||||
bool hasRespiratory,
|
||||
bool hasCoagulation,
|
||||
bool hasLiver,
|
||||
bool hasCardiovascular,
|
||||
bool hasCns,
|
||||
bool hasRenal) =>
|
||||
new[] { hasRespiratory, hasCoagulation, hasLiver,
|
||||
hasCardiovascular, hasCns, hasRenal }
|
||||
.Count(hasData => hasData);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Prometheus;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class SofaDetector
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly SofaLabCache _labCache;
|
||||
private readonly SofaVasopressorResolver _vasopressors;
|
||||
private readonly SofaOptions _options;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
private readonly ILogger<SofaDetector> _logger;
|
||||
|
||||
public SofaDetector(
|
||||
IConnectionMultiplexer redis,
|
||||
IServiceProvider services,
|
||||
SofaLabCache labCache,
|
||||
SofaVasopressorResolver vasopressors,
|
||||
IOptions<SofaOptions> options,
|
||||
ClinicalMetrics metrics,
|
||||
ILogger<SofaDetector> logger)
|
||||
{
|
||||
_redis = redis;
|
||||
_services = services;
|
||||
_labCache = labCache;
|
||||
_vasopressors = vasopressors;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SofaScoringResult> ProcessObservationAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
string observationCode,
|
||||
decimal value,
|
||||
DateTimeOffset recordedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!SofaCalculator.TriggersRescore(observationCode))
|
||||
return SofaScoringResult.NotSofaTrigger;
|
||||
|
||||
if (SofaCalculator.IsSofaCode(observationCode))
|
||||
{
|
||||
await _labCache.StoreAsync(encounterId, observationCode, value, recordedAt);
|
||||
}
|
||||
|
||||
return await TryComputeScoreAsync(encounterId, patientId, ct);
|
||||
}
|
||||
|
||||
public Task<SofaScoringResult> ProcessGcsScoredAsync(
|
||||
Guid encounterId, Guid patientId, CancellationToken ct = default) =>
|
||||
TryComputeScoreAsync(encounterId, patientId, ct);
|
||||
|
||||
private async Task<SofaScoringResult> TryComputeScoreAsync(
|
||||
Guid encounterId, Guid patientId, CancellationToken ct)
|
||||
{
|
||||
using var timer = _metrics.SofaScoringDuration.NewTimer();
|
||||
|
||||
using (var scope = _services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
if (!await db.Encounters.AnyAsync(e => e.Id == encounterId, ct))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping SOFA score for unknown encounter {EncounterId}", encounterId);
|
||||
return SofaScoringResult.EncounterNotFound;
|
||||
}
|
||||
}
|
||||
|
||||
var cached = await _labCache.GetAllAsync(encounterId);
|
||||
var staleComponents = new List<string>();
|
||||
var missingComponents = new List<string>();
|
||||
|
||||
decimal? GetValue(string code)
|
||||
{
|
||||
if (!cached.TryGetValue(code, out var entry))
|
||||
{
|
||||
missingComponents.Add(code);
|
||||
return null;
|
||||
}
|
||||
var status = _labCache.Classify(entry);
|
||||
if (status == SofaValueStatus.Expired)
|
||||
{
|
||||
missingComponents.Add(code);
|
||||
return null;
|
||||
}
|
||||
if (status == SofaValueStatus.Stale)
|
||||
staleComponents.Add(code);
|
||||
return entry.Value;
|
||||
}
|
||||
|
||||
var pao2 = GetValue("PAO2_MMHG");
|
||||
var fio2 = GetValue("FIO2_PCT");
|
||||
var spo2 = GetValue("SPO2");
|
||||
var supplementalO2 = GetValue("SUPPLEMENTAL_O2");
|
||||
var onMechanicalVent = supplementalO2 is >= 1m;
|
||||
|
||||
var usedSpO2Fallback = false;
|
||||
int respiratory;
|
||||
if (pao2 is not null && fio2 is not null)
|
||||
{
|
||||
respiratory = SofaCalculator.ScoreRespiratory(pao2, fio2, onMechanicalVent);
|
||||
}
|
||||
else if (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null)
|
||||
{
|
||||
usedSpO2Fallback = true;
|
||||
respiratory = SofaCalculator.ScoreRespiratoryFromSpo2(spo2, fio2, onMechanicalVent);
|
||||
}
|
||||
else
|
||||
{
|
||||
respiratory = 0;
|
||||
if (pao2 is null) missingComponents.Add("PAO2_MMHG");
|
||||
if (fio2 is null) missingComponents.Add("FIO2_PCT");
|
||||
}
|
||||
|
||||
var coagulation = SofaCalculator.ScoreCoagulation(GetValue("PLATELET_K_UL"));
|
||||
var liver = SofaCalculator.ScoreLiver(GetValue("BILIRUBIN_MG_DL"));
|
||||
|
||||
decimal? map = null;
|
||||
var sbp = GetValue("SYSTOLIC_BP");
|
||||
var dbp = GetValue("DIASTOLIC_BP");
|
||||
if (sbp is not null && dbp is not null)
|
||||
map = MapCalculator.Calculate(sbp.Value, dbp.Value);
|
||||
|
||||
var vasopressor = await _vasopressors.GetActiveVasopressorAsync(encounterId, ct);
|
||||
var cardiovascular = SofaCalculator.ScoreCardiovascular(map, vasopressor);
|
||||
|
||||
var gcsTotal = await LoadGcsTotalAsync(encounterId);
|
||||
var cns = SofaCalculator.ScoreCns(gcsTotal);
|
||||
|
||||
var creatinine = GetValue("CREATININE_MG_DL");
|
||||
var urineMlH = GetValue("URINE_OUTPUT_ML_H");
|
||||
decimal? urineMlDay = urineMlH is not null ? urineMlH * 24m : null;
|
||||
var renal = SofaCalculator.ScoreRenal(creatinine, urineMlDay);
|
||||
|
||||
var result = SofaCalculator.ComputeTotal(
|
||||
respiratory, coagulation, liver, cardiovascular, cns, renal);
|
||||
|
||||
var stalenessFlags = JsonSerializer.Serialize(new SofaStalenessFlags(
|
||||
staleComponents.Distinct().ToList(),
|
||||
missingComponents.Distinct().ToList(),
|
||||
usedSpO2Fallback), JsonOptions);
|
||||
|
||||
var calculatedAt = DateTimeOffset.UtcNow;
|
||||
var (isBaseline, delta) = await ResolveBaselineAndDeltaAsync(
|
||||
encounterId, patientId, result, calculatedAt,
|
||||
SofaCalculator.CountPopulatedOrganSystems(
|
||||
hasRespiratory: (pao2 is not null && fio2 is not null)
|
||||
|| (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null),
|
||||
hasCoagulation: cached.ContainsKey("PLATELET_K_UL")
|
||||
&& _labCache.Classify(cached["PLATELET_K_UL"]) != SofaValueStatus.Expired,
|
||||
hasLiver: cached.ContainsKey("BILIRUBIN_MG_DL")
|
||||
&& _labCache.Classify(cached["BILIRUBIN_MG_DL"]) != SofaValueStatus.Expired,
|
||||
hasCardiovascular: (sbp is not null && dbp is not null) || vasopressor is not null,
|
||||
hasCns: gcsTotal is not null,
|
||||
hasRenal: (cached.ContainsKey("CREATININE_MG_DL")
|
||||
&& _labCache.Classify(cached["CREATININE_MG_DL"]) != SofaValueStatus.Expired)
|
||||
|| (cached.ContainsKey("URINE_OUTPUT_ML_H")
|
||||
&& _labCache.Classify(cached["URINE_OUTPUT_ML_H"]) != SofaValueStatus.Expired)),
|
||||
ct);
|
||||
|
||||
await PersistScoreAsync(
|
||||
encounterId, patientId, result, isBaseline, delta,
|
||||
stalenessFlags, calculatedAt, ct);
|
||||
|
||||
var alertCreated = false;
|
||||
if (delta is >= 2)
|
||||
{
|
||||
alertCreated = await TryCreateAlertAsync(
|
||||
encounterId, patientId, AlertType.SofaSepsis, AlertSeverity.Critical,
|
||||
result, isBaseline ? null : delta, stalenessFlags, ct);
|
||||
}
|
||||
else if (delta == 1)
|
||||
{
|
||||
alertCreated = await TryCreateAlertAsync(
|
||||
encounterId, patientId, AlertType.SofaWarning, AlertSeverity.Warning,
|
||||
result, delta, stalenessFlags, ct);
|
||||
}
|
||||
|
||||
_metrics.SofaScoresTotal
|
||||
.WithLabels(alertCreated ? "true" : "false")
|
||||
.Inc();
|
||||
|
||||
_logger.LogInformation(
|
||||
"SOFA score {Total} for encounter {Id} — baseline={Baseline} delta={Delta}",
|
||||
result.Total, encounterId, isBaseline, delta);
|
||||
|
||||
return new SofaScoringResult(
|
||||
SofaOutcome.ScoreComputed, result, isBaseline, delta, alertCreated);
|
||||
}
|
||||
|
||||
private async Task<int?> LoadGcsTotalAsync(Guid encounterId)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
|
||||
if (!gcsValues.All(v => v.HasValue)) return null;
|
||||
|
||||
var eye = decimal.Parse(gcsValues[0]!, CultureInfo.InvariantCulture);
|
||||
var verbal = decimal.Parse(gcsValues[1]!, CultureInfo.InvariantCulture);
|
||||
var motor = decimal.Parse(gcsValues[2]!, CultureInfo.InvariantCulture);
|
||||
return GcsCalculator.ComputeTotal(eye, verbal, motor);
|
||||
}
|
||||
|
||||
private async Task<(bool IsBaseline, int? Delta)> ResolveBaselineAndDeltaAsync(
|
||||
Guid encounterId, Guid patientId, SofaResult result,
|
||||
DateTimeOffset calculatedAt, int populatedOrganSystems, CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var existingBaseline = await db.SofaScores
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.EncounterId == encounterId && s.IsBaseline, ct);
|
||||
|
||||
if (existingBaseline is null)
|
||||
{
|
||||
if (populatedOrganSystems >= 4)
|
||||
return (true, null);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var delta = result.Total - existingBaseline.TotalScore;
|
||||
return (false, delta);
|
||||
}
|
||||
|
||||
private async Task PersistScoreAsync(
|
||||
Guid encounterId, Guid patientId, SofaResult result,
|
||||
bool isBaseline, int? delta, string stalenessFlags,
|
||||
DateTimeOffset calculatedAt, CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
db.SofaScores.Add(new SofaScore
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
TotalScore = result.Total,
|
||||
RespiratoryScore = result.Respiratory,
|
||||
CoagulationScore = result.Coagulation,
|
||||
LiverScore = result.Liver,
|
||||
CardiovascularScore = result.Cardiovascular,
|
||||
CnsScore = result.Cns,
|
||||
RenalScore = result.Renal,
|
||||
IsBaseline = isBaseline,
|
||||
DeltaFromBaseline = delta,
|
||||
StalenessFlags = stalenessFlags,
|
||||
CalculatedAt = calculatedAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<bool> TryCreateAlertAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
AlertType alertType, AlertSeverity severity,
|
||||
SofaResult result, int? delta, string stalenessFlags,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (alertType == AlertType.SofaWarning)
|
||||
{
|
||||
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
|
||||
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
|
||||
return false;
|
||||
}
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var alertId = Guid.NewGuid();
|
||||
var triggeredAt = DateTimeOffset.UtcNow;
|
||||
var details =
|
||||
$"SOFA score {result.Total} (delta +{delta}). " +
|
||||
$"Components: Resp={result.Respiratory}, Coag={result.Coagulation}, " +
|
||||
$"Liver={result.Liver}, CV={result.Cardiovascular}, CNS={result.Cns}, Renal={result.Renal}. " +
|
||||
$"Staleness: {stalenessFlags}";
|
||||
|
||||
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
|
||||
INSERT INTO clinical_alerts
|
||||
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
|
||||
SELECT {alertId}, {encounterId}, {patientId},
|
||||
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM clinical_alerts
|
||||
WHERE encounter_id = {encounterId}
|
||||
AND alert_type = {alertType.ToDbString()}
|
||||
AND status IN ('OPEN', 'ESCALATED')
|
||||
)
|
||||
""", ct);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Topic = "alert.generated",
|
||||
Payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
alertId,
|
||||
encounterId,
|
||||
patientId,
|
||||
alertType = alertType.ToDbString(),
|
||||
severity = severity.ToDbString(),
|
||||
details,
|
||||
triggeredAt,
|
||||
sofaTotal = result.Total,
|
||||
sofaDelta = delta,
|
||||
partitionKey = encounterId.ToString()
|
||||
}),
|
||||
PartitionKey = encounterId.ToString(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
_metrics.ClinicalAlertsTotal
|
||||
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class SofaLabCache
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly SofaOptions _options;
|
||||
|
||||
public SofaLabCache(IConnectionMultiplexer redis, IOptions<SofaOptions> options)
|
||||
{
|
||||
_redis = redis;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task StoreAsync(
|
||||
Guid encounterId, string code, decimal value, DateTimeOffset recordedAt)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new SofaCachedValue(value, recordedAt), JsonOptions);
|
||||
var key = SofaCalculator.CacheKey(encounterId, code);
|
||||
await _redis.GetDatabase().StringSetAsync(
|
||||
key, json, TimeSpan.FromHours(_options.LabStalenessHours));
|
||||
}
|
||||
|
||||
public async Task<SofaCachedValue?> GetAsync(Guid encounterId, string code)
|
||||
{
|
||||
var cached = await _redis.GetDatabase()
|
||||
.StringGetAsync(SofaCalculator.CacheKey(encounterId, code));
|
||||
if (!cached.HasValue) return null;
|
||||
return JsonSerializer.Deserialize<SofaCachedValue>(cached!, JsonOptions);
|
||||
}
|
||||
|
||||
public SofaValueStatus Classify(SofaCachedValue? value)
|
||||
{
|
||||
if (value is null) return SofaValueStatus.Expired;
|
||||
var age = DateTimeOffset.UtcNow - value.RecordedAt;
|
||||
if (age.TotalHours > _options.LabStalenessHours) return SofaValueStatus.Expired;
|
||||
if (age.TotalHours > _options.LabWarningHours) return SofaValueStatus.Stale;
|
||||
return SofaValueStatus.Current;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, SofaCachedValue>> GetAllAsync(Guid encounterId)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var keys = SofaCalculator.SofaObservationCodes
|
||||
.Select(c => (RedisKey)SofaCalculator.CacheKey(encounterId, c))
|
||||
.ToArray();
|
||||
var values = await cache.StringGetAsync(keys);
|
||||
|
||||
var result = new Dictionary<string, SofaCachedValue>();
|
||||
for (var i = 0; i < SofaCalculator.SofaObservationCodes.Count; i++)
|
||||
{
|
||||
if (!values[i].HasValue) continue;
|
||||
var parsed = JsonSerializer.Deserialize<SofaCachedValue>(values[i]!, JsonOptions);
|
||||
if (parsed is not null)
|
||||
result[SofaCalculator.SofaObservationCodes[i]] = parsed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,8 @@
|
||||
"Topics": {
|
||||
"ObservationRecorded": "observation.recorded",
|
||||
"AlertGenerated": "alert.generated",
|
||||
"EncounterStatusChanged": "encounter.status.changed"
|
||||
"EncounterStatusChanged": "encounter.status.changed",
|
||||
"GcsScored": "gcs.scored"
|
||||
},
|
||||
"NumPartitions": 6,
|
||||
"OutboxBatchSize": 100,
|
||||
@@ -156,5 +157,11 @@
|
||||
|
||||
"albuterol": ["HEART_RATE", "SPO2"]
|
||||
}
|
||||
},
|
||||
"Sofa": {
|
||||
"LabStalenessHours": 24,
|
||||
"LabWarningHours": 12,
|
||||
"UseSpO2FiO2Fallback": true,
|
||||
"VasopressorWindowHours": 1
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user