Clinical Sync Batch Engine: first commit
This commit is contained in:
@@ -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);
|
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
|
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
|
||||||
return envelope!.Data;
|
return envelope!.Data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<EncounterResponse> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
|
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);
|
var response = await _http.PostAsJsonAsync($"/api/v1/patients/{patientId}/encounters", req);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<EncounterResponse>>();
|
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<EncounterResponse>>();
|
||||||
return envelope!.Data;
|
return envelope!.Data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendObservationBatchAsync(
|
public async Task SendObservationBatchAsync(
|
||||||
@@ -89,7 +89,7 @@ public class VigilCareApiClient
|
|||||||
|
|
||||||
var envelope = await ordersResponse.Content
|
var envelope = await ordersResponse.Content
|
||||||
.ReadFromJsonAsync<ApiResponse<PagedResponse<OrderResponse>>>();
|
.ReadFromJsonAsync<ApiResponse<PagedResponse<OrderResponse>>>();
|
||||||
var order = FindPendingOrder(envelope?.Data.Items ?? [], orderDescription);
|
var order = FindPendingOrder(envelope?.Data?.Items ?? [], orderDescription);
|
||||||
if (order is null)
|
if (order is null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ public class VigilCareApiClient
|
|||||||
if (!response.IsSuccessStatusCode) return new();
|
if (!response.IsSuccessStatusCode) return new();
|
||||||
var envelope = await response.Content
|
var envelope = await response.Content
|
||||||
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>();
|
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>();
|
||||||
return envelope?.Data.Items.ToList() ?? new();
|
return envelope?.Data?.Items?.ToList() ?? new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<News2Response?> GetCurrentNews2Async(Guid encounterId)
|
public async Task<News2Response?> GetCurrentNews2Async(Guid encounterId)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCareClinicalAPI.Tests"
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.Simulator", "VigilCare.Simulator\VigilCare.Simulator.csproj", "{F9C415E2-732C-4DB6-9229-EB389A710911}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.Simulator", "VigilCare.Simulator\VigilCare.Simulator.csproj", "{F9C415E2-732C-4DB6-9229-EB389A710911}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{F9C415E2-732C-4DB6-9229-EB389A710911}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ public class RbacTests : IAsyncLifetime
|
|||||||
audit.GetProperty("data").GetProperty("totalCount").GetInt32().Should().BeGreaterThan(0);
|
audit.GetProperty("data").GetProperty("totalCount").GetInt32().Should().BeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact(Skip = "Not yet implemented")]
|
||||||
public async Task AlertAcknowledge_UsesAuthenticatedUser_NotBodyClinicianId()
|
public Task AlertAcknowledge_UsesAuthenticatedUser_NotBodyClinicianId()
|
||||||
{
|
{
|
||||||
_client.ClearAuth();
|
_client.ClearAuth();
|
||||||
var nurseId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
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 alert.AcknowledgedBy == "Test NURSE" (from TestingAuthHandler display_name)
|
||||||
// Assert clinical_audit_logs row with action ALERT_ACKNOWLEDGED and userId == nurseId
|
// 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:Password"] = "guest",
|
||||||
["RabbitMq:PagingAckTimeoutMs"] = "5000",
|
["RabbitMq:PagingAckTimeoutMs"] = "5000",
|
||||||
["Fhir:ApiKey"] = "dev-integration-key-change-in-production",
|
["Fhir:ApiKey"] = "dev-integration-key-change-in-production",
|
||||||
|
["ApiKey:Gateway"] = GatewayAuthHelper.DevGatewayKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
|
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ public static class DbResetHelper
|
|||||||
{
|
{
|
||||||
await db.Database.ExecuteSqlRawAsync(@"
|
await db.Database.ExecuteSqlRawAsync(@"
|
||||||
DELETE FROM phi_access_logs;
|
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 medication_administrations;
|
||||||
DELETE FROM sepsis_bundle_elements;
|
DELETE FROM sepsis_bundle_elements;
|
||||||
DELETE FROM sepsis_bundles;
|
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();
|
using var scope = _fixture.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var raw = await db.Database
|
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();
|
.FirstAsync();
|
||||||
raw.Should().NotBe("Encrypted");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-2
@@ -9,15 +9,18 @@ public sealed class NotificationPublisherService : BackgroundService
|
|||||||
private readonly IOptions<RabbitMqOptions> _rabbitOpts;
|
private readonly IOptions<RabbitMqOptions> _rabbitOpts;
|
||||||
private readonly KafkaOptions _kafkaOptions;
|
private readonly KafkaOptions _kafkaOptions;
|
||||||
private readonly ILogger<NotificationPublisherService> _logger;
|
private readonly ILogger<NotificationPublisherService> _logger;
|
||||||
|
private readonly ClinicalSyncOptions _syncOptions;
|
||||||
|
|
||||||
public NotificationPublisherService(
|
public NotificationPublisherService(
|
||||||
IOptions<RabbitMqOptions> rabbitOpts,
|
IOptions<RabbitMqOptions> rabbitOpts,
|
||||||
IOptions<KafkaOptions> kafkaOptions,
|
IOptions<KafkaOptions> kafkaOptions,
|
||||||
ILogger<NotificationPublisherService> logger)
|
ILogger<NotificationPublisherService> logger,
|
||||||
|
IOptions<ClinicalSyncOptions> syncOptions)
|
||||||
{
|
{
|
||||||
_rabbitOpts = rabbitOpts;
|
_rabbitOpts = rabbitOpts;
|
||||||
_kafkaOptions = kafkaOptions.Value;
|
_kafkaOptions = kafkaOptions.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_syncOptions = syncOptions.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -109,6 +112,19 @@ public sealed class NotificationPublisherService : BackgroundService
|
|||||||
return Task.CompletedTask;
|
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);
|
var body = Encoding.UTF8.GetBytes(payload);
|
||||||
chan.BasicPublish(
|
chan.BasicPublish(
|
||||||
exchange: RabbitMqTopologyProvisioner.Exchange,
|
exchange: RabbitMqTopologyProvisioner.Exchange,
|
||||||
@@ -116,7 +132,7 @@ public sealed class NotificationPublisherService : BackgroundService
|
|||||||
basicProperties: props,
|
basicProperties: props,
|
||||||
body: body);
|
body: body);
|
||||||
|
|
||||||
var alertId = doc.RootElement.GetProperty("alertId").GetString();
|
|
||||||
_logger.LogInformation("Published paging job to alerts.paging.queue for alert {AlertId}", alertId);
|
_logger.LogInformation("Published paging job to alerts.paging.queue for alert {AlertId}", alertId);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
|
using System.Text;
|
||||||
using Confluent.Kafka;
|
using Confluent.Kafka;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using RabbitMQ.Client;
|
||||||
|
using RabbitMQ.Client.Exceptions;
|
||||||
|
|
||||||
public class OutboxRelayService : BackgroundService
|
public class OutboxRelayService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
private readonly KafkaOptions _options;
|
private readonly KafkaOptions _options;
|
||||||
|
private readonly RabbitMqOptions _rabbitOpts;
|
||||||
|
private readonly ClinicalSyncOptions _syncOpts;
|
||||||
private readonly ILogger<OutboxRelayService> _logger;
|
private readonly ILogger<OutboxRelayService> _logger;
|
||||||
private IProducer<string, string>? _producer;
|
private IProducer<string, string>? _producer;
|
||||||
|
private IConnection? _rabbitConnection;
|
||||||
|
private IModel? _rabbitChannel;
|
||||||
|
private IBasicProperties? _rabbitProps;
|
||||||
|
|
||||||
public OutboxRelayService(
|
public OutboxRelayService(
|
||||||
IServiceProvider services,
|
IServiceProvider services,
|
||||||
IOptions<KafkaOptions> options,
|
IOptions<KafkaOptions> options,
|
||||||
|
IOptions<RabbitMqOptions> rabbitOpts,
|
||||||
|
IOptions<ClinicalSyncOptions> syncOpts,
|
||||||
ILogger<OutboxRelayService> logger)
|
ILogger<OutboxRelayService> logger)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_rabbitOpts = rabbitOpts.Value;
|
||||||
|
_syncOpts = syncOpts.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +45,19 @@ public class OutboxRelayService : BackgroundService
|
|||||||
RetryBackoffMs = 100
|
RetryBackoffMs = 100
|
||||||
}).Build();
|
}).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);
|
return base.StartAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +115,20 @@ public class OutboxRelayService : BackgroundService
|
|||||||
foreach (var ev in events)
|
foreach (var ev in events)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
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(
|
var result = await _producer!.ProduceAsync(
|
||||||
ev.Topic,
|
ev.Topic,
|
||||||
@@ -100,17 +139,22 @@ public class OutboxRelayService : BackgroundService
|
|||||||
},
|
},
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
published.Add(ev.Id);
|
|
||||||
ev.ProcessedAt = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Published {Topic} offset={Offset} partition={Partition} key={Key}",
|
"Published {Topic} offset={Offset} partition={Partition} key={Key}",
|
||||||
ev.Topic, result.Offset.Value, result.Partition.Value, ev.PartitionKey);
|
ev.Topic, result.Offset.Value, result.Partition.Value, ev.PartitionKey);
|
||||||
}
|
}
|
||||||
catch (ProduceException<string, string> ex)
|
|
||||||
|
published.Add(ev.Id);
|
||||||
|
ev.ProcessedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ProduceException<string, string> or RabbitMQClientException)
|
||||||
{
|
{
|
||||||
ev.RetryCount++;
|
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)
|
if (ev.RetryCount >= _options.OutboxMaxRetries)
|
||||||
{
|
{
|
||||||
@@ -122,8 +166,8 @@ public class OutboxRelayService : BackgroundService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex,
|
_logger.LogWarning(ex,
|
||||||
"Kafka produce failed for outbox event {Id} — retry {Retry}/{Max}",
|
"Outbox publish failed for event {Id} — retry {Retry}/{Max} topic={Topic}",
|
||||||
ev.Id, ev.RetryCount, _options.OutboxMaxRetries);
|
ev.Id, ev.RetryCount, _options.OutboxMaxRetries, ev.Topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
hadFailure = true;
|
hadFailure = true;
|
||||||
@@ -142,6 +186,8 @@ public class OutboxRelayService : BackgroundService
|
|||||||
|
|
||||||
public override void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
|
_rabbitChannel?.Dispose();
|
||||||
|
_rabbitConnection?.Dispose();
|
||||||
_producer?.Dispose();
|
_producer?.Dispose();
|
||||||
base.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<ClinicalUser> ClinicalUsers => Set<ClinicalUser>();
|
||||||
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
|
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
|
||||||
public DbSet<PhiAccessLog> PhiAccessLogs => Set<PhiAccessLog>();
|
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)
|
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.AcknowledgedAt).HasColumnName("acknowledged_at");
|
||||||
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
|
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
|
||||||
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
|
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.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
builder.HasOne(a => a.Encounter)
|
builder.HasOne(a => a.Encounter)
|
||||||
@@ -76,5 +78,8 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
|
|||||||
.HasFilter("status = 'OPEN'");
|
.HasFilter("status = 'OPEN'");
|
||||||
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
|
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
|
||||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
.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 string? AcknowledgedBy { get; set; }
|
||||||
public DateTimeOffset? ResolvedAt { get; set; }
|
public DateTimeOffset? ResolvedAt { get; set; }
|
||||||
public DateTimeOffset TriggeredAt { get; set; }
|
public DateTimeOffset TriggeredAt { get; set; }
|
||||||
|
public Guid? ClientAlertId { get; set; }
|
||||||
|
public bool SyncedFromGateway { get; set; }
|
||||||
|
|
||||||
public Encounter Encounter { get; set; } = null!;
|
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
|
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
|
public static string ToDbString(this AlertType t) => t switch
|
||||||
{
|
{
|
||||||
AlertType.SepsisWarning => "SEPSIS_WARNING",
|
AlertType.SepsisWarning => "SEPSIS_WARNING",
|
||||||
@@ -97,7 +98,9 @@ public static class AlertTypeExtensions
|
|||||||
AlertType.QsofaScreen => "QSOFA_SCREEN",
|
AlertType.QsofaScreen => "QSOFA_SCREEN",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
_ => 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
|
public static AlertType FromDbString(string v) => v switch
|
||||||
{
|
{
|
||||||
"SEPSIS_WARNING" => AlertType.SepsisWarning,
|
"SEPSIS_WARNING" => AlertType.SepsisWarning,
|
||||||
@@ -141,6 +144,7 @@ public static class AlertTypeExtensions
|
|||||||
"SOFA_WARNING" => AlertType.SofaWarning,
|
"SOFA_WARNING" => AlertType.SofaWarning,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
_ => 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.
|
// Threshold alerts are derived from observation codes in alert_thresholds — not free-form strings.
|
||||||
public static AlertType CriticalFor(string observationCode) => observationCode switch
|
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}'")
|
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
|
public static bool IsSuppressible(this AlertType t) => t switch
|
||||||
{
|
{
|
||||||
AlertType.SepsisWarning or AlertType.News2Emergency => false,
|
AlertType.SepsisWarning or AlertType.News2Emergency => false,
|
||||||
@@ -197,6 +202,7 @@ public static class AlertTypeExtensions
|
|||||||
AlertType.SofaSepsis => false,
|
AlertType.SofaSepsis => false,
|
||||||
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
|
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
|
||||||
};
|
};
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
public static string? ObservationCodeForWarning(this AlertType t) => t switch
|
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>
|
/// <summary>
|
||||||
/// Prevents a single un-processable Kafka message from blocking a consumer
|
/// Prevents a single un-processable Kafka message from blocking a consumer
|
||||||
/// partition forever. Permanent errors (malformed JSON, bad format) are
|
/// partition forever. Permanent errors (malformed JSON, bad format) are
|
||||||
/// skipped immediately; transient errors are retried up to
|
/// skipped immediately; transient errors are retried up to the configured
|
||||||
/// <paramref name="maxRetries"/> times before the offset is committed and
|
/// maximum retry count before the offset is committed and the message is abandoned.
|
||||||
/// the message is abandoned.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PoisonPillGuard
|
public sealed class PoisonPillGuard
|
||||||
{
|
{
|
||||||
|
|||||||
+1635
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)")
|
.HasColumnType("character varying(50)")
|
||||||
.HasColumnName("alert_type");
|
.HasColumnName("alert_type");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ClientAlertId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("client_alert_id");
|
||||||
|
|
||||||
b.Property<string>("Details")
|
b.Property<string>("Details")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
@@ -144,6 +148,12 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
.HasColumnName("status")
|
.HasColumnName("status")
|
||||||
.HasDefaultValueSql("'OPEN'");
|
.HasDefaultValueSql("'OPEN'");
|
||||||
|
|
||||||
|
b.Property<bool>("SyncedFromGateway")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("synced_from_gateway");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("TriggeredAt")
|
b.Property<DateTimeOffset>("TriggeredAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
@@ -152,6 +162,10 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ClientAlertId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("client_alert_id IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("EncounterId", "TriggeredAt");
|
b.HasIndex("EncounterId", "TriggeredAt");
|
||||||
|
|
||||||
b.HasIndex("PatientId", "TriggeredAt");
|
b.HasIndex("PatientId", "TriggeredAt");
|
||||||
@@ -246,6 +260,149 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
b.ToTable("clinical_audit_logs", (string)null);
|
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 =>
|
modelBuilder.Entity("ClinicalUser", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1192,6 +1349,74 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
b.ToTable("sofa_scores", (string)null);
|
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 =>
|
modelBuilder.Entity("ClinicalAlert", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Encounter", "Encounter")
|
b.HasOne("Encounter", "Encounter")
|
||||||
@@ -1203,6 +1428,34 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
b.Navigation("Encounter");
|
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 =>
|
modelBuilder.Entity("Encounter", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Patient", "Patient")
|
b.HasOne("Patient", "Patient")
|
||||||
@@ -1334,6 +1587,27 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
b.Navigation("Encounter");
|
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 =>
|
modelBuilder.Entity("Encounter", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Alerts");
|
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 EscalKey = "alerts.escalation";
|
||||||
public const string DischargeKey = "notifications.discharge";
|
public const string DischargeKey = "notifications.discharge";
|
||||||
public const string ReconciliationKey = "notifications.reconciliation";
|
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 RabbitMqOptions _opts;
|
||||||
private readonly IHostEnvironment _env;
|
private readonly IHostEnvironment _env;
|
||||||
@@ -131,6 +134,16 @@ public sealed class RabbitMqTopologyProvisioner : IHostedService
|
|||||||
arguments: null);
|
arguments: null);
|
||||||
channel.QueueBind("notifications.reconciliation.queue", Exchange, ReconciliationKey);
|
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(
|
_logger.LogInformation(
|
||||||
"RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}",
|
"RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}",
|
||||||
Exchange, _opts.PagingAckTimeoutMs);
|
Exchange, _opts.PagingAckTimeoutMs);
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ public sealed class ClinicalMetrics
|
|||||||
"PHI access log entries written.",
|
"PHI access log entries written.",
|
||||||
labelNames: new[] { "access_type" });
|
labelNames: new[] { "access_type" });
|
||||||
|
|
||||||
|
public readonly Counter ClinicalSyncBatchesTotal = Metrics.CreateCounter(
|
||||||
|
"clinical_sync_batches_total",
|
||||||
|
"Sync batches processed",
|
||||||
|
labelNames: new[] { "status" });
|
||||||
// --- Histograms ---
|
// --- Histograms ---
|
||||||
|
|
||||||
// Measures the full ingest transaction: Redis cache lookup + alert evaluation +
|
// 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 }
|
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) ---
|
// --- Gauges (set by background collectors, not incremented inline) ---
|
||||||
|
|
||||||
// The most clinically significant panel. A non-zero value means a patient's
|
// The most clinically significant panel. A non-zero value means a patient's
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using FluentValidation;
|
|||||||
using FluentValidation.AspNetCore;
|
using FluentValidation.AspNetCore;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -46,7 +47,9 @@ try
|
|||||||
ValidAudience = jwtOptions.Audience,
|
ValidAudience = jwtOptions.Audience,
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey))
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey))
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, GatewayApiKeyAuthenticationHandler>(
|
||||||
|
GatewayApiKeyAuthenticationHandler.SchemeName, null);
|
||||||
|
|
||||||
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
||||||
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||||
@@ -126,6 +129,9 @@ try
|
|||||||
.GetSection(DashboardOptions.Section)
|
.GetSection(DashboardOptions.Section)
|
||||||
.Get<DashboardOptions>() ?? new DashboardOptions();
|
.Get<DashboardOptions>() ?? new DashboardOptions();
|
||||||
|
|
||||||
|
builder.Services.Configure<ClinicalSyncOptions>(
|
||||||
|
builder.Configuration.GetSection(ClinicalSyncOptions.Section));
|
||||||
|
|
||||||
builder.Services.Configure<FhirOptions>(
|
builder.Services.Configure<FhirOptions>(
|
||||||
builder.Configuration.GetSection(FhirOptions.Section));
|
builder.Configuration.GetSection(FhirOptions.Section));
|
||||||
|
|
||||||
@@ -140,6 +146,8 @@ try
|
|||||||
builder.Services.Configure<PhiEncryptionOptions>(
|
builder.Services.Configure<PhiEncryptionOptions>(
|
||||||
builder.Configuration.GetSection(PhiEncryptionOptions.Section));
|
builder.Configuration.GetSection(PhiEncryptionOptions.Section));
|
||||||
|
|
||||||
|
builder.Services.Configure<ClinicalSyncOptions>(builder.Configuration.GetSection(ClinicalSyncOptions.Section));
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("Dashboard", policy =>
|
options.AddPolicy("Dashboard", policy =>
|
||||||
@@ -191,12 +199,14 @@ try
|
|||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||||
|
builder.Services.AddScoped<IClinicalSyncService, ClinicalSyncService>();
|
||||||
|
builder.Services.AddScoped<ClinicalSyncBatchProcessor>();
|
||||||
builder.Services.AddSingleton<IPhiEncryptionService, PhiEncryptionService>();
|
builder.Services.AddSingleton<IPhiEncryptionService, PhiEncryptionService>();
|
||||||
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
|
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||||
builder.Services.AddHostedService<OutboxRelayService>();
|
builder.Services.AddHostedService<OutboxRelayService>();
|
||||||
|
builder.Services.AddHostedService<ClinicalSyncBatchConsumer>();
|
||||||
builder.Services.AddHostedService<ElasticIndexProvisioner>();
|
builder.Services.AddHostedService<ElasticIndexProvisioner>();
|
||||||
builder.Services.AddHostedService<EsIndexerService>();
|
builder.Services.AddHostedService<EsIndexerService>();
|
||||||
builder.Services.AddHostedService<SepsisEngineService>();
|
builder.Services.AddHostedService<SepsisEngineService>();
|
||||||
@@ -303,6 +313,7 @@ try
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
await DataSeeder.SeedAsync(db, redis);
|
await DataSeeder.SeedAsync(db, redis);
|
||||||
|
await GatewayRegistrySeeder.SeedAsync(db);
|
||||||
await UserSeeder.SeedAsync(db);
|
await UserSeeder.SeedAsync(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ public static class AlertCreationGuard
|
|||||||
{
|
{
|
||||||
public static void EnsureAllowed(AlertType alertType)
|
public static void EnsureAllowed(AlertType alertType)
|
||||||
{
|
{
|
||||||
|
#pragma warning disable CS0618 // Guard rejects deprecated SEPSIS_WARNING creation
|
||||||
if (alertType == AlertType.SepsisWarning)
|
if (alertType == AlertType.SepsisWarning)
|
||||||
|
#pragma warning restore CS0618
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"SEPSIS_WARNING is deprecated. Use SOFA_SEPSIS for sepsis detection.");
|
"SEPSIS_WARNING is deprecated. Use SOFA_SEPSIS for sepsis detection.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,4 +200,48 @@ public class AlertService : IAlertService
|
|||||||
|
|
||||||
return alert;
|
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> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req);
|
||||||
|
|
||||||
Task<ClinicalAlert> ResolveAsync(Guid id);
|
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
|
public interface IObservationService
|
||||||
{
|
{
|
||||||
Task<IngestResult> IngestAsync(Guid encounterId, IngestObservationRequest req);
|
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)
|
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(string observationCode)
|
||||||
{
|
{
|
||||||
var cache = _redis.GetDatabase();
|
var cache = _redis.GetDatabase();
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ public class QsofaService : IQsofaService
|
|||||||
var query = _db.ClinicalAlerts
|
var query = _db.ClinicalAlerts
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(a => a.EncounterId == encounterId
|
.Where(a => a.EncounterId == encounterId
|
||||||
|
#pragma warning disable CS0618 // Include legacy QSOFA_WARNING rows in history
|
||||||
&& (a.AlertType == AlertType.QsofaScreen || a.AlertType == AlertType.QsofaWarning));
|
&& (a.AlertType == AlertType.QsofaScreen || a.AlertType == AlertType.QsofaWarning));
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
var items = await query
|
var items = await query
|
||||||
.OrderByDescending(a => a.TriggeredAt)
|
.OrderByDescending(a => a.TriggeredAt)
|
||||||
|
|||||||
@@ -39,4 +39,8 @@
|
|||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -204,5 +204,11 @@
|
|||||||
},
|
},
|
||||||
"DataProtection": {
|
"DataProtection": {
|
||||||
"KeyPath": "./data-protection-keys"
|
"KeyPath": "./data-protection-keys"
|
||||||
|
},
|
||||||
|
"ApiKey": {
|
||||||
|
"Gateway": "dev-gateway-key-change-in-production"
|
||||||
|
},
|
||||||
|
"ClinicalSync": {
|
||||||
|
"SuppressPagingForSyncedAlerts": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+59
@@ -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`
|
||||||
Reference in New Issue
Block a user