feature: Reconciliation Jobs

This commit is contained in:
voltsrage
2026-06-17 14:36:10 +08:00
parent 56d052aa4f
commit 101040f9d9
40 changed files with 2845 additions and 32 deletions
@@ -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");
}
+2 -2
View File
@@ -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());
}
@@ -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");
}
}
}
@@ -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);
+10
View File
@@ -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
+6
View File
@@ -68,5 +68,11 @@
"SecretKey": "minioadmin",
"BucketName": "vigilcare",
"UseSSL": false
},
"ReconciliationJobs": {
"IntervalMinutes": 30,
"UnacknowledgedAlertThresholdMinutes": 30,
"PendingOrderThresholdHours": 4,
"NoObservationThresholdHours": 2
}
}