feature: Ward Gateway Service (Local-First Clinical Path)
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class CentralReachabilityService : BackgroundService
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly CentralApiOptions _central;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private volatile bool _isReachable;
|
||||
|
||||
public bool IsCentralReachable => _isReachable;
|
||||
|
||||
public CentralReachabilityService(
|
||||
IHttpClientFactory http,
|
||||
IOptions<CentralApiOptions> central,
|
||||
IOptions<GatewayOptions> gateway)
|
||||
{
|
||||
_http = http;
|
||||
_central = central.Value;
|
||||
_gateway = gateway.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(
|
||||
TimeSpan.FromSeconds(_gateway.CentralReachabilityIntervalSeconds));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
await ProbeAsync(ct);
|
||||
}
|
||||
|
||||
private async Task ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient("central");
|
||||
client.BaseAddress = new Uri(_central.BaseUrl);
|
||||
var resp = await client.GetAsync("/health/ready", ct);
|
||||
_isReachable = resp.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_isReachable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public sealed class EncounterReplicaSyncService : BackgroundService
|
||||
{
|
||||
private const int EncounterPageSize = 100;
|
||||
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly CentralReachabilityService _reachability;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private readonly ILogger<EncounterReplicaSyncService> _logger;
|
||||
|
||||
public EncounterReplicaSyncService(
|
||||
IServiceScopeFactory scopes,
|
||||
IHttpClientFactory http,
|
||||
CentralReachabilityService reachability,
|
||||
IOptions<GatewayOptions> gateway,
|
||||
ILogger<EncounterReplicaSyncService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_http = http;
|
||||
_reachability = reachability;
|
||||
_gateway = gateway.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
await SyncOnceAsync(ct);
|
||||
|
||||
using var timer = new PeriodicTimer(
|
||||
TimeSpan.FromMinutes(_gateway.EncounterSyncIntervalMinutes));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
if (_reachability.IsCentralReachable)
|
||||
await SyncOnceAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task SyncOnceAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var centralOpts = scope.ServiceProvider.GetRequiredService<IOptions<CentralApiOptions>>().Value;
|
||||
|
||||
var client = _http.CreateClient("central");
|
||||
client.BaseAddress = new Uri(centralOpts.BaseUrl);
|
||||
ConfigureAuth(client);
|
||||
|
||||
if (client.DefaultRequestHeaders.Authorization is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"GATEWAY_SYNC_JWT not set — skipping encounter replica sync (seed local replica manually for dev)");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var syncedCount = await SyncEncountersAsync(client, db, ct);
|
||||
await SyncThresholdsAsync(client, db, redis, ct);
|
||||
|
||||
var state = await db.SyncState.FirstOrDefaultAsync(ct)
|
||||
?? db.SyncState.Add(new GatewaySyncState { Id = Guid.NewGuid() }).Entity;
|
||||
state.LastEncounterSyncAt = DateTimeOffset.UtcNow;
|
||||
state.InitialSyncCompleted = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("Encounter replica sync completed — {Count} encounters", syncedCount);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Encounter replica sync failed — central API unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> SyncEncountersAsync(
|
||||
HttpClient client, GatewayDbContext db, CancellationToken ct)
|
||||
{
|
||||
var dept = Uri.EscapeDataString(_gateway.Department);
|
||||
var summaries = new List<CentralWardEncounterSummary>();
|
||||
var page = 1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var listResp = await client.GetFromJsonAsync<ApiResponse<CentralEncounterListPage>>(
|
||||
$"/api/v1/encounters?status=ACTIVE&department={dept}&page={page}&pageSize={EncounterPageSize}",
|
||||
CentralApiJson.Options,
|
||||
ct);
|
||||
|
||||
if (listResp?.Data?.Items is null || listResp.Data.Items.Count == 0)
|
||||
break;
|
||||
|
||||
summaries.AddRange(listResp.Data.Items);
|
||||
|
||||
if (page >= listResp.Data.TotalPages)
|
||||
break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
var synced = 0;
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
var detailResp = await client.GetFromJsonAsync<ApiResponse<CentralEncounterDetail>>(
|
||||
$"/api/v1/encounters/{summary.EncounterId}",
|
||||
CentralApiJson.Options,
|
||||
ct);
|
||||
|
||||
if (detailResp?.Data is null)
|
||||
{
|
||||
_logger.LogWarning("Central returned no detail for encounter {EncounterId}", summary.EncounterId);
|
||||
continue;
|
||||
}
|
||||
|
||||
UpsertReplica(db, detailResp.Data);
|
||||
synced++;
|
||||
}
|
||||
|
||||
return synced;
|
||||
}
|
||||
|
||||
private static void UpsertReplica(GatewayDbContext db, CentralEncounterDetail detail)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var patient = detail.Patient;
|
||||
|
||||
var replicaPatient = db.Patients.Find(patient.Id);
|
||||
if (replicaPatient is null)
|
||||
{
|
||||
db.Patients.Add(new ReplicaPatient
|
||||
{
|
||||
Id = patient.Id,
|
||||
Mrn = patient.Mrn,
|
||||
FirstName = patient.FirstName,
|
||||
LastName = patient.LastName,
|
||||
DateOfBirth = patient.DateOfBirth,
|
||||
Gender = patient.Gender,
|
||||
SyncedAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
replicaPatient.Mrn = patient.Mrn;
|
||||
replicaPatient.FirstName = patient.FirstName;
|
||||
replicaPatient.LastName = patient.LastName;
|
||||
replicaPatient.DateOfBirth = patient.DateOfBirth;
|
||||
replicaPatient.Gender = patient.Gender;
|
||||
replicaPatient.SyncedAt = now;
|
||||
}
|
||||
|
||||
var enc = db.Encounters.Find(detail.Id);
|
||||
if (enc is null)
|
||||
{
|
||||
db.Encounters.Add(new ReplicaEncounter
|
||||
{
|
||||
Id = detail.Id,
|
||||
PatientId = detail.PatientId,
|
||||
EncounterType = detail.EncounterType,
|
||||
Status = detail.Status,
|
||||
Department = detail.Department,
|
||||
AttendingPhysician = detail.AttendingPhysician,
|
||||
RoomBed = detail.RoomBed,
|
||||
AdmittedAt = detail.AdmittedAt,
|
||||
SyncedAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
enc.PatientId = detail.PatientId;
|
||||
enc.EncounterType = detail.EncounterType;
|
||||
enc.Status = detail.Status;
|
||||
enc.Department = detail.Department;
|
||||
enc.AttendingPhysician = detail.AttendingPhysician;
|
||||
enc.RoomBed = detail.RoomBed;
|
||||
enc.AdmittedAt = detail.AdmittedAt;
|
||||
enc.SyncedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncThresholdsAsync(
|
||||
HttpClient client,
|
||||
GatewayDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var thrResp = await client.GetFromJsonAsync<ApiResponse<List<CentralAlertThreshold>>>(
|
||||
"/api/v1/alert-thresholds",
|
||||
CentralApiJson.Options,
|
||||
ct);
|
||||
|
||||
if (thrResp?.Data is null)
|
||||
return;
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("DELETE FROM alert_thresholds", ct);
|
||||
var syncedAt = DateTimeOffset.UtcNow;
|
||||
db.AlertThresholds.AddRange(thrResp.Data.Select(t => new ReplicaAlertThreshold
|
||||
{
|
||||
Id = t.Id,
|
||||
ObservationCode = t.ObservationCode,
|
||||
DisplayName = t.DisplayName,
|
||||
Unit = t.Unit,
|
||||
CriticalLow = t.CriticalLow,
|
||||
WarningLow = t.WarningLow,
|
||||
WarningHigh = t.WarningHigh,
|
||||
CriticalHigh = t.CriticalHigh,
|
||||
SuppressionWindowMinutes = t.SuppressionWindowMinutes,
|
||||
SyncedAt = syncedAt
|
||||
}));
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await ThresholdCacheLoader.RefreshAsync(db, redis, _logger, ct);
|
||||
}
|
||||
|
||||
private static void ConfigureAuth(HttpClient client)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = null;
|
||||
|
||||
var jwt = Environment.GetEnvironmentVariable("GATEWAY_SYNC_JWT");
|
||||
if (!string.IsNullOrEmpty(jwt))
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class GatewayHeartbeatService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly CentralReachabilityService _reachability;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<GatewayHeartbeatService> _logger;
|
||||
|
||||
public GatewayHeartbeatService(
|
||||
IServiceScopeFactory scopes,
|
||||
IHttpClientFactory http,
|
||||
CentralReachabilityService reachability,
|
||||
IOptions<GatewayOptions> gateway,
|
||||
IConfiguration config,
|
||||
ILogger<GatewayHeartbeatService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_http = http;
|
||||
_reachability = reachability;
|
||||
_gateway = gateway.Value;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_gateway.HeartbeatIntervalSeconds));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
await SendHeartbeatAsync(ct);
|
||||
}
|
||||
|
||||
private async Task SendHeartbeatAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var bufferDepth = await db.BufferedSyncItems.CountAsync(b => !b.Synced, ct);
|
||||
|
||||
var status = _reachability.IsCentralReachable ? "ONLINE" : "DEGRADED";
|
||||
var req = new GatewayHeartbeatRequest(status, bufferDepth, DateTimeOffset.UtcNow);
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient("central");
|
||||
client.BaseAddress = new Uri(
|
||||
scope.ServiceProvider.GetRequiredService<IOptions<CentralApiOptions>>().Value.BaseUrl);
|
||||
client.DefaultRequestHeaders.Remove("X-Api-Key");
|
||||
client.DefaultRequestHeaders.Remove("X-Gateway-Id");
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", _config["ApiKey:Gateway"]);
|
||||
client.DefaultRequestHeaders.Add("X-Gateway-Id", _gateway.GatewayId.ToString());
|
||||
|
||||
var resp = await client.PatchAsJsonAsync(
|
||||
$"/api/v1/gateways/{_gateway.GatewayId}/heartbeat", req, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
_logger.LogWarning("Heartbeat failed: {Status}", resp.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Heartbeat to central failed — gateway continues in DEGRADED mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
public sealed class LocalEscalationWorkerService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<RabbitMqOptions> _opts;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ILogger<LocalEscalationWorkerService> _logger;
|
||||
|
||||
public LocalEscalationWorkerService(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<LocalEscalationWorkerService> logger)
|
||||
{
|
||||
_opts = opts;
|
||||
_scopes = scopes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var o = _opts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("gateway-escalation-worker");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
channel.BasicQos(0, prefetchCount: 5, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.Received += async (_, ea) =>
|
||||
{
|
||||
await HandleEscalationAsync(channel, ea, stoppingToken);
|
||||
};
|
||||
|
||||
channel.BasicConsume("alerts.escalation.queue", autoAck: false, consumer);
|
||||
|
||||
_logger.LogInformation("LocalEscalationWorkerService consuming alerts.escalation.queue");
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task HandleEscalationAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
var doc = JsonDocument.Parse(payload);
|
||||
var alertId = Guid.Parse(doc.RootElement.GetProperty("alertId").GetString()!);
|
||||
|
||||
_logger.LogCritical("[ESCALATION] Paging on-call backup for alert {AlertId}", alertId);
|
||||
|
||||
try
|
||||
{
|
||||
var escalated = await UpdateAlertStatusEscalatedAsync(alertId, ct);
|
||||
channel.BasicAck(ea.DeliveryTag, multiple: false);
|
||||
|
||||
if (escalated)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[ESCALATION-DONE] Alert {AlertId} status → Escalated in gateway DB", alertId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Local escalation worker failed for alert {AlertId}", alertId);
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateAlertStatusEscalatedAsync(Guid alertId, CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
var alert = await db.ClinicalAlerts.FindAsync([alertId], ct);
|
||||
if (alert is null) return false;
|
||||
|
||||
if (alert.Status != AlertStatus.Open) return false;
|
||||
|
||||
alert.Status = AlertStatus.Escalated;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
public sealed class LocalPagingWorkerService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<RabbitMqOptions> _opts;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ILogger<LocalPagingWorkerService> _logger;
|
||||
|
||||
public LocalPagingWorkerService(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<LocalPagingWorkerService> logger)
|
||||
{
|
||||
_opts = opts;
|
||||
_scopes = scopes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var o = _opts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("gateway-paging-worker");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.Received += async (_, ea) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandlePageAsync(channel, ea, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Local paging worker stopping — requeueing in-flight page message");
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Local paging worker failed — NACKing to DLQ");
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
|
||||
}
|
||||
};
|
||||
|
||||
channel.BasicConsume("alerts.paging.queue", autoAck: false, consumer);
|
||||
|
||||
_logger.LogInformation(
|
||||
"LocalPagingWorkerService consuming alerts.paging.queue (prefetch=1, timeout={Timeout}ms)",
|
||||
o.PagingAckTimeoutMs);
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task HandlePageAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
|
||||
{
|
||||
var o = _opts.Value;
|
||||
var payload = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
var doc = JsonDocument.Parse(payload);
|
||||
var alertId = Guid.Parse(doc.RootElement.GetProperty("alertId").GetString()!);
|
||||
var encounterId = doc.RootElement.GetProperty("encounterId").GetString();
|
||||
var details = doc.RootElement.TryGetProperty("details", out var d) ? d.GetString() : null;
|
||||
var physician = doc.RootElement.TryGetProperty("attendingPhysician", out var p)
|
||||
? p.GetString()
|
||||
: "unknown";
|
||||
|
||||
_logger.LogWarning(
|
||||
"[PAGE] Paging attending physician '{Physician}' for encounter {EncounterId} — " +
|
||||
"AlertId={AlertId} Details={Details}",
|
||||
physician, encounterId, alertId, details);
|
||||
|
||||
var deadline = DateTimeOffset.UtcNow.AddMilliseconds(o.PagingAckTimeoutMs);
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline && !ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(2_000, ct);
|
||||
|
||||
if (await IsAlertAcknowledgedAsync(alertId, ct))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[PAGE-ACK] Alert {AlertId} acknowledged — ACKing RabbitMQ message", alertId);
|
||||
channel.BasicAck(ea.DeliveryTag, multiple: false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"[PAGE-TIMEOUT] No acknowledgment within {TimeoutMs}ms for alert {AlertId} — " +
|
||||
"NACKing to DLQ for escalation",
|
||||
o.PagingAckTimeoutMs, alertId);
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
|
||||
}
|
||||
|
||||
private async Task<bool> IsAlertAcknowledgedAsync(Guid alertId, CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
var status = await db.ClinicalAlerts
|
||||
.Where(a => a.Id == alertId)
|
||||
.Select(a => a.Status)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return status is AlertStatus.Acknowledged or AlertStatus.Resolved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using VigilCare.ClinicalContracts.Sync;
|
||||
|
||||
public sealed class SyncUploaderService : BackgroundService
|
||||
{
|
||||
private static readonly JsonSerializerOptions PayloadJson = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly CentralReachabilityService _reachability;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<SyncUploaderService> _logger;
|
||||
private int _backoffSeconds = 30;
|
||||
|
||||
public SyncUploaderService(
|
||||
IServiceScopeFactory scopes,
|
||||
CentralReachabilityService reachability,
|
||||
IOptions<GatewayOptions> gateway,
|
||||
IConfiguration config,
|
||||
ILogger<SyncUploaderService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_reachability = reachability;
|
||||
_gateway = gateway.Value;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
if (!_reachability.IsCentralReachable) continue;
|
||||
try
|
||||
{
|
||||
await UploadBatchAsync(ct);
|
||||
_backoffSeconds = 30;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Sync upload failed — backoff {Seconds}s", _backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(_backoffSeconds), ct);
|
||||
_backoffSeconds = Math.Min(_backoffSeconds * 2, 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadBatchAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
var pending = await db.BufferedSyncItems
|
||||
.Where(b => !b.Synced)
|
||||
.OrderBy(b => b.RecordedAt)
|
||||
.Take(_gateway.SyncBatchSize)
|
||||
.ToListAsync(ct);
|
||||
if (pending.Count == 0) return;
|
||||
|
||||
var batchRef = Guid.NewGuid();
|
||||
var request = BuildBatchRequest(batchRef, pending);
|
||||
|
||||
var client = scope.ServiceProvider.GetRequiredService<IHttpClientFactory>().CreateClient("central");
|
||||
client.BaseAddress = new Uri(
|
||||
scope.ServiceProvider.GetRequiredService<IOptions<CentralApiOptions>>().Value.BaseUrl);
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", _config["ApiKey:Gateway"]!);
|
||||
client.DefaultRequestHeaders.Add("X-Gateway-Id", _gateway.GatewayId.ToString());
|
||||
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/sync/batches", request, ct);
|
||||
submit.EnsureSuccessStatusCode();
|
||||
var batchId = (await submit.Content.ReadFromJsonAsync<ApiResponse<SyncBatchStatus>>(ct))!.Data!.BatchId;
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
await Task.Delay(2000, ct);
|
||||
var status = await client.GetFromJsonAsync<ApiResponse<SyncBatchStatus>>(
|
||||
$"/api/v1/sync/batches/{batchId}", ct);
|
||||
if (status?.Data?.Status is "APPLIED")
|
||||
{
|
||||
foreach (var item in pending)
|
||||
{
|
||||
item.Synced = true;
|
||||
item.SyncedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation(
|
||||
"Sync batch {BatchId} applied — marked {Count} buffered items synced",
|
||||
batchId, pending.Count);
|
||||
return;
|
||||
}
|
||||
if (status?.Data?.Status is "CONFLICT" or "REJECTED")
|
||||
{
|
||||
_logger.LogError(
|
||||
"Sync batch {BatchId} {Status} — items remain buffered",
|
||||
batchId, status.Data.Status);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("Sync batch {BatchId} still processing after poll window", batchId);
|
||||
}
|
||||
|
||||
private ClinicalSyncBatchRequest BuildBatchRequest(Guid batchRef, List<BufferedSyncItem> items)
|
||||
{
|
||||
var observations = new List<SyncedObservation>();
|
||||
var alertEvents = new List<SyncedAlertEvent>();
|
||||
var acks = new List<SyncedAlertAcknowledgment>();
|
||||
var resolves = new List<SyncedAlertResolution>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
switch (item.ItemType)
|
||||
{
|
||||
case BufferedSyncItemType.Observation:
|
||||
observations.Add(MapObservation(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Alert:
|
||||
alertEvents.Add(MapAlert(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Ack:
|
||||
acks.Add(MapAck(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Resolve:
|
||||
resolves.Add(MapResolve(item));
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown buffered sync item type '{item.ItemType}' for item {item.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
return new ClinicalSyncBatchRequest(
|
||||
batchRef,
|
||||
_gateway.GatewayId,
|
||||
_gateway.SiteId,
|
||||
DateTimeOffset.UtcNow,
|
||||
observations,
|
||||
alertEvents,
|
||||
acks,
|
||||
resolves);
|
||||
}
|
||||
|
||||
private static SyncedObservation MapObservation(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedObservationPayload>(item.Payload);
|
||||
return new SyncedObservation(
|
||||
p.ClientRef,
|
||||
p.IdempotencyKey ?? item.IdempotencyKey,
|
||||
p.EncounterId,
|
||||
p.ObservationCode,
|
||||
p.Value,
|
||||
p.Unit,
|
||||
p.Source,
|
||||
p.RecordedAt);
|
||||
}
|
||||
|
||||
private static SyncedAlertEvent MapAlert(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedAlertPayload>(item.Payload);
|
||||
return new SyncedAlertEvent(
|
||||
p.ClientAlertId,
|
||||
p.EncounterId,
|
||||
p.AlertType,
|
||||
p.Severity,
|
||||
p.Details,
|
||||
p.GeneratedAt);
|
||||
}
|
||||
|
||||
private static SyncedAlertAcknowledgment MapAck(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedAckPayload>(item.Payload);
|
||||
return new SyncedAlertAcknowledgment(
|
||||
p.ClientRef,
|
||||
p.ClientAlertId,
|
||||
p.ClinicianId,
|
||||
p.AcknowledgedAt,
|
||||
p.Note);
|
||||
}
|
||||
|
||||
private static SyncedAlertResolution MapResolve(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedResolvePayload>(item.Payload);
|
||||
return new SyncedAlertResolution(
|
||||
p.ClientRef,
|
||||
p.ClientAlertId,
|
||||
p.ClinicianId,
|
||||
p.ResolvedAt,
|
||||
p.Note);
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(string json) =>
|
||||
JsonSerializer.Deserialize<T>(json, PayloadJson)
|
||||
?? throw new JsonException($"Failed to deserialize buffered sync payload as {typeof(T).Name}");
|
||||
|
||||
private sealed record BufferedObservationPayload(
|
||||
Guid ClientRef,
|
||||
string? IdempotencyKey,
|
||||
Guid EncounterId,
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string? Unit,
|
||||
string Source,
|
||||
DateTimeOffset RecordedAt);
|
||||
|
||||
private sealed record BufferedAlertPayload(
|
||||
Guid ClientAlertId,
|
||||
Guid EncounterId,
|
||||
string AlertType,
|
||||
string Severity,
|
||||
string Details,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
private sealed record BufferedAckPayload(
|
||||
Guid ClientRef,
|
||||
Guid ClientAlertId,
|
||||
string ClinicianId,
|
||||
DateTimeOffset AcknowledgedAt,
|
||||
string? Note);
|
||||
|
||||
private sealed record BufferedResolvePayload(
|
||||
Guid ClientRef,
|
||||
Guid ClientAlertId,
|
||||
string ClinicianId,
|
||||
DateTimeOffset ResolvedAt,
|
||||
string? Note);
|
||||
|
||||
private sealed record SyncBatchStatus(Guid BatchId, string Status);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class ThresholdCacheLoader : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ILogger<ThresholdCacheLoader> _logger;
|
||||
|
||||
public ThresholdCacheLoader(
|
||||
IServiceProvider services,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<ThresholdCacheLoader> logger)
|
||||
{
|
||||
_services = services;
|
||||
_redis = redis;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
await RefreshAsync(db, _redis, _logger, cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Reloads all gateway alert thresholds into Redis. Called on startup and after
|
||||
/// <see cref="EncounterReplicaSyncService"/> pulls thresholds from central.
|
||||
/// </summary>
|
||||
public static async Task RefreshAsync(
|
||||
GatewayDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var thresholds = await db.AlertThresholds.ToListAsync(cancellationToken);
|
||||
if (thresholds.Count == 0)
|
||||
{
|
||||
logger.LogWarning("No alert thresholds in gateway DB — Redis cache not updated");
|
||||
return;
|
||||
}
|
||||
|
||||
const int maxAttempts = 3;
|
||||
int[] backoffMs = [2000, 4000, 8000];
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
WriteThresholds(redis, thresholds);
|
||||
logger.LogInformation(
|
||||
"Loaded {Count} alert thresholds into gateway Redis cache", thresholds.Count);
|
||||
return;
|
||||
}
|
||||
catch (RedisException ex)
|
||||
{
|
||||
logger.LogWarning(ex,
|
||||
"Redis unavailable during gateway threshold cache load — attempt {Attempt}/{Max}",
|
||||
attempt + 1, maxAttempts);
|
||||
|
||||
if (attempt < maxAttempts - 1)
|
||||
await Task.Delay(backoffMs[attempt], cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
"Failed to load thresholds into gateway Redis after {Max} attempts — " +
|
||||
"observation ingest falls back to PostgreSQL for threshold lookups",
|
||||
maxAttempts);
|
||||
}
|
||||
|
||||
private static void WriteThresholds(IConnectionMultiplexer redis, List<ReplicaAlertThreshold> thresholds)
|
||||
{
|
||||
var cache = redis.GetDatabase();
|
||||
var batch = cache.CreateBatch();
|
||||
|
||||
foreach (var t in thresholds)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new ThresholdCacheEntry(
|
||||
t.ObservationCode,
|
||||
t.CriticalLow,
|
||||
t.WarningLow,
|
||||
t.WarningHigh,
|
||||
t.CriticalHigh));
|
||||
_ = batch.StringSetAsync($"threshold:{t.ObservationCode}", json);
|
||||
}
|
||||
|
||||
batch.Execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error)
|
||||
{
|
||||
public static ApiResponse<T> Ok(T data) =>
|
||||
new(true, 200, data, null);
|
||||
|
||||
public static ApiResponse<T> Created(T data) =>
|
||||
new(true, 201, data, null);
|
||||
|
||||
public static ApiResponse<T> Fail(int statusCode, string message, string code) =>
|
||||
new(false, statusCode, default, new ApiError(message, code));
|
||||
}
|
||||
|
||||
public record ApiError(string Message, string Code);
|
||||
@@ -0,0 +1 @@
|
||||
public record CursorPage<T>(List<T> Items, string? NextCursor, bool HasMore);
|
||||
@@ -0,0 +1,5 @@
|
||||
public class ConflictException : DomainException
|
||||
{
|
||||
public ConflictException(string message, string errorCode = "CONFLICT_ERROR")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public static class DbExceptions
|
||||
{
|
||||
public static bool IsUniqueViolation(DbUpdateException ex) =>
|
||||
ex.InnerException?.Message.Contains("23505") == true
|
||||
|| ex.InnerException?.Message.Contains("unique constraint") == true;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public abstract class DomainException : Exception
|
||||
{
|
||||
public string ErrorCode { get; }
|
||||
|
||||
protected DomainException(string message, string errorCode) : base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public class NotFoundException : DomainException
|
||||
{
|
||||
public NotFoundException(string message, string errorCode = "NOT_FOUND")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public class ValidationException : DomainException
|
||||
{
|
||||
public ValidationException(string message, string errorCode = "VALIDATION_ERROR")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public record PagedResult<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalCount
|
||||
)
|
||||
{
|
||||
public int TotalPages => (int)Math.Ceiling((double)TotalCount/PageSize);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public sealed class CentralApiOptions
|
||||
{
|
||||
public const string Section = "CentralApi";
|
||||
public string BaseUrl { get; init; } = "http://localhost:5080";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public sealed class GatewayOptions
|
||||
{
|
||||
public const string Section = "Gateway";
|
||||
public Guid GatewayId { get; init; }
|
||||
public Guid SiteId { get; init; }
|
||||
public string Department { get; init; } = "ICU";
|
||||
public int EncounterSyncIntervalMinutes { get; init; } = 5;
|
||||
public int CentralReachabilityIntervalSeconds { get; init; } = 30;
|
||||
public int HeartbeatIntervalSeconds { get; init; } = 60;
|
||||
public int SyncBatchSize { get; init; } = 500;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public sealed class RabbitMqOptions
|
||||
{
|
||||
public const string Section = "RabbitMq";
|
||||
public string Host { get; init; } = "localhost";
|
||||
public int Port { get; init; } = 5674;
|
||||
public string Username { get; init; } = "guest";
|
||||
public string Password { get; init; } = "guest";
|
||||
// Drives both the paging worker poll timeout and the DLQ x-message-ttl.
|
||||
// In production: 300000 (5 min). In tests: 5000 (5 sec).
|
||||
public int PagingAckTimeoutMs { get; init; } = 300000;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public class SuppressionOptions
|
||||
{
|
||||
public const string SectionName = "AlertSuppression";
|
||||
|
||||
/// <summary>Default suppression window after acknowledgment (minutes).</summary>
|
||||
public int DefaultWindowMinutes { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AlertsController : ControllerBase
|
||||
{
|
||||
private readonly ILocalAlertService _alerts;
|
||||
|
||||
public AlertsController(ILocalAlertService alerts) => _alerts = alerts;
|
||||
|
||||
[HttpGet("api/v1/encounters/{encounterId:guid}/alerts")]
|
||||
public async Task<IActionResult> ListByEncounter(
|
||||
Guid encounterId,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(status) && !TryParseStatus(status, out _))
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
|
||||
|
||||
var result = await _alerts.ListAsync(encounterId, status, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
page = result.Page,
|
||||
pageSize = result.PageSize,
|
||||
totalCount = result.TotalCount,
|
||||
totalPages = result.TotalPages
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/alerts")]
|
||||
public async Task<IActionResult> ListGlobal(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(status) && !TryParseStatus(status, out _))
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
|
||||
|
||||
var result = await _alerts.ListAsync(null, status, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
page = result.Page,
|
||||
pageSize = result.PageSize,
|
||||
totalCount = result.TotalCount,
|
||||
totalPages = result.TotalPages
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpPost("api/v1/alerts/{id:guid}/acknowledge")]
|
||||
public async Task<IActionResult> Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req)
|
||||
{
|
||||
var clinicianId = User.Identity?.Name ?? "unknown";
|
||||
var alert = await _alerts.AcknowledgeAsync(id, req, clinicianId);
|
||||
return Ok(ApiResponse<LocalClinicalAlert>.Ok(alert));
|
||||
}
|
||||
|
||||
[HttpPost("api/v1/alerts/{id:guid}/resolve")]
|
||||
public async Task<IActionResult> Resolve(Guid id)
|
||||
{
|
||||
var alert = await _alerts.ResolveAsync(id);
|
||||
return Ok(ApiResponse<LocalClinicalAlert>.Ok(alert));
|
||||
}
|
||||
|
||||
private static bool TryParseStatus(string status, out AlertStatus _)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = AlertStatusExtensions.FromDbString(status);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
_ = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Stubs for central-only scoring and bundle endpoints — returns 503 during degraded mode.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}")]
|
||||
[Authorize]
|
||||
public class CentralRequiredController : ControllerBase
|
||||
{
|
||||
[HttpGet("news2")]
|
||||
public IActionResult News2History(Guid encounterId) =>
|
||||
CentralRequired("NEWS2 history requires central connection.");
|
||||
|
||||
[HttpGet("news2/current")]
|
||||
public IActionResult News2Current(Guid encounterId) =>
|
||||
CentralRequired("NEWS2 current score requires central connection.");
|
||||
|
||||
[HttpGet("qsofa/current")]
|
||||
public IActionResult QsofaCurrent(Guid encounterId) =>
|
||||
CentralRequired("qSOFA current score requires central connection.");
|
||||
|
||||
[HttpGet("sofa/current")]
|
||||
public IActionResult SofaCurrent(Guid encounterId) =>
|
||||
CentralRequired("SOFA current score requires central connection.");
|
||||
|
||||
[HttpGet("sepsis-bundle/current")]
|
||||
public IActionResult SepsisBundleCurrent(Guid encounterId) =>
|
||||
CentralRequired("Sepsis bundle status requires central connection.");
|
||||
|
||||
private static IActionResult CentralRequired(string message) =>
|
||||
new ObjectResult(ApiResponse<object>.Fail(503, message, "CENTRAL_REQUIRED"))
|
||||
{
|
||||
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class EncountersController : ControllerBase
|
||||
{
|
||||
private readonly IEncounterReadService _encounters;
|
||||
|
||||
public EncountersController(IEncounterReadService encounters) => _encounters = encounters;
|
||||
|
||||
[HttpGet("api/v1/encounters")]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? department)
|
||||
{
|
||||
if (status is not null and not "ACTIVE")
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Gateway supports ACTIVE only.", "INVALID_STATUS"));
|
||||
|
||||
if (!string.IsNullOrEmpty(department) && !TryParseDepartment(department, out _))
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid department filter.", "INVALID_DEPARTMENT"));
|
||||
|
||||
var items = await _encounters.ListActiveAsync(department);
|
||||
return Ok(ApiResponse<object>.Ok(new { items }));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/encounters/{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var detail = await _encounters.GetByIdAsync(id);
|
||||
if (detail is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404, "Encounter not found.", "ENCOUNTER_NOT_FOUND"));
|
||||
|
||||
return Ok(ApiResponse<EncounterDetail>.Ok(detail));
|
||||
}
|
||||
|
||||
private static bool TryParseDepartment(string department, out Department _)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = DepartmentExtensions.FromDbString(department);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
_ = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/observations")]
|
||||
[Authorize]
|
||||
public class ObservationsController : ControllerBase
|
||||
{
|
||||
private readonly ILocalObservationService _observations;
|
||||
private readonly IObservationQueryService _query;
|
||||
|
||||
public ObservationsController(
|
||||
ILocalObservationService observations,
|
||||
IObservationQueryService query)
|
||||
{
|
||||
_observations = observations;
|
||||
_query = query;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Ingest(Guid encounterId, [FromBody] IngestObservationRequest req)
|
||||
{
|
||||
var result = await _observations.IngestAsync(encounterId, req);
|
||||
if (result.IsDuplicate)
|
||||
return Ok(ApiResponse<object>.Ok(new { duplicate = true, result.Observation }));
|
||||
return StatusCode(201, ApiResponse<object>.Created(new
|
||||
{
|
||||
observation = result.Observation,
|
||||
alert = result.Alert
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> History(
|
||||
Guid encounterId,
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? cursor = null)
|
||||
{
|
||||
var page = await _query.GetHistoryAsync(encounterId, code, from, to, limit, cursor);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = page.Items,
|
||||
nextCursor = page.NextCursor,
|
||||
hasMore = page.HasMore
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class BufferedSyncItemConfiguration : IEntityTypeConfiguration<BufferedSyncItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BufferedSyncItem> builder)
|
||||
{
|
||||
builder.ToTable("buffered_sync_items", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_buffered_sync_items_item_type",
|
||||
"item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
|
||||
});
|
||||
builder.HasKey(b => b.Id);
|
||||
builder.Property(b => b.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(b => b.ItemType)
|
||||
.HasColumnName("item_type")
|
||||
.HasMaxLength(32)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => BufferedSyncItemTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(b => b.Payload).HasColumnName("payload").HasColumnType("jsonb").IsRequired();
|
||||
builder.Property(b => b.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(200).IsRequired();
|
||||
builder.Property(b => b.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(b => b.RecordedAt).HasColumnName("recorded_at");
|
||||
builder.Property(b => b.Synced).HasColumnName("synced").HasDefaultValue(false);
|
||||
builder.Property(b => b.SyncedAt).HasColumnName("synced_at");
|
||||
builder.Property(b => b.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasIndex(b => b.IdempotencyKey)
|
||||
.IsUnique()
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
builder.HasIndex(b => b.CreatedAt)
|
||||
.HasFilter("NOT synced");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class GatewaySyncStateConfiguration : IEntityTypeConfiguration<GatewaySyncState>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GatewaySyncState> builder)
|
||||
{
|
||||
builder.ToTable("gateway_sync_state");
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(s => s.LastEncounterSyncAt).HasColumnName("last_encounter_sync_at");
|
||||
builder.Property(s => s.InitialSyncCompleted)
|
||||
.HasColumnName("initial_sync_completed")
|
||||
.HasDefaultValue(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class LocalClinicalAlertConfiguration : IEntityTypeConfiguration<LocalClinicalAlert>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LocalClinicalAlert> builder)
|
||||
{
|
||||
builder.ToTable("clinical_alerts", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity",
|
||||
"severity IN ('WARNING', 'CRITICAL')");
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status",
|
||||
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type",
|
||||
"alert_type IN (" +
|
||||
"'SEPSIS_WARNING', " +
|
||||
"'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', " +
|
||||
"'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " +
|
||||
"'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " +
|
||||
"'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', " +
|
||||
"'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', " +
|
||||
"'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', " +
|
||||
"'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', " +
|
||||
"'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " +
|
||||
"'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " +
|
||||
"'WARNING_GLUCOSE_MG_DL', " +
|
||||
"'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', " +
|
||||
"'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', " +
|
||||
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
|
||||
"'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', " +
|
||||
"'GCS_CRITICAL', 'GCS_WARNING', " +
|
||||
"'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
});
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(a => a.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(a => a.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(a => a.ObservationId).HasColumnName("observation_id");
|
||||
builder.Property(a => a.AlertType)
|
||||
.HasColumnName("alert_type")
|
||||
.HasMaxLength(50)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(a => a.Severity)
|
||||
.HasColumnName("severity")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertSeverityExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(a => a.Details).HasColumnName("details").IsRequired();
|
||||
builder.Property(a => a.ObservationCode).HasColumnName("observation_code").HasMaxLength(50);
|
||||
builder.Property(a => a.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'OPEN'")
|
||||
.HasSentinel((AlertStatus)(-1));
|
||||
builder.Property(a => a.AcknowledgedAt).HasColumnName("acknowledged_at");
|
||||
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
|
||||
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
|
||||
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id").IsRequired();
|
||||
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(a => a.Encounter)
|
||||
.WithMany(e => e.Alerts)
|
||||
.HasForeignKey(a => a.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(a => new { a.EncounterId, a.TriggeredAt });
|
||||
builder.HasIndex(a => new { a.PatientId, a.TriggeredAt });
|
||||
builder.HasIndex(a => new { a.Severity, a.TriggeredAt })
|
||||
.HasFilter("status = 'OPEN'");
|
||||
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
|
||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||
builder.HasIndex(a => a.ClientAlertId).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class LocalObservationConfiguration : IEntityTypeConfiguration<LocalObservation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LocalObservation> builder)
|
||||
{
|
||||
builder.ToTable("observations", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source",
|
||||
"source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(o => o.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(o => o.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||
builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired();
|
||||
builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||
builder.Property(o => o.Source)
|
||||
.HasColumnName("source")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => ObservationSourceExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'MANUAL'")
|
||||
.HasSentinel((ObservationSource)(-1));
|
||||
builder.Property(o => o.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(100);
|
||||
builder.Property(o => o.RecordedAt).HasColumnName("recorded_at");
|
||||
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(o => o.Encounter)
|
||||
.WithMany(e => e.Observations)
|
||||
.HasForeignKey(o => o.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(o => o.IdempotencyKey)
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
builder.HasIndex(o => new { o.EncounterId, o.ObservationCode, o.RecordedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ReplicaAlertThresholdConfiguration : IEntityTypeConfiguration<ReplicaAlertThreshold>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReplicaAlertThreshold> builder)
|
||||
{
|
||||
builder.ToTable("alert_thresholds");
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(t => t.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||
builder.Property(t => t.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
|
||||
builder.Property(t => t.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||
builder.Property(t => t.CriticalLow).HasColumnName("critical_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.SuppressionWindowMinutes).HasColumnName("suppression_window_minutes");
|
||||
builder.Property(t => t.SyncedAt).HasColumnName("synced_at");
|
||||
|
||||
builder.HasIndex(t => t.ObservationCode).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ReplicaEncounterConfiguration : IEntityTypeConfiguration<ReplicaEncounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReplicaEncounter> builder)
|
||||
{
|
||||
builder.ToTable("encounters", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type",
|
||||
"encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
t.HasCheckConstraint("chk_encounters_status",
|
||||
"status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
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()");
|
||||
builder.Property(e => e.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(e => e.EncounterType)
|
||||
.HasColumnName("encounter_type")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => EncounterTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(e => e.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => EncounterStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'SCHEDULED'")
|
||||
.HasSentinel((EncounterStatus)(-1));
|
||||
builder.Property(e => e.Department)
|
||||
.HasColumnName("department")
|
||||
.HasMaxLength(100)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => DepartmentExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(20);
|
||||
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.SyncedAt).HasColumnName("synced_at");
|
||||
|
||||
builder.HasOne(e => e.Patient)
|
||||
.WithMany(p => p.Encounters)
|
||||
.HasForeignKey(e => e.PatientId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(e => new { e.PatientId, e.AdmittedAt });
|
||||
builder.HasIndex(e => new { e.Status, e.AdmittedAt })
|
||||
.HasFilter("status = 'ACTIVE'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ReplicaPatientConfiguration : IEntityTypeConfiguration<ReplicaPatient>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReplicaPatient> builder)
|
||||
{
|
||||
builder.ToTable("patients");
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(p => p.Mrn).HasColumnName("mrn").HasMaxLength(20).IsRequired();
|
||||
builder.Property(p => p.FirstName).HasColumnName("first_name").HasMaxLength(100).IsRequired();
|
||||
builder.Property(p => p.LastName).HasColumnName("last_name").HasMaxLength(100).IsRequired();
|
||||
builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth");
|
||||
builder.Property(p => p.Gender).HasColumnName("gender").HasMaxLength(10).IsRequired();
|
||||
builder.Property(p => p.SyncedAt).HasColumnName("synced_at");
|
||||
|
||||
builder.HasIndex(p => p.Mrn).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class SyncOutboxEntryConfiguration : IEntityTypeConfiguration<SyncOutboxEntry>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SyncOutboxEntry> builder)
|
||||
{
|
||||
builder.ToTable("sync_outbox");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(e => e.BatchId).HasColumnName("batch_id");
|
||||
builder.Property(e => e.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(16)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => SyncOutboxStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'PENDING'")
|
||||
.HasSentinel((SyncOutboxStatus)(-1));
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(e => e.SubmittedAt).HasColumnName("submitted_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class GatewayDbContext : DbContext
|
||||
{
|
||||
public GatewayDbContext(DbContextOptions<GatewayDbContext> opts) : base(opts) { }
|
||||
|
||||
public DbSet<ReplicaPatient> Patients => Set<ReplicaPatient>();
|
||||
public DbSet<ReplicaEncounter> Encounters => Set<ReplicaEncounter>();
|
||||
public DbSet<ReplicaAlertThreshold> AlertThresholds => Set<ReplicaAlertThreshold>();
|
||||
public DbSet<LocalObservation> Observations => Set<LocalObservation>();
|
||||
public DbSet<LocalClinicalAlert> ClinicalAlerts => Set<LocalClinicalAlert>();
|
||||
public DbSet<BufferedSyncItem> BufferedSyncItems => Set<BufferedSyncItem>();
|
||||
public DbSet<SyncOutboxEntry> SyncOutbox => Set<SyncOutboxEntry>();
|
||||
public DbSet<GatewaySyncState> SyncState => Set<GatewaySyncState>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(GatewayDbContext).Assembly);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY VigilCareClinical.sln ./
|
||||
COPY VigilCare.ClinicalContracts/ VigilCare.ClinicalContracts/
|
||||
COPY VigilCare.WardGateway/ VigilCare.WardGateway/
|
||||
RUN dotnet restore VigilCare.WardGateway/VigilCare.WardGateway.csproj
|
||||
RUN dotnet publish VigilCare.WardGateway/VigilCare.WardGateway.csproj -c Release -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "VigilCare.WardGateway.dll"]
|
||||
@@ -0,0 +1,12 @@
|
||||
public class BufferedSyncItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public BufferedSyncItemType ItemType { get; set; }
|
||||
public string Payload { get; set; } = null!;
|
||||
public string IdempotencyKey { get; set; } = null!;
|
||||
public Guid EncounterId { get; set; }
|
||||
public DateTimeOffset RecordedAt { get; set; }
|
||||
public bool Synced { get; set; }
|
||||
public DateTimeOffset? SyncedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public class GatewaySyncState
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTimeOffset? LastEncounterSyncAt { get; set; }
|
||||
public bool InitialSyncCompleted { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
public class LocalClinicalAlert
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public Guid? ObservationId { get; set; }
|
||||
public AlertType AlertType { get; set; }
|
||||
public AlertSeverity Severity { get; set; }
|
||||
public string Details { get; set; } = null!;
|
||||
public string? ObservationCode { get; set; }
|
||||
public AlertStatus Status { get; set; } = AlertStatus.Open;
|
||||
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
public DateTimeOffset TriggeredAt { get; set; }
|
||||
public Guid ClientAlertId { get; set; }
|
||||
|
||||
public ReplicaEncounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
public class LocalObservation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public decimal Value { get; set; }
|
||||
public string Unit { get; set; } = null!;
|
||||
public ObservationSource Source { get; set; } = ObservationSource.Manual;
|
||||
public string? IdempotencyKey { get; set; }
|
||||
public DateTimeOffset RecordedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public ReplicaEncounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public class ReplicaAlertThreshold
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public string Unit { get; set; } = null!;
|
||||
public decimal? CriticalLow { get; set; }
|
||||
public decimal? WarningLow { get; set; }
|
||||
public decimal? WarningHigh { get; set; }
|
||||
public decimal? CriticalHigh { get; set; }
|
||||
public int? SuppressionWindowMinutes { get; set; }
|
||||
public DateTimeOffset SyncedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
public class ReplicaEncounter
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public EncounterType EncounterType { get; set; } = EncounterType.Inpatient;
|
||||
public EncounterStatus Status { get; set; }
|
||||
public Department Department { get; set; }
|
||||
public string AttendingPhysician { get; set; } = null!;
|
||||
public string? RoomBed { get; set; }
|
||||
public DateTimeOffset AdmittedAt { get; set; }
|
||||
public DateTimeOffset SyncedAt { get; set; }
|
||||
|
||||
public ReplicaPatient Patient { get; set; } = null!;
|
||||
public ICollection<LocalObservation> Observations { get; set; } = new List<LocalObservation>();
|
||||
public ICollection<LocalClinicalAlert> Alerts { get; set; } = new List<LocalClinicalAlert>();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
public class ReplicaPatient
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Mrn { get; set; } = null!;
|
||||
public string FirstName { get; set; } = null!;
|
||||
public string LastName { get; set; } = null!;
|
||||
public DateOnly DateOfBirth { get; set; }
|
||||
public string Gender { get; set; } = null!;
|
||||
public DateTimeOffset SyncedAt { get; set; }
|
||||
|
||||
public ICollection<ReplicaEncounter> Encounters { get; set; } = new List<ReplicaEncounter>();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
public class SyncOutboxEntry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
public SyncOutboxStatus Status { get; set; } = SyncOutboxStatus.Pending;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
public enum AlertSeverity { Warning, Critical }
|
||||
|
||||
public static class AlertSeverityExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertSeverity s) => s switch
|
||||
{
|
||||
AlertSeverity.Warning => "WARNING",
|
||||
AlertSeverity.Critical => "CRITICAL",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static AlertSeverity FromDbString(string v) => v switch
|
||||
{
|
||||
"WARNING" => AlertSeverity.Warning,
|
||||
"CRITICAL" => AlertSeverity.Critical,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert severity: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum AlertStatus { Open, Acknowledged, Resolved, Escalated }
|
||||
|
||||
public static class AlertStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertStatus s) => s switch
|
||||
{
|
||||
AlertStatus.Open => "OPEN",
|
||||
AlertStatus.Acknowledged => "ACKNOWLEDGED",
|
||||
AlertStatus.Resolved => "RESOLVED",
|
||||
AlertStatus.Escalated => "ESCALATED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static AlertStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"OPEN" => AlertStatus.Open,
|
||||
"ACKNOWLEDGED" => AlertStatus.Acknowledged,
|
||||
"RESOLVED" => AlertStatus.Resolved,
|
||||
"ESCALATED" => AlertStatus.Escalated,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
public enum AlertType
|
||||
{
|
||||
[Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")]
|
||||
SepsisWarning,
|
||||
CriticalHeartRate,
|
||||
CriticalTempC,
|
||||
CriticalPotassiumMeqL,
|
||||
CriticalSpo2,
|
||||
CriticalRespRate,
|
||||
CriticalWbcKUl,
|
||||
CriticalSystolicBp,
|
||||
CriticalDiastolicBp,
|
||||
CriticalLactateMmolL,
|
||||
CriticalAvpu,
|
||||
CriticalGlucoseMgDl,
|
||||
|
||||
// New — warning-level threshold alerts
|
||||
WarningHeartRate,
|
||||
WarningTempC,
|
||||
WarningPotassiumMeqL,
|
||||
WarningSpo2,
|
||||
WarningRespRate,
|
||||
WarningWbcKUl,
|
||||
WarningSystolicBp,
|
||||
WarningDiastolicBp,
|
||||
WarningLactateMmolL,
|
||||
WarningGlucoseMgDl,
|
||||
|
||||
News2Warning,
|
||||
News2Emergency,
|
||||
|
||||
RapidDeterioration,
|
||||
|
||||
[Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")]
|
||||
QsofaWarning,
|
||||
|
||||
QsofaScreen,
|
||||
|
||||
GcsCritical,
|
||||
GcsWarning,
|
||||
|
||||
CriticalPao2MmHg,
|
||||
WarningPao2MmHg,
|
||||
CriticalPlateletKUl,
|
||||
WarningPlateletKUl,
|
||||
CriticalBilirubinMgDl,
|
||||
WarningBilirubinMgDl,
|
||||
CriticalCreatinineMgDl,
|
||||
WarningCreatinineMgDl,
|
||||
|
||||
SofaSepsis,
|
||||
SofaWarning,
|
||||
}
|
||||
|
||||
public static class AlertTypeExtensions
|
||||
{
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static string ToDbString(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.SepsisWarning => "SEPSIS_WARNING",
|
||||
AlertType.CriticalHeartRate => "CRITICAL_HEART_RATE",
|
||||
AlertType.CriticalTempC => "CRITICAL_TEMP_C",
|
||||
AlertType.CriticalPotassiumMeqL => "CRITICAL_POTASSIUM_MEQ_L",
|
||||
AlertType.CriticalSpo2 => "CRITICAL_SPO2",
|
||||
AlertType.CriticalRespRate => "CRITICAL_RESP_RATE",
|
||||
AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL",
|
||||
AlertType.CriticalSystolicBp => "CRITICAL_SYSTOLIC_BP",
|
||||
AlertType.CriticalDiastolicBp => "CRITICAL_DIASTOLIC_BP",
|
||||
AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L",
|
||||
AlertType.CriticalAvpu => "CRITICAL_AVPU",
|
||||
AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL",
|
||||
AlertType.WarningHeartRate => "WARNING_HEART_RATE",
|
||||
AlertType.WarningTempC => "WARNING_TEMP_C",
|
||||
AlertType.WarningPotassiumMeqL => "WARNING_POTASSIUM_MEQ_L",
|
||||
AlertType.WarningSpo2 => "WARNING_SPO2",
|
||||
AlertType.WarningRespRate => "WARNING_RESP_RATE",
|
||||
AlertType.WarningWbcKUl => "WARNING_WBC_K_UL",
|
||||
AlertType.WarningSystolicBp => "WARNING_SYSTOLIC_BP",
|
||||
AlertType.WarningDiastolicBp => "WARNING_DIASTOLIC_BP",
|
||||
AlertType.WarningLactateMmolL => "WARNING_LACTATE_MMOL_L",
|
||||
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
|
||||
AlertType.News2Warning => "NEWS2_WARNING",
|
||||
AlertType.News2Emergency => "NEWS2_EMERGENCY",
|
||||
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
|
||||
AlertType.QsofaWarning => "QSOFA_WARNING",
|
||||
AlertType.GcsCritical => "GCS_CRITICAL",
|
||||
AlertType.GcsWarning => "GCS_WARNING",
|
||||
AlertType.CriticalPao2MmHg => "CRITICAL_PAO2_MMHG",
|
||||
AlertType.WarningPao2MmHg => "WARNING_PAO2_MMHG",
|
||||
AlertType.CriticalPlateletKUl => "CRITICAL_PLATELET_K_UL",
|
||||
AlertType.WarningPlateletKUl => "WARNING_PLATELET_K_UL",
|
||||
AlertType.CriticalBilirubinMgDl => "CRITICAL_BILIRUBIN_MG_DL",
|
||||
AlertType.WarningBilirubinMgDl => "WARNING_BILIRUBIN_MG_DL",
|
||||
AlertType.CriticalCreatinineMgDl => "CRITICAL_CREATININE_MG_DL",
|
||||
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
|
||||
AlertType.SofaSepsis => "SOFA_SEPSIS",
|
||||
AlertType.SofaWarning => "SOFA_WARNING",
|
||||
AlertType.QsofaScreen => "QSOFA_SCREEN",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static AlertType FromDbString(string v) => v switch
|
||||
{
|
||||
"SEPSIS_WARNING" => AlertType.SepsisWarning,
|
||||
"CRITICAL_HEART_RATE" => AlertType.CriticalHeartRate,
|
||||
"CRITICAL_TEMP_C" => AlertType.CriticalTempC,
|
||||
"CRITICAL_POTASSIUM_MEQ_L"=> AlertType.CriticalPotassiumMeqL,
|
||||
"CRITICAL_SPO2" => AlertType.CriticalSpo2,
|
||||
"CRITICAL_RESP_RATE" => AlertType.CriticalRespRate,
|
||||
"CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||
"CRITICAL_SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
|
||||
"CRITICAL_DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
|
||||
"CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||
"CRITICAL_AVPU" => AlertType.CriticalAvpu,
|
||||
"CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||
"WARNING_HEART_RATE" => AlertType.WarningHeartRate,
|
||||
"WARNING_TEMP_C" => AlertType.WarningTempC,
|
||||
"WARNING_POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||
"WARNING_SPO2" => AlertType.WarningSpo2,
|
||||
"WARNING_RESP_RATE" => AlertType.WarningRespRate,
|
||||
"WARNING_WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||
"WARNING_SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||
"WARNING_DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||
"WARNING_LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||
"NEWS2_WARNING" => AlertType.News2Warning,
|
||||
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
|
||||
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
|
||||
"QSOFA_WARNING" => AlertType.QsofaWarning,
|
||||
"QSOFA_SCREEN" => AlertType.QsofaScreen,
|
||||
"GCS_CRITICAL" => AlertType.GcsCritical,
|
||||
"GCS_WARNING" => AlertType.GcsWarning,
|
||||
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
|
||||
"WARNING_PAO2_MMHG" => AlertType.WarningPao2MmHg,
|
||||
"CRITICAL_PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
|
||||
"WARNING_PLATELET_K_UL" => AlertType.WarningPlateletKUl,
|
||||
"CRITICAL_BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
|
||||
"WARNING_BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
|
||||
"CRITICAL_CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
|
||||
"WARNING_CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
|
||||
"SOFA_SEPSIS" => AlertType.SofaSepsis,
|
||||
"SOFA_WARNING" => AlertType.SofaWarning,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// Threshold alerts are derived from observation codes in alert_thresholds — not free-form strings.
|
||||
public static AlertType CriticalFor(string observationCode) => observationCode switch
|
||||
{
|
||||
"HEART_RATE" => AlertType.CriticalHeartRate,
|
||||
"TEMP_C" => AlertType.CriticalTempC,
|
||||
"POTASSIUM_MEQ_L" => AlertType.CriticalPotassiumMeqL,
|
||||
"SPO2" => AlertType.CriticalSpo2,
|
||||
"RESP_RATE" => AlertType.CriticalRespRate,
|
||||
"WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||
"SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
|
||||
"DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
|
||||
"LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||
"AVPU" => AlertType.CriticalAvpu,
|
||||
"GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||
"PAO2_MMHG" => AlertType.CriticalPao2MmHg,
|
||||
"PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
|
||||
"BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
|
||||
"CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
|
||||
};
|
||||
|
||||
public static AlertType WarningFor(string observationCode) => observationCode switch
|
||||
{
|
||||
"HEART_RATE" => AlertType.WarningHeartRate,
|
||||
"TEMP_C" => AlertType.WarningTempC,
|
||||
"POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||
"SPO2" => AlertType.WarningSpo2,
|
||||
"RESP_RATE" => AlertType.WarningRespRate,
|
||||
"WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||
"SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||
"PAO2_MMHG" => AlertType.WarningPao2MmHg,
|
||||
"PLATELET_K_UL" => AlertType.WarningPlateletKUl,
|
||||
"BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
|
||||
"CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
|
||||
};
|
||||
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static bool IsSuppressible(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.SepsisWarning or AlertType.News2Emergency => false,
|
||||
AlertType.CriticalHeartRate or AlertType.CriticalTempC or AlertType.CriticalPotassiumMeqL
|
||||
or AlertType.CriticalSpo2 or AlertType.CriticalRespRate or AlertType.CriticalWbcKUl
|
||||
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
|
||||
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
|
||||
or AlertType.CriticalGlucoseMgDl => false,
|
||||
AlertType.RapidDeterioration => false,
|
||||
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
|
||||
AlertType.SofaSepsis => false,
|
||||
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
public static string? ObservationCodeForWarning(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.WarningHeartRate => "HEART_RATE",
|
||||
AlertType.WarningTempC => "TEMP_C",
|
||||
AlertType.WarningPotassiumMeqL => "POTASSIUM_MEQ_L",
|
||||
AlertType.WarningSpo2 => "SPO2",
|
||||
AlertType.WarningRespRate => "RESP_RATE",
|
||||
AlertType.WarningWbcKUl => "WBC_K_UL",
|
||||
AlertType.WarningSystolicBp => "SYSTOLIC_BP",
|
||||
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
|
||||
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
|
||||
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
|
||||
AlertType.WarningPao2MmHg => "PAO2_MMHG",
|
||||
AlertType.WarningPlateletKUl => "PLATELET_K_UL",
|
||||
AlertType.WarningBilirubinMgDl => "BILIRUBIN_MG_DL",
|
||||
AlertType.WarningCreatinineMgDl => "CREATININE_MG_DL",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum BufferedSyncItemType { Observation, Alert, Ack, Resolve }
|
||||
|
||||
public static class BufferedSyncItemTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this BufferedSyncItemType t) => t switch
|
||||
{
|
||||
BufferedSyncItemType.Observation => "OBSERVATION",
|
||||
BufferedSyncItemType.Alert => "ALERT",
|
||||
BufferedSyncItemType.Ack => "ACK",
|
||||
BufferedSyncItemType.Resolve => "RESOLVE",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static BufferedSyncItemType FromDbString(string v) => v switch
|
||||
{
|
||||
"OBSERVATION" => BufferedSyncItemType.Observation,
|
||||
"ALERT" => BufferedSyncItemType.Alert,
|
||||
"ACK" => BufferedSyncItemType.Ack,
|
||||
"RESOLVE" => BufferedSyncItemType.Resolve,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown buffered sync item type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -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 EncounterStatus { Scheduled, Active, Discharged, Cancelled }
|
||||
|
||||
public static class EncounterStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this EncounterStatus s) => s switch
|
||||
{
|
||||
EncounterStatus.Scheduled => "SCHEDULED",
|
||||
EncounterStatus.Active => "ACTIVE",
|
||||
EncounterStatus.Discharged => "DISCHARGED",
|
||||
EncounterStatus.Cancelled => "CANCELLED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static EncounterStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"SCHEDULED" => EncounterStatus.Scheduled,
|
||||
"ACTIVE" => EncounterStatus.Active,
|
||||
"DISCHARGED" => EncounterStatus.Discharged,
|
||||
"CANCELLED" => EncounterStatus.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown encounter status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public enum EncounterType { Inpatient, Outpatient, Emergency }
|
||||
|
||||
public static class EncounterTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this EncounterType t) => t switch
|
||||
{
|
||||
EncounterType.Inpatient => "INPATIENT",
|
||||
EncounterType.Outpatient => "OUTPATIENT",
|
||||
EncounterType.Emergency => "EMERGENCY",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static EncounterType FromDbString(string v) => v switch
|
||||
{
|
||||
"INPATIENT" => EncounterType.Inpatient,
|
||||
"OUTPATIENT" => EncounterType.Outpatient,
|
||||
"EMERGENCY" => EncounterType.Emergency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown encounter type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public enum ObservationSource { Manual, Device, Lab }
|
||||
|
||||
public static class ObservationSourceExtensions
|
||||
{
|
||||
public static string ToDbString(this ObservationSource s) => s switch
|
||||
{
|
||||
ObservationSource.Manual => "MANUAL",
|
||||
ObservationSource.Device => "DEVICE",
|
||||
ObservationSource.Lab => "LAB",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static ObservationSource FromDbString(string v) => v switch
|
||||
{
|
||||
"MANUAL" => ObservationSource.Manual,
|
||||
"DEVICE" => ObservationSource.Device,
|
||||
"LAB" => ObservationSource.Lab,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown observation source: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
public enum SyncOutboxStatus { Pending, Submitted }
|
||||
|
||||
public static class SyncOutboxStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this SyncOutboxStatus s) => s switch
|
||||
{
|
||||
SyncOutboxStatus.Pending => "PENDING",
|
||||
SyncOutboxStatus.Submitted => "SUBMITTED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static SyncOutboxStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"PENDING" => SyncOutboxStatus.Pending,
|
||||
"SUBMITTED" => SyncOutboxStatus.Submitted,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown sync outbox status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class DepartmentJsonConverter : JsonConverter<Department>
|
||||
{
|
||||
public override Department Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> DepartmentExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Department value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
public sealed class EncounterReplicaReadyCheck : IHealthCheck
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
|
||||
public EncounterReplicaReadyCheck(GatewayDbContext db) => _db = db;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken ct = default)
|
||||
{
|
||||
var encounterCount = await _db.Encounters.CountAsync(ct);
|
||||
if (encounterCount > 0)
|
||||
return HealthCheckResult.Healthy("Replica data available.");
|
||||
|
||||
var state = await _db.SyncState.FirstOrDefaultAsync(ct);
|
||||
if (state?.InitialSyncCompleted == true)
|
||||
return HealthCheckResult.Healthy("Initial sync completed.");
|
||||
|
||||
return HealthCheckResult.Unhealthy(
|
||||
"No encounter replica data — first boot requires central sync.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
public static class HealthCheckResponseWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static async Task WriteAsync(HttpContext context, HealthReport report)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = new
|
||||
{
|
||||
status = report.Status.ToString(),
|
||||
totalDurationMs = report.TotalDuration.TotalMilliseconds,
|
||||
checks = report.Entries.Select(e => new
|
||||
{
|
||||
name = e.Key,
|
||||
status = e.Value.Status.ToString(),
|
||||
durationMs = e.Value.Duration.TotalMilliseconds,
|
||||
description = e.Value.Description,
|
||||
data = e.Value.Data.Count > 0 ? e.Value.Data : null,
|
||||
exception = e.Value.Exception?.Message
|
||||
})
|
||||
};
|
||||
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
|
||||
public sealed class RabbitMqHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly RabbitMqOptions _options;
|
||||
|
||||
public RabbitMqHealthCheck(IOptions<RabbitMqOptions> options) => _options = options.Value;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = _options.Host,
|
||||
Port = _options.Port,
|
||||
UserName = _options.Username,
|
||||
Password = _options.Password
|
||||
};
|
||||
|
||||
using var connection = await Task.Run(() => factory.CreateConnection(), cancellationToken);
|
||||
var data = new Dictionary<string, object> { ["endpoint"] = connection.Endpoint.ToString() };
|
||||
return HealthCheckResult.Healthy(data: data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public sealed class RedisHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
|
||||
public RedisHealthCheck(IConnectionMultiplexer redis) => _redis = redis;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var latency = await db.PingAsync();
|
||||
var data = new Dictionary<string, object> { ["ping_ms"] = latency.TotalMilliseconds };
|
||||
return HealthCheckResult.Healthy(data: data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
public class ExceptionHandlerMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
|
||||
|
||||
public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status404NotFound,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status404NotFound, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status422UnprocessableEntity,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status422UnprocessableEntity, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (ConflictException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status409Conflict,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status409Conflict, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception");
|
||||
await WriteAsync(context, StatusCodes.Status500InternalServerError,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status500InternalServerError,
|
||||
"An unexpected error occurred", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteAsync<T>(HttpContext context, int status, ApiResponse<T> body)
|
||||
{
|
||||
context.Response.StatusCode = status;
|
||||
await context.Response.WriteAsJsonAsync(body);
|
||||
}
|
||||
}
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
// <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 VigilCare.WardGateway.Migrations
|
||||
{
|
||||
[DbContext(typeof(GatewayDbContext))]
|
||||
[Migration("20260623073142_InitialGatewaySchema")]
|
||||
partial class InitialGatewaySchema
|
||||
{
|
||||
/// <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("BufferedSyncItem", 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")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ItemType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("item_type");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<bool>("Synced")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("synced");
|
||||
|
||||
b.Property<DateTimeOffset?>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
b.ToTable("buffered_sync_items", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_buffered_sync_items_item_type", "item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GatewaySyncState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<bool>("InitialSyncCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("initial_sync_completed");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastEncounterSyncAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_encounter_sync_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("gateway_sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalClinicalAlert", 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<Guid>("ClientAlertId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("client_alert_id");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
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("ClientAlertId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("EncounterId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("PatientId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("Severity", "TriggeredAt")
|
||||
.HasFilter("status = 'OPEN'");
|
||||
|
||||
b.HasIndex("EncounterId", "AlertType", "ObservationCode")
|
||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||
|
||||
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', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalObservation", 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("ReplicaAlertThreshold", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
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<int?>("SuppressionWindowMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("suppression_window_minutes");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
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("ReplicaEncounter", 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<string>("Department")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
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>("RoomBed")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'SCHEDULED'");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
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("ReplicaPatient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
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<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("patients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SyncOutboxEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("batch_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("submitted_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("sync_outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("ReplicaEncounter", "Encounter")
|
||||
.WithMany("Alerts")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalObservation", b =>
|
||||
{
|
||||
b.HasOne("ReplicaEncounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.HasOne("ReplicaPatient", "Patient")
|
||||
.WithMany("Encounters")
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaPatient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCare.WardGateway.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialGatewaySchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "alert_thresholds",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
critical_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
warning_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
warning_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
critical_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
suppression_window_minutes = table.Column<int>(type: "integer", nullable: true),
|
||||
synced_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_alert_thresholds", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "buffered_sync_items",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
item_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
payload = table.Column<string>(type: "jsonb", nullable: false),
|
||||
idempotency_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
synced = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
synced_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_buffered_sync_items", x => x.id);
|
||||
table.CheckConstraint("chk_buffered_sync_items_item_type", "item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "gateway_sync_state",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
last_encounter_sync_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
initial_sync_completed = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_gateway_sync_state", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "patients",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
mrn = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
first_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
last_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
date_of_birth = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
gender = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
synced_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_patients", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sync_outbox",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false, defaultValueSql: "'PENDING'"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
submitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sync_outbox", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "encounters",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
encounter_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'SCHEDULED'"),
|
||||
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
attending_physician = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
room_bed = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||
admitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
synced_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_encounters", x => x.id);
|
||||
table.CheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
table.CheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
table.CheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
table.ForeignKey(
|
||||
name: "FK_encounters_patients_patient_id",
|
||||
column: x => x.patient_id,
|
||||
principalTable: "patients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "clinical_alerts",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
observation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
alert_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
severity = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
details = table.Column<string>(type: "text", nullable: false),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'OPEN'"),
|
||||
acknowledged_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
acknowledged_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
resolved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
triggered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
client_alert_id = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_clinical_alerts", x => x.id);
|
||||
table.CheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
table.CheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
table.CheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
table.ForeignKey(
|
||||
name: "FK_clinical_alerts_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "observations",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
value = table.Column<decimal>(type: "numeric(10,3)", nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
source = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'MANUAL'"),
|
||||
idempotency_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_observations", x => x.id);
|
||||
table.CheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
table.ForeignKey(
|
||||
name: "FK_observations_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_thresholds_observation_code",
|
||||
table: "alert_thresholds",
|
||||
column: "observation_code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_buffered_sync_items_created_at",
|
||||
table: "buffered_sync_items",
|
||||
column: "created_at",
|
||||
filter: "NOT synced");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_buffered_sync_items_idempotency_key",
|
||||
table: "buffered_sync_items",
|
||||
column: "idempotency_key",
|
||||
unique: true,
|
||||
filter: "NOT synced");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_client_alert_id",
|
||||
table: "clinical_alerts",
|
||||
column: "client_alert_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_encounter_id_alert_type_observation_code",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "encounter_id", "alert_type", "observation_code" },
|
||||
filter: "status IN ('OPEN', 'ESCALATED')");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_encounter_id_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "encounter_id", "triggered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_patient_id_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "patient_id", "triggered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_severity_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "severity", "triggered_at" },
|
||||
filter: "status = 'OPEN'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_encounters_patient_id_admitted_at",
|
||||
table: "encounters",
|
||||
columns: new[] { "patient_id", "admitted_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_encounters_status_admitted_at",
|
||||
table: "encounters",
|
||||
columns: new[] { "status", "admitted_at" },
|
||||
filter: "status = 'ACTIVE'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_observations_encounter_id_observation_code_recorded_at",
|
||||
table: "observations",
|
||||
columns: new[] { "encounter_id", "observation_code", "recorded_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_observations_idempotency_key",
|
||||
table: "observations",
|
||||
column: "idempotency_key",
|
||||
unique: true,
|
||||
filter: "idempotency_key IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_patients_mrn",
|
||||
table: "patients",
|
||||
column: "mrn",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "alert_thresholds");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "buffered_sync_items");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "clinical_alerts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "gateway_sync_state");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "observations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sync_outbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "encounters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "patients");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCare.WardGateway.Migrations
|
||||
{
|
||||
[DbContext(typeof(GatewayDbContext))]
|
||||
partial class GatewayDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("BufferedSyncItem", 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")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ItemType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("item_type");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<bool>("Synced")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("synced");
|
||||
|
||||
b.Property<DateTimeOffset?>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
b.ToTable("buffered_sync_items", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_buffered_sync_items_item_type", "item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GatewaySyncState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<bool>("InitialSyncCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("initial_sync_completed");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastEncounterSyncAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_encounter_sync_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("gateway_sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalClinicalAlert", 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<Guid>("ClientAlertId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("client_alert_id");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
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("ClientAlertId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("EncounterId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("PatientId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("Severity", "TriggeredAt")
|
||||
.HasFilter("status = 'OPEN'");
|
||||
|
||||
b.HasIndex("EncounterId", "AlertType", "ObservationCode")
|
||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||
|
||||
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', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalObservation", 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("ReplicaAlertThreshold", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
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<int?>("SuppressionWindowMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("suppression_window_minutes");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
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("ReplicaEncounter", 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<string>("Department")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
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>("RoomBed")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'SCHEDULED'");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
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("ReplicaPatient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
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<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("patients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SyncOutboxEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("batch_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("submitted_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("sync_outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("ReplicaEncounter", "Encounter")
|
||||
.WithMany("Alerts")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalObservation", b =>
|
||||
{
|
||||
b.HasOne("ReplicaEncounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.HasOne("ReplicaPatient", "Patient")
|
||||
.WithMany("Encounters")
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaPatient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public static class CentralApiJson
|
||||
{
|
||||
public static JsonSerializerOptions Options { get; } = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(),
|
||||
new DepartmentJsonConverter()
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
public sealed class CentralEncounterListPage
|
||||
{
|
||||
public List<CentralWardEncounterSummary> Items { get; set; } = [];
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
public int TotalPages { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CentralWardEncounterSummary
|
||||
{
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CentralEncounterDetail
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public EncounterType EncounterType { get; set; }
|
||||
public EncounterStatus Status { get; set; }
|
||||
public Department Department { get; set; }
|
||||
public string AttendingPhysician { get; set; } = null!;
|
||||
public string? RoomBed { get; set; }
|
||||
public DateTimeOffset AdmittedAt { get; set; }
|
||||
public CentralPatientDetail Patient { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class CentralPatientDetail
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Mrn { get; set; } = null!;
|
||||
public string FirstName { get; set; } = null!;
|
||||
public string LastName { get; set; } = null!;
|
||||
public DateOnly DateOfBirth { get; set; }
|
||||
public string Gender { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class CentralAlertThreshold
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public string Unit { get; set; } = null!;
|
||||
public decimal? CriticalLow { get; set; }
|
||||
public decimal? WarningLow { get; set; }
|
||||
public decimal? WarningHigh { get; set; }
|
||||
public decimal? CriticalHigh { get; set; }
|
||||
public int? SuppressionWindowMinutes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
public record AcknowledgeAlertRequest(string? Note);
|
||||
@@ -0,0 +1,13 @@
|
||||
public record EncounterDetail(
|
||||
Guid Id,
|
||||
Guid PatientId,
|
||||
string Mrn,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
Department Department,
|
||||
EncounterStatus Status,
|
||||
string? RoomBed,
|
||||
string AttendingPhysician,
|
||||
DateTimeOffset AdmittedAt,
|
||||
IReadOnlyList<LocalObservation> RecentObservations,
|
||||
IReadOnlyList<LocalClinicalAlert> OpenAlerts);
|
||||
@@ -0,0 +1,13 @@
|
||||
public record WardEncounterSummary(
|
||||
Guid EncounterId,
|
||||
string Mrn,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
Department Department,
|
||||
string? RoomBed,
|
||||
string AttendingPhysician,
|
||||
DateTimeOffset AdmittedAt,
|
||||
int OpenAlertCount,
|
||||
int? News2Score,
|
||||
int? QsofaScore,
|
||||
string? SepsisBundleStatus);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record LocalIngestResult(bool IsDuplicate, LocalObservation Observation, LocalClinicalAlert? Alert)
|
||||
{
|
||||
public static LocalIngestResult Created(LocalObservation o, LocalClinicalAlert? a) =>
|
||||
new(false, o, a);
|
||||
public static LocalIngestResult Duplicate(LocalObservation o) =>
|
||||
new(true, o, null);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public record IngestObservationRequest(
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
ObservationSource Source,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? IdempotencyKey);
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
public record ObservationCursor(DateTimeOffset RecordedAt, Guid Id)
|
||||
{
|
||||
public string Encode()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(this);
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
|
||||
}
|
||||
|
||||
public static ObservationCursor? Decode(string? encoded)
|
||||
{
|
||||
if (string.IsNullOrEmpty(encoded)) return null;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
|
||||
return JsonSerializer.Deserialize<ObservationCursor>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public record ThresholdCacheEntry(
|
||||
string ObservationCode,
|
||||
decimal? CriticalLow,
|
||||
decimal? WarningLow,
|
||||
decimal? WarningHigh,
|
||||
decimal? CriticalHigh);
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Exceptions;
|
||||
|
||||
public sealed class RabbitMqTopologyProvisioner : IHostedService
|
||||
{
|
||||
public const string Exchange = "clinical.notifications.exchange";
|
||||
public const string PagingKey = "alerts.paging";
|
||||
public const string EscalKey = "alerts.escalation";
|
||||
|
||||
private readonly RabbitMqOptions _opts;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly ILogger<RabbitMqTopologyProvisioner> _logger;
|
||||
|
||||
public RabbitMqTopologyProvisioner(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IHostEnvironment env,
|
||||
ILogger<RabbitMqTopologyProvisioner> logger)
|
||||
{
|
||||
_opts = opts.Value;
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
var factory = BuildFactory();
|
||||
using var connection = factory.CreateConnection("gateway-topology");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
if (_env.IsDevelopment() || _env.EnvironmentName == "Testing")
|
||||
{
|
||||
using var cleanup = connection.CreateModel();
|
||||
try
|
||||
{
|
||||
cleanup.QueueDelete("alerts.paging.dlq", ifUnused: false, ifEmpty: false);
|
||||
}
|
||||
catch (OperationInterruptedException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "DLQ delete skipped — queue may not exist yet");
|
||||
}
|
||||
}
|
||||
|
||||
channel.ExchangeDeclare(Exchange, ExchangeType.Direct, durable: true);
|
||||
|
||||
channel.QueueDeclare(
|
||||
queue: "alerts.paging.queue",
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: new Dictionary<string, object>
|
||||
{
|
||||
["x-dead-letter-exchange"] = "",
|
||||
["x-dead-letter-routing-key"] = "alerts.paging.dlq",
|
||||
});
|
||||
channel.QueueBind("alerts.paging.queue", Exchange, PagingKey);
|
||||
|
||||
channel.QueueDeclare(
|
||||
queue: "alerts.paging.dlq",
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: new Dictionary<string, object>
|
||||
{
|
||||
["x-message-ttl"] = _opts.PagingAckTimeoutMs,
|
||||
["x-dead-letter-exchange"] = Exchange,
|
||||
["x-dead-letter-routing-key"] = EscalKey,
|
||||
});
|
||||
|
||||
channel.QueueDeclare(
|
||||
queue: "alerts.escalation.queue",
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null);
|
||||
channel.QueueBind("alerts.escalation.queue", Exchange, EscalKey);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Gateway RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}",
|
||||
Exchange, _opts.PagingAckTimeoutMs);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public ConnectionFactory BuildFactory() => new()
|
||||
{
|
||||
HostName = _opts.Host,
|
||||
Port = _opts.Port,
|
||||
UserName = _opts.Username,
|
||||
Password = _opts.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Prometheus;
|
||||
using Serilog;
|
||||
using StackExchange.Redis;
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
if (!builder.Environment.IsEnvironment("Testing"))
|
||||
builder.Host.UseSerilog((ctx, _, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration));
|
||||
|
||||
builder.Services.AddDbContext<GatewayDbContext>(opts =>
|
||||
opts.UseNpgsql(builder.Configuration.GetConnectionString("GatewayDb")));
|
||||
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!));
|
||||
|
||||
builder.Services.Configure<GatewayOptions>(builder.Configuration.GetSection(GatewayOptions.Section));
|
||||
builder.Services.Configure<CentralApiOptions>(builder.Configuration.GetSection(CentralApiOptions.Section));
|
||||
builder.Services.Configure<RabbitMqOptions>(builder.Configuration.GetSection(RabbitMqOptions.Section));
|
||||
builder.Services.Configure<SuppressionOptions>(builder.Configuration.GetSection(SuppressionOptions.SectionName));
|
||||
|
||||
var jwt = builder.Configuration.GetSection("Jwt");
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(o =>
|
||||
{
|
||||
o.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwt["Issuer"],
|
||||
ValidAudience = jwt["Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(jwt["SigningKey"]!))
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
builder.Services.AddHttpClient("central");
|
||||
|
||||
builder.Services.AddSingleton<CentralReachabilityService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CentralReachabilityService>());
|
||||
builder.Services.AddHostedService<GatewayHeartbeatService>();
|
||||
builder.Services.AddHostedService<EncounterReplicaSyncService>();
|
||||
builder.Services.AddHostedService<SyncUploaderService>();
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
|
||||
builder.Services.AddSingleton<RabbitMqTopologyProvisioner>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<RabbitMqTopologyProvisioner>());
|
||||
builder.Services.AddSingleton<LocalPagingPublisher>();
|
||||
builder.Services.AddHostedService<LocalPagingWorkerService>();
|
||||
builder.Services.AddHostedService<LocalEscalationWorkerService>();
|
||||
builder.Services.AddScoped<LocalWarningEvaluator>();
|
||||
builder.Services.AddScoped<ILocalObservationService, LocalObservationService>();
|
||||
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
|
||||
builder.Services.AddScoped<ILocalAlertService, LocalAlertService>();
|
||||
builder.Services.AddScoped<IEncounterReadService, EncounterReadService>();
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<GatewayDbContext>("postgresql", tags: ["ready"])
|
||||
.AddCheck<RedisHealthCheck>("redis", tags: ["ready"])
|
||||
.AddCheck<RabbitMqHealthCheck>("rabbitmq", tags: ["ready"])
|
||||
.AddCheck<EncounterReplicaReadyCheck>("encounter_replica", tags: ["ready"]);
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
builder.Services.AddCors(o => o.AddPolicy("Dashboard", p =>
|
||||
p.WithOrigins("http://localhost:5173").AllowAnyHeader().AllowAnyMethod()));
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseMiddleware<ExceptionHandlerMiddleware>();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
app.UseSerilogRequestLogging();
|
||||
}
|
||||
|
||||
app.UseCors("Dashboard");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseHttpMetrics();
|
||||
app.MapControllers();
|
||||
app.MapMetrics("/metrics");
|
||||
|
||||
app.MapHealthChecks("/health/live", new()
|
||||
{
|
||||
Predicate = _ => false,
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteAsync
|
||||
});
|
||||
app.MapHealthChecks("/health/ready", new()
|
||||
{
|
||||
Predicate = c => c.Tags.Contains("ready"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteAsync
|
||||
});
|
||||
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:50602",
|
||||
"sslPort": 44341
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5249",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7257;http://localhost:5249",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
|
||||
public static class BufferedSyncWriter
|
||||
{
|
||||
public static void Enqueue(
|
||||
GatewayDbContext db,
|
||||
BufferedSyncItemType itemType,
|
||||
object payload,
|
||||
string idempotencyKey,
|
||||
Guid encounterId,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
db.BufferedSyncItems.Add(new BufferedSyncItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ItemType = itemType,
|
||||
Payload = JsonSerializer.Serialize(payload),
|
||||
IdempotencyKey = idempotencyKey,
|
||||
EncounterId = encounterId,
|
||||
RecordedAt = recordedAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class EncounterReadService : IEncounterReadService
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
|
||||
public EncounterReadService(GatewayDbContext db) => _db = db;
|
||||
|
||||
public async Task<List<WardEncounterSummary>> ListActiveAsync(string? department)
|
||||
{
|
||||
var q = _db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.Where(e => e.Status == EncounterStatus.Active);
|
||||
|
||||
if (!string.IsNullOrEmpty(department))
|
||||
{
|
||||
var dept = DepartmentExtensions.FromDbString(department);
|
||||
q = q.Where(e => e.Department == dept);
|
||||
}
|
||||
|
||||
var encounters = await q
|
||||
.OrderByDescending(e => e.AdmittedAt)
|
||||
.ToListAsync();
|
||||
|
||||
if (encounters.Count == 0)
|
||||
return [];
|
||||
|
||||
var encounterIds = encounters.Select(e => e.Id).ToList();
|
||||
var openAlertCounts = await _db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
.Where(a => encounterIds.Contains(a.EncounterId) && a.Status == AlertStatus.Open)
|
||||
.GroupBy(a => a.EncounterId)
|
||||
.Select(g => new { EncounterId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.EncounterId, x => x.Count);
|
||||
|
||||
return encounters.Select(e => new WardEncounterSummary(
|
||||
e.Id,
|
||||
e.Patient.Mrn,
|
||||
e.Patient.FirstName,
|
||||
e.Patient.LastName,
|
||||
e.Department,
|
||||
e.RoomBed,
|
||||
e.AttendingPhysician,
|
||||
e.AdmittedAt,
|
||||
openAlertCounts.GetValueOrDefault(e.Id),
|
||||
News2Score: null,
|
||||
QsofaScore: null,
|
||||
SepsisBundleStatus: null)).ToList();
|
||||
}
|
||||
|
||||
public async Task<EncounterDetail?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var enc = await _db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
if (enc is null) return null;
|
||||
|
||||
var observations = await _db.Observations
|
||||
.AsNoTracking()
|
||||
.Where(o => o.EncounterId == id)
|
||||
.OrderByDescending(o => o.RecordedAt)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
var alerts = await _db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.EncounterId == id && a.Status == AlertStatus.Open)
|
||||
.OrderByDescending(a => a.TriggeredAt)
|
||||
.ToListAsync();
|
||||
|
||||
return new EncounterDetail(
|
||||
enc.Id,
|
||||
enc.PatientId,
|
||||
enc.Patient.Mrn,
|
||||
enc.Patient.FirstName,
|
||||
enc.Patient.LastName,
|
||||
enc.Department,
|
||||
enc.Status,
|
||||
enc.RoomBed,
|
||||
enc.AttendingPhysician,
|
||||
enc.AdmittedAt,
|
||||
observations,
|
||||
alerts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public interface IEncounterReadService
|
||||
{
|
||||
Task<List<WardEncounterSummary>> ListActiveAsync(string? department);
|
||||
Task<EncounterDetail?> GetByIdAsync(Guid id);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public interface ILocalAlertService
|
||||
{
|
||||
Task<LocalClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req, string clinicianId);
|
||||
Task<LocalClinicalAlert> ResolveAsync(Guid id);
|
||||
Task<PagedResult<LocalClinicalAlert>> ListAsync(Guid? encounterId, string? status, int page, int pageSize);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public interface ILocalObservationService
|
||||
{
|
||||
Task<LocalIngestResult> IngestAsync(Guid encounterId, IngestObservationRequest req);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
public interface IObservationQueryService
|
||||
{
|
||||
Task<CursorPage<LocalObservation>> GetHistoryAsync(
|
||||
Guid encounterId,
|
||||
string? code,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int limit,
|
||||
string? cursorToken);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class LocalAlertService : ILocalAlertService
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
|
||||
public LocalAlertService(GatewayDbContext db) => _db = db;
|
||||
|
||||
public async Task<LocalClinicalAlert> AcknowledgeAsync(
|
||||
Guid id, AcknowledgeAlertRequest req, string clinicianId)
|
||||
{
|
||||
var alert = await _db.ClinicalAlerts.FindAsync(id)
|
||||
?? throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
|
||||
|
||||
if (alert.Status is not (AlertStatus.Open or AlertStatus.Escalated))
|
||||
throw new ConflictException("Alert cannot be acknowledged.", "ALERT_NOT_ACKNOWLEDGEABLE");
|
||||
|
||||
alert.Status = AlertStatus.Acknowledged;
|
||||
alert.AcknowledgedAt = DateTimeOffset.UtcNow;
|
||||
alert.AcknowledgedBy = clinicianId;
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Ack, new
|
||||
{
|
||||
clientRef = Guid.NewGuid(),
|
||||
alert.ClientAlertId,
|
||||
clinicianId,
|
||||
acknowledgedAt = alert.AcknowledgedAt,
|
||||
req.Note
|
||||
}, $"ack:{alert.ClientAlertId}:{alert.AcknowledgedAt:o}", alert.EncounterId, alert.AcknowledgedAt!.Value);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return alert;
|
||||
}
|
||||
|
||||
public async Task<LocalClinicalAlert> ResolveAsync(Guid id)
|
||||
{
|
||||
var alert = await _db.ClinicalAlerts.FindAsync(id)
|
||||
?? throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
|
||||
|
||||
if (alert.Status != AlertStatus.Acknowledged)
|
||||
throw new ConflictException("Alert must be acknowledged first.", "ALERT_NOT_ACKNOWLEDGED");
|
||||
|
||||
alert.Status = AlertStatus.Resolved;
|
||||
alert.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Resolve, new
|
||||
{
|
||||
clientRef = Guid.NewGuid(),
|
||||
alert.ClientAlertId,
|
||||
clinicianId = alert.AcknowledgedBy,
|
||||
resolvedAt = alert.ResolvedAt,
|
||||
note = (string?)null
|
||||
}, $"resolve:{alert.ClientAlertId}", alert.EncounterId, alert.ResolvedAt!.Value);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return alert;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<LocalClinicalAlert>> ListAsync(
|
||||
Guid? encounterId, string? status, int page, int pageSize)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var q = _db.ClinicalAlerts.AsNoTracking().AsQueryable();
|
||||
if (encounterId.HasValue) q = q.Where(a => a.EncounterId == encounterId);
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
var parsedStatus = AlertStatusExtensions.FromDbString(status);
|
||||
q = q.Where(a => a.Status == parsedStatus);
|
||||
}
|
||||
var total = await q.CountAsync();
|
||||
var items = await q.OrderByDescending(a => a.TriggeredAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
return new PagedResult<LocalClinicalAlert>(items, page, pageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class LocalObservationService : ILocalObservationService
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly LocalWarningEvaluator _warnings;
|
||||
private readonly LocalPagingPublisher _paging;
|
||||
private readonly ILogger<LocalObservationService> _logger;
|
||||
|
||||
public LocalObservationService(
|
||||
GatewayDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
LocalWarningEvaluator warnings,
|
||||
LocalPagingPublisher paging,
|
||||
ILogger<LocalObservationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
_warnings = warnings;
|
||||
_paging = paging;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<LocalIngestResult> IngestAsync(Guid encounterId, IngestObservationRequest req)
|
||||
{
|
||||
var encounter = await _db.Encounters
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == encounterId);
|
||||
if (encounter is null)
|
||||
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
||||
if (encounter.Status != EncounterStatus.Active)
|
||||
throw new ConflictException("Encounter is not active.", "ENCOUNTER_NOT_ACTIVE");
|
||||
|
||||
if (!string.IsNullOrEmpty(req.IdempotencyKey))
|
||||
{
|
||||
var existing = await _db.Observations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(o => o.IdempotencyKey == req.IdempotencyKey);
|
||||
if (existing is not null)
|
||||
return LocalIngestResult.Duplicate(existing);
|
||||
}
|
||||
|
||||
if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
|
||||
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var observation = new LocalObservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
ObservationCode = req.ObservationCode,
|
||||
Value = req.Value,
|
||||
Unit = req.Unit,
|
||||
Source = req.Source,
|
||||
IdempotencyKey = req.IdempotencyKey,
|
||||
RecordedAt = req.RecordedAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Observations.Add(observation);
|
||||
|
||||
var threshold = await LoadThresholdAsync(req.ObservationCode);
|
||||
if (threshold is null)
|
||||
throw new ValidationException("Unknown observation code.", "UNKNOWN_OBSERVATION_CODE");
|
||||
|
||||
LocalClinicalAlert? alert = null;
|
||||
|
||||
if (IsCriticalBreach(req.Value, threshold))
|
||||
{
|
||||
var triggeredAt = DateTimeOffset.UtcNow;
|
||||
alert = new LocalClinicalAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClientAlertId = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = encounter.PatientId,
|
||||
ObservationId = observation.Id,
|
||||
AlertType = AlertTypeExtensions.CriticalFor(req.ObservationCode),
|
||||
Severity = AlertSeverity.Critical,
|
||||
Details = BuildCriticalDetails(req, threshold),
|
||||
Status = AlertStatus.Open,
|
||||
TriggeredAt = triggeredAt
|
||||
};
|
||||
_db.ClinicalAlerts.Add(alert);
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Alert, new
|
||||
{
|
||||
alert.ClientAlertId,
|
||||
alert.EncounterId,
|
||||
alertType = alert.AlertType.ToDbString(),
|
||||
severity = alert.Severity.ToDbString(),
|
||||
alert.Details,
|
||||
generatedAt = triggeredAt
|
||||
}, $"alert:{alert.ClientAlertId}", encounterId, triggeredAt);
|
||||
|
||||
await _paging.PublishCriticalAsync(alert, encounter);
|
||||
}
|
||||
|
||||
if (alert is null)
|
||||
await _warnings.TryEvaluateAsync(
|
||||
observation.Id, encounterId, encounter.PatientId,
|
||||
req.ObservationCode, req.Value, threshold);
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Observation, new
|
||||
{
|
||||
clientRef = observation.Id,
|
||||
req.IdempotencyKey,
|
||||
encounterId,
|
||||
req.ObservationCode,
|
||||
req.Value,
|
||||
req.Unit,
|
||||
source = req.Source.ToDbString(),
|
||||
req.RecordedAt
|
||||
}, req.IdempotencyKey ?? $"obs:{observation.Id}", encounterId, req.RecordedAt);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return LocalIngestResult.Created(observation, alert);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
await tx.RollbackAsync();
|
||||
var existing = await _db.Observations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(o => o.IdempotencyKey == req.IdempotencyKey);
|
||||
if (existing is not null) return LocalIngestResult.Duplicate(existing);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(string observationCode)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var cacheKey = $"threshold:{observationCode}";
|
||||
|
||||
var cached = await cache.StringGetAsync(cacheKey);
|
||||
if (cached.HasValue)
|
||||
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
|
||||
|
||||
var threshold = await _db.AlertThresholds
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.ObservationCode == observationCode);
|
||||
|
||||
if (threshold is null) return null;
|
||||
|
||||
var entry = new ThresholdCacheEntry(
|
||||
threshold.ObservationCode,
|
||||
threshold.CriticalLow,
|
||||
threshold.WarningLow,
|
||||
threshold.WarningHigh,
|
||||
threshold.CriticalHigh);
|
||||
|
||||
await cache.StringSetAsync(cacheKey, JsonSerializer.Serialize(entry));
|
||||
_logger.LogDebug("Cache miss for threshold {Code} — loaded from PostgreSQL", observationCode);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static bool IsCriticalBreach(decimal value, ThresholdCacheEntry t) =>
|
||||
(t.CriticalLow.HasValue && value < t.CriticalLow.Value) ||
|
||||
(t.CriticalHigh.HasValue && value > t.CriticalHigh.Value);
|
||||
|
||||
private static string BuildCriticalDetails(IngestObservationRequest req, ThresholdCacheEntry t)
|
||||
{
|
||||
if (t.CriticalLow.HasValue && req.Value < t.CriticalLow.Value)
|
||||
return $"{req.ObservationCode} value {req.Value} {req.Unit} is below critical low of {t.CriticalLow} {req.Unit}.";
|
||||
return $"{req.ObservationCode} value {req.Value} {req.Unit} is above critical high of {t.CriticalHigh} {req.Unit}.";
|
||||
}
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException ex) =>
|
||||
ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
|
||||
public sealed class LocalPagingPublisher
|
||||
{
|
||||
private readonly RabbitMqOptions _opts;
|
||||
private readonly ILogger<LocalPagingPublisher> _logger;
|
||||
|
||||
public LocalPagingPublisher(IOptions<RabbitMqOptions> opts, ILogger<LocalPagingPublisher> logger)
|
||||
{
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task PublishCriticalAsync(LocalClinicalAlert alert, ReplicaEncounter encounter)
|
||||
{
|
||||
try
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = _opts.Host,
|
||||
Port = _opts.Port,
|
||||
UserName = _opts.Username,
|
||||
Password = _opts.Password
|
||||
};
|
||||
using var conn = factory.CreateConnection("gateway-publisher");
|
||||
using var channel = conn.CreateModel();
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
alertId = alert.Id,
|
||||
encounterId = alert.EncounterId,
|
||||
details = alert.Details,
|
||||
attendingPhysician = encounter.AttendingPhysician
|
||||
});
|
||||
|
||||
var body = Encoding.UTF8.GetBytes(payload);
|
||||
channel.BasicPublish(
|
||||
RabbitMqTopologyProvisioner.Exchange,
|
||||
RabbitMqTopologyProvisioner.PagingKey,
|
||||
body: body);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published critical alert {AlertId} to paging queue for encounter {EncounterId}",
|
||||
alert.Id, alert.EncounterId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to publish critical alert {AlertId} to paging queue — alert persisted locally",
|
||||
alert.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class LocalWarningEvaluator
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
private readonly ILogger<LocalWarningEvaluator> _logger;
|
||||
|
||||
public LocalWarningEvaluator(GatewayDbContext db, ILogger<LocalWarningEvaluator> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> TryEvaluateAsync(
|
||||
Guid observationId, Guid encounterId, Guid patientId,
|
||||
string observationCode, decimal value, ThresholdCacheEntry threshold)
|
||||
{
|
||||
if (!IsWarningBreach(value, threshold)) return false;
|
||||
if (IsCriticalBreach(value, threshold)) return false;
|
||||
|
||||
var alertType = AlertTypeExtensions.WarningFor(observationCode);
|
||||
var clientAlertId = Guid.NewGuid();
|
||||
var triggeredAt = DateTimeOffset.UtcNow;
|
||||
var details = BuildWarningDetails(observationCode, value, threshold);
|
||||
|
||||
var exists = await _db.ClinicalAlerts.AnyAsync(a =>
|
||||
a.EncounterId == encounterId
|
||||
&& a.AlertType == alertType
|
||||
&& (a.Status == AlertStatus.Open || a.Status == AlertStatus.Acknowledged));
|
||||
if (exists) return false;
|
||||
|
||||
var alert = new LocalClinicalAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClientAlertId = clientAlertId,
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
ObservationId = observationId,
|
||||
AlertType = alertType,
|
||||
Severity = AlertSeverity.Warning,
|
||||
Details = details,
|
||||
Status = AlertStatus.Open,
|
||||
TriggeredAt = triggeredAt
|
||||
};
|
||||
_db.ClinicalAlerts.Add(alert);
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Alert, new
|
||||
{
|
||||
clientAlertId,
|
||||
encounterId,
|
||||
alertType = alertType.ToDbString(),
|
||||
severity = AlertSeverity.Warning.ToDbString(),
|
||||
details,
|
||||
generatedAt = triggeredAt
|
||||
}, $"alert:{clientAlertId}", encounterId, triggeredAt);
|
||||
|
||||
_logger.LogInformation(
|
||||
"WARNING alert {AlertId} created locally for encounter {EncounterId}",
|
||||
alert.Id, encounterId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsWarningBreach(decimal value, ThresholdCacheEntry t) =>
|
||||
(t.WarningHigh.HasValue && value > t.WarningHigh.Value) ||
|
||||
(t.WarningLow.HasValue && value < t.WarningLow.Value);
|
||||
|
||||
private static bool IsCriticalBreach(decimal value, ThresholdCacheEntry t) =>
|
||||
(t.CriticalLow.HasValue && value < t.CriticalLow.Value) ||
|
||||
(t.CriticalHigh.HasValue && value > t.CriticalHigh.Value);
|
||||
|
||||
private static string BuildWarningDetails(string code, decimal value, ThresholdCacheEntry t) =>
|
||||
t.WarningHigh.HasValue && value > t.WarningHigh.Value
|
||||
? $"{code} value {value} is above warning high of {t.WarningHigh}."
|
||||
: $"{code} value {value} is below warning low of {t.WarningLow}.";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class ObservationQueryService : IObservationQueryService
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
|
||||
public ObservationQueryService(GatewayDbContext db) => _db = db;
|
||||
|
||||
public async Task<CursorPage<LocalObservation>> GetHistoryAsync(
|
||||
Guid encounterId,
|
||||
string? code,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int limit,
|
||||
string? cursorToken)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var cursor = ObservationCursor.Decode(cursorToken);
|
||||
|
||||
var query = _db.Observations
|
||||
.AsNoTracking()
|
||||
.Where(o => o.EncounterId == encounterId);
|
||||
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
query = query.Where(o => o.ObservationCode == code);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(o => o.RecordedAt >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(o => o.RecordedAt <= to.Value);
|
||||
|
||||
if (cursor is not null)
|
||||
{
|
||||
var cursorTime = cursor.RecordedAt;
|
||||
var cursorId = cursor.Id;
|
||||
query = query.Where(o =>
|
||||
o.RecordedAt < cursorTime ||
|
||||
(o.RecordedAt == cursorTime && o.Id.CompareTo(cursorId) < 0));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(o => o.RecordedAt)
|
||||
.ThenByDescending(o => o.Id)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync();
|
||||
|
||||
var hasMore = items.Count > limit;
|
||||
if (hasMore) items.RemoveAt(limit);
|
||||
|
||||
var nextCursor = hasMore
|
||||
? new ObservationCursor(items[^1].RecordedAt, items[^1].Id).Encode()
|
||||
: null;
|
||||
|
||||
return new CursorPage<LocalObservation>(items, nextCursor, hasMore);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
public static class PlausibilityValidator
|
||||
{
|
||||
// Plausible ranges define the outer boundary of physically possible values.
|
||||
// These are NOT clinical thresholds — they catch device malfunctions and typos.
|
||||
private static readonly Dictionary<string, (decimal Min, decimal Max)> _ranges = new()
|
||||
{
|
||||
["HEART_RATE"] = (1, 300),
|
||||
["TEMP_C"] = (15, 50),
|
||||
["POTASSIUM_MEQ_L"] = (0.1m, 12),
|
||||
["SPO2"] = (50, 100),
|
||||
["RESP_RATE"] = (1, 80),
|
||||
["WBC_K_UL"] = (0.1m, 500),
|
||||
["GLUCOSE_MG_DL"] = (10, 1000),
|
||||
["SYSTOLIC_BP"] = (40, 300),
|
||||
["DIASTOLIC_BP"] = (20, 200),
|
||||
["LACTATE_MMOL_L"] = (0.1m, 30),
|
||||
["AVPU"] = (0, 3),
|
||||
["SUPPLEMENTAL_O2"] = (0, 1),
|
||||
["GCS_EYE"] = (1, 4),
|
||||
["GCS_VERBAL"] = (1, 5),
|
||||
["GCS_MOTOR"] = (1, 6),
|
||||
["PAO2_MMHG"] = (20, 600),
|
||||
["FIO2_PCT"] = (21, 100),
|
||||
["PLATELET_K_UL"] = (1, 1500),
|
||||
["BILIRUBIN_MG_DL"] = (0.1m, 50),
|
||||
["CREATININE_MG_DL"] = (0.1m, 20),
|
||||
["URINE_OUTPUT_ML_H"] = (0, 500),
|
||||
};
|
||||
|
||||
public static bool IsPlausible(string observationCode, decimal value, out string? reason)
|
||||
{
|
||||
if (!_ranges.TryGetValue(observationCode, out var range))
|
||||
{
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value < range.Min || value > range.Max)
|
||||
{
|
||||
reason = $"Value {value} is outside the plausible range [{range.Min}–{range.Max}] for {observationCode}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class AcknowledgeAlertRequestValidator : AbstractValidator<AcknowledgeAlertRequest>
|
||||
{
|
||||
public AcknowledgeAlertRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Note).MaximumLength(1000).When(x => x.Note is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class IngestObservationRequestValidator : AbstractValidator<IngestObservationRequest>
|
||||
{
|
||||
public IngestObservationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
|
||||
RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.RecordedAt)
|
||||
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
.WithMessage("RecordedAt cannot be more than 5 minutes in the future.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="6.8.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@VigilCare.WardGateway_HostAddress = http://localhost:5249
|
||||
|
||||
GET {{VigilCare.WardGateway_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"GatewayDb": "Host=localhost;Port=5437;Database=vigilcare_ward;Username=postgres;Password=password"
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6383"
|
||||
},
|
||||
"RabbitMq": {
|
||||
"Host": "localhost",
|
||||
"Port": 5675,
|
||||
"Username": "guest",
|
||||
"Password": "guest",
|
||||
"PagingAckTimeoutMs": 300000
|
||||
},
|
||||
"CentralApi": {
|
||||
"BaseUrl": "http://localhost:5080"
|
||||
},
|
||||
"Gateway": {
|
||||
"GatewayId": "22222222-2222-2222-2222-222222222222",
|
||||
"SiteId": "11111111-1111-1111-1111-111111111111",
|
||||
"Department": "ICU",
|
||||
"EncounterSyncIntervalMinutes": 5,
|
||||
"CentralReachabilityIntervalSeconds": 30,
|
||||
"HeartbeatIntervalSeconds": 60,
|
||||
"SyncBatchSize": 500
|
||||
},
|
||||
"ApiKey": {
|
||||
"Gateway": "dev-gateway-key-change-in-production"
|
||||
},
|
||||
"Jwt": {
|
||||
"SigningKey": "dev-signing-key-minimum-32-bytes-long!!",
|
||||
"Issuer": "vigilcare-gateway",
|
||||
"Audience": "vigilcare-dashboard"
|
||||
},
|
||||
"AlertSuppression": {
|
||||
"DefaultWindowMinutes": 30
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": { "Default": "Information" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user