Clinical Sync Batch Engine: first commit

This commit is contained in:
voltsrage
2026-06-23 03:02:30 +08:00
parent 90b8baa2a1
commit c9994b1ba2
60 changed files with 3547 additions and 31 deletions
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
namespace VigilCare.ClinicalContracts.Sync;
public record ClinicalSyncBatchRequest(
Guid BatchReference,
Guid GatewayId,
Guid SiteId,
DateTimeOffset CapturedAtUtc,
IReadOnlyList<SyncedObservation> Observations,
IReadOnlyList<SyncedAlertEvent> AlertEvents,
IReadOnlyList<SyncedAlertAcknowledgment> AlertAcknowledgments,
IReadOnlyList<SyncedAlertResolution> AlertResolutions);
@@ -0,0 +1,4 @@
public record GatewayHeartbeatRequest(
string Status, // ONLINE | DEGRADED | OFFLINE
int BufferDepth,
DateTimeOffset ReportedAtUtc);
@@ -0,0 +1,6 @@
public record SyncedAlertAcknowledgment(
Guid ClientRef,
Guid ClientAlertId,
string ClinicianId,
DateTimeOffset AcknowledgedAt,
string? Note);
@@ -0,0 +1,7 @@
public record SyncedAlertEvent(
Guid ClientAlertId,
Guid EncounterId,
string AlertType,
string Severity,
string Details,
DateTimeOffset GeneratedAt);
@@ -0,0 +1,6 @@
public record SyncedAlertResolution(
Guid ClientRef,
Guid ClientAlertId,
string ClinicianId,
DateTimeOffset ResolvedAt,
string? Note);
@@ -0,0 +1,9 @@
public record SyncedObservation(
Guid ClientRef,
string IdempotencyKey,
Guid EncounterId,
string ObservationCode,
decimal Value,
string? Unit,
string Source,
DateTimeOffset RecordedAt);
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>VigilCare.ClinicalContracts</RootNamespace>
</PropertyGroup>
</Project>
@@ -32,7 +32,7 @@ public class VigilCareApiClient
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
response.EnsureSuccessStatusCode();
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
return envelope!.Data;
return envelope!.Data!;
}
public async Task<EncounterResponse> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
@@ -40,7 +40,7 @@ public class VigilCareApiClient
var response = await _http.PostAsJsonAsync($"/api/v1/patients/{patientId}/encounters", req);
response.EnsureSuccessStatusCode();
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<EncounterResponse>>();
return envelope!.Data;
return envelope!.Data!;
}
public async Task SendObservationBatchAsync(
@@ -89,7 +89,7 @@ public class VigilCareApiClient
var envelope = await ordersResponse.Content
.ReadFromJsonAsync<ApiResponse<PagedResponse<OrderResponse>>>();
var order = FindPendingOrder(envelope?.Data.Items ?? [], orderDescription);
var order = FindPendingOrder(envelope?.Data?.Items ?? [], orderDescription);
if (order is null)
return false;
@@ -117,7 +117,7 @@ public class VigilCareApiClient
if (!response.IsSuccessStatusCode) return new();
var envelope = await response.Content
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>();
return envelope?.Data.Items.ToList() ?? new();
return envelope?.Data?.Items?.ToList() ?? new();
}
public async Task<News2Response?> GetCurrentNews2Async(Guid encounterId)
+12
View File
@@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCareClinicalAPI.Tests"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.Simulator", "VigilCare.Simulator\VigilCare.Simulator.csproj", "{F9C415E2-732C-4DB6-9229-EB389A710911}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.ClinicalContracts", "VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj", "{C8168D36-01F5-4C42-87A0-7A4D9FB57BA8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.ClinicalContracts.Tests", "VigilCare.ClinicalContracts.Tests\VigilCare.ClinicalContracts.Tests.csproj", "{5E441BE7-F27E-461C-B4AC-034158A8A4B2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -30,5 +34,13 @@ Global
{F9C415E2-732C-4DB6-9229-EB389A710911}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F9C415E2-732C-4DB6-9229-EB389A710911}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F9C415E2-732C-4DB6-9229-EB389A710911}.Release|Any CPU.Build.0 = Release|Any CPU
{C8168D36-01F5-4C42-87A0-7A4D9FB57BA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C8168D36-01F5-4C42-87A0-7A4D9FB57BA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C8168D36-01F5-4C42-87A0-7A4D9FB57BA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C8168D36-01F5-4C42-87A0-7A4D9FB57BA8}.Release|Any CPU.Build.0 = Release|Any CPU
{5E441BE7-F27E-461C-B4AC-034158A8A4B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E441BE7-F27E-461C-B4AC-034158A8A4B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E441BE7-F27E-461C-B4AC-034158A8A4B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E441BE7-F27E-461C-B4AC-034158A8A4B2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+3 -2
View File
@@ -90,8 +90,8 @@ public class RbacTests : IAsyncLifetime
audit.GetProperty("data").GetProperty("totalCount").GetInt32().Should().BeGreaterThan(0);
}
[Fact]
public async Task AlertAcknowledge_UsesAuthenticatedUser_NotBodyClinicianId()
[Fact(Skip = "Not yet implemented")]
public Task AlertAcknowledge_UsesAuthenticatedUser_NotBodyClinicianId()
{
_client.ClearAuth();
var nurseId = Guid.Parse("11111111-1111-1111-1111-111111111111");
@@ -102,5 +102,6 @@ public class RbacTests : IAsyncLifetime
// Assert alert.AcknowledgedBy == "Test NURSE" (from TestingAuthHandler display_name)
// Assert clinical_audit_logs row with action ALERT_ACKNOWLEDGED and userId == nurseId
return Task.CompletedTask;
}
}
@@ -0,0 +1,142 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
using VigilCare.ClinicalContracts.Sync;
[Collection("Integration")]
public class ClinicalSyncBatchTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
private Guid _gatewayId;
private Guid _siteId;
private Guid _encounterId;
public ClinicalSyncBatchTests(ApiFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await GatewayRegistrySeeder.SeedAsync(db);
await DataSeeder.SeedAsync(db, scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>());
_gatewayId = GatewayRegistrySeeder.DemoGatewayId;
_siteId = GatewayRegistrySeeder.DemoSiteId;
_encounterId = await db.Encounters.Where(e => e.Status == EncounterStatus.Active)
.Select(e => e.Id).FirstAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
private ClinicalSyncBatchRequest BuildBatch(int obsCount = 1)
{
var batchRef = Guid.NewGuid();
var observations = Enumerable.Range(0, obsCount).Select(i => new SyncedObservation(
Guid.NewGuid(), $"sync-key-{batchRef}-{i}", _encounterId,
"HEART_RATE", 80m + i, "bpm", "DEVICE", DateTimeOffset.UtcNow.AddMinutes(-i)
)).ToList();
return new ClinicalSyncBatchRequest(
batchRef, _gatewayId, _siteId, DateTimeOffset.UtcNow,
observations, [], [], []);
}
[Fact]
public async Task UploadBatch_ReturnsReceived()
{
_client.WithGatewayApiKey(_gatewayId);
var resp = await _client.PostAsJsonAsync("/api/v1/sync/batches", BuildBatch());
resp.StatusCode.Should().Be(HttpStatusCode.Created);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.OutboxEvents.CountAsync(e => e.Topic == "clinical.sync.batch_received"))
.Should().BeGreaterThan(0);
}
[Fact]
public async Task DuplicateBatchReference_ReturnsExisting()
{
_client.WithGatewayApiKey(_gatewayId);
var batch = BuildBatch();
var first = await _client.PostAsJsonAsync("/api/v1/sync/batches", batch);
var firstBody = await first.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>();
var second = await _client.PostAsJsonAsync("/api/v1/sync/batches", batch);
var secondBody = await second.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>();
secondBody!.Data!.BatchId.Should().Be(firstBody!.Data!.BatchId);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.ClinicalSyncBatches.CountAsync(b => b.BatchReference == batch.BatchReference))
.Should().Be(1);
}
[Fact]
public async Task Processor_AppliesObservations()
{
_client.WithGatewayApiKey(_gatewayId);
var upload = await _client.PostAsJsonAsync("/api/v1/sync/batches", BuildBatch(3));
var batchId = (await upload.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>())!.Data!.BatchId;
using var scope = _fixture.Services.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<ClinicalSyncBatchProcessor>();
await processor.ProcessBatchAsync(batchId, CancellationToken.None);
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.Observations.CountAsync()).Should().BeGreaterThanOrEqualTo(3);
(await db.OutboxEvents.CountAsync(e => e.Topic == "observation.recorded")).Should().BeGreaterThanOrEqualTo(3);
}
[Fact]
public async Task Processor_ConflictOnAckBeforeAlert()
{
var batchRef = Guid.NewGuid();
var clientAlertId = Guid.NewGuid();
var batch = new ClinicalSyncBatchRequest(
batchRef, _gatewayId, _siteId, DateTimeOffset.UtcNow,
[],
[],
[new SyncedAlertAcknowledgment(Guid.NewGuid(), clientAlertId, "RN-Smith",
DateTimeOffset.UtcNow, null)],
[]);
_client.WithGatewayApiKey(_gatewayId);
var upload = await _client.PostAsJsonAsync("/api/v1/sync/batches", batch);
var batchId = (await upload.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>())!.Data!.BatchId;
using var scope = _fixture.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<ClinicalSyncBatchProcessor>()
.ProcessBatchAsync(batchId, CancellationToken.None);
var status = await _client.GetAsync($"/api/v1/sync/batches/{batchId}");
var body = await status.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchStatusResponse>>();
body!.Data!.Status.Should().Be("CONFLICT");
body.Data.Conflicts.Should().ContainSingle(c => c.ConflictReason == "ALERT_NOT_YET_SYNCED");
}
[Fact]
public async Task Processor_UpdatesGatewayLastSyncAt()
{
_client.WithGatewayApiKey(_gatewayId);
var upload = await _client.PostAsJsonAsync("/api/v1/sync/batches", BuildBatch());
var batchId = (await upload.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>())!.Data!.BatchId;
using var scope = _fixture.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<ClinicalSyncBatchProcessor>()
.ProcessBatchAsync(batchId, CancellationToken.None);
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var gw = await db.WardGateways.FindAsync(_gatewayId);
gw!.LastSyncAt.Should().NotBeNull();
gw.ReportedBufferDepth.Should().Be(0);
}
}
@@ -26,6 +26,7 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
["RabbitMq:Password"] = "guest",
["RabbitMq:PagingAckTimeoutMs"] = "5000",
["Fhir:ApiKey"] = "dev-integration-key-change-in-production",
["ApiKey:Gateway"] = GatewayAuthHelper.DevGatewayKey,
});
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
@@ -12,6 +12,10 @@ public static class DbResetHelper
{
await db.Database.ExecuteSqlRawAsync(@"
DELETE FROM phi_access_logs;
DELETE FROM clinical_sync_conflicts;
DELETE FROM clinical_sync_batches;
DELETE FROM ward_gateways;
DELETE FROM clinical_sites;
DELETE FROM medication_administrations;
DELETE FROM sepsis_bundle_elements;
DELETE FROM sepsis_bundles;
@@ -0,0 +1,13 @@
public static class GatewayAuthHelper
{
public const string DevGatewayKey = "dev-gateway-key-change-in-production";
public static void WithGatewayApiKey(
this HttpClient client, Guid gatewayId, string? apiKey = null)
{
client.DefaultRequestHeaders.Remove("X-Api-Key");
client.DefaultRequestHeaders.Remove("X-Gateway-Id");
client.DefaultRequestHeaders.Add("X-Api-Key", apiKey ?? DevGatewayKey);
client.DefaultRequestHeaders.Add("X-Gateway-Id", gatewayId.ToString());
}
}
@@ -33,7 +33,7 @@ public class PhiEncryptionTests
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var raw = await db.Database
.SqlQueryRaw<string>($"SELECT first_name AS \"Value\" FROM patients WHERE id = '{patientId}'")
.SqlQuery<string>($"SELECT first_name AS \"Value\" FROM patients WHERE id = {patientId}")
.FirstAsync();
raw.Should().NotBe("Encrypted");
@@ -0,0 +1,50 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
public sealed class GatewayApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "GatewayApiKey";
private readonly IConfiguration _config;
public GatewayApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration config)
: base(options, logger, encoder) => _config = config;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Api-Key", out var suppliedHeader))
return Task.FromResult(AuthenticateResult.NoResult());
var configured = _config["ApiKey:Gateway"];
if (string.IsNullOrEmpty(configured))
return Task.FromResult(AuthenticateResult.Fail("Gateway API key not configured."));
if (!FixedTimeEquals(suppliedHeader.ToString(), configured))
return Task.FromResult(AuthenticateResult.Fail("Invalid API key."));
var claims = new List<Claim> { new("client_type", "gateway") };
if (Request.Headers.TryGetValue("X-Gateway-Id", out var gatewayIdHeader)
&& Guid.TryParse(gatewayIdHeader.ToString(), out _))
claims.Add(new Claim("gateway_id", gatewayIdHeader.ToString()!));
var identity = new ClaimsIdentity(claims, SchemeName);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
private static bool FixedTimeEquals(string supplied, string configured)
{
var suppliedBytes = Encoding.UTF8.GetBytes(supplied);
var configuredBytes = Encoding.UTF8.GetBytes(configured);
return CryptographicOperations.FixedTimeEquals(suppliedBytes, configuredBytes);
}
}
@@ -0,0 +1,77 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
public sealed class ClinicalSyncBatchConsumer : BackgroundService
{
private readonly IServiceScopeFactory _scopes;
private readonly RabbitMqOptions _rabbitOpts;
private readonly ILogger<ClinicalSyncBatchConsumer> _logger;
public ClinicalSyncBatchConsumer(
IServiceScopeFactory scopes,
IOptions<RabbitMqOptions> rabbitOpts,
ILogger<ClinicalSyncBatchConsumer> logger)
{
_scopes = scopes;
_rabbitOpts = rabbitOpts.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
var factory = new ConnectionFactory
{
HostName = _rabbitOpts.Host,
Port = _rabbitOpts.Port,
UserName = _rabbitOpts.Username,
Password = _rabbitOpts.Password,
DispatchConsumersAsync = true
};
using var connection = factory.CreateConnection("clinical-sync-consumer");
using var channel = connection.CreateModel();
channel.BasicQos(0, prefetchCount: 5, global: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += async (_, ea) =>
{
try
{
await HandleMessageAsync(channel, ea, stoppingToken);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Invalid sync batch message — NACK no requeue");
channel.BasicNack(ea.DeliveryTag, false, requeue: false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Sync batch consumer failed — NACK requeue");
channel.BasicNack(ea.DeliveryTag, false, requeue: true);
}
};
channel.BasicConsume(RabbitMqTopologyProvisioner.SyncBatchQueue, autoAck: false, consumer);
_logger.LogInformation("ClinicalSyncBatchConsumer consuming {Queue}", RabbitMqTopologyProvisioner.SyncBatchQueue);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
private async Task HandleMessageAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
{
var payload = Encoding.UTF8.GetString(ea.Body.Span);
var doc = JsonDocument.Parse(payload);
var batchId = Guid.Parse(doc.RootElement.GetProperty("batchId").GetString()!);
await using var scope = _scopes.CreateAsyncScope();
var processor = scope.ServiceProvider.GetRequiredService<ClinicalSyncBatchProcessor>();
await processor.ProcessBatchAsync(batchId, ct);
channel.BasicAck(ea.DeliveryTag, false);
}
}
@@ -9,15 +9,18 @@ public sealed class NotificationPublisherService : BackgroundService
private readonly IOptions<RabbitMqOptions> _rabbitOpts;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger<NotificationPublisherService> _logger;
private readonly ClinicalSyncOptions _syncOptions;
public NotificationPublisherService(
IOptions<RabbitMqOptions> rabbitOpts,
IOptions<KafkaOptions> kafkaOptions,
ILogger<NotificationPublisherService> logger)
ILogger<NotificationPublisherService> logger,
IOptions<ClinicalSyncOptions> syncOptions)
{
_rabbitOpts = rabbitOpts;
_kafkaOptions = kafkaOptions.Value;
_logger = logger;
_syncOptions = syncOptions.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -109,6 +112,19 @@ public sealed class NotificationPublisherService : BackgroundService
return Task.CompletedTask;
}
var alertId = doc.RootElement.GetProperty("alertId").GetString();
// Skip paging for gateway-synced alerts when configured
if (_syncOptions.SuppressPagingForSyncedAlerts
&& doc.RootElement.TryGetProperty("syncedFromGateway", out var synced)
&& synced.GetBoolean())
{
_logger.LogInformation(
"Skipping central paging for gateway-synced alert {AlertId} — ward already paged locally",
alertId);
return Task.CompletedTask;
}
var body = Encoding.UTF8.GetBytes(payload);
chan.BasicPublish(
exchange: RabbitMqTopologyProvisioner.Exchange,
@@ -116,7 +132,7 @@ public sealed class NotificationPublisherService : BackgroundService
basicProperties: props,
body: body);
var alertId = doc.RootElement.GetProperty("alertId").GetString();
_logger.LogInformation("Published paging job to alerts.paging.queue for alert {AlertId}", alertId);
return Task.CompletedTask;
@@ -1,21 +1,33 @@
using System.Text;
using Confluent.Kafka;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Exceptions;
public class OutboxRelayService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly KafkaOptions _options;
private readonly RabbitMqOptions _rabbitOpts;
private readonly ClinicalSyncOptions _syncOpts;
private readonly ILogger<OutboxRelayService> _logger;
private IProducer<string, string>? _producer;
private IConnection? _rabbitConnection;
private IModel? _rabbitChannel;
private IBasicProperties? _rabbitProps;
public OutboxRelayService(
IServiceProvider services,
IOptions<KafkaOptions> options,
IOptions<RabbitMqOptions> rabbitOpts,
IOptions<ClinicalSyncOptions> syncOpts,
ILogger<OutboxRelayService> logger)
{
_services = services;
_options = options.Value;
_rabbitOpts = rabbitOpts.Value;
_syncOpts = syncOpts.Value;
_logger = logger;
}
@@ -33,6 +45,19 @@ public class OutboxRelayService : BackgroundService
RetryBackoffMs = 100
}).Build();
var factory = new ConnectionFactory
{
HostName = _rabbitOpts.Host,
Port = _rabbitOpts.Port,
UserName = _rabbitOpts.Username,
Password = _rabbitOpts.Password,
DispatchConsumersAsync = true,
};
_rabbitConnection = factory.CreateConnection("outbox-relay");
_rabbitChannel = _rabbitConnection.CreateModel();
_rabbitProps = _rabbitChannel.CreateBasicProperties();
_rabbitProps.Persistent = true;
return base.StartAsync(cancellationToken);
}
@@ -91,26 +116,45 @@ public class OutboxRelayService : BackgroundService
{
try
{
var result = await _producer!.ProduceAsync(
ev.Topic,
new Message<string, string>
{
Key = ev.PartitionKey ?? string.Empty,
Value = ev.Payload
},
ct);
if (ev.Topic == ClinicalSyncOptions.BatchReceivedOutboxTopic)
{
_rabbitChannel!.BasicPublish(
exchange: _syncOpts.SyncExchange,
routingKey: _syncOpts.SyncBatchReceivedRoutingKey,
basicProperties: _rabbitProps,
body: Encoding.UTF8.GetBytes(ev.Payload));
_logger.LogDebug(
"Published sync batch to RabbitMQ exchange={Exchange} routingKey={RoutingKey}",
_syncOpts.SyncExchange, _syncOpts.SyncBatchReceivedRoutingKey);
}
else
{
var result = await _producer!.ProduceAsync(
ev.Topic,
new Message<string, string>
{
Key = ev.PartitionKey ?? string.Empty,
Value = ev.Payload
},
ct);
_logger.LogDebug(
"Published {Topic} offset={Offset} partition={Partition} key={Key}",
ev.Topic, result.Offset.Value, result.Partition.Value, ev.PartitionKey);
}
published.Add(ev.Id);
ev.ProcessedAt = DateTimeOffset.UtcNow;
_logger.LogDebug(
"Published {Topic} offset={Offset} partition={Partition} key={Key}",
ev.Topic, result.Offset.Value, result.Partition.Value, ev.PartitionKey);
}
catch (ProduceException<string, string> ex)
catch (Exception ex) when (ex is ProduceException<string, string> or RabbitMQClientException)
{
ev.RetryCount++;
ev.LastError = ex.Error.Reason;
ev.LastError = ex switch
{
ProduceException<string, string> kex => kex.Error.Reason,
_ => ex.Message
};
if (ev.RetryCount >= _options.OutboxMaxRetries)
{
@@ -122,8 +166,8 @@ public class OutboxRelayService : BackgroundService
else
{
_logger.LogWarning(ex,
"Kafka produce failed for outbox event {Id} — retry {Retry}/{Max}",
ev.Id, ev.RetryCount, _options.OutboxMaxRetries);
"Outbox publish failed for event {Id} — retry {Retry}/{Max} topic={Topic}",
ev.Id, ev.RetryCount, _options.OutboxMaxRetries, ev.Topic);
}
hadFailure = true;
@@ -142,7 +186,9 @@ public class OutboxRelayService : BackgroundService
public override void Dispose()
{
_rabbitChannel?.Dispose();
_rabbitConnection?.Dispose();
_producer?.Dispose();
base.Dispose();
}
}
}
@@ -0,0 +1,8 @@
public sealed class ClinicalSyncOptions
{
public const string Section = "ClinicalSync";
public const string BatchReceivedOutboxTopic = "clinical.sync.batch_received";
public bool SuppressPagingForSyncedAlerts { get; init; } = true;
public string SyncExchange { get; init; } = "clinical.sync";
public string SyncBatchReceivedRoutingKey { get; init; } = "sync.batch_received";
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using VigilCare.ClinicalContracts.Sync;
[ApiController]
[Produces("application/json")]
public class ClinicalSyncController : ControllerBase
{
private readonly IClinicalSyncService _sync;
public ClinicalSyncController(IClinicalSyncService sync) => _sync = sync;
/// <summary>Upload a buffered sync batch from a ward gateway.</summary>
[HttpPost("api/v1/sync/batches")]
[Authorize(AuthenticationSchemes = GatewayApiKeyAuthenticationHandler.SchemeName)]
[ProducesResponseType(typeof(ApiResponse<ClinicalBatchUploadResponse>), StatusCodes.Status201Created)]
public async Task<IActionResult> UploadBatch(
[FromBody] ClinicalSyncBatchRequest request, CancellationToken ct)
{
var result = await _sync.UploadBatchAsync(request, ct);
return StatusCode(201, ApiResponse<ClinicalBatchUploadResponse>.Created(result));
}
/// <summary>Poll batch processing status and conflicts.</summary>
[HttpGet("api/v1/sync/batches/{batchId:guid}")]
[Authorize(AuthenticationSchemes =
$"{JwtBearerDefaults.AuthenticationScheme},{GatewayApiKeyAuthenticationHandler.SchemeName}")]
[ProducesResponseType(typeof(ApiResponse<ClinicalBatchStatusResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetBatchStatus(Guid batchId, CancellationToken ct)
{
var result = await _sync.GetBatchStatusAsync(batchId, ct);
return Ok(ApiResponse<ClinicalBatchStatusResponse>.Ok(result));
}
/// <summary>Last 50 sync batches for a gateway (admin JWT).</summary>
[HttpGet("api/v1/sites/{siteId:guid}/gateways/{gatewayId:guid}/sync-history")]
[Authorize]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ClinicalSyncHistoryItem>>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSyncHistory(
Guid siteId, Guid gatewayId, CancellationToken ct)
{
var history = await _sync.GetSyncHistoryAsync(siteId, gatewayId, 50, ct);
return Ok(ApiResponse<IReadOnlyList<ClinicalSyncHistoryItem>>.Ok(history));
}
}
@@ -29,6 +29,10 @@ public class AppDbContext : DbContext
public DbSet<ClinicalUser> ClinicalUsers => Set<ClinicalUser>();
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
public DbSet<PhiAccessLog> PhiAccessLogs => Set<PhiAccessLog>();
public DbSet<ClinicalSite> ClinicalSites => Set<ClinicalSite>();
public DbSet<WardGateway> WardGateways => Set<WardGateway>();
public DbSet<ClinicalSyncBatch> ClinicalSyncBatches => Set<ClinicalSyncBatch>();
public DbSet<ClinicalSyncConflict> ClinicalSyncConflicts => Set<ClinicalSyncConflict>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -63,6 +63,8 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
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");
builder.Property(a => a.SyncedFromGateway).HasColumnName("synced_from_gateway").HasDefaultValue(false);
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
builder.HasOne(a => a.Encounter)
@@ -76,5 +78,8 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
.HasFilter("status = 'OPEN'");
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
.HasFilter("status IN ('OPEN', 'ESCALATED')");
builder.HasIndex(a => a.ClientAlertId)
.IsUnique()
.HasFilter("client_alert_id IS NOT NULL");
}
}
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalSiteConfiguration : IEntityTypeConfiguration<ClinicalSite>
{
public void Configure(EntityTypeBuilder<ClinicalSite> builder)
{
builder.ToTable("clinical_sites");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(s => s.SiteCode).HasColumnName("site_code").HasMaxLength(32).IsRequired();
builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(200).IsRequired();
builder.Property(s => s.Address).HasColumnName("address");
builder.Property(s => s.Active).HasColumnName("active").HasDefaultValue(true);
builder.Property(s => s.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(s => s.SiteCode).IsUnique();
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalSyncBatchConfiguration : IEntityTypeConfiguration<ClinicalSyncBatch>
{
public void Configure(EntityTypeBuilder<ClinicalSyncBatch> builder)
{
builder.ToTable("clinical_sync_batches", t =>
{
t.HasCheckConstraint("chk_clinical_sync_batches_status",
"status IN ('RECEIVED','PROCESSING','APPLIED','CONFLICT','REJECTED')");
});
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(b => b.GatewayId).HasColumnName("gateway_id");
builder.Property(b => b.SiteId).HasColumnName("site_id");
builder.Property(b => b.BatchReference).HasColumnName("batch_reference");
builder.Property(b => b.Status)
.HasColumnName("status")
.HasMaxLength(16)
.HasConversion(v => v.ToDbString(), v => ClinicalSyncBatchStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'RECEIVED'");
builder.Property(b => b.Payload).HasColumnName("payload").HasColumnType("jsonb");
builder.Property(b => b.SubmittedAt).HasColumnName("submitted_at").HasDefaultValueSql("NOW()");
builder.Property(b => b.ProcessedAt).HasColumnName("processed_at");
builder.HasOne(b => b.Gateway)
.WithMany()
.HasForeignKey(b => b.GatewayId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ClinicalSite>()
.WithMany()
.HasForeignKey(b => b.SiteId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(b => b.BatchReference).IsUnique();
builder.HasIndex(b => new { b.GatewayId, b.SubmittedAt });
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalSyncConflictConfiguration : IEntityTypeConfiguration<ClinicalSyncConflict>
{
public void Configure(EntityTypeBuilder<ClinicalSyncConflict> builder)
{
builder.ToTable("clinical_sync_conflicts");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(c => c.BatchId).HasColumnName("batch_id");
builder.Property(c => c.ClientRef).HasColumnName("client_ref");
builder.Property(c => c.ItemType).HasColumnName("item_type").HasMaxLength(16);
builder.Property(c => c.ConflictReason).HasColumnName("conflict_reason").HasMaxLength(500);
builder.Property(c => c.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasOne(c => c.Batch)
.WithMany(b => b.Conflicts)
.HasForeignKey(c => c.BatchId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class WardGatewayConfiguration : IEntityTypeConfiguration<WardGateway>
{
public void Configure(EntityTypeBuilder<WardGateway> builder)
{
builder.ToTable("ward_gateways", t =>
{
t.HasCheckConstraint("chk_ward_gateways_status",
"status IN ('ONLINE','DEGRADED','OFFLINE')");
});
builder.HasKey(g => g.Id);
builder.Property(g => g.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(g => g.SiteId).HasColumnName("site_id");
builder.Property(g => g.GatewayCode).HasColumnName("gateway_code").HasMaxLength(64).IsRequired();
builder.Property(g => g.Department).HasColumnName("department").HasMaxLength(100).IsRequired();
builder.Property(g => g.Status)
.HasColumnName("status")
.HasMaxLength(16)
.HasConversion(v => v.ToDbString(), v => GatewayStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'OFFLINE'");
builder.Property(g => g.ReportedBufferDepth).HasColumnName("reported_buffer_depth").HasDefaultValue(0);
builder.Property(g => g.LastHeartbeatAt).HasColumnName("last_heartbeat_at");
builder.Property(g => g.LastSyncAt).HasColumnName("last_sync_at");
builder.Property(g => g.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasOne(g => g.Site)
.WithMany(s => s.Gateways)
.HasForeignKey(g => g.SiteId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => new { g.SiteId, g.GatewayCode }).IsUnique();
builder.HasIndex(g => new { g.SiteId, g.Department });
builder.HasIndex(g => g.Status).HasFilter("status != 'ONLINE'");
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
public static class GatewayRegistrySeeder
{
public static readonly Guid DemoSiteId =
Guid.Parse("11111111-1111-1111-1111-111111111111");
public static readonly Guid DemoGatewayId =
Guid.Parse("22222222-2222-2222-2222-222222222222");
public static async Task SeedAsync(AppDbContext db)
{
if (await db.ClinicalSites.AnyAsync()) return;
var site = new ClinicalSite("SITE-DEMO", "Demo General Hospital", "123 Main St");
db.Entry(site).Property(nameof(ClinicalSite.Id)).CurrentValue = DemoSiteId;
db.ClinicalSites.Add(site);
var gateway = new WardGateway(DemoSiteId, "GW-ICU-3B", "ICU");
db.Entry(gateway).Property(nameof(WardGateway.Id)).CurrentValue = DemoGatewayId;
db.WardGateways.Add(gateway);
await db.SaveChangesAsync();
}
}
@@ -13,6 +13,8 @@ public class ClinicalAlert
public string? AcknowledgedBy { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
public DateTimeOffset TriggeredAt { get; set; }
public Guid? ClientAlertId { get; set; }
public bool SyncedFromGateway { get; set; }
public Encounter Encounter { get; set; } = null!;
}
@@ -0,0 +1,21 @@
public class ClinicalSite
{
public Guid Id { get; private set; }
public string SiteCode { get; private set; } = string.Empty;
public string Name { get; private set; } = string.Empty;
public string? Address { get; private set; }
public bool Active { get; private set; } = true;
public DateTimeOffset CreatedAt { get; private set; }
public ICollection<WardGateway> Gateways { get; private set; } = [];
private ClinicalSite() { }
public ClinicalSite(string siteCode, string name, string? address = null)
{
SiteCode = siteCode;
Name = name;
Address = address;
CreatedAt = DateTimeOffset.UtcNow;
}
}
@@ -0,0 +1,30 @@
public class ClinicalSyncBatch
{
public Guid Id { get; private set; }
public Guid GatewayId { get; private set; }
public Guid SiteId { get; private set; }
public Guid BatchReference { get; private set; }
public ClinicalSyncBatchStatus Status { get; private set; } = ClinicalSyncBatchStatus.Received;
public string Payload { get; private set; } = "{}";
public DateTimeOffset SubmittedAt { get; private set; }
public DateTimeOffset? ProcessedAt { get; private set; }
public WardGateway Gateway { get; private set; } = null!;
public ICollection<ClinicalSyncConflict> Conflicts { get; private set; } = [];
private ClinicalSyncBatch() { }
public ClinicalSyncBatch(Guid gatewayId, Guid siteId, Guid batchReference, string payload)
{
GatewayId = gatewayId;
SiteId = siteId;
BatchReference = batchReference;
Payload = payload;
SubmittedAt = DateTimeOffset.UtcNow;
}
public void MarkProcessing() => Status = ClinicalSyncBatchStatus.Processing;
public void MarkApplied() { Status = ClinicalSyncBatchStatus.Applied; ProcessedAt = DateTimeOffset.UtcNow; }
public void MarkConflict() { Status = ClinicalSyncBatchStatus.Conflict; ProcessedAt = DateTimeOffset.UtcNow; }
public void MarkRejected() { Status = ClinicalSyncBatchStatus.Rejected; ProcessedAt = DateTimeOffset.UtcNow; }
}
@@ -0,0 +1,22 @@
public class ClinicalSyncConflict
{
public Guid Id { get; private set; }
public Guid BatchId { get; private set; }
public Guid ClientRef { get; private set; }
public string ItemType { get; private set; } = string.Empty;
public string ConflictReason { get; private set; } = string.Empty;
public DateTimeOffset CreatedAt { get; private set; }
public ClinicalSyncBatch Batch { get; private set; } = null!;
private ClinicalSyncConflict() { }
public ClinicalSyncConflict(Guid batchId, Guid clientRef, string itemType, string conflictReason)
{
BatchId = batchId;
ClientRef = clientRef;
ItemType = itemType;
ConflictReason = conflictReason;
CreatedAt = DateTimeOffset.UtcNow;
}
}
@@ -0,0 +1,44 @@
public class WardGateway
{
public Guid Id { get; private set; }
public Guid SiteId { get; private set; }
public string GatewayCode { get; private set; } = string.Empty;
public string Department { get; private set; } = string.Empty;
public GatewayStatus Status { get; private set; } = GatewayStatus.Offline;
public int ReportedBufferDepth { get; private set; }
public DateTimeOffset? LastHeartbeatAt { get; private set; }
public DateTimeOffset? LastSyncAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public ClinicalSite Site { get; private set; } = null!;
private WardGateway() { }
public WardGateway(Guid siteId, string gatewayCode, string department)
{
SiteId = siteId;
GatewayCode = gatewayCode;
Department = department;
CreatedAt = DateTimeOffset.UtcNow;
}
public void RecordHeartbeat(GatewayStatus status, int bufferDepth, DateTimeOffset at)
{
Status = status;
ReportedBufferDepth = bufferDepth;
LastHeartbeatAt = at;
}
public void MarkSynced(DateTimeOffset at)
{
LastSyncAt = at;
ReportedBufferDepth = 0;
if (Status == GatewayStatus.Degraded)
Status = GatewayStatus.Online;
}
public void MarkOffline()
{
Status = GatewayStatus.Offline;
}
}
@@ -54,6 +54,7 @@ public enum AlertType
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",
@@ -97,7 +98,9 @@ public static class AlertTypeExtensions
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,
@@ -141,6 +144,7 @@ public static class AlertTypeExtensions
"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
@@ -184,6 +188,7 @@ public static class AlertTypeExtensions
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,
@@ -197,6 +202,7 @@ public static class AlertTypeExtensions
AlertType.SofaSepsis => false,
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
};
#pragma warning restore CS0618
public static string? ObservationCodeForWarning(this AlertType t) => t switch
{
@@ -0,0 +1,24 @@
public enum ClinicalSyncBatchStatus { Received, Processing, Applied, Conflict, Rejected }
public static class ClinicalSyncBatchStatusExtensions
{
public static string ToDbString(this ClinicalSyncBatchStatus s) => s switch
{
ClinicalSyncBatchStatus.Received => "RECEIVED",
ClinicalSyncBatchStatus.Processing => "PROCESSING",
ClinicalSyncBatchStatus.Applied => "APPLIED",
ClinicalSyncBatchStatus.Conflict => "CONFLICT",
ClinicalSyncBatchStatus.Rejected => "REJECTED",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static ClinicalSyncBatchStatus FromDbString(string v) => v switch
{
"RECEIVED" => ClinicalSyncBatchStatus.Received,
"PROCESSING" => ClinicalSyncBatchStatus.Processing,
"APPLIED" => ClinicalSyncBatchStatus.Applied,
"CONFLICT" => ClinicalSyncBatchStatus.Conflict,
"REJECTED" => ClinicalSyncBatchStatus.Rejected,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown sync batch status: '{v}'")
};
}
@@ -0,0 +1,20 @@
public enum GatewayStatus { Online, Degraded, Offline }
public static class GatewayStatusExtensions
{
public static string ToDbString(this GatewayStatus s) => s switch
{
GatewayStatus.Online => "ONLINE",
GatewayStatus.Degraded => "DEGRADED",
GatewayStatus.Offline => "OFFLINE",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static GatewayStatus FromDbString(string v) => v switch
{
"ONLINE" => GatewayStatus.Online,
"DEGRADED" => GatewayStatus.Degraded,
"OFFLINE" => GatewayStatus.Offline,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown gateway status: '{v}'")
};
}
@@ -5,9 +5,8 @@ using Prometheus;
/// <summary>
/// Prevents a single un-processable Kafka message from blocking a consumer
/// partition forever. Permanent errors (malformed JSON, bad format) are
/// skipped immediately; transient errors are retried up to
/// <paramref name="maxRetries"/> times before the offset is committed and
/// the message is abandoned.
/// skipped immediately; transient errors are retried up to the configured
/// maximum retry count before the offset is committed and the message is abandoned.
/// </summary>
public sealed class PoisonPillGuard
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddClinicalSyncInfrastructure : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "client_alert_id",
table: "clinical_alerts",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "synced_from_gateway",
table: "clinical_alerts",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "clinical_sites",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
site_code = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
address = table.Column<string>(type: "text", nullable: true),
active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_clinical_sites", x => x.id);
});
migrationBuilder.CreateTable(
name: "ward_gateways",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
site_id = table.Column<Guid>(type: "uuid", nullable: false),
gateway_code = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false, defaultValueSql: "'OFFLINE'"),
reported_buffer_depth = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
last_heartbeat_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
last_sync_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_ward_gateways", x => x.id);
table.CheckConstraint("chk_ward_gateways_status", "status IN ('ONLINE','DEGRADED','OFFLINE')");
table.ForeignKey(
name: "FK_ward_gateways_clinical_sites_site_id",
column: x => x.site_id,
principalTable: "clinical_sites",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "clinical_sync_batches",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
gateway_id = table.Column<Guid>(type: "uuid", nullable: false),
site_id = table.Column<Guid>(type: "uuid", nullable: false),
batch_reference = table.Column<Guid>(type: "uuid", nullable: false),
status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false, defaultValueSql: "'RECEIVED'"),
payload = table.Column<string>(type: "jsonb", nullable: false),
submitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_clinical_sync_batches", x => x.id);
table.CheckConstraint("chk_clinical_sync_batches_status", "status IN ('RECEIVED','PROCESSING','APPLIED','CONFLICT','REJECTED')");
table.ForeignKey(
name: "FK_clinical_sync_batches_clinical_sites_site_id",
column: x => x.site_id,
principalTable: "clinical_sites",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_clinical_sync_batches_ward_gateways_gateway_id",
column: x => x.gateway_id,
principalTable: "ward_gateways",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "clinical_sync_conflicts",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
client_ref = table.Column<Guid>(type: "uuid", nullable: false),
item_type = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
conflict_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_clinical_sync_conflicts", x => x.id);
table.ForeignKey(
name: "FK_clinical_sync_conflicts_clinical_sync_batches_batch_id",
column: x => x.batch_id,
principalTable: "clinical_sync_batches",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_client_alert_id",
table: "clinical_alerts",
column: "client_alert_id",
unique: true,
filter: "client_alert_id IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_clinical_sites_site_code",
table: "clinical_sites",
column: "site_code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_clinical_sync_batches_batch_reference",
table: "clinical_sync_batches",
column: "batch_reference",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_clinical_sync_batches_gateway_id_submitted_at",
table: "clinical_sync_batches",
columns: new[] { "gateway_id", "submitted_at" });
migrationBuilder.CreateIndex(
name: "IX_clinical_sync_batches_site_id",
table: "clinical_sync_batches",
column: "site_id");
migrationBuilder.CreateIndex(
name: "IX_clinical_sync_conflicts_batch_id",
table: "clinical_sync_conflicts",
column: "batch_id");
migrationBuilder.CreateIndex(
name: "IX_ward_gateways_site_id_department",
table: "ward_gateways",
columns: new[] { "site_id", "department" });
migrationBuilder.CreateIndex(
name: "IX_ward_gateways_site_id_gateway_code",
table: "ward_gateways",
columns: new[] { "site_id", "gateway_code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ward_gateways_status",
table: "ward_gateways",
column: "status",
filter: "status != 'ONLINE'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "clinical_sync_conflicts");
migrationBuilder.DropTable(
name: "clinical_sync_batches");
migrationBuilder.DropTable(
name: "ward_gateways");
migrationBuilder.DropTable(
name: "clinical_sites");
migrationBuilder.DropIndex(
name: "IX_clinical_alerts_client_alert_id",
table: "clinical_alerts");
migrationBuilder.DropColumn(
name: "client_alert_id",
table: "clinical_alerts");
migrationBuilder.DropColumn(
name: "synced_from_gateway",
table: "clinical_alerts");
}
}
}
@@ -104,6 +104,10 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<Guid?>("ClientAlertId")
.HasColumnType("uuid")
.HasColumnName("client_alert_id");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
@@ -144,6 +148,12 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<bool>("SyncedFromGateway")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("synced_from_gateway");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -152,6 +162,10 @@ namespace VigilCareClinicalAPI.Migrations
b.HasKey("Id");
b.HasIndex("ClientAlertId")
.IsUnique()
.HasFilter("client_alert_id IS NOT NULL");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
@@ -246,6 +260,149 @@ namespace VigilCareClinicalAPI.Migrations
b.ToTable("clinical_audit_logs", (string)null);
});
modelBuilder.Entity("ClinicalSite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<bool>("Active")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("active");
b.Property<string>("Address")
.HasColumnType("text")
.HasColumnName("address");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("SiteCode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("site_code");
b.HasKey("Id");
b.HasIndex("SiteCode")
.IsUnique();
b.ToTable("clinical_sites", (string)null);
});
modelBuilder.Entity("ClinicalSyncBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchReference")
.HasColumnType("uuid")
.HasColumnName("batch_reference");
b.Property<Guid>("GatewayId")
.HasColumnType("uuid")
.HasColumnName("gateway_id");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<Guid>("SiteId")
.HasColumnType("uuid")
.HasColumnName("site_id");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("status")
.HasDefaultValueSql("'RECEIVED'");
b.Property<DateTimeOffset>("SubmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("submitted_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchReference")
.IsUnique();
b.HasIndex("SiteId");
b.HasIndex("GatewayId", "SubmittedAt");
b.ToTable("clinical_sync_batches", null, t =>
{
t.HasCheckConstraint("chk_clinical_sync_batches_status", "status IN ('RECEIVED','PROCESSING','APPLIED','CONFLICT','REJECTED')");
});
});
modelBuilder.Entity("ClinicalSyncConflict", 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<Guid>("ClientRef")
.HasColumnType("uuid")
.HasColumnName("client_ref");
b.Property<string>("ConflictReason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("conflict_reason");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("ItemType")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("item_type");
b.HasKey("Id");
b.HasIndex("BatchId");
b.ToTable("clinical_sync_conflicts", (string)null);
});
modelBuilder.Entity("ClinicalUser", b =>
{
b.Property<Guid>("Id")
@@ -1192,6 +1349,74 @@ namespace VigilCareClinicalAPI.Migrations
b.ToTable("sofa_scores", (string)null);
});
modelBuilder.Entity("WardGateway", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("GatewayCode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("gateway_code");
b.Property<DateTimeOffset?>("LastHeartbeatAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_heartbeat_at");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_sync_at");
b.Property<int>("ReportedBufferDepth")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("reported_buffer_depth");
b.Property<Guid>("SiteId")
.HasColumnType("uuid")
.HasColumnName("site_id");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("status")
.HasDefaultValueSql("'OFFLINE'");
b.HasKey("Id");
b.HasIndex("Status")
.HasFilter("status != 'ONLINE'");
b.HasIndex("SiteId", "Department");
b.HasIndex("SiteId", "GatewayCode")
.IsUnique();
b.ToTable("ward_gateways", null, t =>
{
t.HasCheckConstraint("chk_ward_gateways_status", "status IN ('ONLINE','DEGRADED','OFFLINE')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
@@ -1203,6 +1428,34 @@ namespace VigilCareClinicalAPI.Migrations
b.Navigation("Encounter");
});
modelBuilder.Entity("ClinicalSyncBatch", b =>
{
b.HasOne("WardGateway", "Gateway")
.WithMany()
.HasForeignKey("GatewayId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ClinicalSite", null)
.WithMany()
.HasForeignKey("SiteId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Gateway");
});
modelBuilder.Entity("ClinicalSyncConflict", b =>
{
b.HasOne("ClinicalSyncBatch", "Batch")
.WithMany("Conflicts")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
@@ -1334,6 +1587,27 @@ namespace VigilCareClinicalAPI.Migrations
b.Navigation("Encounter");
});
modelBuilder.Entity("WardGateway", b =>
{
b.HasOne("ClinicalSite", "Site")
.WithMany("Gateways")
.HasForeignKey("SiteId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Site");
});
modelBuilder.Entity("ClinicalSite", b =>
{
b.Navigation("Gateways");
});
modelBuilder.Entity("ClinicalSyncBatch", b =>
{
b.Navigation("Conflicts");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
@@ -0,0 +1,2 @@
public record ClinicalBatchStatusResponse(
Guid BatchId, string Status, IReadOnlyList<ClinicalConflictDetail> Conflicts);
@@ -0,0 +1 @@
public record ClinicalBatchUploadResponse(Guid BatchId, string Status);
@@ -0,0 +1 @@
public record ClinicalConflictDetail(Guid ClientRef, string ItemType, string ConflictReason);
@@ -0,0 +1,3 @@
public record ClinicalSyncHistoryItem(
Guid BatchId, Guid BatchReference, string Status,
int ConflictCount, DateTimeOffset SubmittedAt);
@@ -10,6 +10,9 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
public const string EscalKey = "alerts.escalation";
public const string DischargeKey = "notifications.discharge";
public const string ReconciliationKey = "notifications.reconciliation";
public const string SyncExchange = "clinical.sync";
public const string SyncBatchReceivedKey = "sync.batch_received";
public const string SyncBatchQueue = "clinical.sync.batch_received";
private readonly RabbitMqOptions _opts;
private readonly IHostEnvironment _env;
@@ -131,6 +134,16 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
arguments: null);
channel.QueueBind("notifications.reconciliation.queue", Exchange, ReconciliationKey);
channel.ExchangeDeclare(SyncExchange, ExchangeType.Topic, durable: true);
channel.QueueDeclare(
queue: SyncBatchQueue,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
channel.QueueBind(SyncBatchQueue, SyncExchange, SyncBatchReceivedKey);
_logger.LogInformation(
"RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}",
Exchange, _opts.PagingAckTimeoutMs);
@@ -83,6 +83,10 @@ public sealed class ClinicalMetrics
"PHI access log entries written.",
labelNames: new[] { "access_type" });
public readonly Counter ClinicalSyncBatchesTotal = Metrics.CreateCounter(
"clinical_sync_batches_total",
"Sync batches processed",
labelNames: new[] { "status" });
// --- Histograms ---
// Measures the full ingest transaction: Redis cache lookup + alert evaluation +
@@ -120,6 +124,14 @@ public sealed class ClinicalMetrics
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
});
public readonly Histogram ClinicalSyncBatchDuration = Metrics.CreateHistogram(
"clinical_sync_batch_duration_seconds",
"Batch processing duration",
new HistogramConfiguration
{
Buckets = new[] { 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0 }
});
// --- Gauges (set by background collectors, not incremented inline) ---
// The most clinically significant panel. A non-zero value means a patient's
+13 -2
View File
@@ -9,6 +9,7 @@ using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Mvc;
using System.Reflection;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Authorization;
@@ -46,7 +47,9 @@ try
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey))
};
});
})
.AddScheme<AuthenticationSchemeOptions, GatewayApiKeyAuthenticationHandler>(
GatewayApiKeyAuthenticationHandler.SchemeName, null);
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
@@ -126,6 +129,9 @@ try
.GetSection(DashboardOptions.Section)
.Get<DashboardOptions>() ?? new DashboardOptions();
builder.Services.Configure<ClinicalSyncOptions>(
builder.Configuration.GetSection(ClinicalSyncOptions.Section));
builder.Services.Configure<FhirOptions>(
builder.Configuration.GetSection(FhirOptions.Section));
@@ -140,6 +146,8 @@ try
builder.Services.Configure<PhiEncryptionOptions>(
builder.Configuration.GetSection(PhiEncryptionOptions.Section));
builder.Services.Configure<ClinicalSyncOptions>(builder.Configuration.GetSection(ClinicalSyncOptions.Section));
builder.Services.AddCors(options =>
{
options.AddPolicy("Dashboard", policy =>
@@ -191,12 +199,14 @@ try
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAuditService, AuditService>();
builder.Services.AddScoped<IClinicalSyncService, ClinicalSyncService>();
builder.Services.AddScoped<ClinicalSyncBatchProcessor>();
builder.Services.AddSingleton<IPhiEncryptionService, PhiEncryptionService>();
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>();
builder.Services.AddHostedService<OutboxRelayService>();
builder.Services.AddHostedService<ClinicalSyncBatchConsumer>();
builder.Services.AddHostedService<ElasticIndexProvisioner>();
builder.Services.AddHostedService<EsIndexerService>();
builder.Services.AddHostedService<SepsisEngineService>();
@@ -303,6 +313,7 @@ try
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DataSeeder.SeedAsync(db, redis);
await GatewayRegistrySeeder.SeedAsync(db);
await UserSeeder.SeedAsync(db);
}
@@ -2,7 +2,9 @@ public static class AlertCreationGuard
{
public static void EnsureAllowed(AlertType alertType)
{
#pragma warning disable CS0618 // Guard rejects deprecated SEPSIS_WARNING creation
if (alertType == AlertType.SepsisWarning)
#pragma warning restore CS0618
throw new InvalidOperationException(
"SEPSIS_WARNING is deprecated. Use SOFA_SEPSIS for sepsis detection.");
}
@@ -200,4 +200,48 @@ public class AlertService : IAlertService
return alert;
}
public async Task ApplySyncedAcknowledgmentAsync(
Guid alertId, SyncedAlertAcknowledgment ack, CancellationToken ct)
{
var alert = await _db.ClinicalAlerts.FindAsync([alertId], ct)
?? throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
alert.Status = AlertStatus.Acknowledged;
alert.AcknowledgedAt = ack.AcknowledgedAt;
alert.AcknowledgedBy = ack.ClinicianId;
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = "alert.acknowledged",
Payload = JsonSerializer.Serialize(new
{
alertId = alert.Id,
encounterId = alert.EncounterId,
acknowledgedBy = ack.ClinicianId,
acknowledgedAt = ack.AcknowledgedAt,
note = ack.Note,
syncedFromGateway = true
}),
PartitionKey = alert.EncounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync(ct);
}
public async Task ApplySyncedResolutionAsync(
Guid alertId, SyncedAlertResolution resolve, CancellationToken ct)
{
var alert = await _db.ClinicalAlerts.FindAsync([alertId], ct)
?? throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
if (alert.Status != AlertStatus.Acknowledged)
alert.Status = AlertStatus.Acknowledged;
alert.Status = AlertStatus.Resolved;
alert.ResolvedAt = resolve.ResolvedAt;
await _db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,234 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Prometheus;
using VigilCare.ClinicalContracts.Sync;
public class ClinicalSyncBatchProcessor
{
private readonly AppDbContext _db;
private readonly IObservationService _observations;
private readonly IAlertService _alerts;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<ClinicalSyncBatchProcessor> _logger;
public ClinicalSyncBatchProcessor(
AppDbContext db,
IObservationService observations,
IAlertService alerts,
ClinicalMetrics metrics,
ILogger<ClinicalSyncBatchProcessor> logger)
{
_db = db;
_observations = observations;
_alerts = alerts;
_metrics = metrics;
_logger = logger;
}
public async Task ProcessBatchAsync(Guid batchId, CancellationToken ct)
{
using var timer = _metrics.ClinicalSyncBatchDuration.NewTimer();
// Phase 1 — lock batch row
ClinicalSyncBatch? batch;
try
{
await using var lockTx = await _db.Database.BeginTransactionAsync(ct);
batch = await _db.ClinicalSyncBatches
.FromSqlInterpolated($"""
SELECT * FROM clinical_sync_batches
WHERE id = {batchId}
FOR UPDATE NOWAIT
""")
.FirstOrDefaultAsync(ct);
if (batch is null || batch.Status != ClinicalSyncBatchStatus.Received)
return;
batch.MarkProcessing();
await _db.SaveChangesAsync(ct);
await lockTx.CommitAsync(ct);
}
catch (PostgresException ex) when (ex.SqlState == "55P03") // lock_not_available
{
_logger.LogInformation("Batch {BatchId} already locked by another consumer", batchId);
return;
}
// Phase 2 — deserialize payload
var request = JsonSerializer.Deserialize<ClinicalSyncBatchRequest>(batch!.Payload)!;
var hasConflict = false;
// Phase 3 — replay order: observations → alerts → acks → resolutions
foreach (var obs in request.Observations.OrderBy(o => o.RecordedAt))
{
try
{
await ApplyObservationAsync(obs, batch, ct);
}
catch (Exception ex)
{
_db.ChangeTracker.Clear();
await RecordConflictAsync(batch.Id, obs.ClientRef, "OBSERVATION", ex.Message, ct);
hasConflict = true;
}
}
foreach (var alert in request.AlertEvents.OrderBy(a => a.GeneratedAt))
{
try
{
await ApplyAlertEventAsync(alert, batch, ct);
}
catch (Exception ex)
{
_db.ChangeTracker.Clear();
await RecordConflictAsync(batch.Id, alert.ClientAlertId, "ALERT", ex.Message, ct);
hasConflict = true;
}
}
foreach (var ack in request.AlertAcknowledgments.OrderBy(a => a.AcknowledgedAt))
{
try
{
if (await ApplyAckAsync(ack, batch, ct))
hasConflict = true;
}
catch (Exception ex)
{
_db.ChangeTracker.Clear();
await RecordConflictAsync(batch.Id, ack.ClientRef, "ACK", ex.Message, ct);
hasConflict = true;
}
}
foreach (var resolve in request.AlertResolutions.OrderBy(r => r.ResolvedAt))
{
try
{
if (await ApplyResolveAsync(resolve, batch, ct))
hasConflict = true;
}
catch (Exception ex)
{
_db.ChangeTracker.Clear();
await RecordConflictAsync(batch.Id, resolve.ClientRef, "RESOLVE", ex.Message, ct);
hasConflict = true;
}
}
// Phase 4 — finalize batch
batch = await _db.ClinicalSyncBatches.FindAsync([batchId], ct);
if (batch is null) return;
if (hasConflict) batch.MarkConflict();
else batch.MarkApplied();
var gateway = await _db.WardGateways.FindAsync([batch.GatewayId], ct);
gateway?.MarkSynced(DateTimeOffset.UtcNow);
await _db.SaveChangesAsync(ct);
_metrics.ClinicalSyncBatchesTotal.WithLabels(batch.Status.ToDbString()).Inc();
_logger.LogInformation("Batch {BatchId} finalized as {Status}", batchId, batch.Status);
}
private async Task ApplyObservationAsync(
SyncedObservation obs, ClinicalSyncBatch batch, CancellationToken ct)
{
if (await _db.Observations.AnyAsync(o => o.IdempotencyKey == obs.IdempotencyKey, ct))
return;
await _observations.ApplySyncedObservationAsync(obs, ct);
}
private async Task ApplyAlertEventAsync(
SyncedAlertEvent alert, ClinicalSyncBatch batch, CancellationToken ct)
{
if (await _db.ClinicalAlerts.AnyAsync(a => a.ClientAlertId == alert.ClientAlertId, ct))
return;
var encounter = await _db.Encounters.FindAsync([alert.EncounterId], ct)
?? throw new ValidationException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
var clinicalAlert = new ClinicalAlert
{
Id = Guid.NewGuid(),
ClientAlertId = alert.ClientAlertId,
SyncedFromGateway = true,
EncounterId = alert.EncounterId,
PatientId = encounter.PatientId,
AlertType = AlertTypeExtensions.FromDbString(alert.AlertType),
Severity = AlertSeverityExtensions.FromDbString(alert.Severity),
Details = alert.Details,
Status = AlertStatus.Open,
TriggeredAt = alert.GeneratedAt
};
_db.ClinicalAlerts.Add(clinicalAlert);
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
{
alertId = clinicalAlert.Id,
encounterId = alert.EncounterId,
patientId = encounter.PatientId,
alertType = alert.AlertType,
severity = alert.Severity,
details = alert.Details,
syncedFromGateway = true,
triggeredAt = alert.GeneratedAt,
partitionKey = alert.EncounterId.ToString()
}),
PartitionKey = alert.EncounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync(ct);
}
private async Task<bool> ApplyAckAsync(
SyncedAlertAcknowledgment ack, ClinicalSyncBatch batch, CancellationToken ct)
{
var alert = await _db.ClinicalAlerts
.FirstOrDefaultAsync(a => a.ClientAlertId == ack.ClientAlertId, ct);
if (alert is null)
{
await RecordConflictAsync(batch.Id, ack.ClientRef, "ACK", "ALERT_NOT_YET_SYNCED", ct);
return true;
}
if (alert.Status != AlertStatus.Open && alert.Status != AlertStatus.Escalated)
return false;
await _alerts.ApplySyncedAcknowledgmentAsync(alert.Id, ack, ct);
return false;
}
private async Task<bool> ApplyResolveAsync(
SyncedAlertResolution resolve, ClinicalSyncBatch batch, CancellationToken ct)
{
var alert = await _db.ClinicalAlerts
.FirstOrDefaultAsync(a => a.ClientAlertId == resolve.ClientAlertId, ct);
if (alert is null)
{
await RecordConflictAsync(batch.Id, resolve.ClientRef, "RESOLVE", "ALERT_NOT_YET_SYNCED", ct);
return true;
}
if (alert.Status == AlertStatus.Resolved)
return false;
await _alerts.ApplySyncedResolutionAsync(alert.Id, resolve, ct);
return false;
}
private async Task RecordConflictAsync(
Guid batchId, Guid clientRef, string itemType, string reason, CancellationToken ct)
{
_db.ClinicalSyncConflicts.Add(new ClinicalSyncConflict(batchId, clientRef, itemType, reason));
await _db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,94 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using VigilCare.ClinicalContracts.Sync;
public class ClinicalSyncService : IClinicalSyncService
{
private readonly AppDbContext _db;
private readonly ILogger<ClinicalSyncService> _logger;
public ClinicalSyncService(AppDbContext db, ILogger<ClinicalSyncService> logger)
{
_db = db;
_logger = logger;
}
public async Task<ClinicalBatchUploadResponse> UploadBatchAsync(
ClinicalSyncBatchRequest request, CancellationToken ct)
{
var existing = await _db.ClinicalSyncBatches
.AsNoTracking()
.FirstOrDefaultAsync(b => b.BatchReference == request.BatchReference, ct);
if (existing is not null)
return new ClinicalBatchUploadResponse(existing.Id, existing.Status.ToDbString());
var gateway = await _db.WardGateways
.FirstOrDefaultAsync(g => g.Id == request.GatewayId && g.SiteId == request.SiteId, ct)
?? throw new NotFoundException("Gateway not found for site.", "GATEWAY_NOT_FOUND");
var payload = JsonSerializer.Serialize(request);
var batch = new ClinicalSyncBatch(gateway.Id, request.SiteId, request.BatchReference, payload);
var batchId = Guid.NewGuid();
_db.Entry(batch).Property(nameof(ClinicalSyncBatch.Id)).CurrentValue = batchId;
await using var tx = await _db.Database.BeginTransactionAsync(ct);
_db.ClinicalSyncBatches.Add(batch);
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = ClinicalSyncOptions.BatchReceivedOutboxTopic,
Payload = JsonSerializer.Serialize(new
{
batchId,
gatewayId = gateway.Id,
siteId = request.SiteId
}),
PartitionKey = gateway.Id.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_logger.LogInformation(
"Sync batch {BatchId} received from gateway {GatewayId} — {ObsCount} observations",
batch.Id, gateway.Id, request.Observations.Count);
return new ClinicalBatchUploadResponse(batch.Id, "RECEIVED");
}
public async Task<ClinicalBatchStatusResponse> GetBatchStatusAsync(Guid batchId, CancellationToken ct)
{
var batch = await _db.ClinicalSyncBatches
.AsNoTracking()
.Include(b => b.Conflicts)
.FirstOrDefaultAsync(b => b.Id == batchId, ct)
?? throw new NotFoundException("Sync batch not found.", "BATCH_NOT_FOUND");
var conflicts = batch.Conflicts.Select(c =>
new ClinicalConflictDetail(c.ClientRef, c.ItemType, c.ConflictReason)).ToList();
return new ClinicalBatchStatusResponse(batch.Id, batch.Status.ToDbString(), conflicts);
}
public async Task<IReadOnlyList<ClinicalSyncHistoryItem>> GetSyncHistoryAsync(
Guid siteId, Guid gatewayId, int limit, CancellationToken ct)
{
var gatewayExists = await _db.WardGateways
.AnyAsync(g => g.Id == gatewayId && g.SiteId == siteId, ct);
if (!gatewayExists)
throw new NotFoundException("Gateway not found for site.", "GATEWAY_NOT_FOUND");
return await _db.ClinicalSyncBatches
.AsNoTracking()
.Where(b => b.GatewayId == gatewayId && b.SiteId == siteId)
.OrderByDescending(b => b.SubmittedAt)
.Take(limit)
.Select(b => new ClinicalSyncHistoryItem(
b.Id,
b.BatchReference,
b.Status.ToDbString(),
b.Conflicts.Count,
b.SubmittedAt))
.ToListAsync(ct);
}
}
@@ -11,4 +11,6 @@ public interface IAlertService
Task<ClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req);
Task<ClinicalAlert> ResolveAsync(Guid id);
Task ApplySyncedAcknowledgmentAsync(Guid alertId, SyncedAlertAcknowledgment ack, CancellationToken ct);
Task ApplySyncedResolutionAsync(Guid alertId, SyncedAlertResolution resolve, CancellationToken ct);
}
@@ -0,0 +1,9 @@
using VigilCare.ClinicalContracts.Sync;
public interface IClinicalSyncService
{
Task<ClinicalBatchUploadResponse> UploadBatchAsync(ClinicalSyncBatchRequest request, CancellationToken ct);
Task<ClinicalBatchStatusResponse> GetBatchStatusAsync(Guid batchId, CancellationToken ct);
Task<IReadOnlyList<ClinicalSyncHistoryItem>> GetSyncHistoryAsync(
Guid siteId, Guid gatewayId, int limit, CancellationToken ct);
}
@@ -1,4 +1,5 @@
public interface IObservationService
{
Task<IngestResult> IngestAsync(Guid encounterId, IngestObservationRequest req);
Task ApplySyncedObservationAsync(SyncedObservation obs, CancellationToken ct);
}
@@ -187,6 +187,95 @@ public class ObservationService : IObservationService
}
}
public async Task ApplySyncedObservationAsync(SyncedObservation obs, CancellationToken ct)
{
var encounter = await _db.Encounters
.Include(e => e.Patient)
.FirstOrDefaultAsync(e => e.Id == obs.EncounterId, ct)
?? throw new ValidationException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
if (encounter.Status != EncounterStatus.Active)
throw new ValidationException("Encounter not active.", "ENCOUNTER_NOT_ACTIVE");
await using var tx = await _db.Database.BeginTransactionAsync(ct);
try
{
var observation = new Observation
{
Id = obs.ClientRef,
EncounterId = obs.EncounterId,
ObservationCode = obs.ObservationCode,
Value = obs.Value,
Unit = obs.Unit ?? "",
Source = ObservationSourceExtensions.FromDbString(obs.Source),
IdempotencyKey = obs.IdempotencyKey,
RecordedAt = obs.RecordedAt,
CreatedAt = DateTimeOffset.UtcNow
};
_db.Observations.Add(observation);
var threshold = await LoadThresholdAsync(obs.ObservationCode);
if (threshold is not null && IsCriticalBreach(obs.Value, threshold))
{
var hasOpenAlert = await _db.ClinicalAlerts.AnyAsync(a =>
a.EncounterId == obs.EncounterId
&& a.ObservationId == observation.Id, ct);
if (!hasOpenAlert)
{
var alert = new ClinicalAlert
{
Id = Guid.NewGuid(),
EncounterId = obs.EncounterId,
PatientId = encounter.PatientId,
ObservationId = observation.Id,
AlertType = AlertTypeExtensions.CriticalFor(obs.ObservationCode),
Severity = AlertSeverity.Critical,
Details = BuildCriticalDetails(
new IngestObservationRequest(obs.ObservationCode, obs.Value, obs.Unit ?? "",
observation.Source, obs.RecordedAt, obs.IdempotencyKey),
threshold),
Status = AlertStatus.Open,
TriggeredAt = obs.RecordedAt
};
_db.ClinicalAlerts.Add(alert);
_db.OutboxEvents.Add(BuildOutboxEvent("alert.generated", new
{
alertId = alert.Id,
encounterId = obs.EncounterId,
patientId = encounter.PatientId,
alertType = alert.AlertType.ToDbString(),
severity = alert.Severity.ToDbString(),
details = alert.Details,
triggeredAt = alert.TriggeredAt,
partitionKey = obs.EncounterId.ToString()
}, obs.EncounterId.ToString()));
}
}
_db.OutboxEvents.Add(BuildOutboxEvent("observation.recorded", new
{
observationId = observation.Id,
encounterId = obs.EncounterId,
patientId = encounter.PatientId,
observationCode = obs.ObservationCode,
value = obs.Value,
unit = obs.Unit,
source = obs.Source,
recordedAt = obs.RecordedAt,
partitionKey = obs.EncounterId.ToString()
}, obs.EncounterId.ToString()));
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
await tx.RollbackAsync(ct);
// silent skip — idempotency key already applied
}
}
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(string observationCode)
{
var cache = _redis.GetDatabase();
@@ -34,7 +34,9 @@ public class QsofaService : IQsofaService
var query = _db.ClinicalAlerts
.AsNoTracking()
.Where(a => a.EncounterId == encounterId
#pragma warning disable CS0618 // Include legacy QSOFA_WARNING rows in history
&& (a.AlertType == AlertType.QsofaScreen || a.AlertType == AlertType.QsofaWarning));
#pragma warning restore CS0618
var items = await query
.OrderByDescending(a => a.TriggeredAt)
@@ -39,4 +39,8 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -204,5 +204,11 @@
},
"DataProtection": {
"KeyPath": "./data-protection-keys"
},
"ApiKey": {
"Gateway": "dev-gateway-key-change-in-production"
},
"ClinicalSync": {
"SuppressPagingForSyncedAlerts": true
}
}
+59
View File
@@ -0,0 +1,59 @@
**`scripts/run-phase22-verification.sh`:**
```bash
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
CENTRAL="${CENTRAL_URL:-http://localhost:5080}"
GATEWAY="${GATEWAY_URL:-http://localhost:5081}"
KEY="${GATEWAY_API_KEY:-dev-gateway-key-change-in-production}"
GW_ID="${GATEWAY_ID:-22222222-2222-2222-2222-222222222222}"
JWT="${ADMIN_JWT:?Set ADMIN_JWT}"
echo "==> Run sync batch tests"
dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~ClinicalSyncBatch"
echo "==> Start full stack"
docker compose --profile full --profile ward-gateway up -d
sleep 30
echo "==> Stop central API to buffer events on gateway"
# Stop only central API process/container — gateway stack stays up
ENCOUNTER_ID=$(curl -sf "$GATEWAY/api/v1/encounters?status=ACTIVE&department=ICU" \
-H "Authorization: Bearer ${GATEWAY_JWT:?Set GATEWAY_JWT}" | jq -r '.data.items[0].id')
echo "==> Post observations to gateway while central down"
for i in $(seq 1 10); do
curl -sf -X POST "$GATEWAY/api/v1/encounters/$ENCOUNTER_ID/observations" \
-H "Authorization: Bearer $GATEWAY_JWT" \
-H "Content-Type: application/json" \
-d "{\"observationCode\":\"HEART_RATE\",\"value\":$((100+i)),\"unit\":\"bpm\",\"source\":\"DEVICE\",\"recordedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"idempotencyKey\":\"verify-$i\"}" \
> /dev/null
done
echo "==> Restart central and wait for sync"
# Start central API; poll gateway buffer depth → 0
echo "==> Poll batch status"
BATCH_ID=$(curl -sf "$CENTRAL/api/v1/sites/11111111-1111-1111-1111-111111111111/gateways/$GW_ID/sync-history" \
-H "Authorization: Bearer $JWT" | jq -r '.data[0].batchId')
curl -sf "$CENTRAL/api/v1/sync/batches/$BATCH_ID" \
-H "Authorization: Bearer $JWT" | jq -e '.data.status == "APPLIED"'
echo "==> Verify observations on central"
curl -sf "$CENTRAL/api/v1/encounters/$ENCOUNTER_ID/observations" \
-H "Authorization: Bearer $JWT" | jq -e '.data.items | length >= 10'
echo "==> Verify client_alert_id populated"
docker exec -i $(docker ps -qf name=postgres) psql -U postgres -d vigilcare \
-c "SELECT COUNT(*) FROM clinical_alerts WHERE client_alert_id IS NOT NULL;" | grep -v "^-" | grep -v row | awk '{print $1}' | grep -v '^0$'
echo "==> Check no duplicate paging (grep application logs for [PAGE] count)"
echo "Phase 22 verification passed."
```
Make executable: `chmod +x scripts/run-phase22-verification.sh`