diff --git a/VigilCare.ClinicalContracts.Tests/ClinicalContractsTests.cs b/VigilCare.ClinicalContracts.Tests/ClinicalContractsTests.cs new file mode 100644 index 0000000..5ea2838 --- /dev/null +++ b/VigilCare.ClinicalContracts.Tests/ClinicalContractsTests.cs @@ -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(json); + + restored.Should().BeEquivalentTo(original); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs b/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs index 0a399e5..25d12f7 100644 --- a/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs +++ b/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs @@ -40,10 +40,18 @@ public class ClinicalRefactorEndToEndTests : IAsyncLifetime await ScenarioReplayHelper.WaitForAlertTypeAsync( _fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(30)); - using var scope = _fixture.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId); - bundle.TriggeringAlertType.Should().Be("SOFA_SEPSIS"); + SepsisBundle? bundle = null; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (DateTime.UtcNow < deadline) + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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] diff --git a/VigilCareClinicalAPI.Tests/GatewayRegistryTests.cs b/VigilCareClinicalAPI.Tests/GatewayRegistryTests.cs new file mode 100644 index 0000000..1ab1f92 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/GatewayRegistryTests.cs @@ -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(); + 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>(); + 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>(); + 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>(); + 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>>(); + body!.Data.Should().OnlyContain(g => g.Department == "ICU"); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/Helpers/AuthHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/AuthHelper.cs index 1485693..30b306a 100644 --- a/VigilCareClinicalAPI.Tests/Helpers/AuthHelper.cs +++ b/VigilCareClinicalAPI.Tests/Helpers/AuthHelper.cs @@ -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"); + } - 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"); + } - 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"); + } public static void ClearAuth(this HttpClient client) { diff --git a/VigilCareClinicalAPI/BackgroundServices/Metrics/WardGatewayMetricsCollector.cs b/VigilCareClinicalAPI/BackgroundServices/Metrics/WardGatewayMetricsCollector.cs new file mode 100644 index 0000000..efb5056 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/Metrics/WardGatewayMetricsCollector.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; + +public sealed class WardGatewayMetricsCollector : BackgroundService +{ + private readonly IServiceScopeFactory _scopes; + private readonly ClinicalMetrics _metrics; + private readonly ILogger _logger; + + public WardGatewayMetricsCollector( + IServiceScopeFactory scopes, + ClinicalMetrics metrics, + ILogger 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(); + + 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"); + } + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/GatewaysController.cs b/VigilCareClinicalAPI/Controllers/GatewaysController.cs new file mode 100644 index 0000000..e2acc60 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/GatewaysController.cs @@ -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), StatusCodes.Status201Created)] + public async Task Register(Guid siteId, [FromBody] RegisterGatewayRequest req) + { + var gateway = await _gateways.RegisterAsync(siteId, req); + return StatusCode(201, ApiResponse.Created(MapGateway(gateway))); + } + + [HttpGet("api/v1/sites/{siteId:guid}/gateways")] + [Authorize] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + public async Task ListBySite(Guid siteId, [FromQuery] string? department) + { + var gateways = await _gateways.ListBySiteAsync(siteId, department); + return Ok(ApiResponse>.Ok(gateways.Select(MapGateway).ToList())); + } + + [HttpGet("api/v1/gateways/{gatewayId:guid}")] + [Authorize] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + public async Task Get(Guid gatewayId) + { + var gateway = await _gateways.GetByIdAsync(gatewayId); + return Ok(ApiResponse.Ok(MapGateway(gateway))); + } + + [HttpPatch("api/v1/gateways/{gatewayId:guid}/heartbeat")] + [Authorize(AuthenticationSchemes = GatewayApiKeyAuthenticationHandler.SchemeName)] + public async Task Heartbeat(Guid gatewayId, [FromBody] GatewayHeartbeatRequest req) + { + var authGatewayId = User.FindFirstValue("gateway_id"); + var gateway = await _gateways.RecordHeartbeatAsync(gatewayId, req, authGatewayId); + return Ok(ApiResponse.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); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/SitesController.cs b/VigilCareClinicalAPI/Controllers/SitesController.cs new file mode 100644 index 0000000..2f4b884 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/SitesController.cs @@ -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), StatusCodes.Status201Created)] + public async Task Create([FromBody] CreateSiteRequest req) + { + var site = await _sites.CreateAsync(req); + return StatusCode(201, ApiResponse.Created(MapSite(site))); + } + + [HttpGet] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + public async Task List() + { + var sites = await _sites.ListAsync(); + return Ok(ApiResponse>.Ok(sites.Select(MapSite).ToList())); + } + + [HttpGet("{siteId:guid}")] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + public async Task Get(Guid siteId) + { + var site = await _sites.GetByIdAsync(siteId); + return Ok(ApiResponse.Ok(MapSite(site))); + } + + private static SiteResponse MapSite(ClinicalSite s) => + new(s.Id, s.SiteCode, s.Name, s.Address, s.Active, s.CreatedAt); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Data/Configurations/ClinicalSyncBatchConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/ClinicalSyncBatchConfiguration.cs index 0f24a84..0190e05 100644 --- a/VigilCareClinicalAPI/Data/Configurations/ClinicalSyncBatchConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/ClinicalSyncBatchConfiguration.cs @@ -19,7 +19,8 @@ public class ClinicalSyncBatchConfiguration : IEntityTypeConfiguration 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.SubmittedAt).HasColumnName("submitted_at").HasDefaultValueSql("NOW()"); builder.Property(b => b.ProcessedAt).HasColumnName("processed_at"); diff --git a/VigilCareClinicalAPI/Data/Configurations/WardGatewayConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/WardGatewayConfiguration.cs index c11046d..b6e4081 100644 --- a/VigilCareClinicalAPI/Data/Configurations/WardGatewayConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/WardGatewayConfiguration.cs @@ -19,7 +19,8 @@ public class WardGatewayConfiguration : IEntityTypeConfiguration .HasColumnName("status") .HasMaxLength(16) .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.LastHeartbeatAt).HasColumnName("last_heartbeat_at"); builder.Property(g => g.LastSyncAt).HasColumnName("last_sync_at"); diff --git a/VigilCareClinicalAPI/Models/Records/Gateway/RegisterGatewayRequest.cs b/VigilCareClinicalAPI/Models/Records/Gateway/RegisterGatewayRequest.cs new file mode 100644 index 0000000..f565199 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Gateway/RegisterGatewayRequest.cs @@ -0,0 +1 @@ +public record RegisterGatewayRequest(string GatewayCode, string Department); diff --git a/VigilCareClinicalAPI/Models/Records/Gateway/WardGatewayResponse.cs b/VigilCareClinicalAPI/Models/Records/Gateway/WardGatewayResponse.cs new file mode 100644 index 0000000..3861a77 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Gateway/WardGatewayResponse.cs @@ -0,0 +1,4 @@ +public record WardGatewayResponse( + Guid Id, Guid SiteId, string GatewayCode, string Department, + string Status, int ReportedBufferDepth, + DateTimeOffset? LastHeartbeatAt, DateTimeOffset? LastSyncAt); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Site/CreateSiteRequest.cs b/VigilCareClinicalAPI/Models/Records/Site/CreateSiteRequest.cs new file mode 100644 index 0000000..b84ed99 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Site/CreateSiteRequest.cs @@ -0,0 +1 @@ +public record CreateSiteRequest(string SiteCode, string Name, string? Address); diff --git a/VigilCareClinicalAPI/Models/Records/Site/SiteResponse.cs b/VigilCareClinicalAPI/Models/Records/Site/SiteResponse.cs new file mode 100644 index 0000000..9acda2c --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Site/SiteResponse.cs @@ -0,0 +1,3 @@ +public record SiteResponse( + Guid Id, string SiteCode, string Name, string? Address, + bool Active, DateTimeOffset CreatedAt); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs b/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs index 1eaff00..6bb08ec 100644 --- a/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs +++ b/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs @@ -153,4 +153,14 @@ public sealed class ClinicalMetrics public readonly Gauge OutboxPendingEvents = Metrics.CreateGauge( "outbox_pending_events", "Count of outbox events not yet relayed to Kafka."); -} \ No newline at end of file + + 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" }); + } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index 1728753..aa3f8ec 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -203,6 +203,9 @@ try builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -227,6 +230,7 @@ try builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddHealthChecks() .AddDbContextCheck("postgresql", tags: new[] { "ready" }) diff --git a/VigilCareClinicalAPI/Services/GatewayRegistryService.cs b/VigilCareClinicalAPI/Services/GatewayRegistryService.cs new file mode 100644 index 0000000..87ea0a0 --- /dev/null +++ b/VigilCareClinicalAPI/Services/GatewayRegistryService.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; + +public class GatewayRegistryService : IGatewayRegistryService +{ + private readonly AppDbContext _db; + + public GatewayRegistryService(AppDbContext db) => _db = db; + + public async Task 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 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> 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 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(); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IGatewayRegistryService.cs b/VigilCareClinicalAPI/Services/Interfaces/IGatewayRegistryService.cs new file mode 100644 index 0000000..51c1ed6 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IGatewayRegistryService.cs @@ -0,0 +1,8 @@ +public interface IGatewayRegistryService +{ + Task RegisterAsync(Guid siteId, RegisterGatewayRequest req); + Task GetByIdAsync(Guid id); + Task> ListBySiteAsync(Guid siteId, string? department); + Task RecordHeartbeatAsync(Guid gatewayId, GatewayHeartbeatRequest req, string? authenticatedGatewayId); + Task MarkOfflineAsync(Guid gatewayId); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/ISiteService.cs b/VigilCareClinicalAPI/Services/Interfaces/ISiteService.cs new file mode 100644 index 0000000..9575e40 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/ISiteService.cs @@ -0,0 +1,6 @@ +public interface ISiteService +{ + Task CreateAsync(CreateSiteRequest req); + Task GetByIdAsync(Guid id); + Task> ListAsync(); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/SiteService.cs b/VigilCareClinicalAPI/Services/SiteService.cs new file mode 100644 index 0000000..3dc7270 --- /dev/null +++ b/VigilCareClinicalAPI/Services/SiteService.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; + +public class SiteService : ISiteService +{ + private readonly AppDbContext _db; + + public SiteService(AppDbContext db) => _db = db; + + public async Task 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 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> ListAsync() => + await _db.ClinicalSites.AsNoTracking() + .OrderBy(s => s.SiteCode) + .ToListAsync(); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/CreateSiteRequestValidator.cs b/VigilCareClinicalAPI/Validators/CreateSiteRequestValidator.cs new file mode 100644 index 0000000..4caad9e --- /dev/null +++ b/VigilCareClinicalAPI/Validators/CreateSiteRequestValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +public class CreateSiteRequestValidator : AbstractValidator +{ + 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); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/GatewayHeartbeatRequestValidator.cs b/VigilCareClinicalAPI/Validators/GatewayHeartbeatRequestValidator.cs new file mode 100644 index 0000000..b3ae99b --- /dev/null +++ b/VigilCareClinicalAPI/Validators/GatewayHeartbeatRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using VigilCare.ClinicalContracts.Sync; + +public class GatewayHeartbeatRequestValidator : AbstractValidator +{ + 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(); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Validators/RegisterGatewayRequestValidator.cs b/VigilCareClinicalAPI/Validators/RegisterGatewayRequestValidator.cs new file mode 100644 index 0000000..638bebc --- /dev/null +++ b/VigilCareClinicalAPI/Validators/RegisterGatewayRequestValidator.cs @@ -0,0 +1,10 @@ +using FluentValidation; + +public class RegisterGatewayRequestValidator : AbstractValidator +{ + public RegisterGatewayRequestValidator() + { + RuleFor(x => x.GatewayCode).NotEmpty().MaximumLength(64); + RuleFor(x => x.Department).NotEmpty().MaximumLength(100); + } +} \ No newline at end of file diff --git a/scripts/run-phase20-verification.sh b/scripts/run-phase20-verification.sh new file mode 100755 index 0000000..50739b2 --- /dev/null +++ b/scripts/run-phase20-verification.sh @@ -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." \ No newline at end of file diff --git a/scripts/run-phase22-verification.sh b/scripts/run-phase22-verification.sh index 80e7fc1..e62e8be 100755 --- a/scripts/run-phase22-verification.sh +++ b/scripts/run-phase22-verification.sh @@ -1,6 +1,3 @@ -**`scripts/run-phase22-verification.sh`:** - -```bash #!/usr/bin/env bash 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}" 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 @@ -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 "Phase 22 verification passed." -``` - -Make executable: `chmod +x scripts/run-phase22-verification.sh` \ No newline at end of file