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,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);
+7
View File
@@ -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" />
+4
View File
@@ -210,5 +210,9 @@
},
"ClinicalSync": {
"SuppressPagingForSyncedAlerts": true
},
"GatewayMonitoring": {
"StaleThresholdMinutes": 10,
"PollIntervalMinutes": 5
}
}