using System.Text.Json.Serialization; using Microsoft.AspNetCore.SignalR.Client; namespace IronNotify.Client; /// /// Real-time notification client using SignalR for receiving live notifications /// public class NotifyRealTimeClient : IAsyncDisposable { private readonly HubConnection _connection; private readonly NotifyRealTimeOptions _options; private bool _disposed; /// /// Fired when a new notification is received /// public event EventHandler? NotificationReceived; /// /// Fired when the unread count changes /// public event EventHandler? UnreadCountChanged; /// /// Fired when a notification is marked as read /// public event EventHandler? NotificationRead; /// /// Fired when an event status changes (acknowledged, resolved) /// public event EventHandler? EventStatusChanged; /// /// Fired when the connection state changes /// public event EventHandler? ConnectionStateChanged; /// /// Current connection state /// public HubConnectionState State => _connection.State; /// /// Whether the client is currently connected /// public bool IsConnected => _connection.State == HubConnectionState.Connected; public NotifyRealTimeClient(NotifyRealTimeOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); var hubUrl = options.BaseUrl.TrimEnd('/') + "/hubs/notifications"; _connection = new HubConnectionBuilder() .WithUrl(hubUrl, httpOptions => { httpOptions.Headers.Add("Authorization", $"Bearer {options.ApiKey}"); }) .WithAutomaticReconnect(new RetryPolicy(options.MaxReconnectAttempts)) .Build(); SetupEventHandlers(); } public NotifyRealTimeClient(string apiKey, string baseUrl = "https://ironnotify.com") : this(new NotifyRealTimeOptions { ApiKey = apiKey, BaseUrl = baseUrl }) { } private void SetupEventHandlers() { _connection.On("NotificationReceived", notification => { NotificationReceived?.Invoke(this, new NotificationReceivedEventArgs(notification)); }); _connection.On("UnreadCountChanged", count => { UnreadCountChanged?.Invoke(this, new UnreadCountChangedEventArgs(count)); }); _connection.On("NotificationRead", payload => { NotificationRead?.Invoke(this, new NotificationReadEventArgs(payload.EventId, payload.ReadAt)); }); _connection.On("EventStatusChanged", payload => { EventStatusChanged?.Invoke(this, new EventStatusChangedEventArgs(payload.EventId, payload.Status, payload.UpdatedAt)); }); _connection.Reconnecting += error => { ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs( HubConnectionState.Reconnecting, error?.Message)); return Task.CompletedTask; }; _connection.Reconnected += connectionId => { ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs( HubConnectionState.Connected, null)); // Re-subscribe to user notifications after reconnect if (_options.UserId.HasValue) { _ = SubscribeToUserAsync(_options.UserId.Value); } return Task.CompletedTask; }; _connection.Closed += error => { ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs( HubConnectionState.Disconnected, error?.Message)); return Task.CompletedTask; }; } /// /// Connect to the notification hub /// public async Task ConnectAsync(CancellationToken cancellationToken = default) { if (_connection.State == HubConnectionState.Disconnected) { await _connection.StartAsync(cancellationToken); ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs( HubConnectionState.Connected, null)); } } /// /// Disconnect from the notification hub /// public async Task DisconnectAsync(CancellationToken cancellationToken = default) { if (_connection.State == HubConnectionState.Connected) { await _connection.StopAsync(cancellationToken); } } /// /// Subscribe to notifications for a specific user /// public async Task SubscribeToUserAsync(Guid userId, CancellationToken cancellationToken = default) { await EnsureConnectedAsync(cancellationToken); await _connection.InvokeAsync("SubscribeToUser", userId, cancellationToken); _options.UserId = userId; } /// /// Unsubscribe from user notifications /// public async Task UnsubscribeFromUserAsync(Guid userId, CancellationToken cancellationToken = default) { await EnsureConnectedAsync(cancellationToken); await _connection.InvokeAsync("UnsubscribeFromUser", userId, cancellationToken); } /// /// Subscribe to notifications for a specific app /// public async Task SubscribeToAppAsync(Guid appId, CancellationToken cancellationToken = default) { await EnsureConnectedAsync(cancellationToken); await _connection.InvokeAsync("SubscribeToApp", appId, cancellationToken); } /// /// Unsubscribe from app notifications /// public async Task UnsubscribeFromAppAsync(Guid appId, CancellationToken cancellationToken = default) { await EnsureConnectedAsync(cancellationToken); await _connection.InvokeAsync("UnsubscribeFromApp", appId, cancellationToken); } /// /// Mark a notification as read (triggers server-side update and broadcasts to other clients) /// public async Task MarkAsReadAsync(Guid userId, Guid eventId, CancellationToken cancellationToken = default) { await EnsureConnectedAsync(cancellationToken); await _connection.InvokeAsync("MarkAsRead", userId, eventId, cancellationToken); } private async Task EnsureConnectedAsync(CancellationToken cancellationToken) { if (_connection.State == HubConnectionState.Disconnected) { await ConnectAsync(cancellationToken); } } public async ValueTask DisposeAsync() { if (!_disposed) { await _connection.DisposeAsync(); _disposed = true; } } private class RetryPolicy : IRetryPolicy { private readonly int _maxAttempts; public RetryPolicy(int maxAttempts) => _maxAttempts = maxAttempts; public TimeSpan? NextRetryDelay(RetryContext retryContext) { if (retryContext.PreviousRetryCount >= _maxAttempts) return null; // Exponential backoff: 0s, 2s, 4s, 8s, 16s, max 30s var delay = Math.Min(Math.Pow(2, retryContext.PreviousRetryCount), 30); return TimeSpan.FromSeconds(delay); } } } public class NotifyRealTimeOptions { public string ApiKey { get; set; } = string.Empty; public string BaseUrl { get; set; } = "https://ironnotify.com"; public Guid? UserId { get; set; } public int MaxReconnectAttempts { get; set; } = 5; } #region Event Args public class NotificationReceivedEventArgs : EventArgs { public RealTimeNotification Notification { get; } public NotificationReceivedEventArgs(RealTimeNotification notification) { Notification = notification; } } public class UnreadCountChangedEventArgs : EventArgs { public int UnreadCount { get; } public UnreadCountChangedEventArgs(int unreadCount) { UnreadCount = unreadCount; } } public class NotificationReadEventArgs : EventArgs { public Guid EventId { get; } public DateTime ReadAt { get; } public NotificationReadEventArgs(Guid eventId, DateTime readAt) { EventId = eventId; ReadAt = readAt; } } public class EventStatusChangedEventArgs : EventArgs { public Guid EventId { get; } public string Status { get; } public DateTime UpdatedAt { get; } public EventStatusChangedEventArgs(Guid eventId, string status, DateTime updatedAt) { EventId = eventId; Status = status; UpdatedAt = updatedAt; } } public class ConnectionStateChangedEventArgs : EventArgs { public HubConnectionState State { get; } public string? Error { get; } public ConnectionStateChangedEventArgs(HubConnectionState state, string? error) { State = state; Error = error; } } #endregion #region DTOs public class RealTimeNotification { [JsonPropertyName("eventId")] public Guid EventId { get; set; } [JsonPropertyName("deliveryId")] public Guid DeliveryId { get; set; } [JsonPropertyName("eventType")] public string EventType { get; set; } = string.Empty; [JsonPropertyName("severity")] public string Severity { get; set; } = string.Empty; [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; [JsonPropertyName("message")] public string? Message { get; set; } [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } [JsonPropertyName("appId")] public Guid AppId { get; set; } [JsonPropertyName("appName")] public string? AppName { get; set; } [JsonPropertyName("appSlug")] public string? AppSlug { get; set; } [JsonPropertyName("data")] public Dictionary? Data { get; set; } [JsonPropertyName("actions")] public List? Actions { get; set; } } public class RealTimeAction { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; [JsonPropertyName("url")] public string? Url { get; set; } [JsonPropertyName("isPrimary")] public bool IsPrimary { get; set; } } internal class NotificationReadPayload { [JsonPropertyName("eventId")] public Guid EventId { get; set; } [JsonPropertyName("readAt")] public DateTime ReadAt { get; set; } } internal class EventStatusPayload { [JsonPropertyName("eventId")] public Guid EventId { get; set; } [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; [JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; } } #endregion