feature: Degraded Operations Visibility
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class GatewayStaleDetectorService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly GatewayMonitoringOptions _opts;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
private readonly ILogger<GatewayStaleDetectorService> _logger;
|
||||
|
||||
public GatewayStaleDetectorService(
|
||||
IServiceScopeFactory scopes,
|
||||
IOptions<GatewayMonitoringOptions> opts,
|
||||
ClinicalMetrics metrics,
|
||||
ILogger<GatewayStaleDetectorService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_opts = opts.Value;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_opts.PollIntervalMinutes));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
await DetectStaleAsync(ct);
|
||||
}
|
||||
|
||||
private async Task DetectStaleAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var cutoff = DateTimeOffset.UtcNow.AddMinutes(-_opts.StaleThresholdMinutes);
|
||||
|
||||
var stale = await db.WardGateways
|
||||
.Include(g => g.Site)
|
||||
.Where(g => g.Status != GatewayStatus.Offline
|
||||
&& (g.LastHeartbeatAt == null || g.LastHeartbeatAt < cutoff))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var gateway in stale)
|
||||
{
|
||||
gateway.MarkOffline();
|
||||
_logger.LogWarning(
|
||||
"Gateway {Code} ({Department}) marked OFFLINE — last heartbeat {LastHeartbeat}",
|
||||
gateway.GatewayCode, gateway.Department, gateway.LastHeartbeatAt);
|
||||
|
||||
_metrics.WardGatewaysOffline
|
||||
.WithLabels(gateway.Site.SiteCode)
|
||||
.Inc();
|
||||
}
|
||||
|
||||
if (stale.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public sealed class GatewayMonitoringOptions
|
||||
{
|
||||
public const string Section = "GatewayMonitoring";
|
||||
public int StaleThresholdMinutes { get; init; } = 10;
|
||||
public int PollIntervalMinutes { get; init; } = 5;
|
||||
}
|
||||
@@ -12,8 +12,15 @@ using Microsoft.AspNetCore.Mvc;
|
||||
public class EncountersController : ControllerBase
|
||||
{
|
||||
private readonly IEncounterService _encounters;
|
||||
private readonly IDischargeSummaryService _dischargeSummary;
|
||||
|
||||
public EncountersController(IEncounterService encounters) => _encounters = encounters;
|
||||
public EncountersController(
|
||||
IEncounterService encounters,
|
||||
IDischargeSummaryService dischargeSummary)
|
||||
{
|
||||
_encounters = encounters;
|
||||
_dischargeSummary = dischargeSummary;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists encounters for ward dashboards with denormalized clinical summary fields.
|
||||
@@ -115,4 +122,31 @@ public class EncountersController : ControllerBase
|
||||
var timeline = await _encounters.GetTimelineAsync(id);
|
||||
return Ok(ApiResponse<object>.Ok(timeline));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns discharge summary availability for a discharged encounter.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}/discharge-summary")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<DischargeSummaryInfo>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetDischargeSummary(Guid id)
|
||||
{
|
||||
var info = await _dischargeSummary.GetInfoAsync(id);
|
||||
return Ok(ApiResponse<DischargeSummaryInfo>.Ok(info));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the generated discharge summary document.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}/discharge-summary/content")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> DownloadDischargeSummary(Guid id)
|
||||
{
|
||||
var content = await _dischargeSummary.GetContentAsync(id);
|
||||
return File(content.Stream, content.ContentType, content.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Gateway fleet operations: fleet listing, gateway detail, and site summaries.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/operations")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class OperationsController : ControllerBase
|
||||
{
|
||||
private readonly IOperationsService _operations;
|
||||
|
||||
public OperationsController(IOperationsService operations) => _operations = operations;
|
||||
|
||||
/// <summary>
|
||||
/// Lists registered gateways with optional status and site filters.
|
||||
/// </summary>
|
||||
/// <param name="status">Optional gateway status filter (DB literal, e.g. DEGRADED).</param>
|
||||
/// <param name="siteId">Optional site id filter.</param>
|
||||
/// <returns>Gateway fleet items.</returns>
|
||||
[HttpGet("gateways")]
|
||||
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<GatewayFleetItem>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ListGateways(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] Guid? siteId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var items = await _operations.GetGatewayFleetAsync(
|
||||
new GatewayFleetFilter(status, siteId), ct);
|
||||
return Ok(ApiResponse<IReadOnlyList<GatewayFleetItem>>.Ok(items));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single gateway by id with buffer depth, heartbeat metadata, and sync history.
|
||||
/// </summary>
|
||||
/// <param name="gatewayId">Gateway id.</param>
|
||||
/// <returns>Gateway detail.</returns>
|
||||
[HttpGet("gateways/{gatewayId:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||
[ProducesResponseType(typeof(ApiResponse<GatewayDetailResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetGateway(Guid gatewayId, CancellationToken ct)
|
||||
{
|
||||
var detail = await _operations.GetGatewayDetailAsync(gatewayId, ct);
|
||||
return Ok(ApiResponse<GatewayDetailResponse>.Ok(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an aggregate gateway summary for a site.
|
||||
/// </summary>
|
||||
/// <param name="siteId">Site id.</param>
|
||||
/// <returns>Site gateway summary.</returns>
|
||||
[HttpGet("sites/{siteId:guid}/summary")]
|
||||
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SiteGatewaySummaryResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSiteSummary(Guid siteId, CancellationToken ct)
|
||||
{
|
||||
var summary = await _operations.GetSiteSummaryAsync(siteId, ct);
|
||||
return Ok(ApiResponse<SiteGatewaySummaryResponse>.Ok(summary));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Clinical user account management for hospital IT administrators.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/users")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserService _users;
|
||||
|
||||
public UsersController(IUserService users) => _users = users;
|
||||
|
||||
/// <summary>Lists all clinical user accounts.</summary>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<ClinicalUserResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var users = await _users.ListAsync();
|
||||
return Ok(ApiResponse<List<ClinicalUserResponse>>.Ok(users));
|
||||
}
|
||||
|
||||
/// <summary>Creates a new clinical user account.</summary>
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||
[ProducesResponseType(typeof(ApiResponse<ClinicalUserResponse>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateUserRequest req)
|
||||
{
|
||||
var user = await _users.CreateAsync(req);
|
||||
return StatusCode(201, ApiResponse<ClinicalUserResponse>.Created(user));
|
||||
}
|
||||
|
||||
/// <summary>Updates role, display name, or active status for a user.</summary>
|
||||
[HttpPatch("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
|
||||
[ProducesResponseType(typeof(ApiResponse<ClinicalUserResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateUserRequest req)
|
||||
{
|
||||
var user = await _users.UpdateAsync(id, req);
|
||||
return Ok(ApiResponse<ClinicalUserResponse>.Ok(user));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public record DischargeSummaryInfo(
|
||||
string Status,
|
||||
DateTimeOffset? DischargedAt,
|
||||
string? ContentType,
|
||||
string? FileName);
|
||||
|
||||
public record DischargeSummaryContent(Stream Stream, string ContentType, string FileName);
|
||||
@@ -0,0 +1,4 @@
|
||||
public record GatewayDetailResponse(
|
||||
GatewayFleetItem Gateway,
|
||||
IReadOnlyList<ClinicalSyncHistoryItem> RecentBatches,
|
||||
int PendingConflictCount);
|
||||
@@ -0,0 +1 @@
|
||||
public record GatewayFleetFilter(string? Status, Guid? SiteId);
|
||||
@@ -0,0 +1,11 @@
|
||||
public record GatewayFleetItem(
|
||||
Guid Id,
|
||||
string GatewayCode,
|
||||
string Department,
|
||||
string SiteCode,
|
||||
string SiteName,
|
||||
string Status,
|
||||
int ReportedBufferDepth,
|
||||
DateTimeOffset? LastHeartbeatAt,
|
||||
DateTimeOffset? LastSyncAt,
|
||||
double? MinutesSinceHeartbeat);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record SiteGatewaySummaryResponse(
|
||||
Guid SiteId,
|
||||
int TotalGateways,
|
||||
int Online,
|
||||
int Degraded,
|
||||
int Offline,
|
||||
int TotalBufferedEvents);
|
||||
@@ -0,0 +1,8 @@
|
||||
public record ClinicalUserResponse(
|
||||
Guid Id,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string Role,
|
||||
bool IsActive,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastLoginAt);
|
||||
@@ -0,0 +1,5 @@
|
||||
public record CreateUserRequest(
|
||||
string Username,
|
||||
string Password,
|
||||
string DisplayName,
|
||||
string Role);
|
||||
@@ -0,0 +1,4 @@
|
||||
public record UpdateUserRequest(
|
||||
string? DisplayName,
|
||||
string? Role,
|
||||
bool? IsActive);
|
||||
@@ -147,6 +147,9 @@ try
|
||||
builder.Configuration.GetSection(PhiEncryptionOptions.Section));
|
||||
|
||||
builder.Services.Configure<ClinicalSyncOptions>(builder.Configuration.GetSection(ClinicalSyncOptions.Section));
|
||||
|
||||
builder.Services.Configure<GatewayMonitoringOptions>(
|
||||
builder.Configuration.GetSection(GatewayMonitoringOptions.Section));
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
@@ -160,6 +163,7 @@ try
|
||||
|
||||
builder.Services.AddScoped<IPatientService, PatientService>();
|
||||
builder.Services.AddScoped<IEncounterService, EncounterService>();
|
||||
builder.Services.AddScoped<IDischargeSummaryService, DischargeSummaryService>();
|
||||
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
|
||||
builder.Services.AddScoped<IObservationService, ObservationService>();
|
||||
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
|
||||
@@ -198,6 +202,7 @@ try
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||
builder.Services.AddScoped<IClinicalSyncService, ClinicalSyncService>();
|
||||
builder.Services.AddScoped<ClinicalSyncBatchProcessor>();
|
||||
@@ -205,6 +210,8 @@ try
|
||||
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
|
||||
builder.Services.AddScoped<ISiteService, SiteService>();
|
||||
builder.Services.AddScoped<IGatewayRegistryService, GatewayRegistryService>();
|
||||
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
||||
builder.Services.AddHostedService<GatewayStaleDetectorService>();
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Minio;
|
||||
using Minio.DataModel.Args;
|
||||
using Minio.Exceptions;
|
||||
|
||||
public sealed class DischargeSummaryService : IDischargeSummaryService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly MinioOptions _minioOpts;
|
||||
|
||||
public DischargeSummaryService(AppDbContext db, IOptions<MinioOptions> minioOpts)
|
||||
{
|
||||
_db = db;
|
||||
_minioOpts = minioOpts.Value;
|
||||
}
|
||||
|
||||
public async Task<DischargeSummaryInfo> GetInfoAsync(Guid encounterId, CancellationToken ct = default)
|
||||
{
|
||||
var encounter = await _db.Encounters
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == encounterId, ct);
|
||||
|
||||
if (encounter is null)
|
||||
throw new NotFoundException("Encounter not found.");
|
||||
|
||||
if (encounter.Status != EncounterStatus.Discharged)
|
||||
{
|
||||
return new DischargeSummaryInfo(
|
||||
"NotDischarged",
|
||||
encounter.DischargedAt,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
var exists = await ObjectExistsAsync(encounterId, ct);
|
||||
if (!exists)
|
||||
{
|
||||
return new DischargeSummaryInfo(
|
||||
"Pending",
|
||||
encounter.DischargedAt,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
return new DischargeSummaryInfo(
|
||||
"Ready",
|
||||
encounter.DischargedAt,
|
||||
"text/plain",
|
||||
"discharge-summary.pdf");
|
||||
}
|
||||
|
||||
public async Task<DischargeSummaryContent> GetContentAsync(Guid encounterId, CancellationToken ct = default)
|
||||
{
|
||||
var info = await GetInfoAsync(encounterId, ct);
|
||||
|
||||
if (info.Status == "NotDischarged")
|
||||
{
|
||||
throw new ConflictException(
|
||||
"Discharge summary is only available for discharged encounters.",
|
||||
"ENCOUNTER_NOT_DISCHARGED");
|
||||
}
|
||||
|
||||
if (info.Status == "Pending")
|
||||
{
|
||||
throw new NotFoundException(
|
||||
"Discharge summary is still being generated.",
|
||||
"DISCHARGE_SUMMARY_PENDING");
|
||||
}
|
||||
|
||||
var client = MinioClientFactory.Build(_minioOpts);
|
||||
var bucket = _minioOpts.BucketName;
|
||||
var objectKey = ObjectKey(encounterId);
|
||||
var ms = new MemoryStream();
|
||||
|
||||
await client.GetObjectAsync(new GetObjectArgs()
|
||||
.WithBucket(bucket)
|
||||
.WithObject(objectKey)
|
||||
.WithCallbackStream(stream => stream.CopyTo(ms)), ct);
|
||||
|
||||
ms.Position = 0;
|
||||
return new DischargeSummaryContent(ms, info.ContentType!, info.FileName!);
|
||||
}
|
||||
|
||||
private async Task<bool> ObjectExistsAsync(Guid encounterId, CancellationToken ct)
|
||||
{
|
||||
var client = MinioClientFactory.Build(_minioOpts);
|
||||
var bucket = _minioOpts.BucketName;
|
||||
var objectKey = ObjectKey(encounterId);
|
||||
|
||||
try
|
||||
{
|
||||
await client.StatObjectAsync(new StatObjectArgs()
|
||||
.WithBucket(bucket)
|
||||
.WithObject(objectKey), ct);
|
||||
return true;
|
||||
}
|
||||
catch (ObjectNotFoundException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ObjectKey(Guid encounterId) =>
|
||||
$"discharge-summaries/{encounterId}/summary.pdf";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public interface IDischargeSummaryService
|
||||
{
|
||||
Task<DischargeSummaryInfo> GetInfoAsync(Guid encounterId, CancellationToken ct = default);
|
||||
Task<DischargeSummaryContent> GetContentAsync(Guid encounterId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public interface IOperationsService
|
||||
{
|
||||
Task<IReadOnlyList<GatewayFleetItem>> GetGatewayFleetAsync(GatewayFleetFilter filter, CancellationToken ct);
|
||||
Task<GatewayDetailResponse> GetGatewayDetailAsync(Guid gatewayId, CancellationToken ct);
|
||||
Task<SiteGatewaySummaryResponse> GetSiteSummaryAsync(Guid siteId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public interface IUserService
|
||||
{
|
||||
Task<List<ClinicalUserResponse>> ListAsync();
|
||||
Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req);
|
||||
Task<ClinicalUserResponse> UpdateAsync(Guid id, UpdateUserRequest req);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
public class OperationsService : IOperationsService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IClinicalSyncService _sync;
|
||||
|
||||
public OperationsService(AppDbContext db, IClinicalSyncService sync)
|
||||
{
|
||||
_db = db;
|
||||
_sync = sync;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GatewayFleetItem>> GetGatewayFleetAsync(
|
||||
GatewayFleetFilter filter, CancellationToken ct)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_db.Database.GetConnectionString());
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
g.id,
|
||||
g.gateway_code AS GatewayCode,
|
||||
g.department AS Department,
|
||||
s.site_code AS SiteCode,
|
||||
s.name AS SiteName,
|
||||
g.status AS Status,
|
||||
g.reported_buffer_depth AS ReportedBufferDepth,
|
||||
g.last_heartbeat_at AS LastHeartbeatAt,
|
||||
g.last_sync_at AS LastSyncAt,
|
||||
EXTRACT(EPOCH FROM (NOW() - g.last_heartbeat_at)) / 60 AS MinutesSinceHeartbeat
|
||||
FROM ward_gateways g
|
||||
JOIN clinical_sites s ON s.id = g.site_id
|
||||
WHERE (@Status IS NULL OR g.status = @Status)
|
||||
AND (@SiteId IS NULL OR g.site_id = @SiteId)
|
||||
ORDER BY g.status DESC, MinutesSinceHeartbeat DESC NULLS LAST
|
||||
""";
|
||||
|
||||
var rows = await conn.QueryAsync<GatewayFleetItem>(sql, new
|
||||
{
|
||||
Status = filter.Status,
|
||||
SiteId = filter.SiteId
|
||||
});
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<GatewayDetailResponse> GetGatewayDetailAsync(Guid gatewayId, CancellationToken ct)
|
||||
{
|
||||
var fleet = await GetGatewayFleetAsync(new GatewayFleetFilter(null, null), ct);
|
||||
var gateway = fleet.FirstOrDefault(g => g.Id == gatewayId)
|
||||
?? throw new NotFoundException("Gateway not found.", "GATEWAY_NOT_FOUND");
|
||||
|
||||
var siteId = await _db.WardGateways
|
||||
.Where(g => g.Id == gatewayId)
|
||||
.Select(g => g.SiteId)
|
||||
.FirstAsync(ct);
|
||||
|
||||
var history = await _sync.GetSyncHistoryAsync(siteId, gatewayId, 10, ct);
|
||||
|
||||
var lastConflictBatch = await _db.ClinicalSyncBatches
|
||||
.AsNoTracking()
|
||||
.Where(b => b.GatewayId == gatewayId && b.Status == ClinicalSyncBatchStatus.Conflict)
|
||||
.OrderByDescending(b => b.SubmittedAt)
|
||||
.Include(b => b.Conflicts)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var pendingConflicts = lastConflictBatch?.Conflicts.Count ?? 0;
|
||||
|
||||
return new GatewayDetailResponse(gateway, history, pendingConflicts);
|
||||
}
|
||||
|
||||
public async Task<SiteGatewaySummaryResponse> GetSiteSummaryAsync(Guid siteId, CancellationToken ct)
|
||||
{
|
||||
var siteExists = await _db.ClinicalSites.AnyAsync(s => s.Id == siteId, ct);
|
||||
if (!siteExists)
|
||||
throw new NotFoundException("Site not found.", "SITE_NOT_FOUND");
|
||||
|
||||
await using var conn = new NpgsqlConnection(_db.Database.GetConnectionString());
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) AS TotalGateways,
|
||||
COUNT(*) FILTER (WHERE status = 'ONLINE') AS Online,
|
||||
COUNT(*) FILTER (WHERE status = 'DEGRADED') AS Degraded,
|
||||
COUNT(*) FILTER (WHERE status = 'OFFLINE') AS Offline,
|
||||
COALESCE(SUM(reported_buffer_depth), 0) AS TotalBufferedEvents
|
||||
FROM ward_gateways
|
||||
WHERE site_id = @SiteId
|
||||
""";
|
||||
|
||||
var row = await conn.QuerySingleAsync(sql, new { SiteId = siteId });
|
||||
|
||||
return new SiteGatewaySummaryResponse(
|
||||
siteId,
|
||||
(int)row.totalgateways,
|
||||
(int)row.online,
|
||||
(int)row.degraded,
|
||||
(int)row.offline,
|
||||
(int)row.totalbufferedevents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class UserService : IUserService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public UserService(AppDbContext db, ICurrentUserService currentUser)
|
||||
{
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<List<ClinicalUserResponse>> ListAsync()
|
||||
{
|
||||
var users = await _db.ClinicalUsers
|
||||
.AsNoTracking()
|
||||
.OrderBy(u => u.Username)
|
||||
.ToListAsync();
|
||||
return users.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req)
|
||||
{
|
||||
ValidateUsername(req.Username);
|
||||
ValidatePassword(req.Password);
|
||||
ValidateDisplayName(req.DisplayName);
|
||||
var role = ParseRole(req.Role);
|
||||
|
||||
var exists = await _db.ClinicalUsers.AnyAsync(u => u.Username == req.Username);
|
||||
if (exists)
|
||||
throw new ConflictException(
|
||||
$"Username '{req.Username}' is already taken.",
|
||||
"USERNAME_CONFLICT");
|
||||
|
||||
var user = new ClinicalUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = req.Username.Trim(),
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
|
||||
DisplayName = req.DisplayName.Trim(),
|
||||
Role = role,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.ClinicalUsers.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<ClinicalUserResponse> UpdateAsync(Guid id, UpdateUserRequest req)
|
||||
{
|
||||
var user = await _db.ClinicalUsers.FindAsync(id);
|
||||
if (user is null)
|
||||
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
|
||||
|
||||
if (req.DisplayName is not null)
|
||||
{
|
||||
ValidateDisplayName(req.DisplayName);
|
||||
user.DisplayName = req.DisplayName.Trim();
|
||||
}
|
||||
|
||||
if (req.Role is not null)
|
||||
user.Role = ParseRole(req.Role);
|
||||
|
||||
if (req.IsActive is not null)
|
||||
{
|
||||
if (!req.IsActive.Value && _currentUser.UserId == id)
|
||||
throw new ValidationException(
|
||||
"You cannot deactivate your own account.",
|
||||
"SELF_DEACTIVATION_DENIED");
|
||||
user.IsActive = req.IsActive.Value;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
private static ClinicalUserResponse Map(ClinicalUser u) =>
|
||||
new(u.Id, u.Username, u.DisplayName, u.Role.ToDbString(), u.IsActive, u.CreatedAt, u.LastLoginAt);
|
||||
|
||||
private static void ValidateUsername(string username)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
throw new ValidationException("Username is required.", "USERNAME_REQUIRED");
|
||||
if (username.Trim().Length > 100)
|
||||
throw new ValidationException("Username must be 100 characters or fewer.", "USERNAME_TOO_LONG");
|
||||
}
|
||||
|
||||
private static void ValidatePassword(string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
throw new ValidationException("Password is required.", "PASSWORD_REQUIRED");
|
||||
if (password.Length < 8)
|
||||
throw new ValidationException("Password must be at least 8 characters.", "PASSWORD_TOO_SHORT");
|
||||
}
|
||||
|
||||
private static void ValidateDisplayName(string displayName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
throw new ValidationException("Display name is required.", "DISPLAY_NAME_REQUIRED");
|
||||
if (displayName.Trim().Length > 200)
|
||||
throw new ValidationException("Display name must be 200 characters or fewer.", "DISPLAY_NAME_TOO_LONG");
|
||||
}
|
||||
|
||||
private static ClinicalRole ParseRole(string role)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
throw new ValidationException("Role is required.", "ROLE_REQUIRED");
|
||||
try
|
||||
{
|
||||
return ClinicalRoleExtensions.FromDbString(role.Trim().ToUpperInvariant());
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
throw new ValidationException(
|
||||
"Role must be one of: NURSE, PHYSICIAN, ADMIN, INTEGRATION.",
|
||||
"INVALID_ROLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.1" />
|
||||
|
||||
@@ -210,5 +210,9 @@
|
||||
},
|
||||
"ClinicalSync": {
|
||||
"SuppressPagingForSyncedAlerts": true
|
||||
},
|
||||
"GatewayMonitoring": {
|
||||
"StaleThresholdMinutes": 10,
|
||||
"PollIntervalMinutes": 5
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user