feature: Ward Gateway Service (Local-First Clinical Path)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user