feature: Ward Gateway Service (Local-First Clinical Path)

This commit is contained in:
voltsrage
2026-06-23 16:45:38 +08:00
parent d8e142fffe
commit 1bf8359097
100 changed files with 5474 additions and 4 deletions
@@ -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);
}
+14
View File
@@ -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);
}
}
@@ -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,
};
}
+141
View File
@@ -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
###
+41
View File
@@ -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" }
}
}