feature: RabbitMQ Notification Workers and DLQ Escalation

This commit is contained in:
voltsrage
2026-06-17 02:37:30 +08:00
parent ec780144c6
commit 0246143815
17 changed files with 1410 additions and 2 deletions
@@ -0,0 +1,158 @@
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Minio.DataModel.Args;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
public sealed class DischargeSummaryWorkerService : BackgroundService
{
private readonly IOptions<RabbitMqOptions> _rabbitOpts;
private readonly IOptions<MinioOptions> _minioOpts;
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<DischargeSummaryWorkerService> _logger;
public DischargeSummaryWorkerService(
IOptions<RabbitMqOptions> rabbitOpts,
IOptions<MinioOptions> minioOpts,
IServiceScopeFactory scopes,
ILogger<DischargeSummaryWorkerService> logger)
{
_rabbitOpts = rabbitOpts;
_minioOpts = minioOpts;
_scopes = scopes;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
var o = _rabbitOpts.Value;
var factory = new ConnectionFactory
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
DispatchConsumersAsync = true,
};
using var connection = factory.CreateConnection("discharge-summary-worker");
using var channel = connection.CreateModel();
channel.BasicQos(0, prefetchCount: 3, global: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += async (sender, ea) =>
{
await HandleDischargeSummaryAsync(channel, ea, stoppingToken);
};
channel.BasicConsume("notifications.discharge.queue", autoAck: false, consumer);
_logger.LogInformation("DischargeSummaryWorkerService consuming notifications.discharge.queue");
await Task.Delay(Timeout.Infinite, stoppingToken);
}
private async Task HandleDischargeSummaryAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
{
var payload = Encoding.UTF8.GetString(ea.Body.Span);
var doc = JsonDocument.Parse(payload);
var encounterId = Guid.Parse(doc.RootElement.GetProperty("encounterId").GetString()!);
try
{
var summary = await BuildSummaryAsync(encounterId, ct);
await UploadToMinioAsync(encounterId, summary, ct);
channel.BasicAck(ea.DeliveryTag, multiple: false);
_logger.LogInformation(
"[DISCHARGE-SUMMARY] Uploaded for encounter {EncounterId}", encounterId);
}
catch (Exception ex)
{
_logger.LogError(ex, "DischargeSummaryWorker failed for encounter {EncounterId}", encounterId);
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
}
}
private async Task<string> BuildSummaryAsync(Guid encounterId, CancellationToken ct)
{
await using var scope = _scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var encounter = await db.Encounters
.Include(e => e.Patient)
.FirstOrDefaultAsync(e => e.Id == encounterId, ct);
if (encounter is null)
return $"DISCHARGE SUMMARY\nEncounter {encounterId} not found in database.";
var obsCount = await db.Observations.CountAsync(o => o.EncounterId == encounterId, ct);
var alertCount = await db.ClinicalAlerts.CountAsync(a => a.EncounterId == encounterId, ct);
var orders = await db.Orders
.Where(o => o.EncounterId == encounterId)
.Select(o => new { o.OrderType, o.Description, o.Status, o.OrderedAt, o.ResultedAt })
.ToListAsync(ct);
var sb = new StringBuilder();
sb.AppendLine("=== VIGILCARE DISCHARGE SUMMARY ===");
sb.AppendLine($"Generated: {DateTimeOffset.UtcNow:u}");
sb.AppendLine();
sb.AppendLine($"Patient: {encounter.Patient.FirstName} {encounter.Patient.LastName}");
sb.AppendLine($"MRN: {encounter.Patient.Mrn}");
sb.AppendLine($"Date of Birth: {encounter.Patient.DateOfBirth:yyyy-MM-dd}");
sb.AppendLine();
sb.AppendLine($"Encounter ID: {encounter.Id}");
sb.AppendLine($"Type: {encounter.EncounterType}");
sb.AppendLine($"Department: {encounter.Department}");
sb.AppendLine($"Attending: {encounter.AttendingPhysician}");
sb.AppendLine($"Admitted: {encounter.AdmittedAt:u}");
sb.AppendLine($"Discharged: {encounter.DischargedAt:u}");
sb.AppendLine();
sb.AppendLine($"Observations Recorded: {obsCount}");
sb.AppendLine($"Clinical Alerts: {alertCount}");
sb.AppendLine();
if (orders.Any())
{
sb.AppendLine("--- Orders ---");
foreach (var o in orders)
sb.AppendLine($" [{o.Status}] {o.OrderType.ToDbString()}: {o.Description} (ordered: {o.OrderedAt:u})");
}
sb.AppendLine();
sb.AppendLine("NOTE: This summary is generated automatically. Clinician review required.");
sb.AppendLine("NOTE: HIPAA Notice — This document contains protected health information.");
return sb.ToString();
}
private async Task UploadToMinioAsync(Guid encounterId, string content, CancellationToken ct)
{
var mo = _minioOpts.Value;
var client = MinioClientFactory.Build(mo);
var bucket = mo.BucketName;
var exists = await client.BucketExistsAsync(
new BucketExistsArgs().WithBucket(bucket), ct);
if (!exists)
await client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), ct);
var objectKey = $"discharge-summaries/{encounterId}/summary.pdf";
var bytes = Encoding.UTF8.GetBytes(content);
await client.PutObjectAsync(new PutObjectArgs()
.WithBucket(bucket)
.WithObject(objectKey)
.WithStreamData(new MemoryStream(bytes))
.WithObjectSize(bytes.Length)
.WithContentType("text/plain"),
ct);
}
}
@@ -0,0 +1,95 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
public sealed class EscalationWorkerService : BackgroundService
{
private readonly IOptions<RabbitMqOptions> _opts;
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<EscalationWorkerService> _logger;
public EscalationWorkerService(
IOptions<RabbitMqOptions> opts,
IServiceScopeFactory scopes,
ILogger<EscalationWorkerService> logger)
{
_opts = opts;
_scopes = scopes;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
var o = _opts.Value;
var factory = new ConnectionFactory
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
DispatchConsumersAsync = true,
};
using var connection = factory.CreateConnection("escalation-worker");
using var channel = connection.CreateModel();
channel.BasicQos(0, prefetchCount: 5, global: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += async (sender, ea) =>
{
await HandleEscalationAsync(channel, ea, stoppingToken);
};
channel.BasicConsume("alerts.escalation.queue", autoAck: false, consumer);
_logger.LogInformation("EscalationWorkerService consuming alerts.escalation.queue");
await Task.Delay(Timeout.Infinite, stoppingToken);
}
private async Task HandleEscalationAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
{
var payload = Encoding.UTF8.GetString(ea.Body.Span);
var doc = JsonDocument.Parse(payload);
var alertId = Guid.Parse(doc.RootElement.GetProperty("alertId").GetString()!);
var encounterId = doc.RootElement.GetProperty("encounterId").GetString();
_logger.LogCritical(
"[ESCALATION] Paging on-call backup — AlertId={AlertId} EncounterId={EncounterId}",
alertId, encounterId);
try
{
await UpdateAlertStatusEscalatedAsync(alertId, ct);
channel.BasicAck(ea.DeliveryTag, multiple: false);
_logger.LogWarning(
"[ESCALATION-DONE] Alert {AlertId} status → Escalated in PostgreSQL", alertId);
}
catch (Exception ex)
{
_logger.LogError(ex, "EscalationWorker failed for alert {AlertId}", alertId);
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
}
}
private async Task UpdateAlertStatusEscalatedAsync(Guid alertId, CancellationToken ct)
{
await using var scope = _scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts.FindAsync(new object[] { alertId }, ct);
if (alert is null) return;
// Only escalate if still open — if acknowledged between NACK and TTL expiry, leave it.
if (alert.Status != AlertStatus.Open) return;
alert.Status = AlertStatus.Escalated;
await db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,151 @@
using System.Text;
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
public sealed class NotificationPublisherService : BackgroundService
{
private readonly IOptions<RabbitMqOptions> _rabbitOpts;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger<NotificationPublisherService> _logger;
public NotificationPublisherService(
IOptions<RabbitMqOptions> rabbitOpts,
IOptions<KafkaOptions> kafkaOptions,
ILogger<NotificationPublisherService> logger)
{
_rabbitOpts = rabbitOpts;
_kafkaOptions = kafkaOptions.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Short delay so topology provisioner finishes before first publish.
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
var factory = BuildRabbitFactory();
using var conn = factory.CreateConnection("notification-publisher");
using var chan = conn.CreateModel();
var props = chan.CreateBasicProperties();
props.Persistent = true; // messages survive broker restart
var consumerConfig = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "notification-publisher",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false,
};
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
consumer.Subscribe(new[]
{
_kafkaOptions.Topics.AlertGenerated,
_kafkaOptions.Topics.EncounterStatusChanged,
});
_logger.LogInformation("NotificationPublisherService started — consuming {Topics}",
string.Join(", ", _kafkaOptions.Topics.AlertGenerated, _kafkaOptions.Topics.EncounterStatusChanged));
try
{
while (!stoppingToken.IsCancellationRequested)
{
ConsumeResult<string, string>? result;
try
{
result = consumer.Consume(TimeSpan.FromMilliseconds(500));
}
catch (ConsumeException ex)
{
_logger.LogError(ex, "NotificationPublisher consume error");
continue;
}
if (result is null) continue;
try
{
if (result.Topic == _kafkaOptions.Topics.AlertGenerated)
await HandleAlertGeneratedAsync(chan, props, result.Message.Value, stoppingToken);
else if (result.Topic == _kafkaOptions.Topics.EncounterStatusChanged)
await HandleEncounterStatusChangedAsync(chan, props, result.Message.Value, stoppingToken);
consumer.Commit(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "NotificationPublisher failed to process message from {Topic}", result.Topic);
// Do not commit — message will be reprocessed after consumer restart.
}
}
}
finally
{
consumer.Close();
}
}
private Task HandleAlertGeneratedAsync(
IModel chan, IBasicProperties props, string payload, CancellationToken ct)
{
var doc = JsonDocument.Parse(payload);
var severity = doc.RootElement.GetProperty("severity").GetString();
if (!string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase))
{
// Warning-level alerts are not paged — they appear in the clinician dashboard only.
return Task.CompletedTask;
}
var body = Encoding.UTF8.GetBytes(payload);
chan.BasicPublish(
exchange: RabbitMqTopologyProvisioner.Exchange,
routingKey: RabbitMqTopologyProvisioner.PagingKey,
basicProperties: props,
body: body);
var alertId = doc.RootElement.GetProperty("alertId").GetString();
_logger.LogInformation("Published paging job to alerts.paging.queue for alert {AlertId}", alertId);
return Task.CompletedTask;
}
private Task HandleEncounterStatusChangedAsync(
IModel chan, IBasicProperties props, string payload, CancellationToken ct)
{
var doc = JsonDocument.Parse(payload);
var newStatus = doc.RootElement.GetProperty("newStatus").GetString();
if (!string.Equals(newStatus, "Discharged", StringComparison.OrdinalIgnoreCase))
return Task.CompletedTask;
var body = Encoding.UTF8.GetBytes(payload);
chan.BasicPublish(
exchange: RabbitMqTopologyProvisioner.Exchange,
routingKey: RabbitMqTopologyProvisioner.DischargeKey,
basicProperties: props,
body: body);
var encounterId = doc.RootElement.GetProperty("encounterId").GetString();
_logger.LogInformation("Published discharge summary job for encounter {EncounterId}", encounterId);
return Task.CompletedTask;
}
private IConnectionFactory BuildRabbitFactory()
{
var o = _rabbitOpts.Value;
return new ConnectionFactory
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
DispatchConsumersAsync = true,
};
}
}
@@ -0,0 +1,121 @@
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
public sealed class PagingWorkerService : BackgroundService
{
private readonly IOptions<RabbitMqOptions> _opts;
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<PagingWorkerService> _logger;
public PagingWorkerService(
IOptions<RabbitMqOptions> opts,
IServiceScopeFactory scopes,
ILogger<PagingWorkerService> logger)
{
_opts = opts;
_scopes = scopes;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); // wait for topology
var o = _opts.Value;
var factory = new ConnectionFactory
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
DispatchConsumersAsync = true,
};
using var connection = factory.CreateConnection("paging-worker");
using var channel = connection.CreateModel();
// Prefetch 1: hold exactly one page at a time.
// Releasing the next message only after ACK or NACK keeps the worker
// from consuming more alerts than it can actively page on.
channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += async (sender, ea) =>
{
try
{
await HandlePageAsync(channel, ea, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "PagingWorker failed — NACKing to DLQ");
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
}
};
channel.BasicConsume("alerts.paging.queue", autoAck: false, consumer);
_logger.LogInformation("PagingWorkerService consuming alerts.paging.queue (prefetch=1, timeout={Timeout}ms)",
o.PagingAckTimeoutMs);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
private async Task HandlePageAsync(RabbitMQ.Client.IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
{
var o = _opts.Value;
var payload = Encoding.UTF8.GetString(ea.Body.Span);
var doc = JsonDocument.Parse(payload);
var alertId = Guid.Parse(doc.RootElement.GetProperty("alertId").GetString()!);
var encounterId = doc.RootElement.GetProperty("encounterId").GetString();
var details = doc.RootElement.TryGetProperty("details", out var d) ? d.GetString() : null;
var physician = doc.RootElement.TryGetProperty("attendingPhysician", out var p) ? p.GetString() : "unknown";
_logger.LogWarning(
"[PAGE] Paging attending physician '{Physician}' for encounter {EncounterId} — " +
"AlertId={AlertId} Details={Details}",
physician, encounterId, alertId, details);
var deadline = DateTimeOffset.UtcNow.AddMilliseconds(o.PagingAckTimeoutMs);
while (DateTimeOffset.UtcNow < deadline && !ct.IsCancellationRequested)
{
await Task.Delay(2_000, ct);
var acknowledged = await IsAlertAcknowledgedAsync(alertId, ct);
if (acknowledged)
{
_logger.LogInformation(
"[PAGE-ACK] Alert {AlertId} acknowledged — ACKing RabbitMQ message", alertId);
channel.BasicAck(ea.DeliveryTag, multiple: false);
return;
}
}
// Timeout: NACK with requeue=false → message routes to alerts.paging.dlq.
// After x-message-ttl expires on the DLQ, it re-routes to alerts.escalation.queue.
_logger.LogWarning(
"[PAGE-TIMEOUT] No acknowledgment within {TimeoutMs}ms for alert {AlertId} — " +
"NACKing to DLQ for escalation",
o.PagingAckTimeoutMs, alertId);
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
}
private async Task<bool> IsAlertAcknowledgedAsync(Guid alertId, CancellationToken ct)
{
await using var scope = _scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var status = await db.ClinicalAlerts
.Where(a => a.Id == alertId)
.Select(a => a.Status)
.FirstOrDefaultAsync(ct);
return status is AlertStatus.Acknowledged or AlertStatus.Resolved;
}
}
@@ -0,0 +1,9 @@
public sealed class MinioOptions
{
public const string Section = "Minio";
public string Endpoint { get; init; } = "localhost:9005";
public string AccessKey { get; init; } = "minioadmin";
public string SecretKey { get; init; } = "minioadmin";
public string BucketName { get; init; } = "vigilcare";
public bool UseSSL { get; init; } = false;
}
@@ -0,0 +1,11 @@
public sealed class RabbitMqOptions
{
public const string Section = "RabbitMq";
public string Host { get; init; } = "localhost";
public int Port { get; init; } = 5674;
public string Username { get; init; } = "guest";
public string Password { get; init; } = "guest";
// Drives both the paging worker poll timeout and the DLQ x-message-ttl.
// In production: 300000 (5 min). In tests: 5000 (5 sec).
public int PagingAckTimeoutMs { get; init; } = 300000;
}
@@ -0,0 +1,139 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Exceptions;
public sealed class RabbitMqTopologyProvisioner : IHostedService
{
public const string Exchange = "clinical.notifications.exchange";
public const string PagingKey = "alerts.paging";
public const string EscalKey = "alerts.escalation";
public const string DischargeKey = "notifications.discharge";
private readonly RabbitMqOptions _opts;
private readonly IHostEnvironment _env;
private readonly ILogger<RabbitMqTopologyProvisioner> _logger;
public RabbitMqTopologyProvisioner(
IOptions<RabbitMqOptions> opts,
IHostEnvironment env,
ILogger<RabbitMqTopologyProvisioner> logger)
{
_opts = opts.Value;
_env = env;
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
var factory = BuildFactory();
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
if (_env.IsEnvironment("Testing"))
{
// Use a throwaway channel — a failed purge on a missing queue closes the
// channel, which would break the declare calls below.
using var cleanup = connection.CreateModel();
try
{
// Dev runs provision the DLQ with a 5-minute TTL; delete it so the
// test timeout (5 s) is applied when the queue is re-declared below.
cleanup.QueueDelete("alerts.paging.dlq", ifUnused: false, ifEmpty: false);
}
catch (OperationInterruptedException ex)
{
_logger.LogDebug(ex, "DLQ delete skipped — queue may not exist yet");
}
foreach (var queue in new[] { "alerts.paging.queue", "alerts.escalation.queue" })
{
try { cleanup.QueuePurge(queue); }
catch (OperationInterruptedException ex)
{
_logger.LogDebug(ex, "Queue purge skipped for {Queue}", queue);
}
}
}
// Direct exchange — routing key determines destination queue.
channel.ExchangeDeclare(Exchange, ExchangeType.Direct, durable: true);
// --- alerts.paging.queue ---
// Dead-letters to the default exchange with routing key = alerts.paging.dlq.
// Prefetch is set per-consumer, not here.
channel.QueueDeclare(
queue: "alerts.paging.queue",
durable: true,
exclusive: false,
autoDelete: false,
arguments: new Dictionary<string, object>
{
["x-dead-letter-exchange"] = "", // default exchange
["x-dead-letter-routing-key"] = "alerts.paging.dlq",
});
channel.QueueBind("alerts.paging.queue", Exchange, PagingKey);
// --- alerts.paging.dlq ---
// Messages land here after NACK from the paging worker.
// After x-message-ttl expires, re-routes to clinical.notifications.exchange
// with routing key alerts.escalation → reaches alerts.escalation.queue.
channel.QueueDeclare(
queue: "alerts.paging.dlq",
durable: true,
exclusive: false,
autoDelete: false,
arguments: new Dictionary<string, object>
{
["x-message-ttl"] = (int)_opts.PagingAckTimeoutMs,
["x-dead-letter-exchange"] = Exchange,
["x-dead-letter-routing-key"] = EscalKey,
});
// DLQ is reached via the default exchange — no binding to the direct exchange needed.
// --- alerts.escalation.queue ---
channel.QueueDeclare(
queue: "alerts.escalation.queue",
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
channel.QueueBind("alerts.escalation.queue", Exchange, EscalKey);
// --- notifications.discharge.queue ---
channel.QueueDeclare(
queue: "notifications.discharge.queue",
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
channel.QueueBind("notifications.discharge.queue", Exchange, DischargeKey);
// --- notifications.appointment.queue (placeholder) ---
channel.QueueDeclare(
queue: "notifications.appointment.queue",
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
channel.QueueBind("notifications.appointment.queue", Exchange, "notifications.appointment");
_logger.LogInformation(
"RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}",
Exchange, _opts.PagingAckTimeoutMs);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
public IConnectionFactory BuildFactory() => new ConnectionFactory
{
HostName = _opts.Host,
Port = _opts.Port,
UserName = _opts.Username,
Password = _opts.Password,
DispatchConsumersAsync = true,
};
}
+12
View File
@@ -41,6 +41,14 @@ try
builder.Services.AddSingleton(
new ElasticsearchClient(new Uri(esOptions.Uri)));
builder.Services.Configure<RabbitMqOptions>(
builder.Configuration.GetSection(RabbitMqOptions.Section));
builder.Services.Configure<MinioOptions>(
builder.Configuration.GetSection(MinioOptions.Section));
builder.Services.AddSingleton<RabbitMqTopologyProvisioner>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RabbitMqTopologyProvisioner>());
builder.Services.AddScoped<IPatientService, PatientService>();
builder.Services.AddScoped<IEncounterService, EncounterService>();
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
@@ -56,6 +64,10 @@ try
builder.Services.AddHostedService<ElasticIndexProvisioner>();
builder.Services.AddHostedService<EsIndexerService>();
builder.Services.AddHostedService<SepsisEngineService>();
builder.Services.AddHostedService<NotificationPublisherService>();
builder.Services.AddHostedService<PagingWorkerService>();
builder.Services.AddHostedService<EscalationWorkerService>();
builder.Services.AddHostedService<DischargeSummaryWorkerService>();
builder.Services.AddControllers()
.AddJsonOptions(opts =>
@@ -116,6 +116,8 @@ public class ObservationService : IObservationService
department = encounter.Department,
alertType = alert.AlertType.ToDbString(),
severity = alert.Severity.ToDbString(),
details = alert.Details,
attendingPhysician = encounter.AttendingPhysician,
triggeredAt = alert.TriggeredAt,
partitionKey = encounterId.ToString()
}, encounterId.ToString()));
@@ -0,0 +1,15 @@
using Minio;
public static class MinioClientFactory
{
public static IMinioClient Build(MinioOptions opts)
{
var client = new MinioClient()
.WithEndpoint(opts.Endpoint)
.WithCredentials(opts.AccessKey, opts.SecretKey);
if (opts.UseSSL) client = client.WithSSL();
return client.Build();
}
}
@@ -16,7 +16,9 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Minio" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="RabbitMQ.Client" Version="6.8.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
@@ -10,5 +10,8 @@
"WriteTo": [
{ "Name": "Console" }
]
},
"RabbitMq": {
"PagingAckTimeoutMs": 5000
}
}
+14
View File
@@ -54,5 +54,19 @@
"Observations": "observations",
"ClinicalAlerts": "clinical_alerts"
}
},
"RabbitMq": {
"Host": "localhost",
"Port": 5674,
"Username": "guest",
"Password": "guest",
"PagingAckTimeoutMs": 300000
},
"Minio": {
"Endpoint": "localhost:9005",
"AccessKey": "minioadmin",
"SecretKey": "minioadmin",
"BucketName": "vigilcare",
"UseSSL": false
}
}