feature: Reconciliation Jobs
This commit is contained in:
+2
-2
@@ -109,7 +109,7 @@ public sealed class DischargeSummaryWorkerService : BackgroundService
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Encounter ID: {encounter.Id}");
|
||||
sb.AppendLine($"Type: {encounter.EncounterType}");
|
||||
sb.AppendLine($"Department: {encounter.Department}");
|
||||
sb.AppendLine($"Department: {encounter.Department.ToDbString()}");
|
||||
sb.AppendLine($"Attending: {encounter.AttendingPhysician}");
|
||||
sb.AppendLine($"Admitted: {encounter.AdmittedAt:u}");
|
||||
sb.AppendLine($"Discharged: {encounter.DischargedAt:u}");
|
||||
@@ -122,7 +122,7 @@ public sealed class DischargeSummaryWorkerService : BackgroundService
|
||||
{
|
||||
sb.AppendLine("--- Orders ---");
|
||||
foreach (var o in orders)
|
||||
sb.AppendLine($" [{o.Status}] {o.OrderType.ToDbString()}: {o.Description} (ordered: {o.OrderedAt:u})");
|
||||
sb.AppendLine($" [{o.Status.ToDbString()}] {o.OrderType.ToDbString()}: {o.Description} (ordered: {o.OrderedAt:u})");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class DisconnectedMonitorsCheck
|
||||
{
|
||||
public static readonly ReconciliationCheckType CheckType = ReconciliationCheckType.ActiveInpatientNoObservation;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ReconciliationPublisher _publisher;
|
||||
private readonly ReconciliationJobOptions _opts;
|
||||
private readonly ILogger<DisconnectedMonitorsCheck> _logger;
|
||||
|
||||
public DisconnectedMonitorsCheck(
|
||||
AppDbContext db,
|
||||
ReconciliationPublisher publisher,
|
||||
IOptions<ReconciliationJobOptions> opts,
|
||||
ILogger<DisconnectedMonitorsCheck> logger)
|
||||
{
|
||||
_db = db;
|
||||
_publisher = publisher;
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(CancellationToken ct)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow
|
||||
.AddHours(-_opts.NoObservationThresholdHours);
|
||||
|
||||
// Limitation: this check detects absence of recorded observations, not absence
|
||||
// of actual clinical monitoring. A nurse who took manual vitals but did not
|
||||
// enter them into the system would still trigger a reconciliation alert.
|
||||
// This is a known gap; the check errs on the side of false positives.
|
||||
var candidates = await _db.Encounters
|
||||
.Where(e => e.Status == EncounterStatus.Active
|
||||
&& e.EncounterType == EncounterType.Inpatient)
|
||||
.Select(e => new
|
||||
{
|
||||
EncounterId = e.Id,
|
||||
e.PatientId,
|
||||
LastObservationAt = e.Observations
|
||||
.OrderByDescending(o => o.RecordedAt)
|
||||
.Select(o => (DateTimeOffset?)o.RecordedAt)
|
||||
.FirstOrDefault(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var stale = candidates
|
||||
.Where(e => e.LastObservationAt == null || e.LastObservationAt < cutoff)
|
||||
.ToList();
|
||||
|
||||
if (!stale.Any())
|
||||
{
|
||||
_logger.LogDebug("[RECONCILIATION] Check3 — all active inpatients have recent observations");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var inserted = 0;
|
||||
foreach (var enc in stale)
|
||||
{
|
||||
var alreadyOpen = await _db.ReconciliationAlerts
|
||||
.AnyAsync(r => r.CheckType == CheckType
|
||||
&& r.EncounterId == enc.EncounterId
|
||||
&& r.ResolvedAt == null, ct);
|
||||
if (alreadyOpen) continue;
|
||||
|
||||
var lastObs = enc.LastObservationAt.HasValue
|
||||
? $"last observation {enc.LastObservationAt:u}"
|
||||
: "no observations ever recorded";
|
||||
var details = $"Active inpatient encounter {enc.EncounterId} has no recent observations "
|
||||
+ $"({lastObs}). Monitor may be disconnected.";
|
||||
|
||||
var rec = new ReconciliationAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CheckType = CheckType, // ReconciliationCheckType enum
|
||||
EncounterId = enc.EncounterId,
|
||||
PatientId = enc.PatientId,
|
||||
Details = details,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_db.ReconciliationAlerts.Add(rec);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _publisher.PublishAsync(rec, ct);
|
||||
|
||||
_logger.LogWarning(
|
||||
"[RECONCILIATION] {CheckType} — encounter {EncounterId}: {LastObs}",
|
||||
CheckType, enc.EncounterId, lastObs);
|
||||
|
||||
inserted++;
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class PendingOrdersCheck
|
||||
{
|
||||
public static readonly ReconciliationCheckType CheckType = ReconciliationCheckType.PendingOrderNoResult;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ReconciliationPublisher _publisher;
|
||||
private readonly ReconciliationJobOptions _opts;
|
||||
private readonly ILogger<PendingOrdersCheck> _logger;
|
||||
|
||||
public PendingOrdersCheck(
|
||||
AppDbContext db,
|
||||
ReconciliationPublisher publisher,
|
||||
IOptions<ReconciliationJobOptions> opts,
|
||||
ILogger<PendingOrdersCheck> logger)
|
||||
{
|
||||
_db = db;
|
||||
_publisher = publisher;
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(CancellationToken ct)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow
|
||||
.AddHours(-_opts.PendingOrderThresholdHours);
|
||||
|
||||
var staleOrders = await _db.Orders
|
||||
.Where(o => (o.Status == OrderStatus.Pending || o.Status == OrderStatus.InProgress)
|
||||
&& o.OrderedAt < cutoff
|
||||
&& o.ResultedAt == null)
|
||||
.Select(o => new
|
||||
{
|
||||
o.Id,
|
||||
o.EncounterId,
|
||||
o.OrderType,
|
||||
o.Description,
|
||||
o.OrderedAt,
|
||||
PatientId = o.Encounter!.PatientId,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (!staleOrders.Any())
|
||||
{
|
||||
_logger.LogDebug("[RECONCILIATION] Check2 — no stale pending orders");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var byEncounter = staleOrders
|
||||
.GroupBy(o => new { o.EncounterId, o.PatientId })
|
||||
.ToList();
|
||||
|
||||
var inserted = 0;
|
||||
foreach (var group in byEncounter)
|
||||
{
|
||||
var alreadyOpen = await _db.ReconciliationAlerts
|
||||
.AnyAsync(r => r.CheckType == CheckType
|
||||
&& r.EncounterId == group.Key.EncounterId
|
||||
&& r.ResolvedAt == null, ct);
|
||||
if (alreadyOpen) continue;
|
||||
|
||||
var lines = group.Select(o =>
|
||||
$" [{o.OrderType.ToDbString()}] {o.Description} (ordered {o.OrderedAt:u}, "
|
||||
+ $"pending {(DateTimeOffset.UtcNow - o.OrderedAt).TotalHours:F1}h) ID={o.Id}");
|
||||
var details = $"{group.Count()} order(s) pending without result for encounter "
|
||||
+ $"{group.Key.EncounterId}:\n{string.Join("\n", lines)}";
|
||||
|
||||
var rec = new ReconciliationAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CheckType = CheckType, // ReconciliationCheckType enum
|
||||
EncounterId = group.Key.EncounterId,
|
||||
PatientId = group.Key.PatientId,
|
||||
Details = details,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_db.ReconciliationAlerts.Add(rec);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _publisher.PublishAsync(rec, ct);
|
||||
|
||||
_logger.LogWarning(
|
||||
"[RECONCILIATION] {CheckType} — encounter {EncounterId}: {Count} stale order(s)",
|
||||
CheckType, group.Key.EncounterId, group.Count());
|
||||
|
||||
inserted++;
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
|
||||
public interface IReconciliationPublisher
|
||||
{
|
||||
Task PublishAsync(ReconciliationAlert alert, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ReconciliationPublisher : IReconciliationPublisher
|
||||
{
|
||||
private readonly RabbitMqOptions _opts;
|
||||
private readonly ILogger<ReconciliationPublisher> _logger;
|
||||
|
||||
public ReconciliationPublisher(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
ILogger<ReconciliationPublisher> logger)
|
||||
{
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task PublishAsync(ReconciliationAlert alert, CancellationToken ct)
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = _opts.Host,
|
||||
Port = _opts.Port,
|
||||
UserName = _opts.Username,
|
||||
Password = _opts.Password,
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("reconciliation-publisher");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
var props = channel.CreateBasicProperties();
|
||||
props.Persistent = true;
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
reconciliationAlertId = alert.Id,
|
||||
checkType = alert.CheckType.ToDbString(),
|
||||
encounterId = alert.EncounterId,
|
||||
patientId = alert.PatientId,
|
||||
details = alert.Details,
|
||||
createdAt = alert.CreatedAt,
|
||||
});
|
||||
|
||||
channel.BasicPublish(
|
||||
exchange: RabbitMqTopologyProvisioner.Exchange,
|
||||
routingKey: RabbitMqTopologyProvisioner.ReconciliationKey,
|
||||
basicProperties: props,
|
||||
body: Encoding.UTF8.GetBytes(payload));
|
||||
|
||||
_logger.LogInformation(
|
||||
"[RECONCILIATION-PUBLISHED] CheckType={CheckType} EncounterId={EncounterId}",
|
||||
alert.CheckType.ToDbString(), alert.EncounterId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class ReconciliationScheduler : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ReconciliationJobOptions _opts;
|
||||
private readonly ILogger<ReconciliationScheduler> _logger;
|
||||
|
||||
public ReconciliationScheduler(
|
||||
IServiceScopeFactory scopes,
|
||||
IOptions<ReconciliationJobOptions> opts,
|
||||
ILogger<ReconciliationScheduler> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait for RabbitMQ topology and migrations before first cycle.
|
||||
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"ReconciliationScheduler started — interval {IntervalMinutes} min", _opts.IntervalMinutes);
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_opts.IntervalMinutes));
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
_logger.LogInformation("[RECONCILIATION] Starting reconciliation cycle");
|
||||
|
||||
await RunCheckAsync<UnacknowledgedAlertsCheck>(
|
||||
check => check.RunAsync(stoppingToken), stoppingToken);
|
||||
|
||||
await RunCheckAsync<PendingOrdersCheck>(
|
||||
check => check.RunAsync(stoppingToken), stoppingToken);
|
||||
|
||||
await RunCheckAsync<DisconnectedMonitorsCheck>(
|
||||
check => check.RunAsync(stoppingToken), stoppingToken);
|
||||
|
||||
_logger.LogInformation("[RECONCILIATION] Cycle complete");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCheckAsync<T>(Func<T, Task<int>> run, CancellationToken ct)
|
||||
where T : notnull
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var check = scope.ServiceProvider.GetRequiredService<T>();
|
||||
var count = await run(check);
|
||||
_logger.LogInformation(
|
||||
"[RECONCILIATION] {Check} — new alerts: {Count}", typeof(T).Name, count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[RECONCILIATION] {Check} failed", typeof(T).Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class UnacknowledgedAlertsCheck
|
||||
{
|
||||
public static readonly ReconciliationCheckType CheckType = ReconciliationCheckType.UnacknowledgedCriticalAlert;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ReconciliationPublisher _publisher;
|
||||
private readonly ReconciliationJobOptions _opts;
|
||||
private readonly ILogger<UnacknowledgedAlertsCheck> _logger;
|
||||
|
||||
public UnacknowledgedAlertsCheck(
|
||||
AppDbContext db,
|
||||
ReconciliationPublisher publisher,
|
||||
IOptions<ReconciliationJobOptions> opts,
|
||||
ILogger<UnacknowledgedAlertsCheck> logger)
|
||||
{
|
||||
_db = db;
|
||||
_publisher = publisher;
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(CancellationToken ct)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow
|
||||
.AddMinutes(-_opts.UnacknowledgedAlertThresholdMinutes);
|
||||
|
||||
// Load all stale critical alerts, grouped by encounter.
|
||||
var staleAlerts = await _db.ClinicalAlerts
|
||||
.Where(a => a.Severity == AlertSeverity.Critical
|
||||
&& a.Status == AlertStatus.Open
|
||||
&& a.TriggeredAt < cutoff)
|
||||
.Select(a => new { a.Id, a.EncounterId, a.PatientId, a.TriggeredAt, a.Details })
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (!staleAlerts.Any())
|
||||
{
|
||||
_logger.LogDebug("[RECONCILIATION] Check1 — no stale critical alerts");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Group by encounter so one reconciliation_alert covers all alerts in that encounter.
|
||||
var byEncounter = staleAlerts
|
||||
.GroupBy(a => new { a.EncounterId, a.PatientId })
|
||||
.ToList();
|
||||
|
||||
var inserted = 0;
|
||||
foreach (var group in byEncounter)
|
||||
{
|
||||
// Deduplication: skip if an open reconciliation_alert already exists
|
||||
// for this check type and encounter (partial index makes this fast).
|
||||
var alreadyOpen = await _db.ReconciliationAlerts
|
||||
.AnyAsync(r => r.CheckType == CheckType
|
||||
&& r.EncounterId == group.Key.EncounterId
|
||||
&& r.ResolvedAt == null, ct);
|
||||
if (alreadyOpen) continue;
|
||||
|
||||
var alertIds = group.Select(a => a.Id).ToList();
|
||||
var oldest = group.Min(a => a.TriggeredAt);
|
||||
var details = $"{alertIds.Count} unacknowledged CRITICAL alert(s) for encounter "
|
||||
+ $"{group.Key.EncounterId}. Oldest triggered at {oldest:u}. "
|
||||
+ $"Alert IDs: {string.Join(", ", alertIds)}.";
|
||||
|
||||
var rec = new ReconciliationAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CheckType = CheckType, // ReconciliationCheckType enum
|
||||
EncounterId = group.Key.EncounterId,
|
||||
PatientId = group.Key.PatientId,
|
||||
Details = details,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_db.ReconciliationAlerts.Add(rec);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _publisher.PublishAsync(rec, ct);
|
||||
|
||||
_logger.LogWarning(
|
||||
"[RECONCILIATION] {CheckType} — encounter {EncounterId}: {Details}",
|
||||
CheckType, group.Key.EncounterId, details);
|
||||
|
||||
inserted++;
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
public sealed class ReconciliationJobOptions
|
||||
{
|
||||
public const string Section = "ReconciliationJobs";
|
||||
|
||||
// How often the scheduler fires all three checks.
|
||||
// Production: 30 minutes. Set lower in development to observe behavior quickly.
|
||||
public int IntervalMinutes { get; init; } = 30;
|
||||
|
||||
// Check 1: critical alerts open longer than this are a patient safety failure.
|
||||
public int UnacknowledgedAlertThresholdMinutes { get; init; } = 30;
|
||||
|
||||
// Check 2: orders pending longer than this may indicate a lost sample or LIS failure.
|
||||
public int PendingOrderThresholdHours { get; init; } = 4;
|
||||
|
||||
// Check 3: active inpatients without an observation in this window may have
|
||||
// a disconnected monitor or an undocumented physical move.
|
||||
public int NoObservationThresholdHours { get; init; } = 2;
|
||||
}
|
||||
@@ -98,7 +98,20 @@ public class AlertsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
var result = await _alerts.ListGlobalAsync(parsedStatus, parsedSeverity, department, page, pageSize);
|
||||
Department? parsedDepartment = null;
|
||||
if (!string.IsNullOrEmpty(department))
|
||||
{
|
||||
try
|
||||
{
|
||||
parsedDepartment = DepartmentExtensions.FromDbString(department);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid department filter.", "INVALID_DEPARTMENT"));
|
||||
}
|
||||
}
|
||||
|
||||
var result = await _alerts.ListGlobalAsync(parsedStatus, parsedSeverity, parsedDepartment, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
|
||||
@@ -11,6 +11,8 @@ public class EncounterConfiguration : IEntityTypeConfiguration<Encounter>
|
||||
"encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
t.HasCheckConstraint("chk_encounters_status",
|
||||
"status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
t.HasCheckConstraint("chk_encounters_department",
|
||||
"department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
});
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
@@ -30,7 +32,13 @@ public class EncounterConfiguration : IEntityTypeConfiguration<Encounter>
|
||||
v => EncounterStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'SCHEDULED'")
|
||||
.HasSentinel((EncounterStatus)(-1));
|
||||
builder.Property(e => e.Department).HasColumnName("department").HasMaxLength(100).IsRequired();
|
||||
builder.Property(e => e.Department)
|
||||
.HasColumnName("department")
|
||||
.HasMaxLength(100)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => DepartmentExtensions.FromDbString(v))
|
||||
.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");
|
||||
|
||||
@@ -9,6 +9,8 @@ public class OrderConfiguration : IEntityTypeConfiguration<Order>
|
||||
{
|
||||
t.HasCheckConstraint("chk_orders_order_type",
|
||||
"order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
t.HasCheckConstraint("chk_orders_status",
|
||||
"status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
|
||||
});
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
@@ -22,7 +24,14 @@ public class OrderConfiguration : IEntityTypeConfiguration<Order>
|
||||
.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.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => OrderStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'PENDING'")
|
||||
.HasSentinel((OrderStatus)(-1));
|
||||
builder.Property(o => o.OrderedAt).HasColumnName("ordered_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(o => o.ResultedAt).HasColumnName("resulted_at");
|
||||
|
||||
@@ -33,6 +42,6 @@ public class OrderConfiguration : IEntityTypeConfiguration<Order>
|
||||
|
||||
builder.HasIndex(o => new { o.EncounterId, o.OrderedAt });
|
||||
builder.HasIndex(o => new { o.Status, o.OrderedAt })
|
||||
.HasFilter("status IN ('pending', 'in_progress')");
|
||||
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ public class ReconciliationAlertConfiguration : IEntityTypeConfiguration<Reconci
|
||||
builder.Property(r => r.ResolvedAt).HasColumnName("resolved_at");
|
||||
builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(r => r.Encounter)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.EncounterId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne(r => r.Patient)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.PatientId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasIndex(r => new { r.CheckType, r.EncounterId })
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ public static class DataSeeder
|
||||
var encounter1 = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient1.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = "ICU",
|
||||
Status = EncounterStatus.Active, Department = 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",
|
||||
Status = EncounterStatus.Active, Department = Department.GeneralMedicine,
|
||||
AttendingPhysician = "Dr. Patel", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-12),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddHours(-12)
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ public class Encounter
|
||||
public Guid PatientId { get; set; }
|
||||
public EncounterType EncounterType { get; set; }
|
||||
public EncounterStatus Status { get; set; }
|
||||
public string Department { get; set; } = null!;
|
||||
public Department Department { get; set; }
|
||||
public string AttendingPhysician { get; set; } = null!;
|
||||
public DateTimeOffset AdmittedAt { get; set; }
|
||||
public DateTimeOffset? DischargedAt { get; set; }
|
||||
|
||||
@@ -5,7 +5,7 @@ public class Order
|
||||
public OrderType OrderType { get; set; }
|
||||
public string Description { get; set; } = null!;
|
||||
public string OrderedBy { get; set; } = null!;
|
||||
public string Status { get; set; } = "pending";
|
||||
public OrderStatus Status { get; set; } = OrderStatus.Pending;
|
||||
public DateTimeOffset OrderedAt { get; set; }
|
||||
public DateTimeOffset? ResultedAt { get; set; }
|
||||
|
||||
|
||||
@@ -7,4 +7,7 @@ public class ReconciliationAlert
|
||||
public string Details { get; set; } = null!;
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public Encounter? Encounter { get; set; }
|
||||
public Patient? Patient { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
public enum Department
|
||||
{
|
||||
Icu,
|
||||
GeneralMedicine,
|
||||
Emergency,
|
||||
Cardiology,
|
||||
Surgery,
|
||||
Pediatrics
|
||||
}
|
||||
|
||||
public static class DepartmentExtensions
|
||||
{
|
||||
public static string ToDbString(this Department d) => d switch
|
||||
{
|
||||
Department.Icu => "ICU",
|
||||
Department.GeneralMedicine => "GENERAL_MEDICINE",
|
||||
Department.Emergency => "EMERGENCY",
|
||||
Department.Cardiology => "CARDIOLOGY",
|
||||
Department.Surgery => "SURGERY",
|
||||
Department.Pediatrics => "PEDIATRICS",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(d))
|
||||
};
|
||||
|
||||
public static Department FromDbString(string v) => v switch
|
||||
{
|
||||
"ICU" => Department.Icu,
|
||||
"GENERAL_MEDICINE" => Department.GeneralMedicine,
|
||||
"EMERGENCY" => Department.Emergency,
|
||||
"CARDIOLOGY" => Department.Cardiology,
|
||||
"SURGERY" => Department.Surgery,
|
||||
"PEDIATRICS" => Department.Pediatrics,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown department: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum OrderStatus { Pending, InProgress, Resulted, Cancelled }
|
||||
|
||||
public static class OrderStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this OrderStatus s) => s switch
|
||||
{
|
||||
OrderStatus.Pending => "PENDING",
|
||||
OrderStatus.InProgress => "IN_PROGRESS",
|
||||
OrderStatus.Resulted => "RESULTED",
|
||||
OrderStatus.Cancelled => "CANCELLED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static OrderStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"PENDING" => OrderStatus.Pending,
|
||||
"IN_PROGRESS" => OrderStatus.InProgress,
|
||||
"RESULTED" => OrderStatus.Resulted,
|
||||
"CANCELLED" => OrderStatus.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown order status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class DepartmentJsonConverter : JsonConverter<Department>
|
||||
{
|
||||
public override Department Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> DepartmentExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Department value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
// <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("20260617053159_AddReconciliationAlerts")]
|
||||
partial class AddReconciliationAlerts
|
||||
{
|
||||
/// <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>("PartitionKey")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("partition_key");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("processed_at IS NULL");
|
||||
|
||||
b.ToTable("outbox_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<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("EncounterId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("CheckType", "EncounterId")
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
|
||||
b.ToTable("reconciliation_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("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("ReconciliationAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Encounter");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("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,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReconciliationAlerts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_reconciliation_alerts_encounter_id",
|
||||
table: "reconciliation_alerts",
|
||||
column: "encounter_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_reconciliation_alerts_patient_id",
|
||||
table: "reconciliation_alerts",
|
||||
column: "patient_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_reconciliation_alerts_encounters_encounter_id",
|
||||
table: "reconciliation_alerts",
|
||||
column: "encounter_id",
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_reconciliation_alerts_patients_patient_id",
|
||||
table: "reconciliation_alerts",
|
||||
column: "patient_id",
|
||||
principalTable: "patients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_reconciliation_alerts_encounters_encounter_id",
|
||||
table: "reconciliation_alerts");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_reconciliation_alerts_patients_patient_id",
|
||||
table: "reconciliation_alerts");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_reconciliation_alerts_encounter_id",
|
||||
table: "reconciliation_alerts");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_reconciliation_alerts_patient_id",
|
||||
table: "reconciliation_alerts");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+593
@@ -0,0 +1,593 @@
|
||||
// <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("20260617054821_AddOrderStatusAndDepartmentEnums")]
|
||||
partial class AddOrderStatusAndDepartmentEnums
|
||||
{
|
||||
/// <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_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("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)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "OrderedAt");
|
||||
|
||||
b.HasIndex("Status", "OrderedAt")
|
||||
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
|
||||
|
||||
b.ToTable("orders", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
|
||||
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OutboxEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("PartitionKey")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("partition_key");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("processed_at IS NULL");
|
||||
|
||||
b.ToTable("outbox_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<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("EncounterId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("CheckType", "EncounterId")
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
|
||||
b.ToTable("reconciliation_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("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("ReconciliationAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Encounter");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("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,102 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOrderStatusAndDepartmentEnums : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_orders_status_ordered_at",
|
||||
table: "orders");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE orders SET status = 'PENDING' WHERE status = 'pending';
|
||||
UPDATE orders SET status = 'IN_PROGRESS' WHERE status = 'in_progress';
|
||||
UPDATE orders SET status = 'RESULTED' WHERE status = 'resulted';
|
||||
UPDATE orders SET status = 'CANCELLED' WHERE status = 'cancelled';
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE encounters SET department = 'GENERAL_MEDICINE' WHERE department = 'General Medicine';
|
||||
""");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "status",
|
||||
table: "orders",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValueSql: "'PENDING'",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(20)",
|
||||
oldMaxLength: 20,
|
||||
oldDefaultValue: "pending");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_orders_status_ordered_at",
|
||||
table: "orders",
|
||||
columns: new[] { "status", "ordered_at" },
|
||||
filter: "status IN ('PENDING', 'IN_PROGRESS')");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "chk_orders_status",
|
||||
table: "orders",
|
||||
sql: "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "chk_encounters_department",
|
||||
table: "encounters",
|
||||
sql: "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_orders_status_ordered_at",
|
||||
table: "orders");
|
||||
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "chk_orders_status",
|
||||
table: "orders");
|
||||
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "chk_encounters_department",
|
||||
table: "encounters");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE orders SET status = 'pending' WHERE status = 'PENDING';
|
||||
UPDATE orders SET status = 'in_progress' WHERE status = 'IN_PROGRESS';
|
||||
UPDATE orders SET status = 'resulted' WHERE status = 'RESULTED';
|
||||
UPDATE orders SET status = 'cancelled' WHERE status = 'CANCELLED';
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE encounters SET department = 'General Medicine' WHERE department = 'GENERAL_MEDICINE';
|
||||
""");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "status",
|
||||
table: "orders",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "pending",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(20)",
|
||||
oldMaxLength: 20,
|
||||
oldDefaultValueSql: "'PENDING'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_orders_status_ordered_at",
|
||||
table: "orders",
|
||||
columns: new[] { "status", "ordered_at" },
|
||||
filter: "status IN ('pending', 'in_progress')");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,8 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
|
||||
b.ToTable("encounters", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
@@ -338,19 +340,21 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("pending")
|
||||
.HasColumnName("status");
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId", "OrderedAt");
|
||||
|
||||
b.HasIndex("Status", "OrderedAt")
|
||||
.HasFilter("status IN ('pending', 'in_progress')");
|
||||
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
|
||||
|
||||
b.ToTable("orders", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
|
||||
|
||||
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -493,6 +497,10 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EncounterId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("CheckType", "EncounterId")
|
||||
.HasFilter("resolved_at IS NULL");
|
||||
|
||||
@@ -546,6 +554,23 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Encounter");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
public record OpenEncounterRequest(
|
||||
EncounterType EncounterType,
|
||||
string Department,
|
||||
Department Department,
|
||||
string AttendingPhysician);
|
||||
@@ -9,6 +9,7 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
|
||||
public const string PagingKey = "alerts.paging";
|
||||
public const string EscalKey = "alerts.escalation";
|
||||
public const string DischargeKey = "notifications.discharge";
|
||||
public const string ReconciliationKey = "notifications.reconciliation";
|
||||
|
||||
private readonly RabbitMqOptions _opts;
|
||||
private readonly IHostEnvironment _env;
|
||||
@@ -30,7 +31,7 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
|
||||
using var connection = factory.CreateConnection();
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
if (_env.IsEnvironment("Testing"))
|
||||
if (_env.IsDevelopment() || _env.IsEnvironment("Testing"))
|
||||
{
|
||||
// Use a throwaway channel — a failed purge on a missing queue closes the
|
||||
// channel, which would break the declare calls below.
|
||||
@@ -38,8 +39,8 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
|
||||
|
||||
try
|
||||
{
|
||||
// Dev runs provision the DLQ with a 5-minute TTL; delete it so the
|
||||
// test timeout (5 s) is applied when the queue is re-declared below.
|
||||
// x-message-ttl is immutable once the queue exists. Integration tests
|
||||
// use 5 s while local dev uses 5 min — delete so config drives the TTL.
|
||||
cleanup.QueueDelete("alerts.paging.dlq", ifUnused: false, ifEmpty: false);
|
||||
}
|
||||
catch (OperationInterruptedException ex)
|
||||
@@ -47,12 +48,15 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
|
||||
_logger.LogDebug(ex, "DLQ delete skipped — queue may not exist yet");
|
||||
}
|
||||
|
||||
foreach (var queue in new[] { "alerts.paging.queue", "alerts.escalation.queue" })
|
||||
if (_env.IsEnvironment("Testing"))
|
||||
{
|
||||
try { cleanup.QueuePurge(queue); }
|
||||
catch (OperationInterruptedException ex)
|
||||
foreach (var queue in new[] { "alerts.paging.queue", "alerts.escalation.queue" })
|
||||
{
|
||||
_logger.LogDebug(ex, "Queue purge skipped for {Queue}", queue);
|
||||
try { cleanup.QueuePurge(queue); }
|
||||
catch (OperationInterruptedException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Queue purge skipped for {Queue}", queue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +123,14 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
|
||||
arguments: null);
|
||||
channel.QueueBind("notifications.appointment.queue", Exchange, "notifications.appointment");
|
||||
|
||||
channel.QueueDeclare(
|
||||
queue: "notifications.reconciliation.queue",
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null);
|
||||
channel.QueueBind("notifications.reconciliation.queue", Exchange, ReconciliationKey);
|
||||
|
||||
_logger.LogInformation(
|
||||
"RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}",
|
||||
Exchange, _opts.PagingAckTimeoutMs);
|
||||
|
||||
@@ -49,6 +49,9 @@ try
|
||||
builder.Services.AddSingleton<RabbitMqTopologyProvisioner>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<RabbitMqTopologyProvisioner>());
|
||||
|
||||
builder.Services.Configure<ReconciliationJobOptions>(
|
||||
builder.Configuration.GetSection(ReconciliationJobOptions.Section));
|
||||
|
||||
builder.Services.AddScoped<IPatientService, PatientService>();
|
||||
builder.Services.AddScoped<IEncounterService, EncounterService>();
|
||||
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
|
||||
@@ -57,6 +60,11 @@ try
|
||||
builder.Services.AddScoped<IAlertService, AlertService>();
|
||||
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||
builder.Services.AddScoped<SirsDetector>();
|
||||
builder.Services.AddScoped<UnacknowledgedAlertsCheck>();
|
||||
builder.Services.AddScoped<PendingOrdersCheck>();
|
||||
builder.Services.AddScoped<DisconnectedMonitorsCheck>();
|
||||
builder.Services.AddScoped<ReconciliationPublisher>();
|
||||
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||
@@ -68,6 +76,7 @@ try
|
||||
builder.Services.AddHostedService<PagingWorkerService>();
|
||||
builder.Services.AddHostedService<EscalationWorkerService>();
|
||||
builder.Services.AddHostedService<DischargeSummaryWorkerService>();
|
||||
builder.Services.AddHostedService<ReconciliationScheduler>();
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(opts =>
|
||||
@@ -75,6 +84,7 @@ try
|
||||
opts.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter());
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
@@ -28,7 +28,7 @@ public class AlertService : IAlertService
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ClinicalAlert>> ListGlobalAsync(
|
||||
AlertStatus? status, AlertSeverity? severity, string? department, int page, int pageSize)
|
||||
AlertStatus? status, AlertSeverity? severity, Department? department, int page, int pageSize)
|
||||
{
|
||||
var query = _db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
@@ -41,8 +41,8 @@ public class AlertService : IAlertService
|
||||
if (severity.HasValue)
|
||||
query = query.Where(a => a.Severity == severity.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(department))
|
||||
query = query.Where(a => a.Encounter.Department == department);
|
||||
if (department.HasValue)
|
||||
query = query.Where(a => a.Encounter.Department == department.Value);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var alerts = await query
|
||||
|
||||
@@ -64,7 +64,7 @@ public class EncounterService : IEncounterService
|
||||
patientName = $"{encounter.Patient.FirstName} {encounter.Patient.LastName}",
|
||||
previousStatus = previousStatus.ToDbString(),
|
||||
newStatus = targetStatus.ToDbString(),
|
||||
department = encounter.Department,
|
||||
department = encounter.Department.ToDbString(),
|
||||
attendingPhysician = encounter.AttendingPhysician,
|
||||
admittedAt = encounter.AdmittedAt,
|
||||
changedAt = DateTimeOffset.UtcNow
|
||||
|
||||
@@ -4,7 +4,7 @@ public interface IAlertService
|
||||
Guid encounterId, AlertStatus? status, int page, int pageSize);
|
||||
|
||||
Task<PagedResult<ClinicalAlert>> ListGlobalAsync(
|
||||
AlertStatus? status, AlertSeverity? severity, string? department, int page, int pageSize);
|
||||
AlertStatus? status, AlertSeverity? severity, Department? department, int page, int pageSize);
|
||||
|
||||
Task<ClinicalAlert> GetByIdAsync(Guid id);
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ public class ObservationService : IObservationService
|
||||
alertId = alert.Id,
|
||||
encounterId,
|
||||
patientId = encounter.PatientId,
|
||||
department = encounter.Department,
|
||||
department = encounter.Department.ToDbString(),
|
||||
alertType = alert.AlertType.ToDbString(),
|
||||
severity = alert.Severity.ToDbString(),
|
||||
details = alert.Details,
|
||||
|
||||
@@ -101,7 +101,7 @@ public class PatientService : IPatientService
|
||||
patientName = $"{patient.FirstName} {patient.LastName}",
|
||||
previousStatus = (string?)null,
|
||||
newStatus = encounter.Status.ToDbString(),
|
||||
department = encounter.Department,
|
||||
department = encounter.Department.ToDbString(),
|
||||
attendingPhysician = encounter.AttendingPhysician,
|
||||
admittedAt = encounter.AdmittedAt,
|
||||
changedAt = DateTimeOffset.UtcNow
|
||||
|
||||
@@ -68,5 +68,11 @@
|
||||
"SecretKey": "minioadmin",
|
||||
"BucketName": "vigilcare",
|
||||
"UseSSL": false
|
||||
},
|
||||
"ReconciliationJobs": {
|
||||
"IntervalMinutes": 30,
|
||||
"UnacknowledgedAlertThresholdMinutes": 30,
|
||||
"PendingOrderThresholdHours": 4,
|
||||
"NoObservationThresholdHours": 2
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user