feature: Degraded Operations Visibility

This commit is contained in:
voltsrage
2026-06-23 23:18:31 +08:00
parent 940e27c0ac
commit 4399996448
81 changed files with 3949 additions and 323 deletions
@@ -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");
}
}
}