feature: Schema, Migrations, Core CRUD, and Redis Threshold Cache
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class ThresholdCacheLoader : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ILogger<ThresholdCacheLoader> _logger;
|
||||
|
||||
public ThresholdCacheLoader(
|
||||
IServiceProvider services,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<ThresholdCacheLoader> logger)
|
||||
{
|
||||
_services = services;
|
||||
_redis = redis;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var cache = _redis.GetDatabase();
|
||||
|
||||
var thresholds = await db.AlertThresholds.ToListAsync(cancellationToken);
|
||||
var batch = cache.CreateBatch();
|
||||
|
||||
foreach (var t in thresholds)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
t.ObservationCode,
|
||||
t.CriticalLow,
|
||||
t.WarningLow,
|
||||
t.WarningHigh,
|
||||
t.CriticalHigh
|
||||
});
|
||||
_ = batch.StringSetAsync($"threshold:{t.ObservationCode}", json);
|
||||
}
|
||||
|
||||
batch.Execute();
|
||||
_logger.LogInformation("Loaded {Count} alert thresholds into Redis cache", thresholds.Count);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error)
|
||||
{
|
||||
public static ApiResponse<T> Ok(T data) =>
|
||||
new(true, 200, data, null);
|
||||
|
||||
public static ApiResponse<T> Created(T data) =>
|
||||
new(true, 201, data, null);
|
||||
|
||||
public static ApiResponse<T> Fail(int statusCode, string message, string code) =>
|
||||
new(false, statusCode, default, new ApiError(message, code));
|
||||
}
|
||||
|
||||
public record ApiError(string Message, string Code);
|
||||
@@ -0,0 +1,5 @@
|
||||
public class ConflictException : DomainException
|
||||
{
|
||||
public ConflictException(string message, string errorCode = "CONFLICT_ERROR")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public static class DbExceptions
|
||||
{
|
||||
public static bool IsUniqueViolation(DbUpdateException ex) =>
|
||||
ex.InnerException?.Message.Contains("23505") == true
|
||||
|| ex.InnerException?.Message.Contains("unique constraint") == true;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public abstract class DomainException : Exception
|
||||
{
|
||||
public string ErrorCode { get; }
|
||||
|
||||
protected DomainException(string message, string errorCode) : base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public class NotFoundException : DomainException
|
||||
{
|
||||
public NotFoundException(string message, string errorCode = "NOT_FOUND")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public class ValidationException : DomainException
|
||||
{
|
||||
public ValidationException(string message, string errorCode = "VALIDATION_ERROR")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public record PagedResult<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalCount
|
||||
)
|
||||
{
|
||||
public int TotalPages => (int)Math.Ceiling((double)TotalCount/PageSize);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// CRUD for clinical alert thresholds with Redis cache invalidation on writes.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/alert-thresholds")]
|
||||
[Produces("application/json")]
|
||||
public class AlertThresholdsController : ControllerBase
|
||||
{
|
||||
private readonly IAlertThresholdService _thresholds;
|
||||
|
||||
public AlertThresholdsController(IAlertThresholdService thresholds) => _thresholds = thresholds;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new alert threshold for an observation code.
|
||||
/// </summary>
|
||||
/// <param name="req">Threshold bounds and display metadata.</param>
|
||||
/// <returns>The created threshold.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] AlertThresholdRequest req)
|
||||
{
|
||||
var threshold = await _thresholds.CreateAsync(req);
|
||||
return StatusCode(201, ApiResponse<AlertThreshold>.Created(threshold));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all alert thresholds ordered by observation code.
|
||||
/// </summary>
|
||||
/// <returns>All configured thresholds.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<AlertThreshold>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var thresholds = await _thresholds.ListAsync();
|
||||
return Ok(ApiResponse<List<AlertThreshold>>.Ok(thresholds));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single alert threshold by id.
|
||||
/// </summary>
|
||||
/// <param name="id">Threshold id.</param>
|
||||
/// <returns>The threshold record.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var threshold = await _thresholds.GetByIdAsync(id);
|
||||
return Ok(ApiResponse<AlertThreshold>.Ok(threshold));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing alert threshold and invalidates the Redis cache entry.
|
||||
/// </summary>
|
||||
/// <param name="id">Threshold id.</param>
|
||||
/// <param name="req">Updated threshold bounds and display metadata.</param>
|
||||
/// <returns>The updated threshold.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] AlertThresholdRequest req)
|
||||
{
|
||||
var threshold = await _thresholds.UpdateAsync(id, req);
|
||||
return Ok(ApiResponse<AlertThreshold>.Ok(threshold));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Encounter retrieval, status transitions, and clinical timeline.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters")]
|
||||
[Produces("application/json")]
|
||||
public class EncountersController : ControllerBase
|
||||
{
|
||||
private readonly IEncounterService _encounters;
|
||||
|
||||
public EncountersController(IEncounterService encounters) => _encounters = encounters;
|
||||
|
||||
/// <summary>
|
||||
/// Gets an encounter with patient, recent observations, and open alerts.
|
||||
/// </summary>
|
||||
/// <param name="id">Encounter id.</param>
|
||||
/// <returns>The encounter with related data.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<Encounter>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var encounter = await _encounters.GetByIdAsync(id);
|
||||
return Ok(ApiResponse<Encounter>.Ok(encounter));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transitions an encounter to a new status via the encounter state machine.
|
||||
/// </summary>
|
||||
/// <param name="id">Encounter id.</param>
|
||||
/// <param name="req">Target status.</param>
|
||||
/// <returns>The encounter id and new status.</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> TransitionStatus(Guid id, [FromBody] TransitionStatusRequest req)
|
||||
{
|
||||
var result = await _encounters.TransitionStatusAsync(id, req.Status);
|
||||
return Ok(ApiResponse<object>.Ok(new { encounterId = result.EncounterId, newStatus = result.NewStatus }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a merged chronological timeline of observations and alerts for an encounter.
|
||||
/// </summary>
|
||||
/// <param name="id">Encounter id.</param>
|
||||
/// <returns>Ordered timeline events.</returns>
|
||||
[HttpGet("{id:guid}/timeline")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Timeline(Guid id)
|
||||
{
|
||||
var timeline = await _encounters.GetTimelineAsync(id);
|
||||
return Ok(ApiResponse<object>.Ok(timeline));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Patient registration, search, and encounter opening.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/patients")]
|
||||
[Produces("application/json")]
|
||||
public class PatientsController : ControllerBase
|
||||
{
|
||||
private readonly IPatientService _patients;
|
||||
|
||||
public PatientsController(IPatientService patients) => _patients = patients;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new patient and assigns a system-generated MRN.
|
||||
/// </summary>
|
||||
/// <param name="req">Patient demographics.</param>
|
||||
/// <returns>The created patient record.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ApiResponse<Patient>), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterPatientRequest req)
|
||||
{
|
||||
var patient = await _patients.RegisterAsync(req);
|
||||
return StatusCode(201, ApiResponse<Patient>.Created(patient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists patients with optional MRN or name search and pagination.
|
||||
/// </summary>
|
||||
/// <param name="q">MRN (exact) or name substring (ILIKE).</param>
|
||||
/// <param name="page">Page number (1-based).</param>
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of patients.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await _patients.ListAsync(q, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
page = result.Page,
|
||||
pageSize = result.PageSize,
|
||||
totalCount = result.TotalCount,
|
||||
totalPages = result.TotalPages
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a patient by id, including active encounters.
|
||||
/// </summary>
|
||||
/// <param name="id">Patient id.</param>
|
||||
/// <returns>The patient record.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<Patient>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var patient = await _patients.GetByIdAsync(id);
|
||||
return Ok(ApiResponse<Patient>.Ok(patient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new active encounter for the patient.
|
||||
/// </summary>
|
||||
/// <param name="id">Patient id.</param>
|
||||
/// <param name="req">Encounter type, department, and attending physician.</param>
|
||||
/// <returns>The created encounter.</returns>
|
||||
[HttpPost("{id:guid}/encounters")]
|
||||
[ProducesResponseType(typeof(ApiResponse<Encounter>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> OpenEncounter(Guid id, [FromBody] OpenEncounterRequest req)
|
||||
{
|
||||
var encounter = await _patients.OpenEncounterAsync(id, req);
|
||||
return StatusCode(201, ApiResponse<Encounter>.Created(encounter));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Patient> Patients => Set<Patient>();
|
||||
public DbSet<Encounter> Encounters => Set<Encounter>();
|
||||
public DbSet<AlertThreshold> AlertThresholds => Set<AlertThreshold>();
|
||||
public DbSet<Observation> Observations => Set<Observation>();
|
||||
public DbSet<ClinicalAlert> ClinicalAlerts => Set<ClinicalAlert>();
|
||||
public DbSet<Order> Orders => Set<Order>();
|
||||
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
||||
public DbSet<ReconciliationAlert> ReconciliationAlerts => Set<ReconciliationAlert>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class AlertThresholdConfiguration : IEntityTypeConfiguration<AlertThreshold>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AlertThreshold> builder)
|
||||
{
|
||||
builder.ToTable("alert_thresholds");
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(t => t.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||
builder.Property(t => t.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
|
||||
builder.Property(t => t.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||
builder.Property(t => t.CriticalLow).HasColumnName("critical_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasIndex(t => t.ObservationCode).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ClinicalAlert> builder)
|
||||
{
|
||||
builder.ToTable("clinical_alerts", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity",
|
||||
"severity IN ('WARNING', 'CRITICAL')");
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status",
|
||||
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type",
|
||||
"alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', " +
|
||||
"'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
|
||||
});
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(a => a.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(a => a.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(a => a.ObservationId).HasColumnName("observation_id");
|
||||
builder.Property(a => a.AlertType)
|
||||
.HasColumnName("alert_type")
|
||||
.HasMaxLength(50)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(a => a.Severity)
|
||||
.HasColumnName("severity")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertSeverityExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(a => a.Details).HasColumnName("details").IsRequired();
|
||||
builder.Property(a => a.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'OPEN'")
|
||||
.HasSentinel((AlertStatus)(-1));
|
||||
builder.Property(a => a.AcknowledgedAt).HasColumnName("acknowledged_at");
|
||||
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
|
||||
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
|
||||
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(a => a.Encounter)
|
||||
.WithMany(e => e.Alerts)
|
||||
.HasForeignKey(a => a.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(a => new { a.EncounterId, a.TriggeredAt });
|
||||
builder.HasIndex(a => new { a.PatientId, a.TriggeredAt });
|
||||
builder.HasIndex(a => new { a.Severity, a.TriggeredAt })
|
||||
.HasFilter("status = 'OPEN'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class EncounterConfiguration : IEntityTypeConfiguration<Encounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Encounter> builder)
|
||||
{
|
||||
builder.ToTable("encounters", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type",
|
||||
"encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
t.HasCheckConstraint("chk_encounters_status",
|
||||
"status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
});
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(e => e.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(e => e.EncounterType)
|
||||
.HasColumnName("encounter_type")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => EncounterTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(e => e.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => EncounterStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'SCHEDULED'")
|
||||
.HasSentinel((EncounterStatus)(-1));
|
||||
builder.Property(e => e.Department).HasColumnName("department").HasMaxLength(100).IsRequired();
|
||||
builder.Property(e => e.AttendingPhysician).HasColumnName("attending_physician").HasMaxLength(200).IsRequired();
|
||||
builder.Property(e => e.AdmittedAt).HasColumnName("admitted_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(e => e.DischargedAt).HasColumnName("discharged_at");
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(e => e.Patient)
|
||||
.WithMany(p => p.Encounters)
|
||||
.HasForeignKey(e => e.PatientId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(e => new { e.PatientId, e.AdmittedAt });
|
||||
builder.HasIndex(e => new { e.Status, e.AdmittedAt })
|
||||
.HasFilter("status = 'ACTIVE'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ObservationConfiguration : IEntityTypeConfiguration<Observation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Observation> builder)
|
||||
{
|
||||
builder.ToTable("observations", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source",
|
||||
"source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(o => o.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(o => o.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||
builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired();
|
||||
builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||
builder.Property(o => o.Source)
|
||||
.HasColumnName("source")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => ObservationSourceExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'MANUAL'")
|
||||
.HasSentinel((ObservationSource)(-1));
|
||||
builder.Property(o => o.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(100);
|
||||
builder.Property(o => o.RecordedAt).HasColumnName("recorded_at");
|
||||
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(o => o.Encounter)
|
||||
.WithMany(e => e.Observations)
|
||||
.HasForeignKey(o => o.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Partial unique index — only non-null idempotency keys are checked for uniqueness.
|
||||
// Devices that do not send a key are not subject to deduplication.
|
||||
builder.HasIndex(o => o.IdempotencyKey)
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
builder.HasIndex(o => new { o.EncounterId, o.ObservationCode, o.RecordedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class OrderConfiguration : IEntityTypeConfiguration<Order>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Order> builder)
|
||||
{
|
||||
builder.ToTable("orders", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_orders_order_type",
|
||||
"order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
});
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(o => o.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(o => o.OrderType)
|
||||
.HasColumnName("order_type")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => OrderTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(o => o.Description).HasColumnName("description").IsRequired();
|
||||
builder.Property(o => o.OrderedBy).HasColumnName("ordered_by").HasMaxLength(200).IsRequired();
|
||||
builder.Property(o => o.Status).HasColumnName("status").HasMaxLength(20).HasDefaultValue("pending");
|
||||
builder.Property(o => o.OrderedAt).HasColumnName("ordered_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(o => o.ResultedAt).HasColumnName("resulted_at");
|
||||
|
||||
builder.HasOne(o => o.Encounter)
|
||||
.WithMany(e => e.Orders)
|
||||
.HasForeignKey(o => o.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(o => new { o.EncounterId, o.OrderedAt });
|
||||
builder.HasIndex(o => new { o.Status, o.OrderedAt })
|
||||
.HasFilter("status IN ('pending', 'in_progress')");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class OutboxEventConfiguration : IEntityTypeConfiguration<OutboxEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OutboxEvent> builder)
|
||||
{
|
||||
builder.ToTable("outbox_events");
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(o => o.Topic).HasColumnName("topic").HasMaxLength(200).IsRequired();
|
||||
builder.Property(o => o.Payload).HasColumnName("payload").HasColumnType("jsonb").IsRequired();
|
||||
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(o => o.ProcessedAt).HasColumnName("processed_at");
|
||||
|
||||
builder.HasIndex(o => o.CreatedAt)
|
||||
.HasFilter("processed_at IS NULL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class PatientConfiguration : IEntityTypeConfiguration<Patient>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Patient> builder)
|
||||
{
|
||||
builder.ToTable("patients");
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(p => p.Mrn).HasColumnName("mrn").HasMaxLength(20).IsRequired();
|
||||
builder.Property(p => p.FirstName).HasColumnName("first_name").HasMaxLength(100).IsRequired();
|
||||
builder.Property(p => p.LastName).HasColumnName("last_name").HasMaxLength(100).IsRequired();
|
||||
builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth");
|
||||
builder.Property(p => p.Gender).HasColumnName("gender").HasMaxLength(10).IsRequired();
|
||||
builder.Property(p => p.Status).HasColumnName("status").HasMaxLength(20).HasDefaultValue("active");
|
||||
builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
// MRN uses exact-match unique index — MRN lookups are always equality checks,
|
||||
// never LIKE/ILIKE. A B-tree unique index satisfies O(log n) point lookup.
|
||||
// Name search uses ILIKE on first_name/last_name — a prefix scan, not an
|
||||
// equality match — so no index here; full ILIKE is intentionally unindexed
|
||||
// at this scale (pg_trgm GIN would be warranted at >500k patients).
|
||||
builder.HasIndex(p => p.Mrn).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ReconciliationAlertConfiguration : IEntityTypeConfiguration<ReconciliationAlert>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReconciliationAlert> builder)
|
||||
{
|
||||
builder.ToTable("reconciliation_alerts", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_reconciliation_alerts_check_type",
|
||||
"check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
|
||||
});
|
||||
builder.HasKey(r => r.Id);
|
||||
builder.Property(r => r.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(r => r.CheckType)
|
||||
.HasColumnName("check_type")
|
||||
.HasMaxLength(50)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => ReconciliationCheckTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(r => r.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(r => r.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(r => r.Details).HasColumnName("details").IsRequired();
|
||||
builder.Property(r => r.ResolvedAt).HasColumnName("resolved_at");
|
||||
builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasIndex(r => new { r.CheckType, r.EncounterId })
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public static class DataSeeder
|
||||
{
|
||||
public static async Task SeedAsync(AppDbContext db, IConnectionMultiplexer redis)
|
||||
{
|
||||
if (await db.Patients.AnyAsync()) return;
|
||||
|
||||
// Two patients
|
||||
var patient1 = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(), Mrn = "MRN-000001", FirstName = "Jane", LastName = "Smith",
|
||||
DateOfBirth = new DateOnly(1975, 4, 12), Gender = "F",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var patient2 = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(), Mrn = "MRN-000002", FirstName = "Robert", LastName = "Chen",
|
||||
DateOfBirth = new DateOnly(1962, 9, 3), Gender = "M",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Patients.AddRange(patient1, patient2);
|
||||
|
||||
// One active inpatient encounter per patient
|
||||
var encounter1 = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient1.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = "ICU",
|
||||
AttendingPhysician = "Dr. Osei", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-6),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddHours(-6)
|
||||
};
|
||||
var encounter2 = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient2.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = "General Medicine",
|
||||
AttendingPhysician = "Dr. Patel", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-12),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddHours(-12)
|
||||
};
|
||||
db.Encounters.AddRange(encounter1, encounter2);
|
||||
|
||||
// Four alert thresholds
|
||||
var thresholds = new List<AlertThreshold>
|
||||
{
|
||||
new() {
|
||||
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", DisplayName = "Heart Rate",
|
||||
Unit = "bpm", CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new() {
|
||||
Id = Guid.NewGuid(), ObservationCode = "TEMP_C", DisplayName = "Body Temperature",
|
||||
Unit = "°C", CriticalLow = 35.0m, WarningLow = 36.0m, WarningHigh = 38.3m, CriticalHigh = 40.0m,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new() {
|
||||
Id = Guid.NewGuid(), ObservationCode = "POTASSIUM_MEQ_L", DisplayName = "Serum Potassium",
|
||||
Unit = "mEq/L", CriticalLow = 2.5m, WarningLow = 3.5m, WarningHigh = 5.0m, CriticalHigh = 6.5m,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new() {
|
||||
Id = Guid.NewGuid(), ObservationCode = "SPO2", DisplayName = "Oxygen Saturation",
|
||||
Unit = "%", CriticalLow = 88, WarningLow = 92, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
db.AlertThresholds.AddRange(thresholds);
|
||||
|
||||
// Observations spanning normal, warning, and critical ranges for encounter1
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var observations = new List<Observation>
|
||||
{
|
||||
// Normal heart rate
|
||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "HEART_RATE",
|
||||
Value = 78, Unit = "bpm", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-30), CreatedAt = now.AddMinutes(-30) },
|
||||
// Warning heart rate
|
||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "HEART_RATE",
|
||||
Value = 104, Unit = "bpm", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-15), CreatedAt = now.AddMinutes(-15) },
|
||||
// Critical potassium (low)
|
||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "POTASSIUM_MEQ_L",
|
||||
Value = 2.3m, Unit = "mEq/L", Source = ObservationSource.Lab, RecordedAt = now.AddMinutes(-10), CreatedAt = now.AddMinutes(-10) },
|
||||
// Normal SpO2
|
||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SPO2",
|
||||
Value = 97, Unit = "%", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-5), CreatedAt = now.AddMinutes(-5) },
|
||||
// Normal temp for encounter2
|
||||
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "TEMP_C",
|
||||
Value = 37.1m, Unit = "°C", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-20), CreatedAt = now.AddMinutes(-20) }
|
||||
};
|
||||
db.Observations.AddRange(observations);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Populate Redis cache with the seeded thresholds
|
||||
var cache = redis.GetDatabase();
|
||||
foreach (var t in thresholds)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
t.ObservationCode, t.CriticalLow, t.WarningLow, t.WarningHigh, t.CriticalHigh
|
||||
});
|
||||
await cache.StringSetAsync($"threshold:{t.ObservationCode}", json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
public class AlertThreshold
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public string Unit { get; set; } = null!;
|
||||
public decimal? CriticalLow { get; set; }
|
||||
public decimal? WarningLow { get; set; }
|
||||
public decimal? WarningHigh { get; set; }
|
||||
public decimal? CriticalHigh { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
public class ClinicalAlert
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public Guid? ObservationId { get; set; }
|
||||
public AlertType AlertType { get; set; }
|
||||
public AlertSeverity Severity { get; set; }
|
||||
public string Details { get; set; } = null!;
|
||||
public AlertStatus Status { get; set; } = AlertStatus.Open;
|
||||
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
public DateTimeOffset TriggeredAt { get; set; }
|
||||
|
||||
public Encounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
public class Encounter
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public EncounterType EncounterType { get; set; }
|
||||
public EncounterStatus Status { get; set; }
|
||||
public string Department { get; set; } = null!;
|
||||
public string AttendingPhysician { get; set; } = null!;
|
||||
public DateTimeOffset AdmittedAt { get; set; }
|
||||
public DateTimeOffset? DischargedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public Patient Patient { get; set; } = null!;
|
||||
public ICollection<Observation> Observations { get; set; } = new List<Observation>();
|
||||
public ICollection<ClinicalAlert> Alerts { get; set; } = new List<ClinicalAlert>();
|
||||
public ICollection<Order> Orders { get; set; } = new List<Order>();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
public class Observation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public decimal Value { get; set; }
|
||||
public string Unit { get; set; } = null!;
|
||||
public ObservationSource Source { get; set; } = ObservationSource.Manual;
|
||||
public string? IdempotencyKey { get; set; }
|
||||
public DateTimeOffset RecordedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public Encounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public class Order
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public OrderType OrderType { get; set; }
|
||||
public string Description { get; set; } = null!;
|
||||
public string OrderedBy { get; set; } = null!;
|
||||
public string Status { get; set; } = "pending";
|
||||
public DateTimeOffset OrderedAt { get; set; }
|
||||
public DateTimeOffset? ResultedAt { get; set; }
|
||||
|
||||
public Encounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
public class OutboxEvent
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Topic { get; set; } = null!;
|
||||
public string Payload { get; set; } = null!; // JSON string
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ProcessedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public class Patient
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Mrn { get; set; } = null!;
|
||||
public string FirstName { get; set; } = null!;
|
||||
public string LastName { get; set; } = null!;
|
||||
public DateOnly DateOfBirth { get; set; }
|
||||
public string Gender { get; set; } = null!;
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public ICollection<Encounter> Encounters { get; set; } = new List<Encounter>();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
public class ReconciliationAlert
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public ReconciliationCheckType CheckType { get; set; }
|
||||
public Guid? EncounterId { get; set; }
|
||||
public Guid? PatientId { get; set; }
|
||||
public string Details { get; set; } = null!;
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
public enum AlertSeverity { Warning, Critical }
|
||||
|
||||
public static class AlertSeverityExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertSeverity s) => s switch
|
||||
{
|
||||
AlertSeverity.Warning => "WARNING",
|
||||
AlertSeverity.Critical => "CRITICAL",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static AlertSeverity FromDbString(string v) => v switch
|
||||
{
|
||||
"WARNING" => AlertSeverity.Warning,
|
||||
"CRITICAL" => AlertSeverity.Critical,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert severity: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum AlertStatus { Open, Acknowledged, Resolved, Escalated }
|
||||
|
||||
public static class AlertStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertStatus s) => s switch
|
||||
{
|
||||
AlertStatus.Open => "OPEN",
|
||||
AlertStatus.Acknowledged => "ACKNOWLEDGED",
|
||||
AlertStatus.Resolved => "RESOLVED",
|
||||
AlertStatus.Escalated => "ESCALATED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static AlertStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"OPEN" => AlertStatus.Open,
|
||||
"ACKNOWLEDGED" => AlertStatus.Acknowledged,
|
||||
"RESOLVED" => AlertStatus.Resolved,
|
||||
"ESCALATED" => AlertStatus.Escalated,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
public enum AlertType
|
||||
{
|
||||
SepsisWarning,
|
||||
CriticalHeartRate,
|
||||
CriticalTempC,
|
||||
CriticalPotassiumMeqL,
|
||||
CriticalSpo2,
|
||||
CriticalRespRate,
|
||||
CriticalWbcKUl
|
||||
}
|
||||
|
||||
public static class AlertTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.SepsisWarning => "SEPSIS_WARNING",
|
||||
AlertType.CriticalHeartRate => "CRITICAL_HEART_RATE",
|
||||
AlertType.CriticalTempC => "CRITICAL_TEMP_C",
|
||||
AlertType.CriticalPotassiumMeqL => "CRITICAL_POTASSIUM_MEQ_L",
|
||||
AlertType.CriticalSpo2 => "CRITICAL_SPO2",
|
||||
AlertType.CriticalRespRate => "CRITICAL_RESP_RATE",
|
||||
AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static AlertType FromDbString(string v) => v switch
|
||||
{
|
||||
"SEPSIS_WARNING" => AlertType.SepsisWarning,
|
||||
"CRITICAL_HEART_RATE" => AlertType.CriticalHeartRate,
|
||||
"CRITICAL_TEMP_C" => AlertType.CriticalTempC,
|
||||
"CRITICAL_POTASSIUM_MEQ_L"=> AlertType.CriticalPotassiumMeqL,
|
||||
"CRITICAL_SPO2" => AlertType.CriticalSpo2,
|
||||
"CRITICAL_RESP_RATE" => AlertType.CriticalRespRate,
|
||||
"CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
||||
};
|
||||
|
||||
// Threshold alerts are derived from observation codes in alert_thresholds — not free-form strings.
|
||||
public static AlertType CriticalFor(string observationCode) => observationCode switch
|
||||
{
|
||||
"HEART_RATE" => AlertType.CriticalHeartRate,
|
||||
"TEMP_C" => AlertType.CriticalTempC,
|
||||
"POTASSIUM_MEQ_L" => AlertType.CriticalPotassiumMeqL,
|
||||
"SPO2" => AlertType.CriticalSpo2,
|
||||
"RESP_RATE" => AlertType.CriticalRespRate,
|
||||
"WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum EncounterStatus { Scheduled, Active, Discharged, Cancelled }
|
||||
|
||||
public static class EncounterStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this EncounterStatus s) => s switch
|
||||
{
|
||||
EncounterStatus.Scheduled => "SCHEDULED",
|
||||
EncounterStatus.Active => "ACTIVE",
|
||||
EncounterStatus.Discharged => "DISCHARGED",
|
||||
EncounterStatus.Cancelled => "CANCELLED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static EncounterStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"SCHEDULED" => EncounterStatus.Scheduled,
|
||||
"ACTIVE" => EncounterStatus.Active,
|
||||
"DISCHARGED" => EncounterStatus.Discharged,
|
||||
"CANCELLED" => EncounterStatus.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown encounter status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public enum EncounterType { Inpatient, Outpatient, Emergency }
|
||||
|
||||
public static class EncounterTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this EncounterType t) => t switch
|
||||
{
|
||||
EncounterType.Inpatient => "INPATIENT",
|
||||
EncounterType.Outpatient => "OUTPATIENT",
|
||||
EncounterType.Emergency => "EMERGENCY",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static EncounterType FromDbString(string v) => v switch
|
||||
{
|
||||
"INPATIENT" => EncounterType.Inpatient,
|
||||
"OUTPATIENT" => EncounterType.Outpatient,
|
||||
"EMERGENCY" => EncounterType.Emergency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown encounter type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public enum ObservationSource { Manual, Device, Lab }
|
||||
|
||||
public static class ObservationSourceExtensions
|
||||
{
|
||||
public static string ToDbString(this ObservationSource s) => s switch
|
||||
{
|
||||
ObservationSource.Manual => "MANUAL",
|
||||
ObservationSource.Device => "DEVICE",
|
||||
ObservationSource.Lab => "LAB",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static ObservationSource FromDbString(string v) => v switch
|
||||
{
|
||||
"MANUAL" => ObservationSource.Manual,
|
||||
"DEVICE" => ObservationSource.Device,
|
||||
"LAB" => ObservationSource.Lab,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown observation source: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum OrderType { Lab, Imaging, Medication, Procedure }
|
||||
|
||||
public static class OrderTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this OrderType t) => t switch
|
||||
{
|
||||
OrderType.Lab => "LAB",
|
||||
OrderType.Imaging => "IMAGING",
|
||||
OrderType.Medication => "MEDICATION",
|
||||
OrderType.Procedure => "PROCEDURE",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static OrderType FromDbString(string v) => v switch
|
||||
{
|
||||
"LAB" => OrderType.Lab,
|
||||
"IMAGING" => OrderType.Imaging,
|
||||
"MEDICATION" => OrderType.Medication,
|
||||
"PROCEDURE" => OrderType.Procedure,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown order type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
public enum ReconciliationCheckType
|
||||
{
|
||||
UnacknowledgedCriticalAlert,
|
||||
PendingOrderNoResult,
|
||||
ActiveInpatientNoObservation
|
||||
}
|
||||
|
||||
public static class ReconciliationCheckTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this ReconciliationCheckType t) => t switch
|
||||
{
|
||||
ReconciliationCheckType.UnacknowledgedCriticalAlert => "UNACKNOWLEDGED_CRITICAL_ALERT",
|
||||
ReconciliationCheckType.PendingOrderNoResult => "PENDING_ORDER_NO_RESULT",
|
||||
ReconciliationCheckType.ActiveInpatientNoObservation => "ACTIVE_INPATIENT_NO_OBSERVATION",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static ReconciliationCheckType FromDbString(string v) => v switch
|
||||
{
|
||||
"UNACKNOWLEDGED_CRITICAL_ALERT" => ReconciliationCheckType.UnacknowledgedCriticalAlert,
|
||||
"PENDING_ORDER_NO_RESULT" => ReconciliationCheckType.PendingOrderNoResult,
|
||||
"ACTIVE_INPATIENT_NO_OBSERVATION" => ReconciliationCheckType.ActiveInpatientNoObservation,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown reconciliation check type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class ObservationSourceJsonConverter : JsonConverter<ObservationSource>
|
||||
{
|
||||
public override ObservationSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> ObservationSourceExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, ObservationSource value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Serilog.Context;
|
||||
|
||||
public sealed class CorrelationIdMiddleware
|
||||
{
|
||||
private const string Header = "X-Correlation-Id";
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public CorrelationIdMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
var correlationId = ctx.Request.Headers[Header].FirstOrDefault()
|
||||
?? Guid.NewGuid().ToString();
|
||||
|
||||
ctx.Response.Headers[Header] = correlationId;
|
||||
|
||||
using(LogContext.PushProperty("CorrelationId", correlationId))
|
||||
{
|
||||
await _next(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
public class ExceptionHandlerMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
|
||||
|
||||
public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status404NotFound,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status404NotFound, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status422UnprocessableEntity,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status422UnprocessableEntity, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (ConflictException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status409Conflict,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status409Conflict, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception");
|
||||
await WriteAsync(context, StatusCodes.Status500InternalServerError,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status500InternalServerError,
|
||||
"An unexpected error occurred", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteAsync<T>(HttpContext context, int status, ApiResponse<T> body)
|
||||
{
|
||||
context.Response.StatusCode = status;
|
||||
await context.Response.WriteAsJsonAsync(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
// <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("20260616042238_InitialSchema")]
|
||||
partial class InitialSchema
|
||||
{
|
||||
/// <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<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')");
|
||||
|
||||
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<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<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>("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_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
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<DateTimeOffset?>("ResultedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resulted_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("pending")
|
||||
.HasColumnName("status");
|
||||
|
||||
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')");
|
||||
});
|
||||
});
|
||||
|
||||
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>("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<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>("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("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("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("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("Encounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
|
||||
b.Navigation("Orders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "alert_thresholds",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
critical_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
warning_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
warning_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
critical_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_alert_thresholds", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_events",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
topic = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
payload = table.Column<string>(type: "jsonb", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_outbox_events", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "patients",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
mrn = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
first_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
last_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
date_of_birth = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
gender = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "active"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_patients", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "reconciliation_alerts",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
check_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
details = table.Column<string>(type: "text", nullable: false),
|
||||
resolved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_reconciliation_alerts", x => x.id);
|
||||
table.CheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "encounters",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
encounter_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'SCHEDULED'"),
|
||||
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
attending_physician = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
admitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
discharged_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_encounters", x => x.id);
|
||||
table.CheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
table.CheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
table.ForeignKey(
|
||||
name: "FK_encounters_patients_patient_id",
|
||||
column: x => x.patient_id,
|
||||
principalTable: "patients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "clinical_alerts",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
observation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
alert_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
severity = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
details = table.Column<string>(type: "text", nullable: false),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'OPEN'"),
|
||||
acknowledged_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
acknowledged_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
resolved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
triggered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_clinical_alerts", x => x.id);
|
||||
table.CheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
|
||||
table.CheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
table.CheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
table.ForeignKey(
|
||||
name: "FK_clinical_alerts_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "observations",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
value = table.Column<decimal>(type: "numeric(10,3)", nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
source = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'MANUAL'"),
|
||||
idempotency_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_observations", x => x.id);
|
||||
table.CheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
table.ForeignKey(
|
||||
name: "FK_observations_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "orders",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
order_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
description = table.Column<string>(type: "text", nullable: false),
|
||||
ordered_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "pending"),
|
||||
ordered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
resulted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_orders", x => x.id);
|
||||
table.CheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
table.ForeignKey(
|
||||
name: "FK_orders_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_thresholds_observation_code",
|
||||
table: "alert_thresholds",
|
||||
column: "observation_code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_encounter_id_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "encounter_id", "triggered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_patient_id_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "patient_id", "triggered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_severity_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "severity", "triggered_at" },
|
||||
filter: "status = 'OPEN'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_encounters_patient_id_admitted_at",
|
||||
table: "encounters",
|
||||
columns: new[] { "patient_id", "admitted_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_encounters_status_admitted_at",
|
||||
table: "encounters",
|
||||
columns: new[] { "status", "admitted_at" },
|
||||
filter: "status = 'ACTIVE'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_observations_encounter_id_observation_code_recorded_at",
|
||||
table: "observations",
|
||||
columns: new[] { "encounter_id", "observation_code", "recorded_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_observations_idempotency_key",
|
||||
table: "observations",
|
||||
column: "idempotency_key",
|
||||
unique: true,
|
||||
filter: "idempotency_key IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_orders_encounter_id_ordered_at",
|
||||
table: "orders",
|
||||
columns: new[] { "encounter_id", "ordered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_orders_status_ordered_at",
|
||||
table: "orders",
|
||||
columns: new[] { "status", "ordered_at" },
|
||||
filter: "status IN ('pending', 'in_progress')");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_outbox_events_created_at",
|
||||
table: "outbox_events",
|
||||
column: "created_at",
|
||||
filter: "processed_at IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_patients_mrn",
|
||||
table: "patients",
|
||||
column: "mrn",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_reconciliation_alerts_check_type_encounter_id",
|
||||
table: "reconciliation_alerts",
|
||||
columns: new[] { "check_type", "encounter_id" },
|
||||
filter: "resolved_at IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "alert_thresholds");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "clinical_alerts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "observations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "orders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_events");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "reconciliation_alerts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "encounters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "patients");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(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<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')");
|
||||
|
||||
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<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<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>("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_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
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<DateTimeOffset?>("ResultedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resulted_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("pending")
|
||||
.HasColumnName("status");
|
||||
|
||||
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')");
|
||||
});
|
||||
});
|
||||
|
||||
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>("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<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>("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("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("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("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("Encounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
|
||||
b.Navigation("Orders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
public record AlertThresholdRequest(
|
||||
string ObservationCode,
|
||||
string DisplayName,
|
||||
string Unit,
|
||||
decimal? CriticalLow,
|
||||
decimal? WarningLow,
|
||||
decimal? WarningHigh,
|
||||
decimal? CriticalHigh);
|
||||
@@ -0,0 +1 @@
|
||||
public record EncounterStatusTransitionResult(Guid EncounterId, EncounterStatus NewStatus);
|
||||
@@ -0,0 +1,4 @@
|
||||
public record OpenEncounterRequest(
|
||||
EncounterType EncounterType,
|
||||
string Department,
|
||||
string AttendingPhysician);
|
||||
@@ -0,0 +1 @@
|
||||
public record TransitionStatusRequest(EncounterStatus Status);
|
||||
@@ -0,0 +1,5 @@
|
||||
public record RegisterPatientRequest(
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateOnly DateOfBirth,
|
||||
string Gender);
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using StackExchange.Redis;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((ctx, services, config) =>
|
||||
config.ReadFrom.Configuration(ctx.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext());
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(opts =>
|
||||
opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(
|
||||
ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!));
|
||||
|
||||
builder.Services.AddScoped<IPatientService, PatientService>();
|
||||
builder.Services.AddScoped<IEncounterService, EncounterService>();
|
||||
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
await DataSeeder.SeedAsync(db, redis);
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging(options =>
|
||||
{
|
||||
options.MessageTemplate =
|
||||
"HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.000}ms";
|
||||
});
|
||||
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseMiddleware<ExceptionHandlerMiddleware>();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
}
|
||||
/***
|
||||
dotnet ef tools use HostFactoryResolver which throws HostAbortedException internally as a control-flow mechanism to stop the host after discovering the DbContext.
|
||||
Your generic catch was swallowing it instead of letting it propagate, so EF saw the process exit abnormally.
|
||||
***/
|
||||
catch (HostAbortedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application failed to start.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:21191",
|
||||
"sslPort": 44387
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5270",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7146;http://localhost:5270",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class AlertThresholdService : IAlertThresholdService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
|
||||
public AlertThresholdService(AppDbContext db, IConnectionMultiplexer redis)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
}
|
||||
|
||||
public async Task<AlertThreshold> CreateAsync(AlertThresholdRequest req)
|
||||
{
|
||||
var exists = await _db.AlertThresholds.AnyAsync(t => t.ObservationCode == req.ObservationCode);
|
||||
if (exists)
|
||||
throw new ConflictException(
|
||||
"A threshold for this observation code already exists.",
|
||||
"THRESHOLD_CODE_CONFLICT");
|
||||
|
||||
var threshold = new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ObservationCode = req.ObservationCode,
|
||||
DisplayName = req.DisplayName,
|
||||
Unit = req.Unit,
|
||||
CriticalLow = req.CriticalLow,
|
||||
WarningLow = req.WarningLow,
|
||||
WarningHigh = req.WarningHigh,
|
||||
CriticalHigh = req.CriticalHigh,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.AlertThresholds.Add(threshold);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await InvalidateCacheAsync(threshold.ObservationCode);
|
||||
return threshold;
|
||||
}
|
||||
|
||||
public async Task<List<AlertThreshold>> ListAsync() =>
|
||||
await _db.AlertThresholds.OrderBy(t => t.ObservationCode).ToListAsync();
|
||||
|
||||
public async Task<AlertThreshold> GetByIdAsync(Guid id)
|
||||
{
|
||||
var threshold = await _db.AlertThresholds.FindAsync(id);
|
||||
if (threshold is null)
|
||||
throw new NotFoundException("Threshold not found.", "THRESHOLD_NOT_FOUND");
|
||||
return threshold;
|
||||
}
|
||||
|
||||
public async Task<AlertThreshold> UpdateAsync(Guid id, AlertThresholdRequest req)
|
||||
{
|
||||
var threshold = await _db.AlertThresholds.FindAsync(id);
|
||||
if (threshold is null)
|
||||
throw new NotFoundException("Threshold not found.", "THRESHOLD_NOT_FOUND");
|
||||
|
||||
threshold.DisplayName = req.DisplayName;
|
||||
threshold.Unit = req.Unit;
|
||||
threshold.CriticalLow = req.CriticalLow;
|
||||
threshold.WarningLow = req.WarningLow;
|
||||
threshold.WarningHigh = req.WarningHigh;
|
||||
threshold.CriticalHigh = req.CriticalHigh;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await InvalidateCacheAsync(threshold.ObservationCode);
|
||||
return threshold;
|
||||
}
|
||||
|
||||
private async Task InvalidateCacheAsync(string observationCode)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
await cache.KeyDeleteAsync($"threshold:{observationCode}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class EncounterService : IEncounterService
|
||||
{
|
||||
// Explicit transition matrix. Every allowed move is listed here.
|
||||
// Any transition not in this dictionary is illegal and throws ConflictException.
|
||||
private static readonly Dictionary<EncounterStatus, HashSet<EncounterStatus>> _allowedTransitions = new()
|
||||
{
|
||||
[EncounterStatus.Scheduled] = new() { EncounterStatus.Active, EncounterStatus.Cancelled },
|
||||
[EncounterStatus.Active] = new() { EncounterStatus.Discharged, EncounterStatus.Cancelled },
|
||||
[EncounterStatus.Discharged] = new(),
|
||||
[EncounterStatus.Cancelled] = new(),
|
||||
};
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public EncounterService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<Encounter> GetByIdAsync(Guid id)
|
||||
{
|
||||
var encounter = await _db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.Include(e => e.Observations.OrderByDescending(o => o.RecordedAt).Take(10))
|
||||
.Include(e => e.Alerts.Where(a => a.Status == AlertStatus.Open))
|
||||
.FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
if (encounter is null)
|
||||
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
||||
|
||||
return encounter;
|
||||
}
|
||||
|
||||
public async Task<EncounterStatusTransitionResult> TransitionStatusAsync(
|
||||
Guid encounterId, EncounterStatus targetStatus)
|
||||
{
|
||||
var encounter = await _db.Encounters.FindAsync(encounterId);
|
||||
if (encounter is null)
|
||||
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
||||
|
||||
if (!_allowedTransitions[encounter.Status].Contains(targetStatus))
|
||||
throw new ConflictException(
|
||||
$"Transition to '{targetStatus}' is not permitted from the current status.",
|
||||
"ILLEGAL_STATUS_TRANSITION");
|
||||
|
||||
encounter.Status = targetStatus;
|
||||
if (targetStatus == EncounterStatus.Discharged)
|
||||
encounter.DischargedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new EncounterStatusTransitionResult(encounterId, targetStatus);
|
||||
}
|
||||
|
||||
public async Task<object> GetTimelineAsync(Guid encounterId)
|
||||
{
|
||||
var encounter = await _db.Encounters.FindAsync(encounterId);
|
||||
if (encounter is null)
|
||||
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
||||
|
||||
var observations = await _db.Observations
|
||||
.Where(o => o.EncounterId == encounterId)
|
||||
.OrderByDescending(o => o.RecordedAt)
|
||||
.Select(o => new { type = "observation", timestamp = o.RecordedAt, o.ObservationCode, o.Value, o.Unit })
|
||||
.ToListAsync();
|
||||
|
||||
var alerts = await _db.ClinicalAlerts
|
||||
.Where(a => a.EncounterId == encounterId)
|
||||
.OrderByDescending(a => a.TriggeredAt)
|
||||
.Select(a => new { type = "alert", timestamp = a.TriggeredAt, a.AlertType, a.Severity, a.Status })
|
||||
.ToListAsync();
|
||||
|
||||
var timeline = observations.Cast<object>()
|
||||
.Concat(alerts.Cast<object>())
|
||||
.OrderByDescending(x => (DateTimeOffset)((dynamic)x).timestamp)
|
||||
.ToList();
|
||||
|
||||
return new { encounterId, events = timeline };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public interface IAlertThresholdService
|
||||
{
|
||||
Task<AlertThreshold> CreateAsync(AlertThresholdRequest req);
|
||||
Task<List<AlertThreshold>> ListAsync();
|
||||
Task<AlertThreshold> GetByIdAsync(Guid id);
|
||||
Task<AlertThreshold> UpdateAsync(Guid id, AlertThresholdRequest req);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public interface IEncounterService
|
||||
{
|
||||
Task<Encounter> GetByIdAsync(Guid id);
|
||||
Task<EncounterStatusTransitionResult> TransitionStatusAsync(Guid encounterId, EncounterStatus targetStatus);
|
||||
Task<object> GetTimelineAsync(Guid encounterId);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public interface IPatientService
|
||||
{
|
||||
Task<Patient> RegisterAsync(RegisterPatientRequest req);
|
||||
Task<PagedResult<Patient>> ListAsync(string? q, int page, int pageSize);
|
||||
Task<Patient> GetByIdAsync(Guid id);
|
||||
Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class PatientService : IPatientService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public PatientService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
||||
{
|
||||
var mrn = await GenerateMrnAsync();
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = mrn,
|
||||
FirstName = req.FirstName,
|
||||
LastName = req.LastName,
|
||||
DateOfBirth = req.DateOfBirth,
|
||||
Gender = req.Gender,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Patients.Add(patient);
|
||||
await _db.SaveChangesAsync();
|
||||
return patient;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<Patient>> ListAsync(
|
||||
string? q, int page, int pageSize)
|
||||
{
|
||||
var query = _db.Patients.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
query = query.Where(p =>
|
||||
p.Mrn == q ||
|
||||
EF.Functions.ILike(p.FirstName, $"%{q}%") ||
|
||||
EF.Functions.ILike(p.LastName, $"%{q}%"));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var patients = await query
|
||||
.OrderBy(p => p.LastName).ThenBy(p => p.FirstName)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return new PagedResult<Patient>(patients, page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<Patient> GetByIdAsync(Guid id)
|
||||
{
|
||||
var patient = await _db.Patients
|
||||
.Include(p => p.Encounters.Where(e => e.Status == EncounterStatus.Active))
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (patient is null)
|
||||
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
|
||||
return patient;
|
||||
}
|
||||
|
||||
public async Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
|
||||
{
|
||||
var patient = await _db.Patients.FindAsync(patientId);
|
||||
if (patient is null)
|
||||
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
|
||||
var hasActiveOfType = await _db.Encounters.AnyAsync(e =>
|
||||
e.PatientId == patientId &&
|
||||
e.EncounterType == req.EncounterType &&
|
||||
e.Status == EncounterStatus.Active);
|
||||
|
||||
if (hasActiveOfType)
|
||||
throw new ConflictException(
|
||||
"Patient already has an active encounter of this type.",
|
||||
"DUPLICATE_ACTIVE_ENCOUNTER");
|
||||
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = patientId,
|
||||
EncounterType = req.EncounterType,
|
||||
Status = EncounterStatus.Active,
|
||||
Department = req.Department,
|
||||
AttendingPhysician = req.AttendingPhysician,
|
||||
AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Encounters.Add(encounter);
|
||||
await _db.SaveChangesAsync();
|
||||
return encounter;
|
||||
}
|
||||
|
||||
private async Task<string> GenerateMrnAsync()
|
||||
{
|
||||
var count = await _db.Patients.CountAsync();
|
||||
return $"MRN-{(count + 1):D6}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@VigilCareClinicalAPI_HostAddress = http://localhost:5270
|
||||
|
||||
GET {{VigilCareClinicalAPI_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5436;Database=vigilcare;Username=postgres;Password=password"
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6382"
|
||||
},
|
||||
"Seq": {
|
||||
"ServerUrl": "http://localhost:5345"
|
||||
},
|
||||
"Serilog": {
|
||||
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" },
|
||||
{
|
||||
"Name": "Seq",
|
||||
"Args": {
|
||||
"serverUrl": "http://localhost:5345"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [ "FromLogContext" ]
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user