feature: Schema, Migrations, Core CRUD, and Redis Threshold Cache
This commit is contained in:
+131
@@ -0,0 +1,131 @@
|
||||
# =========================
|
||||
# Build results
|
||||
# =========================
|
||||
bin/
|
||||
obj/
|
||||
out/
|
||||
publish/
|
||||
|
||||
# =========================
|
||||
# User-specific files
|
||||
# =========================
|
||||
*.user
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# =========================
|
||||
# Logs
|
||||
# =========================
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# =========================
|
||||
# Visual Studio Code
|
||||
# =========================
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# =========================
|
||||
# Rider / JetBrains
|
||||
# =========================
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# =========================
|
||||
# Visual Studio
|
||||
# =========================
|
||||
.vs/
|
||||
|
||||
# =========================
|
||||
# OS generated files
|
||||
# =========================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# =========================
|
||||
# Environment files
|
||||
# =========================
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# =========================
|
||||
# ASP.NET / secrets
|
||||
# =========================
|
||||
appsettings.Development.json
|
||||
appsettings.*.local.json
|
||||
secrets.json
|
||||
|
||||
# User Secrets (ASP.NET Core)
|
||||
secrets/
|
||||
**/secrets.json
|
||||
|
||||
# =========================
|
||||
# Entity Framework
|
||||
# =========================
|
||||
# Migrations should usually be committed (DO NOT ignore)
|
||||
# But temp files:
|
||||
*.dbmdl
|
||||
*.edmx.diagram
|
||||
|
||||
# =========================
|
||||
# NuGet
|
||||
# =========================
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
packages/
|
||||
# keep lock file (important for reproducibility)
|
||||
!packages.lock.json
|
||||
|
||||
# =========================
|
||||
# Node (if using frontend)
|
||||
# =========================
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# =========================
|
||||
# Docker
|
||||
# =========================
|
||||
docker-compose.override.yml
|
||||
*.local.yml
|
||||
|
||||
# =========================
|
||||
# Test results
|
||||
# =========================
|
||||
TestResults/
|
||||
coverage/
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# =========================
|
||||
# Publish profiles
|
||||
# =========================
|
||||
Properties/PublishProfiles/*.pubxml
|
||||
!Properties/PublishProfiles/*.pubxml.user
|
||||
|
||||
# =========================
|
||||
# Azure / cloud artifacts
|
||||
# =========================
|
||||
*.azurePubxml
|
||||
*.publishsettings
|
||||
|
||||
# =========================
|
||||
# Temporary files
|
||||
# =========================
|
||||
*.tmp
|
||||
*.temp
|
||||
*.swp
|
||||
*.bak
|
||||
*.cache
|
||||
|
||||
docs/plans
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCareClinicalAPI", "VigilCareClinicalAPI\VigilCareClinicalAPI.csproj", "{245ED672-EF15-4854-9C06-AB369139F7BE}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{245ED672-EF15-4854-9C06-AB369139F7BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{245ED672-EF15-4854-9C06-AB369139F7BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{245ED672-EF15-4854-9C06-AB369139F7BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{245ED672-EF15-4854-9C06-AB369139F7BE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -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": "*"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: vigilcare
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
ports:
|
||||
- "5436:5432"
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6382:6379"
|
||||
|
||||
seq:
|
||||
image: datalust/seq:latest
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
ports:
|
||||
- "5345:80"
|
||||
volumes:
|
||||
- seq_data:/data
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
seq_data:
|
||||
@@ -0,0 +1,731 @@
|
||||
# PRD: VigilCare — Clinical Data Pipeline & Real-Time Alert Platform
|
||||
|
||||
## Overview
|
||||
|
||||
A production-style clinical backend that models patient encounters, continuous observation ingest, and real-time clinical alerting. The system streams vital signs and lab results through Kafka, fans urgent notifications to clinicians through RabbitMQ, and maintains a searchable CQRS projection in Elasticsearch for patient dashboards and population analytics. Long-term data is archived as Parquet files in an S3-compatible object store — a regulatory requirement in healthcare that has no equivalent in most other domains.
|
||||
|
||||
The domain is deliberately different from the Digital Wallet API. Both projects use Kafka, RabbitMQ, and Elasticsearch, but the trade-off conversations are entirely different. In fintech the core question is "did the money move correctly?" In healthcare the core question is "did the right person get the right alert at the right time?" That distinction — correctness vs timeliness — produces different architectural decisions at every layer.
|
||||
|
||||
This project maps to `sd-mid-009` (Outbox Pattern), `sd-mid-013` (CQRS), `sd-mid-043–048` (Kafka internals), `sd-senior-008` (Real-Time Event Processing), and `sd-senior-011` (Anomaly Detection in Streams).
|
||||
|
||||
**Stack:** .NET 8 Web API, PostgreSQL, Apache Kafka (KRaft), RabbitMQ, Elasticsearch, Redis, MinIO (Parquet archival), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Model the observe-alert-acknowledge lifecycle that sits at the center of any clinical monitoring system
|
||||
- Demonstrate Kafka's multi-consumer log model in a healthcare context where the same observation event must reach the alert engine, the Elasticsearch projection, and the data lake independently
|
||||
- Show RabbitMQ's DLQ pattern as a clinical escalation mechanism — if a critical alert is not acknowledged in five minutes, the message routes through a dead-letter queue and re-delivers as an escalation to the on-call physician
|
||||
- Build a stateful Kafka consumer that detects sepsis early warning signs by maintaining rolling windows of recent observations per patient in Redis
|
||||
- Produce a project that supports senior trade-off conversations in healthcare, medtech, and any domain where real-time alerting and long-term archival coexist
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- HL7 FHIR compliance (reference the standard; do not implement it)
|
||||
- Integration with real medical devices or lab information systems
|
||||
- Medication dispensing or pharmacy workflows
|
||||
- Patient billing or insurance claim adjudication
|
||||
- HIPAA-compliant deployment (model the patterns; don't configure real PHI)
|
||||
|
||||
---
|
||||
|
||||
## Why Both Kafka and RabbitMQ?
|
||||
|
||||
This is the same architectural question as the Digital Wallet — but the healthcare context produces a different answer that is worth knowing independently.
|
||||
|
||||
**Kafka** is an append-only log. Every observation recorded by a bedside monitor, every lab result that arrives from the lab information system, is written to a Kafka topic and retained. Multiple independent consumer groups read the same observation stream at their own pace:
|
||||
|
||||
- The Elasticsearch indexer maintains a searchable patient dashboard
|
||||
- The sepsis detection engine analyzes rolling windows for SIRS criteria
|
||||
- The data lake writer archives observations as Parquet for long-term regulatory retention
|
||||
- A future billing consumer could derive charges from observation codes without touching the operational database
|
||||
|
||||
None of these consumers coordinate with each other. Each holds its own offset. If the sepsis engine is deployed a month after go-live, it can replay all historical observations from offset 0 to catch up. This is only possible because Kafka retains events after consumption.
|
||||
|
||||
**RabbitMQ** handles the action side — what must happen after a clinical event is recognized. When the alert engine detects a critical potassium value, a clinician must be paged. That page is a task: one message, one worker, one action. It must not be processed twice (a duplicate page at 3am is a patient safety concern, not a minor inconvenience). RabbitMQ's acknowledgment model — the message is deleted after exactly one worker acknowledges it — is correct here. Kafka's model is not.
|
||||
|
||||
The escalation pattern makes RabbitMQ's dead-letter queue uniquely valuable in this domain. If a physician does not acknowledge a critical alert within five minutes, the original message NACKs into a dead-letter queue with a `x-message-ttl` of 300 seconds. After that TTL expires, the message is re-routed to an escalation queue targeting the on-call backup. This is the DLQ pattern repurposed as a clinical escalation protocol — a design that does not exist cleanly in Kafka.
|
||||
|
||||
| Use Case | System | Why |
|
||||
|---|---|---|
|
||||
| Vital sign streams from monitors | Kafka | Continuous, high-frequency, multiple consumers |
|
||||
| Lab result events from LIS | Kafka | Replayable; alert engine and data lake both need it |
|
||||
| Encounter admission/discharge events | Kafka | Multiple downstream systems react independently |
|
||||
| Sepsis detection analytics | Kafka → Redis | Stateful windowed analysis over the observation stream |
|
||||
| Page a physician for a critical value | RabbitMQ | One task, one worker, acknowledged-then-deleted |
|
||||
| Escalate if unacknowledged after 5 minutes | RabbitMQ DLQ | Delayed re-delivery is native to DLQ TTL; Kafka has no equivalent |
|
||||
| Generate discharge summary PDF | RabbitMQ | Background job; one per discharge, not replayable |
|
||||
| Appointment reminder SMS | RabbitMQ | Task queue; idempotent at the SMS provider level |
|
||||
|
||||
---
|
||||
|
||||
## API Conventions
|
||||
|
||||
Same response envelope as all other portfolio projects. Prefix: `/api/v1`.
|
||||
|
||||
**Success:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"statusCode": 200,
|
||||
"data": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
**Error:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"statusCode": 422,
|
||||
"data": null,
|
||||
"error": {
|
||||
"message": "Observation value exceeds plausible range for this code.",
|
||||
"code": "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pagination:** Observation history and alert history use cursor pagination on `(recorded_at DESC, id DESC)` — the table is append-only and grows continuously; offset pagination shifts results as new rows arrive. All other list endpoints use offset pagination (`?page=1&pageSize=20`).
|
||||
|
||||
**Idempotency:** `POST /api/v1/encounters/:id/observations` accepts an `Idempotency-Key` header enforced by a unique partial index. Medical device integrations frequently retry on network failure; a duplicate reading must produce the same response without creating a duplicate observation.
|
||||
|
||||
---
|
||||
|
||||
## Domain Model
|
||||
|
||||
A **patient** is the central entity. Each patient has a Medical Record Number (MRN) — a stable identifier issued at first registration that never changes, even across multiple encounters.
|
||||
|
||||
An **encounter** is a single clinical episode — an inpatient admission, an outpatient visit, or an emergency department visit. A patient may have many encounters over their lifetime. An encounter has a status (`scheduled`, `active`, `discharged`, `cancelled`) and a department. All observations, orders, and alerts belong to an encounter, not directly to a patient.
|
||||
|
||||
An **alert threshold** defines the numeric boundaries that trigger a clinical alert for a given observation code. Thresholds are global (not per-patient) and are managed by clinical administrators. Each threshold has four optional bounds: `critical_low`, `warning_low`, `warning_high`, `critical_high`. A potassium value below `critical_low` is an immediate life-threatening emergency; a value below `warning_low` warrants physician review within the hour.
|
||||
|
||||
An **observation** is a single recorded measurement: a vital sign (heart rate, temperature, blood pressure), a lab value (potassium, glucose, white blood cell count), or a pulse oximetry reading. Observations are append-only. They are never updated or deleted. The observation stream is the primary input to both the alert engine and the Kafka pipeline.
|
||||
|
||||
A **clinical alert** is generated when an observation breaches a threshold or when the sepsis detection engine identifies a pattern across multiple recent observations. An alert has a lifecycle: `open` → `acknowledged` → `resolved`. An unacknowledged `CRITICAL` alert triggers the RabbitMQ escalation after five minutes.
|
||||
|
||||
An **order** is a clinician's instruction: run this lab test, administer this medication, perform this imaging study. Orders have a status lifecycle and a `resulted_at` timestamp. The reconciliation job uses pending orders to detect cases where a result was never returned.
|
||||
|
||||
An **outbox event** is written in the same transaction as any observation or alert, then relayed to Kafka by a background worker. This decouples Kafka availability from the database transaction.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
---
|
||||
|
||||
### 1. Patient Registration and Encounter Management
|
||||
|
||||
**Description:** Patients are registered with demographic information and assigned a unique MRN. When a patient presents for care, an encounter is opened against their record. Encounters progress through a controlled status machine. A patient cannot have two active encounters of the same type simultaneously.
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/v1/patients` — register patient; generates MRN
|
||||
- `GET /api/v1/patients` — paginated list with search by name or MRN
|
||||
- `GET /api/v1/patients/:id` — patient detail with active encounter summary
|
||||
- `POST /api/v1/patients/:id/encounters` — open an encounter
|
||||
- `GET /api/v1/encounters/:id` — encounter detail with recent observations and open alerts
|
||||
- `PATCH /api/v1/encounters/:id/status` — advance status (`active → discharged`, `scheduled → active`, etc.); illegal transitions return `409`
|
||||
- `GET /api/v1/encounters/:id/timeline` — merged chronological view: status changes, observation summaries, alerts
|
||||
|
||||
**Encounter status machine:**
|
||||
```
|
||||
scheduled → active → discharged
|
||||
→ cancelled
|
||||
```
|
||||
A `discharged` encounter triggers a RabbitMQ job to generate a discharge summary PDF.
|
||||
|
||||
**Concepts practiced:** Aggregate design (encounter owns observations and alerts), controlled state transitions with explicit transition matrix, 409 on illegal transitions, timeline as a composed projection across multiple tables.
|
||||
|
||||
---
|
||||
|
||||
### 2. Alert Threshold Management
|
||||
|
||||
**Description:** Clinical administrators configure the numeric boundaries that define normal, warning, and critical ranges for each observation code. Thresholds are cached in Redis at application startup and invalidated on write — they are read on every observation ingest and must not add database latency to the ingest path.
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/v1/alert-thresholds`
|
||||
- `GET /api/v1/alert-thresholds`
|
||||
- `GET /api/v1/alert-thresholds/:id`
|
||||
- `PUT /api/v1/alert-thresholds/:id`
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"observationCode": "POTASSIUM_MEQ_L",
|
||||
"displayName": "Serum Potassium",
|
||||
"unit": "mEq/L",
|
||||
"criticalLow": 2.5,
|
||||
"warningLow": 3.5,
|
||||
"warningHigh": 5.0,
|
||||
"criticalHigh": 6.5
|
||||
}
|
||||
```
|
||||
|
||||
**Concepts practiced:** Redis as a configuration cache (not just session/balance cache), cache-aside with write-through invalidation, the difference between data that changes per-request (patient observations) and data that changes per-configuration (thresholds).
|
||||
|
||||
---
|
||||
|
||||
### 3. Observation Ingest
|
||||
|
||||
**Description:** The highest-volume endpoint in the system. Bedside monitors, point-of-care devices, and lab integration systems POST observations continuously. The endpoint must be idempotent (devices retry on network failure), must validate the value against a plausibility range (no human has a heart rate of 400), and must evaluate the observation against alert thresholds on the synchronous path for critical values.
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/v1/encounters/:id/observations` — single or small batch (up to 10); document choice
|
||||
- `GET /api/v1/encounters/:id/observations?code=&from=&to=&limit=&cursor=` — cursor-paginated observation history with optional code filter
|
||||
|
||||
**Ingest transaction sequence:**
|
||||
```
|
||||
1. Validate encounter is active (not discharged or cancelled)
|
||||
2. Check idempotency key against unique index
|
||||
3. Validate observation value within plausible range for the code
|
||||
4. Insert observation row
|
||||
5. Load alert threshold for this code from Redis cache (→ PostgreSQL on miss)
|
||||
6. If value breaches CRITICAL threshold:
|
||||
a. Insert clinical_alert row (status: open)
|
||||
b. Insert outbox event (topic: alert.generated)
|
||||
7. Insert outbox event (topic: observation.recorded, payload: full observation)
|
||||
8. COMMIT
|
||||
```
|
||||
|
||||
**Critical vs warning split:** Critical threshold breaches are detected synchronously within the ingest transaction and immediately create an alert. Warning threshold breaches are detected by the Kafka consumer asynchronously — the additional latency (milliseconds to seconds) is acceptable for a warning, but a critical potassium value must trigger a page before the API returns a response.
|
||||
|
||||
**Idempotency:** A device that retries an observation with the same `Idempotency-Key` receives the original `201` response without creating a duplicate row. The unique partial index enforces this at the database layer.
|
||||
|
||||
**Concepts practiced:** Idempotency key on high-frequency ingest (sd-mid-008), synchronous vs asynchronous alert detection (the split is a clinical safety decision, not an arbitrary one), Redis cache-aside for threshold lookup on the hot path, outbox pattern within ingest transaction.
|
||||
|
||||
---
|
||||
|
||||
### 4. Clinical Alert Lifecycle
|
||||
|
||||
**Description:** Alerts are the patient safety core of the system. Every open `CRITICAL` alert must be acknowledged by a clinician within five minutes or it escalates. Every alert has a documented audit trail: who acknowledged it, when, and with what note.
|
||||
|
||||
**Endpoints:**
|
||||
- `GET /api/v1/encounters/:id/alerts` — paginated alert list for an encounter
|
||||
- `GET /api/v1/alerts` — global alert list filterable by status, severity, department
|
||||
- `GET /api/v1/alerts/:id` — alert detail
|
||||
- `POST /api/v1/alerts/:id/acknowledge` — acknowledge with clinician ID and optional note
|
||||
- `POST /api/v1/alerts/:id/resolve` — resolve (must be acknowledged first)
|
||||
|
||||
**Alert lifecycle:**
|
||||
```
|
||||
open → acknowledged → resolved
|
||||
→ escalated (via RabbitMQ DLQ after 5 min unacknowledged)
|
||||
```
|
||||
|
||||
**The escalation path:** When an alert is created, the outbox relay publishes to `alert.generated` in Kafka. The notification worker Kafka consumer reads this event and publishes a paging job to RabbitMQ. If the RabbitMQ consumer sends the page but receives no acknowledgment event within five minutes, the message NACKs to the DLQ. After the DLQ TTL expires (300 seconds), the message re-routes to the escalation queue and the on-call backup is paged. The alert status transitions to `escalated` in PostgreSQL.
|
||||
|
||||
**Concepts practiced:** Alert acknowledgment as a domain event (not just a status update), escalation via DLQ TTL as a healthcare-specific pattern, the difference between an alert being acknowledged in the app vs a clinician physically responding at the bedside.
|
||||
|
||||
---
|
||||
|
||||
### 5. Outbox Relay and Kafka Pipeline
|
||||
|
||||
**Description:** Same pattern as the Digital Wallet. The relay reads unprocessed outbox rows, publishes to Kafka, marks processed. Every observation and every alert flows through this relay to reach Elasticsearch, the sepsis engine, and the data lake independently.
|
||||
|
||||
**Kafka topics:**
|
||||
|
||||
| Topic | Producer | Consumers |
|
||||
|---|---|---|
|
||||
| `observation.recorded` | Outbox relay | Elasticsearch indexer, Sepsis engine, Data lake writer |
|
||||
| `alert.generated` | Outbox relay | Elasticsearch indexer, Notification worker, Data lake writer |
|
||||
| `encounter.status.changed` | Outbox relay | Elasticsearch indexer, Data lake writer |
|
||||
|
||||
**Partition key:** `encounter_id` for `observation.recorded` and `alert.generated`. All events for the same encounter land on the same partition, preserving per-encounter ordering. This is important for the sepsis engine: observations for the same patient must be processed in arrival order.
|
||||
|
||||
**Consumer group isolation:** `es-indexer`, `sepsis-engine`, and `data-lake-writer` are separate consumer groups. Each maintains its own committed offset. The sepsis engine processing slowly does not affect the Elasticsearch indexer.
|
||||
|
||||
**Concepts practiced:** Partition key design for per-entity ordering guarantees, consumer group independence, at-least-once delivery via outbox relay (and why consumers must be idempotent), Kafka as the backbone that allows adding new consumers without modifying the producer.
|
||||
|
||||
---
|
||||
|
||||
### 6. Elasticsearch Clinical Search and Analytics (CQRS)
|
||||
|
||||
**Description:** The Elasticsearch indexer maintains a denormalized, queryable projection of the clinical record. It is the read side of CQRS — PostgreSQL is always the write side and the source of truth. The index is optimized for the queries clinicians actually run: "show me all patients with a critical potassium alert in the last hour," "show me the average heart rate trend for this patient over the last 24 hours."
|
||||
|
||||
**Index shape — patient_encounters:**
|
||||
```json
|
||||
{
|
||||
"encounterId": "uuid",
|
||||
"patientId": "uuid",
|
||||
"mrn": "MRN-000001",
|
||||
"patientName": "Jane Smith",
|
||||
"department": "ICU",
|
||||
"status": "active",
|
||||
"attendingPhysician": "Dr. Osei",
|
||||
"admittedAt": "2025-01-01T08:00:00Z",
|
||||
"openAlertCount": 2,
|
||||
"lastObservationAt": "2025-01-01T09:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Index shape — observations:**
|
||||
```json
|
||||
{
|
||||
"observationId": "uuid",
|
||||
"encounterId": "uuid",
|
||||
"patientId": "uuid",
|
||||
"mrn": "MRN-000001",
|
||||
"observationCode": "HEART_RATE",
|
||||
"value": 118.0,
|
||||
"unit": "bpm",
|
||||
"source": "DEVICE",
|
||||
"recordedAt": "2025-01-01T09:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Endpoints:**
|
||||
- `GET /api/v1/analytics/patients?q=&department=&status=` — patient/encounter search across MRN, name, department
|
||||
- `GET /api/v1/analytics/observations/trend?encounterId=&code=&from=&to=` — time-series aggregation (hourly average, min, max) for a specific observation code
|
||||
- `GET /api/v1/analytics/alerts/summary?severity=&from=&to=&department=` — alert volume by department and severity over a time window
|
||||
- `GET /api/v1/analytics/population?code=&threshold=&from=&to=` — how many patients had a value above or below a threshold in a given window
|
||||
|
||||
**The replay demo:** Stop the indexer → delete the Elasticsearch index → reset the `es-indexer` consumer group offset to 0 → restart → watch both indices rebuild from Kafka history. This is only possible because Kafka retains events. Document this procedure in the project README. It is the most important operational proof-of-concept in the project.
|
||||
|
||||
**Why Elasticsearch here and not PostgreSQL:** The `population` query — "how many active patients have a heart rate above 100 in the last hour across all departments" — is an aggregation across potentially millions of observation rows. Running this against PostgreSQL on the operational database would compete with ingest writes and introduce latency for both. Elasticsearch's aggregation engine is purpose-built for this pattern. PostgreSQL remains untouched for this query.
|
||||
|
||||
**Concepts practiced:** CQRS read projection design (sd-mid-013), Elasticsearch aggregations as a distinct use case from full-text search (the `population` endpoint uses no full-text search at all — it is a numeric range aggregation), eventual consistency between PostgreSQL and Elasticsearch, replay as a recovery mechanism.
|
||||
|
||||
---
|
||||
|
||||
### 7. Sepsis Early Warning Engine
|
||||
|
||||
**Description:** A Kafka consumer that reads the `observation.recorded` stream and detects SIRS (Systemic Inflammatory Response Syndrome) criteria per patient in near real-time. SIRS is a simplified clinical proxy for sepsis risk — when two or more criteria are met simultaneously, a `SEPSIS_WARNING` alert is generated. State is maintained in Redis as a rolling window of recent observations per encounter.
|
||||
|
||||
**SIRS criteria (simplified for this project):**
|
||||
|
||||
| Criterion | Observation Code | Trigger |
|
||||
|---|---|---|
|
||||
| Fever or hypothermia | `TEMP_C` | > 38.3°C or < 36.0°C |
|
||||
| Tachycardia | `HEART_RATE` | > 90 bpm |
|
||||
| Tachypnea | `RESP_RATE` | > 20 breaths/min |
|
||||
| Abnormal WBC | `WBC_K_UL` | > 12.0 or < 4.0 k/µL |
|
||||
|
||||
**Redis state per encounter:**
|
||||
```
|
||||
sirs:{encounterId}:TEMP_C → "1" (TTL: 30 minutes)
|
||||
sirs:{encounterId}:HEART_RATE → "1" (TTL: 30 minutes)
|
||||
sirs:{encounterId}:RESP_RATE → "1" (TTL: 30 minutes)
|
||||
sirs:{encounterId}:WBC_K_UL → "1" (TTL: 30 minutes)
|
||||
```
|
||||
|
||||
**Detection logic per observation event:**
|
||||
```
|
||||
1. Evaluate the incoming observation against SIRS criteria
|
||||
2. If criterion met: SET sirs:{encounterId}:{code} = "1" EX 1800
|
||||
3. If criterion not met: DEL sirs:{encounterId}:{code}
|
||||
4. Count active SIRS keys for this encounter (KEYS pattern or MGET)
|
||||
5. If count >= 2 and no open SEPSIS_WARNING alert exists for this encounter:
|
||||
a. Write clinical_alert to PostgreSQL (SEPSIS_WARNING, CRITICAL)
|
||||
b. Write outbox event → Kafka alert.generated
|
||||
```
|
||||
|
||||
**Why Redis here and not PostgreSQL:** The SIRS evaluation runs on every observation event, potentially multiple times per minute per patient. Checking "which SIRS criteria were met in the last 30 minutes" against PostgreSQL on every event would require a query against the observations table with a time range filter per encounter — under load, this creates read pressure that competes with ingest writes. Redis's O(1) key operations with TTL-based expiry are correct and fast. The TTL handles the sliding window automatically: a heart rate measurement that was abnormal 31 minutes ago stops contributing to the SIRS count without any cleanup job.
|
||||
|
||||
**Idempotency:** If the consumer crashes between detecting SIRS and committing the Kafka offset, it will re-process the same observation on restart. The alert creation query checks for an existing open `SEPSIS_WARNING` alert before inserting — a duplicate is impossible even with at-least-once delivery.
|
||||
|
||||
**Concepts practiced:** Stateful stream processing with Redis as the state store (sd-senior-011), TTL as a sliding window mechanism, idempotent alert creation, why Kafka consumer + Redis is appropriate here vs a dedicated stream processor like Flink (at the scale of a single hospital, the overhead of a full stream processing framework is not justified — this is a defensible trade-off to articulate in an interview).
|
||||
|
||||
---
|
||||
|
||||
### 8. RabbitMQ Notification Workers and Escalation
|
||||
|
||||
**Description:** The notification worker reads `alert.generated` events from Kafka and dispatches paging jobs to RabbitMQ. The RabbitMQ consumer sends the page and waits for acknowledgment. If no acknowledgment arrives within five minutes, the dead-letter queue escalates to the on-call backup.
|
||||
|
||||
**Exchange topology:**
|
||||
```
|
||||
clinical.notifications.exchange (direct)
|
||||
├── alerts.paging.queue (physician paging, prefetch=3)
|
||||
├── alerts.paging.dlq (unacknowledged pages → escalation)
|
||||
├── alerts.escalation.queue (on-call backup paging)
|
||||
├── notifications.discharge.queue (discharge summary PDF jobs)
|
||||
└── notifications.appointment.queue (appointment reminders)
|
||||
```
|
||||
|
||||
**Escalation flow:**
|
||||
```
|
||||
1. alert.generated event arrives from Kafka
|
||||
2. Notification worker publishes to alerts.paging.queue
|
||||
3. Paging worker sends page to attending physician
|
||||
4. If no POST /alerts/:id/acknowledge within 5 minutes:
|
||||
a. NACK with requeue=false → message goes to alerts.paging.dlq
|
||||
b. DLQ has x-message-ttl = 300000ms (5 min)
|
||||
c. After TTL: message routes back to alerts.escalation.queue
|
||||
d. Escalation worker pages the on-call backup
|
||||
e. clinical_alert.status → 'escalated' in PostgreSQL
|
||||
```
|
||||
|
||||
**Discharge summary job:** When an encounter status changes to `discharged`, the outbox relay publishes to Kafka `encounter.status.changed`. The notification Kafka consumer reads this and publishes to `notifications.discharge.queue`. The worker generates a PDF summary (log the content; no real PDF library required), stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`, and marks the job complete.
|
||||
|
||||
**Concepts practiced:** RabbitMQ exchange-to-queue binding topology, DLQ TTL as a delayed retry and escalation mechanism, prefetch count and worker concurrency, why this escalation pattern is not replicable in Kafka (Kafka has no concept of per-message TTL or conditional re-routing based on consumer acknowledgment).
|
||||
|
||||
---
|
||||
|
||||
### 9. Reconciliation Jobs
|
||||
|
||||
**Description:** Three scheduled checks that verify the system is behaving correctly. Unlike the Digital Wallet where reconciliation checks that money did not disappear, clinical reconciliation checks that actions were taken — alerts were acknowledged, orders were resulted, encounters were closed.
|
||||
|
||||
**Check 1 — Unacknowledged critical alerts:**
|
||||
```sql
|
||||
SELECT id, encounter_id, triggered_at
|
||||
FROM clinical_alerts
|
||||
WHERE severity = 'CRITICAL'
|
||||
AND status = 'open'
|
||||
AND triggered_at < NOW() - INTERVAL '30 minutes';
|
||||
```
|
||||
Any row here means a critical alert sat open for 30 minutes without acknowledgment or escalation — this is a patient safety failure.
|
||||
|
||||
**Check 2 — Pending orders without results:**
|
||||
```sql
|
||||
SELECT id, encounter_id, order_type, ordered_at
|
||||
FROM orders
|
||||
WHERE status IN ('pending', 'in_progress')
|
||||
AND ordered_at < NOW() - INTERVAL '4 hours';
|
||||
```
|
||||
A lab order that has been pending for four hours without a result may indicate a lost sample or a system integration failure.
|
||||
|
||||
**Check 3 — Active encounters without recent observations:**
|
||||
```sql
|
||||
SELECT e.id, e.patient_id, MAX(o.recorded_at) AS last_observation
|
||||
FROM encounters e
|
||||
LEFT JOIN observations o ON o.encounter_id = e.id
|
||||
WHERE e.status = 'active'
|
||||
AND e.encounter_type = 'INPATIENT'
|
||||
GROUP BY e.id, e.patient_id
|
||||
HAVING MAX(o.recorded_at) < NOW() - INTERVAL '2 hours'
|
||||
OR MAX(o.recorded_at) IS NULL;
|
||||
```
|
||||
An active inpatient without any observation in two hours may indicate a disconnected monitor or a patient who was physically moved without a system update.
|
||||
|
||||
Each check creates a `reconciliation_alerts` row and publishes a job to RabbitMQ for operator notification.
|
||||
|
||||
**Concepts practiced:** Reconciliation as a patient safety mechanism (not just a data integrity mechanism), the difference between "did the data record correctly" (Digital Wallet) and "did the required action happen" (VigilCare), scheduled background jobs in .NET.
|
||||
|
||||
---
|
||||
|
||||
### 10. Observability
|
||||
|
||||
**Metrics (Prometheus → Grafana):**
|
||||
|
||||
| Metric | Description |
|
||||
|---|---|
|
||||
| `observations_ingested_total` | Counter, labeled by source and observation_code |
|
||||
| `observation_ingest_duration_seconds` | Histogram of ingest latency (includes threshold evaluation) |
|
||||
| `clinical_alerts_total` | Counter, labeled by alert_type and severity |
|
||||
| `alerts_unacknowledged_gauge` | Gauge — open CRITICAL alerts older than 5 minutes |
|
||||
| `kafka_consumer_lag` | Per consumer group (es-indexer, sepsis-engine, data-lake-writer) |
|
||||
| `outbox_pending_events` | Gauge — unprocessed outbox rows |
|
||||
| `sirs_detections_total` | Counter — how many SEPSIS_WARNING alerts the engine generated |
|
||||
| `escalations_total` | Counter — how many pages went through DLQ escalation |
|
||||
|
||||
**The `alerts_unacknowledged_gauge` panel** is the most clinically significant metric. If this gauge rises, a nurse station monitor or alerting dashboard must surface it immediately. In a real deployment, this panel would be connected to a paging system. In the portfolio, it demonstrates that you understand which metrics have patient safety implications vs which are purely operational.
|
||||
|
||||
**Concepts practiced:** The four golden signals in a clinical context, which metrics are operational (Kafka lag, outbox pending) vs which are patient safety indicators (unacknowledged critical alerts), log enrichment with `correlationId`, `encounterId`, `patientId` on every alert path log line.
|
||||
|
||||
---
|
||||
|
||||
### 11. Data Lake Writer
|
||||
|
||||
**Description:** A Kafka consumer that reads all three topics and writes partitioned Parquet files to MinIO. In healthcare, long-term retention is not optional — medical records must be retained for 7–25 years depending on jurisdiction. The data lake is the tier that satisfies this requirement without keeping the operational PostgreSQL database at 10-year scale.
|
||||
|
||||
**File structure:**
|
||||
```
|
||||
/observations/2025/01/15/partition-0-offset-0000001.parquet
|
||||
/alerts/2025/01/15/partition-0-offset-0000001.parquet
|
||||
/encounters/2025/01/15/partition-0-offset-0000001.parquet
|
||||
```
|
||||
|
||||
**Flush policy:** Buffer 1,000 events or 5 minutes, whichever comes first.
|
||||
|
||||
**Why Parquet:** Columnar storage compresses repetitive observation data (many rows with the same `observation_code` and `unit`) at ratios of 5–10× vs JSON. A population health query — "give me all heart rate values for patients in the ICU in 2024" — reads only the `observation_code` and `value` columns without deserializing the rest of each row. This matters for 10 years of data at a multi-hospital scale.
|
||||
|
||||
**Concepts practiced:** Data lake as a separate retention tier from the operational database, Parquet's columnar advantage over row-based formats for analytics workloads, partition structure as the basis for future query tools (Spark, Athena, DuckDB), regulatory retention as an architectural driver.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema and Indexing Plan
|
||||
|
||||
```sql
|
||||
CREATE TABLE patients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mrn VARCHAR(20) NOT NULL UNIQUE,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
date_of_birth DATE NOT NULL,
|
||||
gender VARCHAR(10) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE encounters (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
patient_id UUID NOT NULL REFERENCES patients(id),
|
||||
encounter_type VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
|
||||
department VARCHAR(100) NOT NULL,
|
||||
attending_physician VARCHAR(200) NOT NULL,
|
||||
admitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
discharged_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_encounters_patient ON encounters (patient_id, admitted_at DESC);
|
||||
CREATE INDEX idx_encounters_active ON encounters (status, admitted_at DESC) WHERE status = 'active';
|
||||
|
||||
CREATE TABLE alert_thresholds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
observation_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(200) NOT NULL,
|
||||
unit VARCHAR(20) NOT NULL,
|
||||
critical_low DECIMAL(10, 3) NULL,
|
||||
warning_low DECIMAL(10, 3) NULL,
|
||||
warning_high DECIMAL(10, 3) NULL,
|
||||
critical_high DECIMAL(10, 3) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE observations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
encounter_id UUID NOT NULL REFERENCES encounters(id),
|
||||
observation_code VARCHAR(50) NOT NULL,
|
||||
value DECIMAL(10, 3) NOT NULL,
|
||||
unit VARCHAR(20) NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'MANUAL',
|
||||
idempotency_key VARCHAR(100) NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_observations_idempotency
|
||||
ON observations (idempotency_key) WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_observations_encounter_time
|
||||
ON observations (encounter_id, observation_code, recorded_at DESC);
|
||||
|
||||
CREATE TABLE clinical_alerts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
encounter_id UUID NOT NULL REFERENCES encounters(id),
|
||||
patient_id UUID NOT NULL REFERENCES patients(id),
|
||||
observation_id UUID NULL REFERENCES observations(id),
|
||||
alert_type VARCHAR(50) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
details TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
acknowledged_at TIMESTAMPTZ NULL,
|
||||
acknowledged_by VARCHAR(200) NULL,
|
||||
resolved_at TIMESTAMPTZ NULL,
|
||||
triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_alerts_encounter ON clinical_alerts (encounter_id, triggered_at DESC);
|
||||
CREATE INDEX idx_alerts_patient ON clinical_alerts (patient_id, triggered_at DESC);
|
||||
CREATE INDEX idx_alerts_open ON clinical_alerts (severity, triggered_at DESC)
|
||||
WHERE status = 'open';
|
||||
|
||||
CREATE TABLE orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
encounter_id UUID NOT NULL REFERENCES encounters(id),
|
||||
order_type VARCHAR(20) NOT NULL,
|
||||
description VARCHAR(500) NOT NULL,
|
||||
ordered_by VARCHAR(200) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
ordered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resulted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_orders_encounter ON orders (encounter_id, ordered_at DESC);
|
||||
CREATE INDEX idx_orders_pending ON orders (status, ordered_at)
|
||||
WHERE status IN ('pending', 'in_progress');
|
||||
|
||||
CREATE TABLE outbox_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
topic VARCHAR(200) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
processed_at TIMESTAMPTZ NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_outbox_pending ON outbox_events (created_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
CREATE TABLE reconciliation_alerts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
check_type VARCHAR(64) NOT NULL,
|
||||
encounter_id UUID NULL REFERENCES encounters(id),
|
||||
patient_id UUID NULL REFERENCES patients(id),
|
||||
details TEXT NOT NULL,
|
||||
resolved_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Synchronous vs Asynchronous Alert Detection — The Split
|
||||
|
||||
The ingest endpoint evaluates critical thresholds synchronously and warning thresholds asynchronously via the Kafka consumer. This is a deliberate patient safety decision.
|
||||
|
||||
A critical potassium of 2.1 mEq/L (normal: 3.5–5.0) is immediately life-threatening. If the API returns `201 Created` before generating the alert, and the Kafka consumer is lagging by 30 seconds, a patient could deteriorate during that window. The synchronous check costs one additional Redis read per observation on the critical path — acceptable for correctness.
|
||||
|
||||
A warning heart rate of 95 bpm (warning threshold: 90) warrants attention but is not an emergency. The additional latency of Kafka consumer processing (milliseconds to seconds) is clinically acceptable for a warning.
|
||||
|
||||
This is the architectural decision that separates thinking about healthcare systems from thinking about financial systems. In fintech, milliseconds of latency matter for user experience. In healthcare, the right tradeoff is latency for correctness — and the correctness definition is clinical, not technical.
|
||||
|
||||
### Observation Codes as Strings (Not an Enum)
|
||||
|
||||
Observation codes are stored as VARCHAR rather than a database enum. This allows new device types and lab panels to be registered by inserting a threshold row without a schema migration. The trade-off is that typos in observation codes produce silent mismatches (an observation with code `HEART_RATE` and a threshold for `HEARTRATE` would never trigger an alert). The application layer validates incoming codes against the `alert_thresholds` table on ingest.
|
||||
|
||||
In production this would use LOINC codes — an international standard for lab and clinical observations. Knowing that LOINC exists and why it exists (interoperability between systems, not just a naming convention) is a senior talking point.
|
||||
|
||||
### Why Not a Time-Series Database for Observations?
|
||||
|
||||
A medium hospital with 200 concurrent inpatients generating five observations per patient per minute produces approximately 17 observations per second at steady state, peaking near 50/second during shift changes. PostgreSQL with the composite index `(encounter_id, observation_code, recorded_at DESC)` handles this volume with headroom on any modern server.
|
||||
|
||||
A time-series database (InfluxDB, TimescaleDB) would be warranted at sustained 10,000+ observations/second — a large hospital network, not a single facility. TimescaleDB specifically is worth mentioning: it is PostgreSQL with automatic time-based partitioning, meaning it shares the operational model of this project and could be swapped in without changing the query layer. The decision to use vanilla PostgreSQL is correct at this scale and defensible at interview.
|
||||
|
||||
The Parquet data lake handles the analytics workload that would otherwise stress the operational database over a 10-year horizon.
|
||||
|
||||
### Encounter as the Aggregate Root (Not Patient)
|
||||
|
||||
Observations, alerts, and orders belong to an encounter, not directly to a patient. This mirrors clinical reality — a patient's blood pressure taken during a 2022 inpatient admission belongs to that admission, not floating freely on the patient record. It also bounds queries naturally: "show me all observations for this encounter" is a bounded query; "show me all observations ever recorded for this patient" is an expensive cross-encounter aggregation that belongs in the data lake, not the operational path.
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
| Concern | Target |
|
||||
|---|---|
|
||||
| Critical alert latency | A CRITICAL threshold breach must generate an alert before the ingest response returns |
|
||||
| Observation idempotency | Duplicate device submissions with the same key must not create duplicate observations or alerts |
|
||||
| Alert acknowledgment | All open CRITICAL alerts must be detectable via the reconciliation job within 30 minutes |
|
||||
| Replay | Deleting and rebuilding the Elasticsearch index from Kafka offset 0 must be demonstrable |
|
||||
| Retention | Data lake writer must write observations to MinIO; nothing is deleted from the lake |
|
||||
| Testing | Integration tests: critical value ingest → alert created; SIRS criteria met across 3 observations → sepsis alert; duplicate idempotency key → no duplicate; encounter discharge → RabbitMQ job published |
|
||||
|
||||
---
|
||||
|
||||
## Build Order
|
||||
|
||||
| Phase | Focus |
|
||||
|---|---|
|
||||
| 1 | Schema, migrations, patient/encounter CRUD, alert threshold CRUD, seed data |
|
||||
| 2 | Observation ingest + synchronous critical value detection + alert lifecycle API |
|
||||
| 3 | Outbox relay + Kafka topics + producer |
|
||||
| 4 | Elasticsearch CQRS projection + clinical search + analytics endpoints |
|
||||
| 5 | Sepsis detection engine (Kafka consumer + Redis SIRS state) |
|
||||
| 6 | RabbitMQ notification workers + DLQ escalation |
|
||||
| 7 | Reconciliation jobs (three checks) |
|
||||
| 8 | Prometheus metrics + Grafana dashboards + Seq logging |
|
||||
| 9 | MinIO data lake writer (Parquet, partitioned) |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
Complete phases in order. The synchronous alert path in Phase 2 must be correct before Kafka is introduced in Phase 3 — mixing the two failure modes early makes debugging very difficult.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Schema, Migrations, and Core CRUD
|
||||
|
||||
**What to do:**
|
||||
1. Model all tables in EF Core with migrations matching this PRD.
|
||||
2. Enforce the encounter status machine at the service layer — build an explicit transition matrix and return `409` with a stable error code on illegal transitions.
|
||||
3. Seed: two patients, one active inpatient encounter each, four alert thresholds (heart rate, temperature, potassium, SpO₂), a set of observations covering normal, warning, and critical ranges.
|
||||
4. Implement patient search supporting both MRN (exact match) and name (partial match via `ILIKE`). Explain in a comment why MRN uses an exact-match index and name uses a prefix scan.
|
||||
5. Pre-load all alert thresholds into Redis on application startup using `IHostedService`. Verify that a threshold update via the API invalidates the cache.
|
||||
|
||||
**Why:**
|
||||
The threshold cache design is worth getting right in Phase 1. Every observation ingest will read from it. Understanding that it is a write-through invalidation (not a TTL expiry) is the correct design for data where staleness has clinical consequences.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Observation Ingest and Synchronous Alert Detection
|
||||
|
||||
**What to do:**
|
||||
1. Implement `POST /encounters/:id/observations` following the transaction sequence in the Features section exactly.
|
||||
2. Load the alert threshold from Redis (not PostgreSQL) inside the ingest transaction. Measure the latency difference with `EXPLAIN ANALYZE` on the PostgreSQL path for comparison.
|
||||
3. On `CRITICAL` breach: insert the `clinical_alerts` row and outbox event within the same transaction. Do not return `201` until the alert is written.
|
||||
4. On `WARNING` breach: insert the outbox event only — alert creation is deferred to the Kafka consumer.
|
||||
5. Implement cursor-paginated observation history. Verify the composite index `(encounter_id, observation_code, recorded_at DESC)` is used.
|
||||
6. Write integration tests: normal observation (no alert), critical breach (alert created in same transaction), duplicate idempotency key (no duplicate), discharged encounter (reject ingest with `409`).
|
||||
|
||||
**Why:**
|
||||
The split between synchronous (critical) and asynchronous (warning) detection is the most clinically significant decision in the codebase. Test both paths and articulate why they are different. A reviewer or interviewer who asks "why not do all alerts asynchronously?" should get a clinical safety answer, not a technical one.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Outbox Relay and Kafka
|
||||
|
||||
**What to do:**
|
||||
1. Implement the outbox relay as an `IHostedService` polling every 500ms.
|
||||
2. Create Kafka topics: `observation.recorded`, `alert.generated`, `encounter.status.changed`.
|
||||
3. Partition all topics by `encounterId` to guarantee per-encounter ordering.
|
||||
4. Verify the relay survives a Kafka restart: observations commit to PostgreSQL while Kafka is down; the relay catches up when Kafka recovers.
|
||||
5. Introduce the outbox bug deliberately: make two separate commits (one for the observation, one for the outbox event) and observe the data loss when the process crashes between them. Fix it. This step is not optional — seeing the failure mode is the fastest path to internalizing the pattern.
|
||||
|
||||
**Why:**
|
||||
The per-encounter partition key is important for the sepsis engine. If observations from the same patient land on different partitions, they may be processed out of order, and SIRS criteria that arrived simultaneously could be missed. Document this in the code.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Elasticsearch CQRS Projection
|
||||
|
||||
**What to do:**
|
||||
1. Build the `es-indexer` consumer group. Upsert `patient_encounters` documents on `encounter.status.changed`; append to `observations` index on `observation.recorded`; update `openAlertCount` on `alert.generated`.
|
||||
2. Implement the analytics endpoints using Elasticsearch aggregations. The `population` query is a numeric range filter aggregation — no full-text search at all. Write this query first to make explicit that Elasticsearch is being used here for its aggregation engine, not its search engine.
|
||||
3. Write the replay procedure to the README: stop the consumer → delete both indices → reset consumer group offset to 0 → restart → wait for rebuild → verify document count matches PostgreSQL row count.
|
||||
4. Run the replay. Verify it completes and the counts match.
|
||||
|
||||
**Why:**
|
||||
The replay is the proof that Elasticsearch is a projection and not a source of truth. It is also the clearest demonstration of why Kafka's event retention matters. Practice running it until it takes less than two minutes to explain what is happening and why it is significant.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Sepsis Detection Engine
|
||||
|
||||
**What to do:**
|
||||
1. Build the `sepsis-engine` consumer group reading `observation.recorded`.
|
||||
2. Implement the Redis SIRS state as described in the Features section: `SET sirs:{encounterId}:{code} EX 1800` on criterion met, `DEL` on criterion not met.
|
||||
3. Use `MGET` on all four SIRS keys per encounter after each observation — four O(1) operations, not a scan.
|
||||
4. On SIRS count >= 2: check for an existing open `SEPSIS_WARNING` alert for this encounter before inserting. The check and insert are one round-trip: `INSERT INTO clinical_alerts ... WHERE NOT EXISTS (SELECT 1 FROM clinical_alerts WHERE encounter_id = ? AND alert_type = 'SEPSIS_WARNING' AND status = 'open')`.
|
||||
5. Write an integration test: ingest three observations that meet two SIRS criteria for the same encounter within 30 minutes → verify one `SEPSIS_WARNING` alert is created. Ingest a normal temperature immediately after → verify the TTL key is deleted but the alert remains open until acknowledged.
|
||||
|
||||
**Why:**
|
||||
The TTL is doing real work here. Without it, a patient who had a fever yesterday would still have `sirs:{encounterId}:TEMP_C = "1"` in Redis today and could trigger a false sepsis alert from a fast heart rate alone. The 30-minute TTL matches the clinical window for SIRS evaluation. Understand this before the interview — the TTL is not an arbitrary expiry, it is a clinical parameter encoded in the data layer.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — RabbitMQ Notifications and Escalation
|
||||
|
||||
**What to do:**
|
||||
1. Create the exchange and queues from the topology in the Features section. Set `x-dead-letter-exchange` on `alerts.paging.queue` pointing to `alerts.paging.dlq`. Set `x-message-ttl = 300000` on `alerts.paging.dlq`.
|
||||
2. Build a Kafka consumer (`notification-publisher` group) reading `alert.generated`. For `CRITICAL` severity alerts: publish a paging job to `alerts.paging.queue`.
|
||||
3. Build the paging worker: log the page (no real pager required), wait for an `acknowledged` webhook or a timeout, then NACK on timeout.
|
||||
4. Build the escalation worker on `alerts.escalation.queue`: log the escalation, update `clinical_alerts.status = 'escalated'` in PostgreSQL.
|
||||
5. Test the full escalation path: create a critical alert → verify page is published → do not acknowledge → wait for TTL → verify escalation fires → verify alert status is `escalated` in the database.
|
||||
|
||||
**Why:**
|
||||
The escalation test requires actually waiting 5 minutes (or temporarily setting TTL to 5 seconds in the test environment). Run it. Watching the message appear in the DLQ, wait, and then re-appear in the escalation queue is the moment the DLQ pattern becomes intuitive. It is also the answer to "how would you build escalation in a paging system?" in an interview — a Kafka-native answer does not exist for this pattern.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Reconciliation Jobs
|
||||
|
||||
**What to do:**
|
||||
1. Implement the three reconciliation queries as scheduled `IHostedService` jobs (every 30 minutes in development).
|
||||
2. For each check: if rows are found, insert `reconciliation_alerts` and publish a job to RabbitMQ.
|
||||
3. Test Check 1 by creating a critical alert and not acknowledging it for 31 minutes (advance the `triggered_at` timestamp in the database directly to simulate time passing).
|
||||
4. Test Check 3 by creating an active inpatient encounter and not posting any observations — verify the job detects it.
|
||||
|
||||
**Why:**
|
||||
Check 3 is the one unique to clinical systems. A financial reconciliation job checks that data is correct. This check verifies that the real-world process (a nurse checking vitals) actually happened and was recorded. The system cannot verify that the nurse physically took the measurement — only that a reading was posted. Understanding this limitation is part of the senior conversation.
|
||||
|
||||
---
|
||||
|
||||
### Phases 8 and 9 — Observability and Data Lake
|
||||
|
||||
Follow the same Prometheus/Grafana and MinIO/Parquet approach as described in the Features section. The `alerts_unacknowledged_gauge` panel is the single most important panel in the Grafana dashboard — build it first and make sure it updates in near real-time (poll the database every 30 seconds).
|
||||
Reference in New Issue
Block a user