feature: Site & Gateway Registry + Clinical Sync Contracts
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using VigilCare.ClinicalContracts.Sync;
|
||||||
|
|
||||||
|
public class ClinicalContractsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ClinicalSyncBatchRequest_RoundTripsJson()
|
||||||
|
{
|
||||||
|
var original = new ClinicalSyncBatchRequest(
|
||||||
|
BatchReference: Guid.NewGuid(),
|
||||||
|
GatewayId: Guid.NewGuid(),
|
||||||
|
SiteId: Guid.NewGuid(),
|
||||||
|
CapturedAtUtc: DateTimeOffset.UtcNow,
|
||||||
|
Observations: [
|
||||||
|
new SyncedObservation(
|
||||||
|
Guid.NewGuid(), "key-001", Guid.NewGuid(),
|
||||||
|
"HEART_RATE", 118m, "bpm", "bedside_monitor",
|
||||||
|
DateTimeOffset.UtcNow)
|
||||||
|
],
|
||||||
|
AlertEvents: [],
|
||||||
|
AlertAcknowledgments: [],
|
||||||
|
AlertResolutions: []);
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(original);
|
||||||
|
var restored = JsonSerializer.Deserialize<ClinicalSyncBatchRequest>(json);
|
||||||
|
|
||||||
|
restored.Should().BeEquivalentTo(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,18 @@ public class ClinicalRefactorEndToEndTests : IAsyncLifetime
|
|||||||
await ScenarioReplayHelper.WaitForAlertTypeAsync(
|
await ScenarioReplayHelper.WaitForAlertTypeAsync(
|
||||||
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(30));
|
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
using var scope = _fixture.Services.CreateScope();
|
SepsisBundle? bundle = null;
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||||
var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId);
|
while (DateTime.UtcNow < deadline)
|
||||||
bundle.TriggeringAlertType.Should().Be("SOFA_SEPSIS");
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
bundle = await db.SepsisBundles.FirstOrDefaultAsync(b => b.EncounterId == encounterId);
|
||||||
|
if (bundle is not null) break;
|
||||||
|
await Task.Delay(500);
|
||||||
|
}
|
||||||
|
bundle.Should().NotBeNull("expected sepsis bundle for encounter after SOFA_SEPSIS alert");
|
||||||
|
bundle!.TriggeringAlertType.Should().Be("SOFA_SEPSIS");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class GatewayRegistryTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private Guid _siteId;
|
||||||
|
private Guid _gatewayId;
|
||||||
|
|
||||||
|
public GatewayRegistryTests(ApiFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
_client = fixture.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_client.AsAdmin();
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
await GatewayRegistrySeeder.SeedAsync(db);
|
||||||
|
_siteId = GatewayRegistrySeeder.DemoSiteId;
|
||||||
|
_gatewayId = GatewayRegistrySeeder.DemoGatewayId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterGateway_UnderSite_Returns201()
|
||||||
|
{
|
||||||
|
var resp = await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/sites/{_siteId}/gateways",
|
||||||
|
new { gatewayCode = "GW-ICU-1A", department = "ICU" });
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<ApiResponse<WardGatewayResponse>>();
|
||||||
|
body!.Data!.GatewayCode.Should().Be("GW-ICU-1A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Heartbeat_UpdatesStatusAndBufferDepth()
|
||||||
|
{
|
||||||
|
_client.WithGatewayApiKey(_gatewayId);
|
||||||
|
var resp = await _client.PatchAsJsonAsync(
|
||||||
|
$"/api/v1/gateways/{_gatewayId}/heartbeat",
|
||||||
|
new { status = "ONLINE", bufferDepth = 42, reportedAtUtc = DateTimeOffset.UtcNow });
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
_client.AsAdmin();
|
||||||
|
var get = await _client.GetAsync($"/api/v1/gateways/{_gatewayId}");
|
||||||
|
var body = await get.Content.ReadFromJsonAsync<ApiResponse<WardGatewayResponse>>();
|
||||||
|
body!.Data!.Status.Should().Be("ONLINE");
|
||||||
|
body.Data.ReportedBufferDepth.Should().Be(42);
|
||||||
|
body.Data.LastHeartbeatAt.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Heartbeat_InvalidApiKey_Returns401()
|
||||||
|
{
|
||||||
|
_client.WithGatewayApiKey(_gatewayId, apiKey: "wrong-key");
|
||||||
|
var resp = await _client.PatchAsJsonAsync(
|
||||||
|
$"/api/v1/gateways/{_gatewayId}/heartbeat",
|
||||||
|
new { status = "ONLINE", bufferDepth = 0, reportedAtUtc = DateTimeOffset.UtcNow });
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Heartbeat_DegradedStatus_Persisted()
|
||||||
|
{
|
||||||
|
_client.WithGatewayApiKey(_gatewayId);
|
||||||
|
await _client.PatchAsJsonAsync(
|
||||||
|
$"/api/v1/gateways/{_gatewayId}/heartbeat",
|
||||||
|
new { status = "DEGRADED", bufferDepth = 847, reportedAtUtc = DateTimeOffset.UtcNow });
|
||||||
|
|
||||||
|
_client.AsAdmin();
|
||||||
|
var get = await _client.GetAsync($"/api/v1/gateways/{_gatewayId}");
|
||||||
|
var body = await get.Content.ReadFromJsonAsync<ApiResponse<WardGatewayResponse>>();
|
||||||
|
body!.Data!.Status.Should().Be("DEGRADED");
|
||||||
|
body.Data.ReportedBufferDepth.Should().Be(847);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListGateways_FilterByDepartment()
|
||||||
|
{
|
||||||
|
await _client.PostAsJsonAsync(
|
||||||
|
$"/api/v1/sites/{_siteId}/gateways",
|
||||||
|
new { gatewayCode = "GW-GM-8B", department = "GENERAL_MEDICINE" });
|
||||||
|
|
||||||
|
var resp = await _client.GetAsync($"/api/v1/sites/{_siteId}/gateways?department=ICU");
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<ApiResponse<List<WardGatewayResponse>>>();
|
||||||
|
body!.Data.Should().OnlyContain(g => g.Department == "ICU");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,23 @@ public static class AuthHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AsAdmin(this HttpClient client) =>
|
public static void AsAdmin(this HttpClient client)
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Remove("X-Test-Role");
|
||||||
client.DefaultRequestHeaders.Add("X-Test-Role", "ADMIN");
|
client.DefaultRequestHeaders.Add("X-Test-Role", "ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
public static void AsPhysician(this HttpClient client) =>
|
public static void AsPhysician(this HttpClient client)
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Remove("X-Test-Role");
|
||||||
client.DefaultRequestHeaders.Add("X-Test-Role", "PHYSICIAN");
|
client.DefaultRequestHeaders.Add("X-Test-Role", "PHYSICIAN");
|
||||||
|
}
|
||||||
|
|
||||||
public static void AsIntegration(this HttpClient client) =>
|
public static void AsIntegration(this HttpClient client)
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Remove("X-Test-Role");
|
||||||
client.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION");
|
client.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION");
|
||||||
|
}
|
||||||
|
|
||||||
public static void ClearAuth(this HttpClient client)
|
public static void ClearAuth(this HttpClient client)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public sealed class WardGatewayMetricsCollector : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly ClinicalMetrics _metrics;
|
||||||
|
private readonly ILogger<WardGatewayMetricsCollector> _logger;
|
||||||
|
|
||||||
|
public WardGatewayMetricsCollector(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
ClinicalMetrics metrics,
|
||||||
|
ILogger<WardGatewayMetricsCollector> logger)
|
||||||
|
{
|
||||||
|
_scopes = scopes;
|
||||||
|
_metrics = metrics;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60));
|
||||||
|
while (await timer.WaitForNextTickAsync(ct))
|
||||||
|
await CollectAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CollectAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopes.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var offlineBySite = await db.WardGateways
|
||||||
|
.Include(g => g.Site)
|
||||||
|
.Where(g => g.Status != GatewayStatus.Online)
|
||||||
|
.GroupBy(g => g.Site.SiteCode)
|
||||||
|
.Select(g => new { SiteCode = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var row in offlineBySite)
|
||||||
|
_metrics.WardGatewaysOffline.WithLabels(row.SiteCode).Set(row.Count);
|
||||||
|
|
||||||
|
var allGateways = await db.WardGateways
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var g in allGateways)
|
||||||
|
_metrics.WardGatewayBufferDepth
|
||||||
|
.WithLabels(g.GatewayCode, g.Department)
|
||||||
|
.Set(g.ReportedBufferDepth);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "WardGatewayMetricsCollector failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using VigilCare.ClinicalContracts.Sync;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public class GatewaysController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IGatewayRegistryService _gateways;
|
||||||
|
|
||||||
|
public GatewaysController(IGatewayRegistryService gateways) => _gateways = gateways;
|
||||||
|
|
||||||
|
[HttpPost("api/v1/sites/{siteId:guid}/gateways")]
|
||||||
|
[Authorize]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<WardGatewayResponse>), StatusCodes.Status201Created)]
|
||||||
|
public async Task<IActionResult> Register(Guid siteId, [FromBody] RegisterGatewayRequest req)
|
||||||
|
{
|
||||||
|
var gateway = await _gateways.RegisterAsync(siteId, req);
|
||||||
|
return StatusCode(201, ApiResponse<WardGatewayResponse>.Created(MapGateway(gateway)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/v1/sites/{siteId:guid}/gateways")]
|
||||||
|
[Authorize]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||||
|
public async Task<IActionResult> ListBySite(Guid siteId, [FromQuery] string? department)
|
||||||
|
{
|
||||||
|
var gateways = await _gateways.ListBySiteAsync(siteId, department);
|
||||||
|
return Ok(ApiResponse<List<WardGatewayResponse>>.Ok(gateways.Select(MapGateway).ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/v1/gateways/{gatewayId:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||||
|
public async Task<IActionResult> Get(Guid gatewayId)
|
||||||
|
{
|
||||||
|
var gateway = await _gateways.GetByIdAsync(gatewayId);
|
||||||
|
return Ok(ApiResponse<WardGatewayResponse>.Ok(MapGateway(gateway)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("api/v1/gateways/{gatewayId:guid}/heartbeat")]
|
||||||
|
[Authorize(AuthenticationSchemes = GatewayApiKeyAuthenticationHandler.SchemeName)]
|
||||||
|
public async Task<IActionResult> Heartbeat(Guid gatewayId, [FromBody] GatewayHeartbeatRequest req)
|
||||||
|
{
|
||||||
|
var authGatewayId = User.FindFirstValue("gateway_id");
|
||||||
|
var gateway = await _gateways.RecordHeartbeatAsync(gatewayId, req, authGatewayId);
|
||||||
|
return Ok(ApiResponse<WardGatewayResponse>.Ok(MapGateway(gateway)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WardGatewayResponse MapGateway(WardGateway g) =>
|
||||||
|
new(g.Id, g.SiteId, g.GatewayCode, g.Department,
|
||||||
|
g.Status.ToDbString(), g.ReportedBufferDepth,
|
||||||
|
g.LastHeartbeatAt, g.LastSyncAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/sites")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Authorize]
|
||||||
|
public class SitesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISiteService _sites;
|
||||||
|
|
||||||
|
public SitesController(ISiteService sites) => _sites = sites;
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SiteResponse>), StatusCodes.Status201Created)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateSiteRequest req)
|
||||||
|
{
|
||||||
|
var site = await _sites.CreateAsync(req);
|
||||||
|
return StatusCode(201, ApiResponse<SiteResponse>.Created(MapSite(site)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||||
|
public async Task<IActionResult> List()
|
||||||
|
{
|
||||||
|
var sites = await _sites.ListAsync();
|
||||||
|
return Ok(ApiResponse<List<SiteResponse>>.Ok(sites.Select(MapSite).ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{siteId:guid}")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||||
|
public async Task<IActionResult> Get(Guid siteId)
|
||||||
|
{
|
||||||
|
var site = await _sites.GetByIdAsync(siteId);
|
||||||
|
return Ok(ApiResponse<SiteResponse>.Ok(MapSite(site)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteResponse MapSite(ClinicalSite s) =>
|
||||||
|
new(s.Id, s.SiteCode, s.Name, s.Address, s.Active, s.CreatedAt);
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@ public class ClinicalSyncBatchConfiguration : IEntityTypeConfiguration<ClinicalS
|
|||||||
.HasColumnName("status")
|
.HasColumnName("status")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasConversion(v => v.ToDbString(), v => ClinicalSyncBatchStatusExtensions.FromDbString(v))
|
.HasConversion(v => v.ToDbString(), v => ClinicalSyncBatchStatusExtensions.FromDbString(v))
|
||||||
.HasDefaultValueSql("'RECEIVED'");
|
.HasDefaultValueSql("'RECEIVED'")
|
||||||
|
.HasSentinel((ClinicalSyncBatchStatus)(-1));
|
||||||
builder.Property(b => b.Payload).HasColumnName("payload").HasColumnType("jsonb");
|
builder.Property(b => b.Payload).HasColumnName("payload").HasColumnType("jsonb");
|
||||||
builder.Property(b => b.SubmittedAt).HasColumnName("submitted_at").HasDefaultValueSql("NOW()");
|
builder.Property(b => b.SubmittedAt).HasColumnName("submitted_at").HasDefaultValueSql("NOW()");
|
||||||
builder.Property(b => b.ProcessedAt).HasColumnName("processed_at");
|
builder.Property(b => b.ProcessedAt).HasColumnName("processed_at");
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ public class WardGatewayConfiguration : IEntityTypeConfiguration<WardGateway>
|
|||||||
.HasColumnName("status")
|
.HasColumnName("status")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasConversion(v => v.ToDbString(), v => GatewayStatusExtensions.FromDbString(v))
|
.HasConversion(v => v.ToDbString(), v => GatewayStatusExtensions.FromDbString(v))
|
||||||
.HasDefaultValueSql("'OFFLINE'");
|
.HasDefaultValueSql("'OFFLINE'")
|
||||||
|
.HasSentinel((GatewayStatus)(-1));
|
||||||
builder.Property(g => g.ReportedBufferDepth).HasColumnName("reported_buffer_depth").HasDefaultValue(0);
|
builder.Property(g => g.ReportedBufferDepth).HasColumnName("reported_buffer_depth").HasDefaultValue(0);
|
||||||
builder.Property(g => g.LastHeartbeatAt).HasColumnName("last_heartbeat_at");
|
builder.Property(g => g.LastHeartbeatAt).HasColumnName("last_heartbeat_at");
|
||||||
builder.Property(g => g.LastSyncAt).HasColumnName("last_sync_at");
|
builder.Property(g => g.LastSyncAt).HasColumnName("last_sync_at");
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
public record RegisterGatewayRequest(string GatewayCode, string Department);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
public record WardGatewayResponse(
|
||||||
|
Guid Id, Guid SiteId, string GatewayCode, string Department,
|
||||||
|
string Status, int ReportedBufferDepth,
|
||||||
|
DateTimeOffset? LastHeartbeatAt, DateTimeOffset? LastSyncAt);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
public record CreateSiteRequest(string SiteCode, string Name, string? Address);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
public record SiteResponse(
|
||||||
|
Guid Id, string SiteCode, string Name, string? Address,
|
||||||
|
bool Active, DateTimeOffset CreatedAt);
|
||||||
@@ -153,4 +153,14 @@ public sealed class ClinicalMetrics
|
|||||||
public readonly Gauge OutboxPendingEvents = Metrics.CreateGauge(
|
public readonly Gauge OutboxPendingEvents = Metrics.CreateGauge(
|
||||||
"outbox_pending_events",
|
"outbox_pending_events",
|
||||||
"Count of outbox events not yet relayed to Kafka.");
|
"Count of outbox events not yet relayed to Kafka.");
|
||||||
}
|
|
||||||
|
public readonly Gauge WardGatewaysOffline = Metrics.CreateGauge(
|
||||||
|
"ward_gateways_offline_gauge",
|
||||||
|
"Ward gateways with status OFFLINE or DEGRADED",
|
||||||
|
labelNames: new[] { "site_code" });
|
||||||
|
|
||||||
|
public readonly Gauge WardGatewayBufferDepth = Metrics.CreateGauge(
|
||||||
|
"ward_gateway_buffer_depth",
|
||||||
|
"Reported unsynced event count per gateway",
|
||||||
|
labelNames: new[] { "gateway_code", "department" });
|
||||||
|
}
|
||||||
@@ -203,6 +203,9 @@ try
|
|||||||
builder.Services.AddScoped<ClinicalSyncBatchProcessor>();
|
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.AddScoped<ISiteService, SiteService>();
|
||||||
|
builder.Services.AddScoped<IGatewayRegistryService, GatewayRegistryService>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||||
builder.Services.AddHostedService<OutboxRelayService>();
|
builder.Services.AddHostedService<OutboxRelayService>();
|
||||||
@@ -227,6 +230,7 @@ try
|
|||||||
builder.Services.AddHostedService<GcsScoringService>();
|
builder.Services.AddHostedService<GcsScoringService>();
|
||||||
builder.Services.AddHostedService<SofaScoringService>();
|
builder.Services.AddHostedService<SofaScoringService>();
|
||||||
builder.Services.AddHostedService<PatientPhiMigrationService>();
|
builder.Services.AddHostedService<PatientPhiMigrationService>();
|
||||||
|
builder.Services.AddHostedService<WardGatewayMetricsCollector>();
|
||||||
|
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddDbContextCheck<AppDbContext>("postgresql", tags: new[] { "ready" })
|
.AddDbContextCheck<AppDbContext>("postgresql", tags: new[] { "ready" })
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class GatewayRegistryService : IGatewayRegistryService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public GatewayRegistryService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<WardGateway> RegisterAsync(Guid siteId, RegisterGatewayRequest req)
|
||||||
|
{
|
||||||
|
var siteExists = await _db.ClinicalSites.AnyAsync(s => s.Id == siteId);
|
||||||
|
if (!siteExists)
|
||||||
|
throw new NotFoundException("Clinical site not found.", "SITE_NOT_FOUND");
|
||||||
|
|
||||||
|
var duplicate = await _db.WardGateways.AnyAsync(g =>
|
||||||
|
g.SiteId == siteId && g.GatewayCode == req.GatewayCode);
|
||||||
|
if (duplicate)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Gateway code '{req.GatewayCode}' already exists for this site.",
|
||||||
|
"GATEWAY_CODE_CONFLICT");
|
||||||
|
|
||||||
|
var gateway = new WardGateway(siteId, req.GatewayCode, req.Department);
|
||||||
|
_db.WardGateways.Add(gateway);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WardGateway> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var gateway = await _db.WardGateways
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(g => g.Site)
|
||||||
|
.FirstOrDefaultAsync(g => g.Id == id);
|
||||||
|
if (gateway is null)
|
||||||
|
throw new NotFoundException("Ward gateway not found.", "GATEWAY_NOT_FOUND");
|
||||||
|
return gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<WardGateway>> ListBySiteAsync(Guid siteId, string? department)
|
||||||
|
{
|
||||||
|
var siteExists = await _db.ClinicalSites.AnyAsync(s => s.Id == siteId);
|
||||||
|
if (!siteExists)
|
||||||
|
throw new NotFoundException("Clinical site not found.", "SITE_NOT_FOUND");
|
||||||
|
|
||||||
|
var query = _db.WardGateways.AsNoTracking().Where(g => g.SiteId == siteId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(department))
|
||||||
|
query = query.Where(g => g.Department == department);
|
||||||
|
|
||||||
|
return await query.OrderBy(g => g.GatewayCode).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WardGateway> RecordHeartbeatAsync(
|
||||||
|
Guid gatewayId, GatewayHeartbeatRequest req, string? authenticatedGatewayId)
|
||||||
|
{
|
||||||
|
if (authenticatedGatewayId is not null
|
||||||
|
&& Guid.TryParse(authenticatedGatewayId, out var authId)
|
||||||
|
&& authId != gatewayId)
|
||||||
|
throw new ValidationException(
|
||||||
|
"Gateway id in URL does not match X-Gateway-Id header.",
|
||||||
|
"GATEWAY_ID_MISMATCH");
|
||||||
|
|
||||||
|
var gateway = await _db.WardGateways.FindAsync(gatewayId);
|
||||||
|
if (gateway is null)
|
||||||
|
throw new NotFoundException("Ward gateway not found.", "GATEWAY_NOT_FOUND");
|
||||||
|
|
||||||
|
var status = GatewayStatusExtensions.FromDbString(req.Status.ToUpperInvariant());
|
||||||
|
gateway.RecordHeartbeat(status, req.BufferDepth, req.ReportedAtUtc);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkOfflineAsync(Guid gatewayId)
|
||||||
|
{
|
||||||
|
var gateway = await _db.WardGateways.FindAsync(gatewayId);
|
||||||
|
if (gateway is null)
|
||||||
|
throw new NotFoundException("Ward gateway not found.", "GATEWAY_NOT_FOUND");
|
||||||
|
gateway.MarkOffline();
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
public interface IGatewayRegistryService
|
||||||
|
{
|
||||||
|
Task<WardGateway> RegisterAsync(Guid siteId, RegisterGatewayRequest req);
|
||||||
|
Task<WardGateway> GetByIdAsync(Guid id);
|
||||||
|
Task<List<WardGateway>> ListBySiteAsync(Guid siteId, string? department);
|
||||||
|
Task<WardGateway> RecordHeartbeatAsync(Guid gatewayId, GatewayHeartbeatRequest req, string? authenticatedGatewayId);
|
||||||
|
Task MarkOfflineAsync(Guid gatewayId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
public interface ISiteService
|
||||||
|
{
|
||||||
|
Task<ClinicalSite> CreateAsync(CreateSiteRequest req);
|
||||||
|
Task<ClinicalSite> GetByIdAsync(Guid id);
|
||||||
|
Task<List<ClinicalSite>> ListAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class SiteService : ISiteService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public SiteService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<ClinicalSite> CreateAsync(CreateSiteRequest req)
|
||||||
|
{
|
||||||
|
var exists = await _db.ClinicalSites.AnyAsync(s => s.SiteCode == req.SiteCode);
|
||||||
|
if (exists)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Site code '{req.SiteCode}' is already registered.",
|
||||||
|
"SITE_CODE_CONFLICT");
|
||||||
|
|
||||||
|
var site = new ClinicalSite(req.SiteCode, req.Name, req.Address);
|
||||||
|
_db.ClinicalSites.Add(site);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClinicalSite> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var site = await _db.ClinicalSites
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
|
if (site is null)
|
||||||
|
throw new NotFoundException("Clinical site not found.", "SITE_NOT_FOUND");
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ClinicalSite>> ListAsync() =>
|
||||||
|
await _db.ClinicalSites.AsNoTracking()
|
||||||
|
.OrderBy(s => s.SiteCode)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class CreateSiteRequestValidator : AbstractValidator<CreateSiteRequest>
|
||||||
|
{
|
||||||
|
public CreateSiteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.SiteCode).NotEmpty().MaximumLength(32)
|
||||||
|
.Matches("^[A-Z0-9-]+$").WithMessage("SiteCode must be uppercase alphanumeric with hyphens.");
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.Address).MaximumLength(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using VigilCare.ClinicalContracts.Sync;
|
||||||
|
|
||||||
|
public class GatewayHeartbeatRequestValidator : AbstractValidator<GatewayHeartbeatRequest>
|
||||||
|
{
|
||||||
|
private static readonly string[] AllowedStatuses = ["ONLINE", "DEGRADED", "OFFLINE"];
|
||||||
|
|
||||||
|
public GatewayHeartbeatRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Status).NotEmpty()
|
||||||
|
.Must(s => AllowedStatuses.Contains(s.ToUpperInvariant()))
|
||||||
|
.WithMessage("Status must be ONLINE, DEGRADED, or OFFLINE.");
|
||||||
|
RuleFor(x => x.BufferDepth).GreaterThanOrEqualTo(0);
|
||||||
|
RuleFor(x => x.ReportedAtUtc).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class RegisterGatewayRequestValidator : AbstractValidator<RegisterGatewayRequest>
|
||||||
|
{
|
||||||
|
public RegisterGatewayRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.GatewayCode).NotEmpty().MaximumLength(64);
|
||||||
|
RuleFor(x => x.Department).NotEmpty().MaximumLength(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "==> Build contracts"
|
||||||
|
dotnet build VigilCare.ClinicalContracts
|
||||||
|
|
||||||
|
echo "==> Run gateway registry tests"
|
||||||
|
dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~GatewayRegistry" --no-build 2>/dev/null \
|
||||||
|
|| dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~GatewayRegistry"
|
||||||
|
|
||||||
|
API="${API_BASE_URL:-http://localhost:5080}"
|
||||||
|
KEY="${GATEWAY_API_KEY:-dev-gateway-key-change-in-production}"
|
||||||
|
|
||||||
|
# Obtain admin JWT (adjust to your local login endpoint)
|
||||||
|
TOKEN="${ADMIN_JWT:?Set ADMIN_JWT to a valid admin bearer token}"
|
||||||
|
|
||||||
|
echo "==> Create site"
|
||||||
|
SITE_RESP=$(curl -sf -X POST "$API/api/v1/sites" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"siteCode":"SITE-VERIFY","name":"Verification Hospital"}')
|
||||||
|
SITE_ID=$(echo "$SITE_RESP" | jq -r '.data.id')
|
||||||
|
|
||||||
|
echo "==> Register gateway"
|
||||||
|
GW_RESP=$(curl -sf -X POST "$API/api/v1/sites/$SITE_ID/gateways" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"gatewayCode":"GW-VERIFY-01","department":"ICU"}')
|
||||||
|
GW_ID=$(echo "$GW_RESP" | jq -r '.data.id')
|
||||||
|
|
||||||
|
echo "==> Heartbeat"
|
||||||
|
curl -sf -X PATCH "$API/api/v1/gateways/$GW_ID/heartbeat" \
|
||||||
|
-H "X-Api-Key: $KEY" \
|
||||||
|
-H "X-Gateway-Id: $GW_ID" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"ONLINE","bufferDepth":3,"reportedAtUtc":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}'
|
||||||
|
|
||||||
|
echo "==> Verify gateway status"
|
||||||
|
GET_RESP=$(curl -sf "$API/api/v1/gateways/$GW_ID" \
|
||||||
|
-H "Authorization: Bearer $TOKEN")
|
||||||
|
echo "$GET_RESP" | jq -e '.data.status == "ONLINE" and .data.reportedBufferDepth == 3'
|
||||||
|
|
||||||
|
echo "Phase 20 verification passed."
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
**`scripts/run-phase22-verification.sh`:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -13,9 +10,6 @@ KEY="${GATEWAY_API_KEY:-dev-gateway-key-change-in-production}"
|
|||||||
GW_ID="${GATEWAY_ID:-22222222-2222-2222-2222-222222222222}"
|
GW_ID="${GATEWAY_ID:-22222222-2222-2222-2222-222222222222}"
|
||||||
JWT="${ADMIN_JWT:?Set ADMIN_JWT}"
|
JWT="${ADMIN_JWT:?Set ADMIN_JWT}"
|
||||||
|
|
||||||
echo "==> Run sync batch tests"
|
|
||||||
dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~ClinicalSyncBatch"
|
|
||||||
|
|
||||||
echo "==> Start full stack"
|
echo "==> Start full stack"
|
||||||
docker compose --profile full --profile ward-gateway up -d
|
docker compose --profile full --profile ward-gateway up -d
|
||||||
sleep 30
|
sleep 30
|
||||||
@@ -54,6 +48,3 @@ docker exec -i $(docker ps -qf name=postgres) psql -U postgres -d vigilcare \
|
|||||||
|
|
||||||
echo "==> Check no duplicate paging (grep application logs for [PAGE] count)"
|
echo "==> Check no duplicate paging (grep application logs for [PAGE] count)"
|
||||||
echo "Phase 22 verification passed."
|
echo "Phase 22 verification passed."
|
||||||
```
|
|
||||||
|
|
||||||
Make executable: `chmod +x scripts/run-phase22-verification.sh`
|
|
||||||
Reference in New Issue
Block a user