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

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