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 _rabbitOpts; private readonly KafkaOptions _kafkaOptions; private readonly ILogger _logger; public NotificationPublisherService( IOptions rabbitOpts, IOptions kafkaOptions, ILogger 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(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? 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, }; } }