diff --git a/VigilCare.ClinicalContracts.Tests/VigilCare.ClinicalContracts.Tests.csproj b/VigilCare.ClinicalContracts.Tests/VigilCare.ClinicalContracts.Tests.csproj
new file mode 100644
index 0000000..8cefb67
--- /dev/null
+++ b/VigilCare.ClinicalContracts.Tests/VigilCare.ClinicalContracts.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VigilCare.ClinicalContracts/Sync/ClinicalSyncBatchRequest.cs b/VigilCare.ClinicalContracts/Sync/ClinicalSyncBatchRequest.cs
new file mode 100644
index 0000000..38fa237
--- /dev/null
+++ b/VigilCare.ClinicalContracts/Sync/ClinicalSyncBatchRequest.cs
@@ -0,0 +1,11 @@
+namespace VigilCare.ClinicalContracts.Sync;
+
+public record ClinicalSyncBatchRequest(
+ Guid BatchReference,
+ Guid GatewayId,
+ Guid SiteId,
+ DateTimeOffset CapturedAtUtc,
+ IReadOnlyList Observations,
+ IReadOnlyList AlertEvents,
+ IReadOnlyList AlertAcknowledgments,
+ IReadOnlyList AlertResolutions);
\ No newline at end of file
diff --git a/VigilCare.ClinicalContracts/Sync/GatewayHeartbeatRequest.cs b/VigilCare.ClinicalContracts/Sync/GatewayHeartbeatRequest.cs
new file mode 100644
index 0000000..01b4f5c
--- /dev/null
+++ b/VigilCare.ClinicalContracts/Sync/GatewayHeartbeatRequest.cs
@@ -0,0 +1,4 @@
+public record GatewayHeartbeatRequest(
+ string Status, // ONLINE | DEGRADED | OFFLINE
+ int BufferDepth,
+ DateTimeOffset ReportedAtUtc);
\ No newline at end of file
diff --git a/VigilCare.ClinicalContracts/Sync/SyncedAlertAcknowledgment.cs b/VigilCare.ClinicalContracts/Sync/SyncedAlertAcknowledgment.cs
new file mode 100644
index 0000000..b7415f5
--- /dev/null
+++ b/VigilCare.ClinicalContracts/Sync/SyncedAlertAcknowledgment.cs
@@ -0,0 +1,6 @@
+public record SyncedAlertAcknowledgment(
+ Guid ClientRef,
+ Guid ClientAlertId,
+ string ClinicianId,
+ DateTimeOffset AcknowledgedAt,
+ string? Note);
\ No newline at end of file
diff --git a/VigilCare.ClinicalContracts/Sync/SyncedAlertEvent.cs b/VigilCare.ClinicalContracts/Sync/SyncedAlertEvent.cs
new file mode 100644
index 0000000..0242168
--- /dev/null
+++ b/VigilCare.ClinicalContracts/Sync/SyncedAlertEvent.cs
@@ -0,0 +1,7 @@
+public record SyncedAlertEvent(
+ Guid ClientAlertId,
+ Guid EncounterId,
+ string AlertType,
+ string Severity,
+ string Details,
+ DateTimeOffset GeneratedAt);
\ No newline at end of file
diff --git a/VigilCare.ClinicalContracts/Sync/SyncedAlertResolution.cs b/VigilCare.ClinicalContracts/Sync/SyncedAlertResolution.cs
new file mode 100644
index 0000000..cf64a26
--- /dev/null
+++ b/VigilCare.ClinicalContracts/Sync/SyncedAlertResolution.cs
@@ -0,0 +1,6 @@
+public record SyncedAlertResolution(
+ Guid ClientRef,
+ Guid ClientAlertId,
+ string ClinicianId,
+ DateTimeOffset ResolvedAt,
+ string? Note);
\ No newline at end of file
diff --git a/VigilCare.ClinicalContracts/Sync/SyncedObservation.cs b/VigilCare.ClinicalContracts/Sync/SyncedObservation.cs
new file mode 100644
index 0000000..aad5e18
--- /dev/null
+++ b/VigilCare.ClinicalContracts/Sync/SyncedObservation.cs
@@ -0,0 +1,9 @@
+public record SyncedObservation(
+ Guid ClientRef,
+ string IdempotencyKey,
+ Guid EncounterId,
+ string ObservationCode,
+ decimal Value,
+ string? Unit,
+ string Source,
+ DateTimeOffset RecordedAt);
\ No newline at end of file
diff --git a/VigilCare.ClinicalContracts/VigilCare.ClinicalContracts.csproj b/VigilCare.ClinicalContracts/VigilCare.ClinicalContracts.csproj
new file mode 100644
index 0000000..132ea98
--- /dev/null
+++ b/VigilCare.ClinicalContracts/VigilCare.ClinicalContracts.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net8.0
+ enable
+ enable
+ VigilCare.ClinicalContracts
+
+
+
diff --git a/VigilCare.Simulator/Client/VigilCareApiClient.cs b/VigilCare.Simulator/Client/VigilCareApiClient.cs
index 0dbee07..ee1c3c2 100644
--- a/VigilCare.Simulator/Client/VigilCareApiClient.cs
+++ b/VigilCare.Simulator/Client/VigilCareApiClient.cs
@@ -32,7 +32,7 @@ public class VigilCareApiClient
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
response.EnsureSuccessStatusCode();
var envelope = await response.Content.ReadFromJsonAsync>();
- return envelope!.Data;
+ return envelope!.Data!;
}
public async Task 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>();
- return envelope!.Data;
+ return envelope!.Data!;
}
public async Task SendObservationBatchAsync(
@@ -89,7 +89,7 @@ public class VigilCareApiClient
var envelope = await ordersResponse.Content
.ReadFromJsonAsync>>();
- 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>>();
- return envelope?.Data.Items.ToList() ?? new();
+ return envelope?.Data?.Items?.ToList() ?? new();
}
public async Task GetCurrentNews2Async(Guid encounterId)
diff --git a/VigilCareClinical.sln b/VigilCareClinical.sln
index a2c9365..20f655e 100644
--- a/VigilCareClinical.sln
+++ b/VigilCareClinical.sln
@@ -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
diff --git a/VigilCareClinicalAPI.Tests/Auth/RbacTests.cs b/VigilCareClinicalAPI.Tests/Auth/RbacTests.cs
index 6dbeeec..78c33a4 100644
--- a/VigilCareClinicalAPI.Tests/Auth/RbacTests.cs
+++ b/VigilCareClinicalAPI.Tests/Auth/RbacTests.cs
@@ -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;
}
}
\ No newline at end of file
diff --git a/VigilCareClinicalAPI.Tests/ClinicalSyncBatchTests.cs b/VigilCareClinicalAPI.Tests/ClinicalSyncBatchTests.cs
new file mode 100644
index 0000000..0e269e0
--- /dev/null
+++ b/VigilCareClinicalAPI.Tests/ClinicalSyncBatchTests.cs
@@ -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();
+ await DbResetHelper.ResetAsync(db);
+ await GatewayRegistrySeeder.SeedAsync(db);
+ await DataSeeder.SeedAsync(db, scope.ServiceProvider.GetRequiredService());
+ _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();
+ (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>();
+
+ var second = await _client.PostAsJsonAsync("/api/v1/sync/batches", batch);
+ var secondBody = await second.Content.ReadFromJsonAsync>();
+
+ secondBody!.Data!.BatchId.Should().Be(firstBody!.Data!.BatchId);
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ (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>())!.Data!.BatchId;
+
+ using var scope = _fixture.Services.CreateScope();
+ var processor = scope.ServiceProvider.GetRequiredService();
+ await processor.ProcessBatchAsync(batchId, CancellationToken.None);
+
+ var db = scope.ServiceProvider.GetRequiredService();
+ (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>())!.Data!.BatchId;
+
+ using var scope = _fixture.Services.CreateScope();
+ await scope.ServiceProvider.GetRequiredService()
+ .ProcessBatchAsync(batchId, CancellationToken.None);
+
+ var status = await _client.GetAsync($"/api/v1/sync/batches/{batchId}");
+ var body = await status.Content.ReadFromJsonAsync>();
+ 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>())!.Data!.BatchId;
+
+ using var scope = _fixture.Services.CreateScope();
+ await scope.ServiceProvider.GetRequiredService()
+ .ProcessBatchAsync(batchId, CancellationToken.None);
+
+ var db = scope.ServiceProvider.GetRequiredService();
+ var gw = await db.WardGateways.FindAsync(_gatewayId);
+ gw!.LastSyncAt.Should().NotBeNull();
+ gw.ReportedBufferDepth.Should().Be(0);
+ }
+}
\ No newline at end of file
diff --git a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs
index 50dbd61..af993bb 100644
--- a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs
+++ b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs
@@ -26,6 +26,7 @@ public class ApiFixture : WebApplicationFactory, 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);
diff --git a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs
index e340c17..774fb5d 100644
--- a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs
+++ b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs
@@ -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;
diff --git a/VigilCareClinicalAPI.Tests/Helpers/GatewayAuthHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/GatewayAuthHelper.cs
new file mode 100644
index 0000000..f2ccad2
--- /dev/null
+++ b/VigilCareClinicalAPI.Tests/Helpers/GatewayAuthHelper.cs
@@ -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());
+ }
+}
diff --git a/VigilCareClinicalAPI.Tests/Phi/PhiEncryptionTests.cs b/VigilCareClinicalAPI.Tests/Phi/PhiEncryptionTests.cs
index 2a4d731..1ab214f 100644
--- a/VigilCareClinicalAPI.Tests/Phi/PhiEncryptionTests.cs
+++ b/VigilCareClinicalAPI.Tests/Phi/PhiEncryptionTests.cs
@@ -33,7 +33,7 @@ public class PhiEncryptionTests
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
var raw = await db.Database
- .SqlQueryRaw($"SELECT first_name AS \"Value\" FROM patients WHERE id = '{patientId}'")
+ .SqlQuery($"SELECT first_name AS \"Value\" FROM patients WHERE id = {patientId}")
.FirstAsync();
raw.Should().NotBe("Encrypted");
diff --git a/VigilCareClinicalAPI/Authentication/GatewayApiKeyAuthenticationHandler.cs b/VigilCareClinicalAPI/Authentication/GatewayApiKeyAuthenticationHandler.cs
new file mode 100644
index 0000000..792b5de
--- /dev/null
+++ b/VigilCareClinicalAPI/Authentication/GatewayApiKeyAuthenticationHandler.cs
@@ -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
+{
+ public const string SchemeName = "GatewayApiKey";
+
+ private readonly IConfiguration _config;
+
+ public GatewayApiKeyAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ IConfiguration config)
+ : base(options, logger, encoder) => _config = config;
+
+ protected override Task 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 { 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);
+ }
+}
diff --git a/VigilCareClinicalAPI/BackgroundServices/ClinicalSyncBatchConsumer.cs b/VigilCareClinicalAPI/BackgroundServices/ClinicalSyncBatchConsumer.cs
new file mode 100644
index 0000000..8145ccd
--- /dev/null
+++ b/VigilCareClinicalAPI/BackgroundServices/ClinicalSyncBatchConsumer.cs
@@ -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 _logger;
+
+ public ClinicalSyncBatchConsumer(
+ IServiceScopeFactory scopes,
+ IOptions rabbitOpts,
+ ILogger 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();
+ await processor.ProcessBatchAsync(batchId, ct);
+
+ channel.BasicAck(ea.DeliveryTag, false);
+ }
+}
\ No newline at end of file
diff --git a/VigilCareClinicalAPI/BackgroundServices/Notifications/NotificationPublisherService.cs b/VigilCareClinicalAPI/BackgroundServices/Notifications/NotificationPublisherService.cs
index 68625d4..31856a5 100644
--- a/VigilCareClinicalAPI/BackgroundServices/Notifications/NotificationPublisherService.cs
+++ b/VigilCareClinicalAPI/BackgroundServices/Notifications/NotificationPublisherService.cs
@@ -9,15 +9,18 @@ public sealed class NotificationPublisherService : BackgroundService
private readonly IOptions _rabbitOpts;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger _logger;
+ private readonly ClinicalSyncOptions _syncOptions;
public NotificationPublisherService(
IOptions rabbitOpts,
IOptions kafkaOptions,
- ILogger logger)
+ ILogger logger,
+ IOptions 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;
diff --git a/VigilCareClinicalAPI/BackgroundServices/OutboxRelayService.cs b/VigilCareClinicalAPI/BackgroundServices/OutboxRelayService.cs
index 6948b4a..97be9e8 100644
--- a/VigilCareClinicalAPI/BackgroundServices/OutboxRelayService.cs
+++ b/VigilCareClinicalAPI/BackgroundServices/OutboxRelayService.cs
@@ -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 _logger;
private IProducer? _producer;
+ private IConnection? _rabbitConnection;
+ private IModel? _rabbitChannel;
+ private IBasicProperties? _rabbitProps;
public OutboxRelayService(
IServiceProvider services,
IOptions options,
+ IOptions rabbitOpts,
+ IOptions syncOpts,
ILogger 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
- {
- 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
+ {
+ 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 ex)
+ catch (Exception ex) when (ex is ProduceException or RabbitMQClientException)
{
ev.RetryCount++;
- ev.LastError = ex.Error.Reason;
+ ev.LastError = ex switch
+ {
+ ProduceException 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();
}
-}
\ No newline at end of file
+}
diff --git a/VigilCareClinicalAPI/Configuration/ClinicalSyncOptions.cs b/VigilCareClinicalAPI/Configuration/ClinicalSyncOptions.cs
new file mode 100644
index 0000000..02f5012
--- /dev/null
+++ b/VigilCareClinicalAPI/Configuration/ClinicalSyncOptions.cs
@@ -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";
+}
diff --git a/VigilCareClinicalAPI/Controllers/ClinicalSyncController.cs b/VigilCareClinicalAPI/Controllers/ClinicalSyncController.cs
new file mode 100644
index 0000000..718d422
--- /dev/null
+++ b/VigilCareClinicalAPI/Controllers/ClinicalSyncController.cs
@@ -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;
+
+ /// Upload a buffered sync batch from a ward gateway.
+ [HttpPost("api/v1/sync/batches")]
+ [Authorize(AuthenticationSchemes = GatewayApiKeyAuthenticationHandler.SchemeName)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)]
+ public async Task UploadBatch(
+ [FromBody] ClinicalSyncBatchRequest request, CancellationToken ct)
+ {
+ var result = await _sync.UploadBatchAsync(request, ct);
+ return StatusCode(201, ApiResponse.Created(result));
+ }
+
+ /// Poll batch processing status and conflicts.
+ [HttpGet("api/v1/sync/batches/{batchId:guid}")]
+ [Authorize(AuthenticationSchemes =
+ $"{JwtBearerDefaults.AuthenticationScheme},{GatewayApiKeyAuthenticationHandler.SchemeName}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse