228 lines
7.7 KiB
C#
228 lines
7.7 KiB
C#
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);
|
|
}
|
|
}
|