diff --git a/code/Executable/Controllers/EventsController.cs b/code/Executable/Controllers/EventsController.cs
new file mode 100644
index 00000000..72948b6c
--- /dev/null
+++ b/code/Executable/Controllers/EventsController.cs
@@ -0,0 +1,144 @@
+using Infrastructure.Events;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Executable.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class EventsController : ControllerBase
+{
+ private readonly EventDbContext _context;
+
+ public EventsController(EventDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Gets recent events
+ ///
+ [HttpGet]
+ public async Task>> GetEvents(
+ [FromQuery] int count = 100,
+ [FromQuery] string? severity = null,
+ [FromQuery] string? eventType = null,
+ [FromQuery] string? source = null)
+ {
+ var query = _context.Events.AsQueryable();
+
+ // Apply filters
+ if (!string.IsNullOrWhiteSpace(severity))
+ query = query.Where(e => e.Severity == severity);
+
+ if (!string.IsNullOrWhiteSpace(eventType))
+ query = query.Where(e => e.EventType == eventType);
+
+ if (!string.IsNullOrWhiteSpace(source))
+ query = query.Where(e => e.Source.Contains(source));
+
+ // Order and limit
+ var events = await query
+ .OrderByDescending(e => e.Timestamp)
+ .Take(Math.Min(count, 1000)) // Cap at 1000
+ .ToListAsync();
+
+ return Ok(events);
+ }
+
+ ///
+ /// Gets a specific event by ID
+ ///
+ [HttpGet("{id}")]
+ public async Task> GetEvent(string id)
+ {
+ var eventEntity = await _context.Events.FindAsync(id);
+
+ if (eventEntity == null)
+ return NotFound();
+
+ return Ok(eventEntity);
+ }
+
+ ///
+ /// Gets events by correlation ID
+ ///
+ [HttpGet("correlation/{correlationId}")]
+ public async Task>> GetEventsByCorrelation(string correlationId)
+ {
+ var events = await _context.Events
+ .Where(e => e.CorrelationId == correlationId)
+ .OrderBy(e => e.Timestamp)
+ .ToListAsync();
+
+ return Ok(events);
+ }
+
+ ///
+ /// Gets event statistics
+ ///
+ [HttpGet("stats")]
+ public async Task> GetEventStats()
+ {
+ var stats = new
+ {
+ TotalEvents = await _context.Events.CountAsync(),
+ EventsBySeverity = await _context.Events
+ .GroupBy(e => e.Severity)
+ .Select(g => new { Severity = g.Key, Count = g.Count() })
+ .ToListAsync(),
+ EventsByType = await _context.Events
+ .GroupBy(e => e.EventType)
+ .Select(g => new { EventType = g.Key, Count = g.Count() })
+ .OrderByDescending(x => x.Count)
+ .Take(10)
+ .ToListAsync(),
+ RecentEventsCount = await _context.Events
+ .Where(e => e.Timestamp > DateTime.UtcNow.AddHours(-24))
+ .CountAsync()
+ };
+
+ return Ok(stats);
+ }
+
+ ///
+ /// Manually triggers cleanup of old events
+ ///
+ [HttpPost("cleanup")]
+ public async Task> CleanupOldEvents([FromQuery] int retentionDays = 30)
+ {
+ var removedCount = await _context.CleanupOldEventsAsync(retentionDays);
+
+ return Ok(new { RemovedCount = removedCount, RetentionDays = retentionDays });
+ }
+
+ ///
+ /// Gets unique event sources
+ ///
+ [HttpGet("sources")]
+ public async Task>> GetEventSources()
+ {
+ var sources = await _context.Events
+ .Select(e => e.Source)
+ .Distinct()
+ .OrderBy(s => s)
+ .ToListAsync();
+
+ return Ok(sources);
+ }
+
+ ///
+ /// Gets unique event types
+ ///
+ [HttpGet("types")]
+ public async Task>> GetEventTypes()
+ {
+ var types = await _context.Events
+ .Select(e => e.EventType)
+ .Distinct()
+ .OrderBy(t => t)
+ .ToListAsync();
+
+ return Ok(types);
+ }
+}
\ No newline at end of file
diff --git a/code/Infrastructure/Events/Event.cs b/code/Infrastructure/Events/Event.cs
new file mode 100644
index 00000000..b5009c05
--- /dev/null
+++ b/code/Infrastructure/Events/Event.cs
@@ -0,0 +1,47 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.EntityFrameworkCore;
+
+namespace Infrastructure.Events;
+
+///
+/// Represents an event in the system
+///
+[Index(nameof(Timestamp), IsDescending = new[] { true })]
+[Index(nameof(EventType))]
+[Index(nameof(Severity))]
+[Index(nameof(Source))]
+public class Event
+{
+ [Key]
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+
+ [Required]
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+
+ [Required]
+ [MaxLength(100)]
+ public string EventType { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(100)]
+ public string Source { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(1000)]
+ public string Message { get; set; } = string.Empty;
+
+ ///
+ /// JSON data associated with the event
+ ///
+ public string? Data { get; set; }
+
+ [Required]
+ [MaxLength(20)]
+ public string Severity { get; set; } = "Info"; // Info, Warning, Error, Critical
+
+ ///
+ /// Optional correlation ID to link related events
+ ///
+ [MaxLength(50)]
+ public string? CorrelationId { get; set; }
+}
\ No newline at end of file
diff --git a/code/Infrastructure/Events/EventCleanupService.cs b/code/Infrastructure/Events/EventCleanupService.cs
new file mode 100644
index 00000000..4ff88f3c
--- /dev/null
+++ b/code/Infrastructure/Events/EventCleanupService.cs
@@ -0,0 +1,104 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.Events;
+
+///
+/// Background service that periodically cleans up old events
+///
+public class EventCleanupService : BackgroundService
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+ private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours
+ private readonly int _retentionDays = 30; // Keep events for 30 days
+
+ public EventCleanupService(IServiceProvider serviceProvider, ILogger logger)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Event cleanup service started. Interval: {interval}, Retention: {retention} days",
+ _cleanupInterval, _retentionDays);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_cleanupInterval, stoppingToken);
+
+ if (stoppingToken.IsCancellationRequested)
+ break;
+
+ await PerformCleanupAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ // Service is stopping
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error during event cleanup");
+ }
+ }
+
+ _logger.LogInformation("Event cleanup service stopped");
+ }
+
+ private async Task PerformCleanupAsync()
+ {
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);
+ var oldEventsQuery = context.Events.Where(e => e.Timestamp < cutoffDate);
+ var count = await oldEventsQuery.CountAsync();
+
+ if (count > 0)
+ {
+ _logger.LogInformation("Cleaning up {count} events older than {cutoffDate:yyyy-MM-dd}", count, cutoffDate);
+
+ // Remove in batches to avoid large transactions
+ const int batchSize = 1000;
+ var totalRemoved = 0;
+
+ while (true)
+ {
+ var batch = await oldEventsQuery.Take(batchSize).ToListAsync();
+ if (batch.Count == 0)
+ break;
+
+ context.Events.RemoveRange(batch);
+ await context.SaveChangesAsync();
+ totalRemoved += batch.Count;
+
+ _logger.LogTrace("Removed batch of {batchCount} events, total removed: {totalRemoved}", batch.Count, totalRemoved);
+ }
+
+ _logger.LogInformation("Event cleanup completed. Removed {totalRemoved} old events", totalRemoved);
+ }
+ else
+ {
+ _logger.LogTrace("No old events to clean up");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to perform event cleanup");
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Event cleanup service stopping...");
+ await base.StopAsync(cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/code/Infrastructure/Events/EventDbContext.cs b/code/Infrastructure/Events/EventDbContext.cs
new file mode 100644
index 00000000..46b8f9f1
--- /dev/null
+++ b/code/Infrastructure/Events/EventDbContext.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore;
+using Infrastructure.Configuration;
+
+namespace Infrastructure.Events;
+
+///
+/// Database context for events
+///
+public class EventDbContext : DbContext
+{
+ public DbSet Events { get; set; }
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ if (!optionsBuilder.IsConfigured)
+ {
+ var dbPath = Path.Combine(ConfigurationPathProvider.GetSettingsPath(), "events.db");
+ optionsBuilder.UseSqlite($"Data Source={dbPath}");
+ }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // Additional configuration if needed
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Timestamp).IsRequired();
+ entity.Property(e => e.EventType).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.Source).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.Message).IsRequired().HasMaxLength(1000);
+ entity.Property(e => e.Severity).IsRequired().HasMaxLength(20);
+ entity.Property(e => e.CorrelationId).HasMaxLength(50);
+ });
+ }
+}
\ No newline at end of file
diff --git a/code/Infrastructure/Events/EventExtensions.cs b/code/Infrastructure/Events/EventExtensions.cs
new file mode 100644
index 00000000..8ecd8e6b
--- /dev/null
+++ b/code/Infrastructure/Events/EventExtensions.cs
@@ -0,0 +1,170 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.Events;
+
+///
+/// Extension methods for easy event publishing
+///
+public static class EventExtensions
+{
+ ///
+ /// Publishes an information event
+ ///
+ public static async Task PublishInfoEventAsync(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null)
+ {
+ await PublishEventAsync(context, "Information", source, message, "Info", data, correlationId);
+ }
+
+ ///
+ /// Publishes a warning event
+ ///
+ public static async Task PublishWarningEventAsync(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null)
+ {
+ await PublishEventAsync(context, "Warning", source, message, "Warning", data, correlationId);
+ }
+
+ ///
+ /// Publishes an error event
+ ///
+ public static async Task PublishErrorEventAsync(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null)
+ {
+ await PublishEventAsync(context, "Error", source, message, "Error", data, correlationId);
+ }
+
+ ///
+ /// Publishes a custom event
+ ///
+ public static async Task PublishEventAsync(this EventDbContext context, string eventType, string source, string message, string severity = "Info", object? data = null, string? correlationId = null)
+ {
+ var eventEntity = new Event
+ {
+ EventType = eventType,
+ Source = source,
+ Message = message,
+ Severity = severity,
+ Data = data != null ? JsonSerializer.Serialize(data) : null,
+ CorrelationId = correlationId
+ };
+
+ context.Events.Add(eventEntity);
+ await context.SaveChangesAsync();
+ }
+
+ ///
+ /// Publishes an information event synchronously
+ ///
+ public static void PublishInfoEvent(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null)
+ {
+ PublishEvent(context, "Information", source, message, "Info", data, correlationId);
+ }
+
+ ///
+ /// Publishes a warning event synchronously
+ ///
+ public static void PublishWarningEvent(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null)
+ {
+ PublishEvent(context, "Warning", source, message, "Warning", data, correlationId);
+ }
+
+ ///
+ /// Publishes an error event synchronously
+ ///
+ public static void PublishErrorEvent(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null)
+ {
+ PublishEvent(context, "Error", source, message, "Error", data, correlationId);
+ }
+
+ ///
+ /// Publishes a custom event synchronously
+ ///
+ public static void PublishEvent(this EventDbContext context, string eventType, string source, string message, string severity = "Info", object? data = null, string? correlationId = null)
+ {
+ var eventEntity = new Event
+ {
+ EventType = eventType,
+ Source = source,
+ Message = message,
+ Severity = severity,
+ Data = data != null ? JsonSerializer.Serialize(data) : null,
+ CorrelationId = correlationId
+ };
+
+ context.Events.Add(eventEntity);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Gets recent events
+ ///
+ public static async Task> GetRecentEventsAsync(this EventDbContext context, int count = 100)
+ {
+ return await context.Events
+ .OrderByDescending(e => e.Timestamp)
+ .Take(count)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets events by type
+ ///
+ public static async Task> GetEventsByTypeAsync(this EventDbContext context, string eventType, int count = 100)
+ {
+ return await context.Events
+ .Where(e => e.EventType == eventType)
+ .OrderByDescending(e => e.Timestamp)
+ .Take(count)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets events by severity
+ ///
+ public static async Task> GetEventsBySeverityAsync(this EventDbContext context, string severity, int count = 100)
+ {
+ return await context.Events
+ .Where(e => e.Severity == severity)
+ .OrderByDescending(e => e.Timestamp)
+ .Take(count)
+ .ToListAsync();
+ }
+
+ ///
+ /// Cleans up events older than the specified number of days
+ ///
+ public static async Task CleanupOldEventsAsync(this EventDbContext context, int retentionDays = 30)
+ {
+ var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
+ var oldEvents = context.Events.Where(e => e.Timestamp < cutoffDate);
+ var count = await oldEvents.CountAsync();
+
+ if (count > 0)
+ {
+ context.Events.RemoveRange(oldEvents);
+ await context.SaveChangesAsync();
+ }
+
+ return count;
+ }
+}
+
+///
+/// Service collection extensions for event system
+///
+public static class EventServiceExtensions
+{
+ ///
+ /// Adds event system with SQLite database
+ ///
+ public static IServiceCollection AddEventSystem(this IServiceCollection services)
+ {
+ services.AddDbContext();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddHostedService();
+ services.AddSignalR();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/code/Infrastructure/Events/EventHub.cs b/code/Infrastructure/Events/EventHub.cs
new file mode 100644
index 00000000..f041e213
--- /dev/null
+++ b/code/Infrastructure/Events/EventHub.cs
@@ -0,0 +1,130 @@
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.Events;
+
+///
+/// SignalR hub for real-time event delivery
+///
+public class EventHub : Hub
+{
+ private readonly EventDbContext _context;
+ private readonly ILogger _logger;
+
+ public EventHub(EventDbContext context, ILogger logger)
+ {
+ _context = context;
+ _logger = logger;
+ }
+
+ ///
+ /// Client connects and subscribes to all events
+ ///
+ public async Task JoinEventsGroup()
+ {
+ await Groups.AddToGroupAsync(Context.ConnectionId, "Events");
+ _logger.LogTrace("Client {connectionId} joined Events group", Context.ConnectionId);
+ }
+
+ ///
+ /// Client unsubscribes from all events
+ ///
+ public async Task LeaveEventsGroup()
+ {
+ await Groups.RemoveFromGroupAsync(Context.ConnectionId, "Events");
+ _logger.LogTrace("Client {connectionId} left Events group", Context.ConnectionId);
+ }
+
+ ///
+ /// Client subscribes to specific severity level
+ ///
+ public async Task JoinSeverityGroup(string severity)
+ {
+ if (IsValidSeverity(severity))
+ {
+ await Groups.AddToGroupAsync(Context.ConnectionId, $"Events_{severity}");
+ _logger.LogTrace("Client {connectionId} joined severity group {severity}", Context.ConnectionId, severity);
+ }
+ }
+
+ ///
+ /// Client unsubscribes from specific severity level
+ ///
+ public async Task LeaveSeverityGroup(string severity)
+ {
+ if (IsValidSeverity(severity))
+ {
+ await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"Events_{severity}");
+ _logger.LogTrace("Client {connectionId} left severity group {severity}", Context.ConnectionId, severity);
+ }
+ }
+
+ ///
+ /// Client subscribes to specific event type
+ ///
+ public async Task JoinTypeGroup(string eventType)
+ {
+ if (!string.IsNullOrWhiteSpace(eventType))
+ {
+ await Groups.AddToGroupAsync(Context.ConnectionId, $"Events_{eventType}");
+ _logger.LogTrace("Client {connectionId} joined type group {eventType}", Context.ConnectionId, eventType);
+ }
+ }
+
+ ///
+ /// Client unsubscribes from specific event type
+ ///
+ public async Task LeaveTypeGroup(string eventType)
+ {
+ if (!string.IsNullOrWhiteSpace(eventType))
+ {
+ await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"Events_{eventType}");
+ _logger.LogTrace("Client {connectionId} left type group {eventType}", Context.ConnectionId, eventType);
+ }
+ }
+
+ ///
+ /// Client requests recent events (for initial load)
+ ///
+ public async Task GetRecentEvents(int count = 50)
+ {
+ try
+ {
+ var events = await _context.Events
+ .OrderByDescending(e => e.Timestamp)
+ .Take(Math.Min(count, 100)) // Cap at 100
+ .ToListAsync();
+
+ await Clients.Caller.SendAsync("RecentEventsReceived", events);
+ _logger.LogTrace("Sent {count} recent events to client {connectionId}", events.Count, Context.ConnectionId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send recent events to client {connectionId}", Context.ConnectionId);
+ }
+ }
+
+ ///
+ /// Client connection established
+ ///
+ public override async Task OnConnectedAsync()
+ {
+ _logger.LogTrace("Client {connectionId} connected to EventHub", Context.ConnectionId);
+ await base.OnConnectedAsync();
+ }
+
+ ///
+ /// Client disconnected
+ ///
+ public override async Task OnDisconnectedAsync(Exception? exception)
+ {
+ _logger.LogTrace("Client {connectionId} disconnected from EventHub", Context.ConnectionId);
+ await base.OnDisconnectedAsync(exception);
+ }
+
+ private static bool IsValidSeverity(string severity)
+ {
+ return severity is "Info" or "Warning" or "Error" or "Critical";
+ }
+}
\ No newline at end of file
diff --git a/code/Infrastructure/Events/EventPublisher.cs b/code/Infrastructure/Events/EventPublisher.cs
new file mode 100644
index 00000000..cfe6088f
--- /dev/null
+++ b/code/Infrastructure/Events/EventPublisher.cs
@@ -0,0 +1,121 @@
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Infrastructure.Events;
+
+///
+/// Service for publishing events to database and SignalR hub
+///
+public class EventPublisher
+{
+ private readonly EventDbContext _context;
+ private readonly IHubContext _hubContext;
+ private readonly ILogger _logger;
+
+ public EventPublisher(
+ EventDbContext context,
+ IHubContext hubContext,
+ ILogger logger)
+ {
+ _context = context;
+ _hubContext = hubContext;
+ _logger = logger;
+ }
+
+ ///
+ /// Publishes an event to database and SignalR clients
+ ///
+ public async Task PublishAsync(string eventType, string source, string message, string severity = "Info", object? data = null, string? correlationId = null)
+ {
+ var eventEntity = new Event
+ {
+ EventType = eventType,
+ Source = source,
+ Message = message,
+ Severity = severity,
+ Data = data != null ? JsonSerializer.Serialize(data) : null,
+ CorrelationId = correlationId
+ };
+
+ // Save to database
+ _context.Events.Add(eventEntity);
+ await _context.SaveChangesAsync();
+
+ // Send to SignalR clients
+ await NotifyClientsAsync(eventEntity);
+
+ _logger.LogTrace("Published event: {eventType} from {source}", eventType, source);
+ }
+
+ ///
+ /// Publishes an info event
+ ///
+ public async Task PublishInfoAsync(string source, string message, object? data = null, string? correlationId = null)
+ {
+ await PublishAsync("Information", source, message, "Info", data, correlationId);
+ }
+
+ ///
+ /// Publishes a warning event
+ ///
+ public async Task PublishWarningAsync(string source, string message, object? data = null, string? correlationId = null)
+ {
+ await PublishAsync("Warning", source, message, "Warning", data, correlationId);
+ }
+
+ ///
+ /// Publishes an error event
+ ///
+ public async Task PublishErrorAsync(string source, string message, object? data = null, string? correlationId = null)
+ {
+ await PublishAsync("Error", source, message, "Error", data, correlationId);
+ }
+
+ ///
+ /// Publishes a notification-related event (for HTTP notifications to Notifiarr/Apprise)
+ ///
+ public async Task PublishNotificationEventAsync(string provider, string message, bool success, object? data = null, string? correlationId = null)
+ {
+ var eventType = success ? "NotificationSent" : "NotificationFailed";
+ var severity = success ? "Info" : "Warning";
+
+ await PublishAsync(eventType, $"NotificationService.{provider}", message, severity, data, correlationId);
+ }
+
+ ///
+ /// Publishes an HTTP call event (for external API calls)
+ ///
+ public async Task PublishHttpCallEventAsync(string endpoint, string method, int statusCode, TimeSpan duration, object? data = null, string? correlationId = null)
+ {
+ var success = statusCode >= 200 && statusCode < 300;
+ var eventType = success ? "HttpCallSuccess" : "HttpCallFailed";
+ var severity = success ? "Info" : "Warning";
+ var message = $"{method} {endpoint} - {statusCode} ({duration.TotalMilliseconds}ms)";
+
+ await PublishAsync(eventType, "HttpClient", message, severity, data, correlationId);
+ }
+
+ private async Task NotifyClientsAsync(Event eventEntity)
+ {
+ try
+ {
+ // Send to all clients in Events group
+ await _hubContext.Clients.Group("Events").SendAsync("EventReceived", eventEntity);
+
+ // Send to severity-specific groups
+ await _hubContext.Clients.Group($"Events_{eventEntity.Severity}")
+ .SendAsync("EventReceived", eventEntity);
+
+ // Send to type-specific groups
+ await _hubContext.Clients.Group($"Events_{eventEntity.EventType}")
+ .SendAsync("EventReceived", eventEntity);
+
+ _logger.LogTrace("Sent event {eventId} to SignalR clients", eventEntity.Id);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", eventEntity.Id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/Infrastructure/Events/NotificationEventWrapper.cs b/code/Infrastructure/Events/NotificationEventWrapper.cs
new file mode 100644
index 00000000..fe729803
--- /dev/null
+++ b/code/Infrastructure/Events/NotificationEventWrapper.cs
@@ -0,0 +1,120 @@
+using Infrastructure.Verticals.Notifications;
+using Infrastructure.Verticals.Notifications.Models;
+using Microsoft.Extensions.Logging;
+using System.Diagnostics;
+
+namespace Infrastructure.Events;
+
+///
+/// Wrapper around NotificationService that publishes events for all notification calls
+///
+public class NotificationEventWrapper
+{
+ private readonly NotificationService _notificationService;
+ private readonly EventPublisher _eventPublisher;
+ private readonly ILogger _logger;
+
+ public NotificationEventWrapper(
+ NotificationService notificationService,
+ EventPublisher eventPublisher,
+ ILogger logger)
+ {
+ _notificationService = notificationService;
+ _eventPublisher = eventPublisher;
+ _logger = logger;
+ }
+
+ public async Task Notify(FailedImportStrikeNotification notification)
+ {
+ await NotifyWithEventLogging("FailedImportStrike", notification,
+ async () => await _notificationService.Notify(notification));
+ }
+
+ public async Task Notify(StalledStrikeNotification notification)
+ {
+ await NotifyWithEventLogging("StalledStrike", notification,
+ async () => await _notificationService.Notify(notification));
+ }
+
+ public async Task Notify(SlowStrikeNotification notification)
+ {
+ await NotifyWithEventLogging("SlowStrike", notification,
+ async () => await _notificationService.Notify(notification));
+ }
+
+ public async Task Notify(QueueItemDeletedNotification notification)
+ {
+ await NotifyWithEventLogging("QueueItemDeleted", notification,
+ async () => await _notificationService.Notify(notification));
+ }
+
+ public async Task Notify(DownloadCleanedNotification notification)
+ {
+ await NotifyWithEventLogging("DownloadCleaned", notification,
+ async () => await _notificationService.Notify(notification));
+ }
+
+ public async Task Notify(CategoryChangedNotification notification)
+ {
+ await NotifyWithEventLogging("CategoryChanged", notification,
+ async () => await _notificationService.Notify(notification));
+ }
+
+ private async Task NotifyWithEventLogging(string notificationType, T notification, Func notifyAction)
+ where T : class
+ {
+ var correlationId = Guid.NewGuid().ToString("N")[..8];
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ // Log notification attempt
+ await _eventPublisher.PublishInfoAsync(
+ source: "NotificationService",
+ message: $"Sending {notificationType} notification",
+ data: new { NotificationType = notificationType, Notification = notification },
+ correlationId: correlationId);
+
+ // Execute the notification
+ await notifyAction();
+
+ stopwatch.Stop();
+
+ // Log successful notification
+ await _eventPublisher.PublishInfoAsync(
+ source: "NotificationService",
+ message: $"{notificationType} notification sent successfully",
+ data: new {
+ NotificationType = notificationType,
+ Duration = stopwatch.ElapsedMilliseconds,
+ Success = true
+ },
+ correlationId: correlationId);
+
+ _logger.LogInformation("Successfully sent {notificationType} notification in {duration}ms",
+ notificationType, stopwatch.ElapsedMilliseconds);
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+
+ // Log failed notification
+ await _eventPublisher.PublishErrorAsync(
+ source: "NotificationService",
+ message: $"Failed to send {notificationType} notification: {ex.Message}",
+ data: new {
+ NotificationType = notificationType,
+ Duration = stopwatch.ElapsedMilliseconds,
+ Success = false,
+ Error = ex.Message,
+ StackTrace = ex.StackTrace
+ },
+ correlationId: correlationId);
+
+ _logger.LogError(ex, "Failed to send {notificationType} notification after {duration}ms",
+ notificationType, stopwatch.ElapsedMilliseconds);
+
+ throw; // Re-throw to maintain original behavior
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/Infrastructure/Infrastructure.csproj b/code/Infrastructure/Infrastructure.csproj
index c38775c4..7b801276 100644
--- a/code/Infrastructure/Infrastructure.csproj
+++ b/code/Infrastructure/Infrastructure.csproj
@@ -17,6 +17,12 @@
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+