feature: Reconciliation Jobs
This commit is contained in:
@@ -31,7 +31,7 @@ public class AlertLifecycleTests : IAsyncLifetime
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = "General Medicine",
|
||||
Status = EncounterStatus.Active, Department = Department.GeneralMedicine,
|
||||
AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
@@ -130,7 +130,7 @@ public class NotificationPipelineTests : IAsyncLifetime
|
||||
|
||||
var resp = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/patients/{patientId}/encounters",
|
||||
new OpenEncounterRequest(EncounterType.Inpatient, "ICU", "Dr. Osei"));
|
||||
new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Osei"));
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
|
||||
@@ -35,7 +35,7 @@ public class ObservationIngestTests : IAsyncLifetime
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = "ICU",
|
||||
Status = EncounterStatus.Active, Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class ReconciliationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public ReconciliationTests(ApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_http = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Check 1 — Unacknowledged critical alerts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Check1_StaleCriticalAlert_CreatesReconciliationAlert()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
var encounterId = await CreateActiveEncounterAsync(patientId);
|
||||
var alertId = await IngestCriticalPotassiumAsync(encounterId);
|
||||
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.FindAsync(alertId);
|
||||
alert!.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
||||
var check = checkScope.ServiceProvider.GetRequiredService<UnacknowledgedAlertsCheck>();
|
||||
var count = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, count);
|
||||
|
||||
using var verifyScope = _fixture.Services.CreateScope();
|
||||
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await verifyDb.ReconciliationAlerts
|
||||
.FirstOrDefaultAsync(r => r.CheckType == UnacknowledgedAlertsCheck.CheckType
|
||||
&& r.EncounterId == encounterId
|
||||
&& r.ResolvedAt == null);
|
||||
|
||||
Assert.NotNull(rec);
|
||||
Assert.Contains(alertId.ToString(), rec!.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check1_AcknowledgedAlert_DoesNotCreateReconciliationAlert()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
var encounterId = await CreateActiveEncounterAsync(patientId);
|
||||
var alertId = await IngestCriticalPotassiumAsync(encounterId);
|
||||
|
||||
var ackResp = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/alerts/{alertId}/acknowledge",
|
||||
new AcknowledgeAlertRequest("Dr. Mensah", "Reviewed."));
|
||||
ackResp.EnsureSuccessStatusCode();
|
||||
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.FindAsync(alertId);
|
||||
alert!.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
||||
var check = checkScope.ServiceProvider.GetRequiredService<UnacknowledgedAlertsCheck>();
|
||||
var count = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check1_SecondRun_DoesNotInsertDuplicate()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
var encounterId = await CreateActiveEncounterAsync(patientId);
|
||||
await IngestCriticalPotassiumAsync(encounterId);
|
||||
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.FirstAsync(a => a.EncounterId == encounterId);
|
||||
alert.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var s = _fixture.Services.CreateAsyncScope())
|
||||
{
|
||||
var c = s.ServiceProvider.GetRequiredService<UnacknowledgedAlertsCheck>();
|
||||
await c.RunAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
int secondCount;
|
||||
await using (var s = _fixture.Services.CreateAsyncScope())
|
||||
{
|
||||
var c = s.ServiceProvider.GetRequiredService<UnacknowledgedAlertsCheck>();
|
||||
secondCount = await c.RunAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Equal(0, secondCount);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Check 2 — Pending orders without results
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Check2_StalePendingOrder_CreatesReconciliationAlert()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
var encounterId = await CreateActiveEncounterAsync(patientId);
|
||||
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Orders.Add(new Order
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
OrderType = OrderType.Lab,
|
||||
Description = "Comprehensive metabolic panel",
|
||||
OrderedBy = "Dr. Osei",
|
||||
Status = OrderStatus.Pending,
|
||||
OrderedAt = DateTimeOffset.UtcNow.AddHours(-5),
|
||||
ResultedAt = null,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
||||
var check = checkScope.ServiceProvider.GetRequiredService<PendingOrdersCheck>();
|
||||
var count = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(count >= 1);
|
||||
|
||||
using var verifyScope = _fixture.Services.CreateScope();
|
||||
var db2 = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await db2.ReconciliationAlerts
|
||||
.FirstOrDefaultAsync(r => r.CheckType == PendingOrdersCheck.CheckType
|
||||
&& r.EncounterId == encounterId
|
||||
&& r.ResolvedAt == null);
|
||||
Assert.NotNull(rec);
|
||||
Assert.Contains("Comprehensive metabolic panel", rec!.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check2_ResultedOrder_DoesNotCreateReconciliationAlert()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
var encounterId = await CreateActiveEncounterAsync(patientId);
|
||||
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Orders.Add(new Order
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
OrderType = OrderType.Lab,
|
||||
Description = "CBC",
|
||||
OrderedBy = "Dr. Osei",
|
||||
Status = OrderStatus.Resulted,
|
||||
OrderedAt = DateTimeOffset.UtcNow.AddHours(-5),
|
||||
ResultedAt = DateTimeOffset.UtcNow.AddHours(-4),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
||||
var check = checkScope.ServiceProvider.GetRequiredService<PendingOrdersCheck>();
|
||||
var count = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Check 3 — Active inpatient without recent observations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Check3_ActiveInpatientNoObservations_CreatesReconciliationAlert()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
var encounterId = await CreateActiveEncounterAsync(patientId);
|
||||
|
||||
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
||||
var check = checkScope.ServiceProvider.GetRequiredService<DisconnectedMonitorsCheck>();
|
||||
var count = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(count >= 1);
|
||||
|
||||
using var verifyScope = _fixture.Services.CreateScope();
|
||||
var db = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await db.ReconciliationAlerts
|
||||
.FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType
|
||||
&& r.EncounterId == encounterId
|
||||
&& r.ResolvedAt == null);
|
||||
Assert.NotNull(rec);
|
||||
Assert.Contains("no observations ever recorded", rec!.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check3_ActiveInpatientStaleObservation_CreatesReconciliationAlert()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
var encounterId = await CreateActiveEncounterAsync(patientId);
|
||||
|
||||
var ingestResp = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/observations",
|
||||
new BatchIngestRequest(new List<IngestObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 72, "bpm", ObservationSource.Device,
|
||||
DateTimeOffset.UtcNow.AddHours(-3), null),
|
||||
}));
|
||||
ingestResp.EnsureSuccessStatusCode();
|
||||
|
||||
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
||||
var check = checkScope.ServiceProvider.GetRequiredService<DisconnectedMonitorsCheck>();
|
||||
var count = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(count >= 1);
|
||||
|
||||
using var verifyScope = _fixture.Services.CreateScope();
|
||||
var db = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await db.ReconciliationAlerts
|
||||
.FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType
|
||||
&& r.EncounterId == encounterId
|
||||
&& r.ResolvedAt == null);
|
||||
Assert.NotNull(rec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check3_RecentObservation_DoesNotCreateReconciliationAlert()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
var encounterId = await CreateActiveEncounterAsync(patientId);
|
||||
|
||||
var ingestResp = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/observations",
|
||||
new BatchIngestRequest(new List<IngestObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 72, "bpm", ObservationSource.Device,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-30), null),
|
||||
}));
|
||||
ingestResp.EnsureSuccessStatusCode();
|
||||
|
||||
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
||||
var check = checkScope.ServiceProvider.GetRequiredService<DisconnectedMonitorsCheck>();
|
||||
var count = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check3_OutpatientEncounter_IsIgnored()
|
||||
{
|
||||
await PrepareAsync();
|
||||
|
||||
var patientId = await CreatePatientAsync();
|
||||
|
||||
var resp = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/patients/{patientId}/encounters",
|
||||
new OpenEncounterRequest(EncounterType.Outpatient, Department.GeneralMedicine, "Dr. Osei"));
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var encounterId = body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
||||
|
||||
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
||||
var check = checkScope.ServiceProvider.GetRequiredService<DisconnectedMonitorsCheck>();
|
||||
await check.RunAsync(CancellationToken.None);
|
||||
|
||||
using var verifyScope = _fixture.Services.CreateScope();
|
||||
var db = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await db.ReconciliationAlerts
|
||||
.FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType
|
||||
&& r.EncounterId == encounterId);
|
||||
Assert.Null(rec);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async Task PrepareAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
|
||||
db.AlertThresholds.AddRange(
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ObservationCode = "POTASSIUM_MEQ_L",
|
||||
DisplayName = "Serum Potassium",
|
||||
Unit = "mEq/L",
|
||||
CriticalLow = 2.5m,
|
||||
WarningLow = 3.5m,
|
||||
WarningHigh = 5.0m,
|
||||
CriticalHigh = 6.5m,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
},
|
||||
new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ObservationCode = "HEART_RATE",
|
||||
DisplayName = "Heart Rate",
|
||||
Unit = "bpm",
|
||||
CriticalLow = 30,
|
||||
WarningLow = 50,
|
||||
WarningHigh = 100,
|
||||
CriticalHigh = 150,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var cache = redis.GetDatabase(1);
|
||||
await cache.StringSetAsync(
|
||||
"threshold:POTASSIUM_MEQ_L",
|
||||
"""{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}""");
|
||||
await cache.StringSetAsync(
|
||||
"threshold:HEART_RATE",
|
||||
"""{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}""");
|
||||
}
|
||||
|
||||
private async Task<Guid> CreatePatientAsync()
|
||||
{
|
||||
var resp = await _http.PostAsJsonAsync(
|
||||
"/api/v1/patients",
|
||||
new RegisterPatientRequest("Reconciliation", "Test", new DateOnly(1970, 6, 1), "Other"));
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
||||
}
|
||||
|
||||
private async Task<Guid> CreateActiveEncounterAsync(Guid patientId)
|
||||
{
|
||||
var resp = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/patients/{patientId}/encounters",
|
||||
new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Osei"));
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
||||
}
|
||||
|
||||
private async Task<Guid> IngestCriticalPotassiumAsync(Guid encounterId)
|
||||
{
|
||||
var ingestResp = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/observations",
|
||||
new BatchIngestRequest(new List<IngestObservationRequest>
|
||||
{
|
||||
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", ObservationSource.Device,
|
||||
DateTimeOffset.UtcNow, null),
|
||||
}));
|
||||
ingestResp.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await ingestResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
return body!.RootElement.GetProperty("data").GetProperty("alertId").GetGuid();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public class SirsDetectorTests : IAsyncLifetime
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = "ICU",
|
||||
Status = EncounterStatus.Active, Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. SIRS", AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+496
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 7 verification — reconciliation jobs (see docs/plans/phase-7-plan.md).
|
||||
#
|
||||
# Prerequisites:
|
||||
# docker compose up -d
|
||||
# dotnet run --project VigilCareClinicalAPI
|
||||
#
|
||||
# For a practical runtime, set ReconciliationJobs.IntervalMinutes to 1 in appsettings.json
|
||||
# and restart the API before running this script (default 30 min is too slow).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-${SCRIPT_DIR}/../docker-compose.yml}"
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:5270}"
|
||||
RABBITMQ_MGMT_URL="${RABBITMQ_MGMT_URL:-http://localhost:15674}"
|
||||
RABBITMQ_USER="${RABBITMQ_USER:-guest}"
|
||||
RABBITMQ_PASS="${RABBITMQ_PASS:-guest}"
|
||||
|
||||
PGHOST="${PGHOST:-localhost}"
|
||||
PGPORT="${PGPORT:-5436}"
|
||||
PGDATABASE="${PGDATABASE:-vigilcare}"
|
||||
PGUSER="${PGUSER:-postgres}"
|
||||
PGPASSWORD="${PGPASSWORD:-password}"
|
||||
|
||||
# Scheduler waits: first tick ~15s after API start, then every IntervalMinutes.
|
||||
RECONCILIATION_WAIT_SECS="${RECONCILIATION_WAIT_SECS:-120}"
|
||||
RECONCILIATION_CYCLE_WAIT_SECS="${RECONCILIATION_CYCLE_WAIT_SECS:-70}"
|
||||
|
||||
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
|
||||
RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
CHECK1="UNACKNOWLEDGED_CRITICAL_ALERT"
|
||||
CHECK2="PENDING_ORDER_NO_RESULT"
|
||||
CHECK3="ACTIVE_INPATIENT_NO_OBSERVATION"
|
||||
|
||||
EXPECTED_QUEUES=(
|
||||
"alerts.paging.queue"
|
||||
"alerts.paging.dlq"
|
||||
"alerts.escalation.queue"
|
||||
"notifications.discharge.queue"
|
||||
"notifications.appointment.queue"
|
||||
"notifications.reconciliation.queue"
|
||||
)
|
||||
|
||||
TMP_FILES=()
|
||||
|
||||
cleanup() {
|
||||
local f
|
||||
for f in "${TMP_FILES[@]}"; do
|
||||
rm -f "${f}" "${f}.status" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Missing dependency: curl"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "Missing dependency: jq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compose() {
|
||||
docker compose -f "${COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
psql_cmd() {
|
||||
local sql="$1"
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
||||
else
|
||||
compose exec -T postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
||||
fi
|
||||
}
|
||||
|
||||
request() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local body="${3:-}"
|
||||
local tmp_body
|
||||
tmp_body="$(mktemp)"
|
||||
TMP_FILES+=("${tmp_body}")
|
||||
local status
|
||||
|
||||
if [[ -n "${body}" ]]; then
|
||||
status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}" \
|
||||
-H "Content-Type: application/json" -d "${body}")"
|
||||
else
|
||||
status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}")"
|
||||
fi
|
||||
|
||||
echo "${status}" > "${tmp_body}.status"
|
||||
echo "${tmp_body}"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
local body_file="$2"
|
||||
local status
|
||||
status="$(cat "${body_file}.status")"
|
||||
if [[ "${status}" != "${expected}" ]]; then
|
||||
echo "Expected HTTP ${expected}, got ${status}" >&2
|
||||
echo "Response body:" >&2
|
||||
cat "${body_file}" >&2
|
||||
echo >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
rabbit_api() {
|
||||
curl -sS -u "${RABBITMQ_USER}:${RABBITMQ_PASS}" "${RABBITMQ_MGMT_URL}/api/${1}"
|
||||
}
|
||||
|
||||
queue_field() {
|
||||
local queue="$1"
|
||||
local field="$2"
|
||||
rabbit_api "queues/%2F/${queue}" | jq -r ".${field} // 0"
|
||||
}
|
||||
|
||||
wait_for_queue_increase() {
|
||||
local queue="$1"
|
||||
local baseline="$2"
|
||||
local timeout_secs="${3:-15}"
|
||||
local elapsed=0
|
||||
while (( elapsed < timeout_secs )); do
|
||||
local depth
|
||||
depth="$(queue_field "${queue}" "messages")"
|
||||
if [[ "${depth}" -gt "${baseline}" ]]; then
|
||||
echo "${depth}"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
echo "${depth:-${baseline}}"
|
||||
return 1
|
||||
}
|
||||
|
||||
reconciliation_count() {
|
||||
local encounter_id="$1"
|
||||
local check_type="$2"
|
||||
psql_cmd "SELECT COUNT(*) FROM reconciliation_alerts
|
||||
WHERE encounter_id = '${encounter_id}'
|
||||
AND check_type = '${check_type}'
|
||||
AND resolved_at IS NULL"
|
||||
}
|
||||
|
||||
wait_for_reconciliation_row() {
|
||||
local encounter_id="$1"
|
||||
local check_type="$2"
|
||||
local timeout_secs="$3"
|
||||
local elapsed=0
|
||||
while (( elapsed < timeout_secs )); do
|
||||
local count
|
||||
count="$(reconciliation_count "${encounter_id}" "${check_type}")"
|
||||
if [[ "${count}" -ge 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
echo "No open reconciliation_alert (${check_type}) for encounter ${encounter_id} within ${timeout_secs}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_scheduler_cycle() {
|
||||
echo " (waiting ${RECONCILIATION_CYCLE_WAIT_SECS}s for reconciliation scheduler cycle...)"
|
||||
sleep "${RECONCILIATION_CYCLE_WAIT_SECS}"
|
||||
}
|
||||
|
||||
create_patient() {
|
||||
local suffix="$1"
|
||||
local payload
|
||||
payload="$(jq -nc \
|
||||
--arg first "Recon${suffix}" \
|
||||
--arg last "Verify${SCRIPT_RUN_ID}" \
|
||||
'{firstName:$first,lastName:$last,dateOfBirth:"1970-06-01",gender:"Other"}')"
|
||||
local resp
|
||||
resp="$(request POST "${BASE_URL}/api/v1/patients" "${payload}")"
|
||||
assert_status "201" "${resp}"
|
||||
jq -r '.data.id' "${resp}"
|
||||
}
|
||||
|
||||
create_encounter() {
|
||||
local patient_id="$1"
|
||||
local encounter_type="$2"
|
||||
local department="${3:-ICU}"
|
||||
local payload
|
||||
payload="$(jq -nc \
|
||||
--arg et "${encounter_type}" \
|
||||
--arg dept "${department}" \
|
||||
--arg physician "Dr. Recon ${SCRIPT_RUN_ID}" \
|
||||
'{encounterType:$et,department:$dept,attendingPhysician:$physician}')"
|
||||
local resp
|
||||
resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${payload}")"
|
||||
assert_status "201" "${resp}"
|
||||
jq -r '.data.id' "${resp}"
|
||||
}
|
||||
|
||||
ingest_critical_potassium() {
|
||||
local encounter_id="$1"
|
||||
local idempotency_key="$2"
|
||||
local payload
|
||||
payload="$(jq -nc \
|
||||
--arg recordedAt "${RECORDED_AT}" \
|
||||
--arg key "${idempotency_key}" \
|
||||
'{observations:[{observationCode:"POTASSIUM_MEQ_L",value:2.1,unit:"mEq/L",source:"LAB",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
|
||||
local resp
|
||||
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")"
|
||||
assert_status "201" "${resp}"
|
||||
if [[ "$(jq -r '.data.alertGenerated' "${resp}")" != "true" ]]; then
|
||||
echo "Expected critical potassium ingest to generate an alert"
|
||||
exit 1
|
||||
fi
|
||||
jq -r '.data.alertId' "${resp}"
|
||||
}
|
||||
|
||||
ingest_heart_rate() {
|
||||
local encounter_id="$1"
|
||||
local recorded_at="$2"
|
||||
local idempotency_key="$3"
|
||||
local payload
|
||||
payload="$(jq -nc \
|
||||
--arg recordedAt "${recorded_at}" \
|
||||
--arg key "${idempotency_key}" \
|
||||
'{observations:[{observationCode:"HEART_RATE",value:72,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
|
||||
local resp
|
||||
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")"
|
||||
assert_status "201" "${resp}"
|
||||
}
|
||||
|
||||
insert_stale_pending_order() {
|
||||
local encounter_id="$1"
|
||||
local description="$2"
|
||||
psql_cmd "INSERT INTO orders (id, encounter_id, order_type, description, ordered_by, status, ordered_at, resulted_at)
|
||||
VALUES (gen_random_uuid(), '${encounter_id}', 'LAB', '${description}', 'Dr. Osei', 'PENDING',
|
||||
NOW() - INTERVAL '5 hours', NULL)"
|
||||
}
|
||||
|
||||
insert_resulted_order() {
|
||||
local encounter_id="$1"
|
||||
psql_cmd "INSERT INTO orders (id, encounter_id, order_type, description, ordered_by, status, ordered_at, resulted_at)
|
||||
VALUES (gen_random_uuid(), '${encounter_id}', 'LAB', 'CBC', 'Dr. Osei', 'RESULTED',
|
||||
NOW() - INTERVAL '5 hours', NOW() - INTERVAL '4 hours')"
|
||||
}
|
||||
|
||||
backdate_alert_triggered_at() {
|
||||
local alert_id="$1"
|
||||
local minutes_ago="$2"
|
||||
psql_cmd "UPDATE clinical_alerts
|
||||
SET triggered_at = NOW() - INTERVAL '${minutes_ago} minutes'
|
||||
WHERE id = '${alert_id}'"
|
||||
}
|
||||
|
||||
TOTAL_STEPS=11
|
||||
|
||||
echo "Running Phase 7 reconciliation verification against ${BASE_URL}"
|
||||
echo "Script run id: ${SCRIPT_RUN_ID}"
|
||||
echo "Hint: set ReconciliationJobs.IntervalMinutes=1 and restart the API for ~${RECONCILIATION_CYCLE_WAIT_SECS}s waits per cycle."
|
||||
|
||||
echo ""
|
||||
echo "[0/${TOTAL_STEPS}] Preflight — API, Postgres, and RabbitMQ reachable"
|
||||
preflight_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)"
|
||||
if [[ "${preflight_status}" != "200" ]]; then
|
||||
echo "API not reachable at ${BASE_URL} (HTTP ${preflight_status})."
|
||||
echo "Start the API with: dotnet run --project VigilCareClinicalAPI"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! psql_cmd "SELECT 1" >/dev/null 2>&1; then
|
||||
echo "Postgres not reachable on ${PGHOST}:${PGPORT}."
|
||||
echo "Start the stack with: docker compose up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rabbit_health="$(rabbit_api "health/checks/alarms" 2>/dev/null | jq -r '.status // empty' || true)"
|
||||
if [[ -z "${rabbit_health}" ]]; then
|
||||
echo "RabbitMQ management API not reachable at ${RABBITMQ_MGMT_URL}."
|
||||
exit 1
|
||||
fi
|
||||
potassium_threshold="$(psql_cmd "SELECT COUNT(*) FROM alert_thresholds WHERE observation_code = 'POTASSIUM_MEQ_L'")"
|
||||
heart_rate_threshold="$(psql_cmd "SELECT COUNT(*) FROM alert_thresholds WHERE observation_code = 'HEART_RATE'")"
|
||||
if [[ "${potassium_threshold}" == "0" || "${heart_rate_threshold}" == "0" ]]; then
|
||||
echo "Missing alert thresholds (POTASSIUM_MEQ_L and/or HEART_RATE)."
|
||||
echo "Start the API once against an empty database so DataSeeder runs, or register thresholds via the API."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: API, Postgres, RabbitMQ, and alert thresholds are ready"
|
||||
|
||||
echo ""
|
||||
echo "[1/${TOTAL_STEPS}] Verifying reconciliation_alerts migration"
|
||||
table_exists="$(psql_cmd "SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'reconciliation_alerts'
|
||||
)")"
|
||||
if [[ "${table_exists}" != "t" ]]; then
|
||||
echo "Table reconciliation_alerts not found. Run: dotnet ef database update --project VigilCareClinicalAPI"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
columns="$(psql_cmd "SELECT string_agg(column_name, ',' ORDER BY ordinal_position)
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'reconciliation_alerts'")"
|
||||
for col in check_type encounter_id patient_id details resolved_at created_at; do
|
||||
if [[ "${columns}" != *"${col}"* ]]; then
|
||||
echo "reconciliation_alerts missing column: ${col}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK: reconciliation_alerts table and columns present"
|
||||
|
||||
echo ""
|
||||
echo "[2/${TOTAL_STEPS}] Verifying RabbitMQ topology includes notifications.reconciliation.queue"
|
||||
exchange_name="$(rabbit_api "exchanges/%2F/clinical.notifications.exchange" | jq -r '.name // empty')"
|
||||
if [[ "${exchange_name}" != "clinical.notifications.exchange" ]]; then
|
||||
echo "Exchange clinical.notifications.exchange not found. Start the API so RabbitMqTopologyProvisioner runs."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for queue in "${EXPECTED_QUEUES[@]}"; do
|
||||
queue_name="$(rabbit_api "queues/%2F/${queue}" | jq -r '.name // empty')"
|
||||
if [[ "${queue_name}" != "${queue}" ]]; then
|
||||
echo "Queue ${queue} not found (got '${queue_name}')."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
recon_queue_before="$(queue_field "notifications.reconciliation.queue" "messages")"
|
||||
echo "OK: 6 queues provisioned (including notifications.reconciliation.queue); messages=${recon_queue_before}"
|
||||
|
||||
echo ""
|
||||
echo "[3/${TOTAL_STEPS}] Check 1 — stale critical alert creates reconciliation_alert"
|
||||
patient_c1="$(create_patient "C1")"
|
||||
encounter_c1="$(create_encounter "${patient_c1}" "Inpatient")"
|
||||
alert_c1="$(ingest_critical_potassium "${encounter_c1}" "recon-c1-${SCRIPT_RUN_ID}")"
|
||||
backdate_alert_triggered_at "${alert_c1}" 31
|
||||
|
||||
if ! wait_for_reconciliation_row "${encounter_c1}" "${CHECK1}" "${RECONCILIATION_WAIT_SECS}"; then
|
||||
echo "Hint: set ReconciliationJobs.IntervalMinutes=1 in appsettings.json and restart the API."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
details_c1="$(psql_cmd "SELECT details FROM reconciliation_alerts
|
||||
WHERE encounter_id = '${encounter_c1}' AND check_type = '${CHECK1}' AND resolved_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1")"
|
||||
if [[ "${details_c1}" != *"${alert_c1}"* ]]; then
|
||||
echo "Check 1 details should reference alert id ${alert_c1}"
|
||||
echo "Got: ${details_c1}"
|
||||
exit 1
|
||||
fi
|
||||
if ! recon_queue_after_c1="$(wait_for_queue_increase "notifications.reconciliation.queue" "${recon_queue_before}" 15)"; then
|
||||
echo "Expected messages on notifications.reconciliation.queue to increase after Check 1"
|
||||
echo "Before=${recon_queue_before}, after=${recon_queue_after_c1}"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: Check 1 reconciliation_alert created; queue depth ${recon_queue_before} -> ${recon_queue_after_c1}"
|
||||
|
||||
echo ""
|
||||
echo "[4/${TOTAL_STEPS}] Check 1 — second scheduler cycle does not duplicate"
|
||||
count_before_dup="$(reconciliation_count "${encounter_c1}" "${CHECK1}")"
|
||||
wait_for_scheduler_cycle
|
||||
count_after_dup="$(reconciliation_count "${encounter_c1}" "${CHECK1}")"
|
||||
if [[ "${count_after_dup}" != "${count_before_dup}" ]]; then
|
||||
echo "Expected deduplication to keep count at ${count_before_dup}, got ${count_after_dup}"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: open reconciliation_alert count unchanged (${count_after_dup})"
|
||||
|
||||
echo ""
|
||||
echo "[5/${TOTAL_STEPS}] Check 1 — acknowledged critical alert is not flagged"
|
||||
patient_c1a="$(create_patient "C1A")"
|
||||
encounter_c1a="$(create_encounter "${patient_c1a}" "Inpatient")"
|
||||
alert_c1a="$(ingest_critical_potassium "${encounter_c1a}" "recon-c1a-${SCRIPT_RUN_ID}")"
|
||||
|
||||
ack_payload='{"clinicianId":"DR-RECON-SCRIPT","note":"Acknowledged during reconciliation verification."}'
|
||||
resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_c1a}/acknowledge" "${ack_payload}")"
|
||||
assert_status "200" "${resp}"
|
||||
|
||||
backdate_alert_triggered_at "${alert_c1a}" 31
|
||||
wait_for_scheduler_cycle
|
||||
|
||||
if [[ "$(reconciliation_count "${encounter_c1a}" "${CHECK1}")" != "0" ]]; then
|
||||
echo "Acknowledged alert should not produce a Check 1 reconciliation_alert"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: acknowledged alert produced no Check 1 row"
|
||||
|
||||
echo ""
|
||||
echo "[6/${TOTAL_STEPS}] Check 2 — stale pending order creates reconciliation_alert"
|
||||
patient_c2="$(create_patient "C2")"
|
||||
encounter_c2="$(create_encounter "${patient_c2}" "Inpatient")"
|
||||
insert_stale_pending_order "${encounter_c2}" "Comprehensive metabolic panel"
|
||||
|
||||
if ! wait_for_reconciliation_row "${encounter_c2}" "${CHECK2}" "${RECONCILIATION_WAIT_SECS}"; then
|
||||
exit 1
|
||||
fi
|
||||
details_c2="$(psql_cmd "SELECT details FROM reconciliation_alerts
|
||||
WHERE encounter_id = '${encounter_c2}' AND check_type = '${CHECK2}' AND resolved_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1")"
|
||||
if [[ "${details_c2}" != *"Comprehensive metabolic panel"* ]]; then
|
||||
echo "Check 2 details should mention the stale order"
|
||||
echo "Got: ${details_c2}"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: Check 2 reconciliation_alert created for stale pending order"
|
||||
|
||||
echo ""
|
||||
echo "[7/${TOTAL_STEPS}] Check 2 — resulted order is not flagged"
|
||||
patient_c2r="$(create_patient "C2R")"
|
||||
encounter_c2r="$(create_encounter "${patient_c2r}" "Inpatient")"
|
||||
insert_resulted_order "${encounter_c2r}"
|
||||
wait_for_scheduler_cycle
|
||||
|
||||
if [[ "$(reconciliation_count "${encounter_c2r}" "${CHECK2}")" != "0" ]]; then
|
||||
echo "Resulted order should not produce a Check 2 reconciliation_alert"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: resulted order produced no Check 2 row"
|
||||
|
||||
echo ""
|
||||
echo "[8/${TOTAL_STEPS}] Check 3 — active inpatient with no observations is flagged"
|
||||
patient_c3="$(create_patient "C3")"
|
||||
encounter_c3="$(create_encounter "${patient_c3}" "Inpatient")"
|
||||
|
||||
if ! wait_for_reconciliation_row "${encounter_c3}" "${CHECK3}" "${RECONCILIATION_WAIT_SECS}"; then
|
||||
exit 1
|
||||
fi
|
||||
details_c3="$(psql_cmd "SELECT details FROM reconciliation_alerts
|
||||
WHERE encounter_id = '${encounter_c3}' AND check_type = '${CHECK3}' AND resolved_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1")"
|
||||
if [[ "${details_c3}" != *"no observations ever recorded"* ]]; then
|
||||
echo "Check 3 details should mention no observations ever recorded"
|
||||
echo "Got: ${details_c3}"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: Check 3 reconciliation_alert created for inpatient without observations"
|
||||
|
||||
echo ""
|
||||
echo "[9/${TOTAL_STEPS}] Check 3 — stale observation (3 hours ago) is flagged"
|
||||
patient_c3s="$(create_patient "C3S")"
|
||||
encounter_c3s="$(create_encounter "${patient_c3s}" "Inpatient")"
|
||||
stale_recorded_at="$(date -u -d '3 hours ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-3H +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
ingest_heart_rate "${encounter_c3s}" "${stale_recorded_at}" "recon-c3s-${SCRIPT_RUN_ID}"
|
||||
|
||||
if ! wait_for_reconciliation_row "${encounter_c3s}" "${CHECK3}" "${RECONCILIATION_WAIT_SECS}"; then
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: Check 3 reconciliation_alert created for stale observation"
|
||||
|
||||
echo ""
|
||||
echo "[10/${TOTAL_STEPS}] Check 3 — recent observation (30 minutes ago) is not flagged"
|
||||
patient_c3r="$(create_patient "C3R")"
|
||||
encounter_c3r="$(create_encounter "${patient_c3r}" "Inpatient")"
|
||||
recent_recorded_at="$(date -u -d '30 minutes ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-30M +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
ingest_heart_rate "${encounter_c3r}" "${recent_recorded_at}" "recon-c3r-${SCRIPT_RUN_ID}"
|
||||
wait_for_scheduler_cycle
|
||||
|
||||
if [[ "$(reconciliation_count "${encounter_c3r}" "${CHECK3}")" != "0" ]]; then
|
||||
echo "Recent observation should not produce a Check 3 reconciliation_alert"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: recent observation produced no Check 3 row"
|
||||
|
||||
echo ""
|
||||
echo "[11/${TOTAL_STEPS}] Check 3 — outpatient encounter without observations is ignored"
|
||||
patient_c3o="$(create_patient "C3O")"
|
||||
encounter_c3o="$(create_encounter "${patient_c3o}" "Outpatient" "GeneralMedicine")"
|
||||
wait_for_scheduler_cycle
|
||||
|
||||
if [[ "$(reconciliation_count "${encounter_c3o}" "${CHECK3}")" != "0" ]]; then
|
||||
echo "Outpatient encounter should not produce a Check 3 reconciliation_alert"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Plan verification query — candidate inpatients with no recent observations
|
||||
candidate_count="$(psql_cmd "
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT e.id
|
||||
FROM encounters e
|
||||
LEFT JOIN observations o ON o.encounter_id = e.id
|
||||
WHERE e.status = 'ACTIVE' AND e.encounter_type = 'INPATIENT'
|
||||
GROUP BY e.id
|
||||
HAVING MAX(o.recorded_at) < NOW() - INTERVAL '2 hours'
|
||||
OR MAX(o.recorded_at) IS NULL
|
||||
) t")"
|
||||
echo "OK: outpatient not flagged; ${candidate_count} active inpatient(s) match Check 3 candidate query"
|
||||
|
||||
echo ""
|
||||
echo "All ${TOTAL_STEPS} Phase 7 reconciliation checks passed."
|
||||
echo ""
|
||||
echo "Prerequisites: docker compose up -d && dotnet run --project VigilCareClinicalAPI"
|
||||
echo "Recommended: ReconciliationJobs.IntervalMinutes=1 in appsettings.json (restart API before running)."
|
||||
echo "Optional: RECONCILIATION_WAIT_SECS=180 RECONCILIATION_CYCLE_WAIT_SECS=75"
|
||||
Reference in New Issue
Block a user