feature: Site & Gateway Registry + Clinical Sync Contracts
This commit is contained in:
@@ -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")
|
||||
.HasMaxLength(16)
|
||||
.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.SubmittedAt).HasColumnName("submitted_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(b => b.ProcessedAt).HasColumnName("processed_at");
|
||||
|
||||
@@ -19,7 +19,8 @@ public class WardGatewayConfiguration : IEntityTypeConfiguration<WardGateway>
|
||||
.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");
|
||||
|
||||
@@ -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(
|
||||
"outbox_pending_events",
|
||||
"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.AddSingleton<IPhiEncryptionService, PhiEncryptionService>();
|
||||
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
|
||||
builder.Services.AddScoped<ISiteService, SiteService>();
|
||||
builder.Services.AddScoped<IGatewayRegistryService, GatewayRegistryService>();
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||
builder.Services.AddHostedService<OutboxRelayService>();
|
||||
@@ -227,6 +230,7 @@ try
|
||||
builder.Services.AddHostedService<GcsScoringService>();
|
||||
builder.Services.AddHostedService<SofaScoringService>();
|
||||
builder.Services.AddHostedService<PatientPhiMigrationService>();
|
||||
builder.Services.AddHostedService<WardGatewayMetricsCollector>();
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user