From 058d50f99b56f28421ec075eb94c687d4ec051fb Mon Sep 17 00:00:00 2001 From: David Friedel Date: Thu, 25 Dec 2025 09:08:13 +0000 Subject: [PATCH] Initial commit: IronNotify.Client SDK Event notification SDK for .NET applications with: - Multi-channel notifications (push, email, SMS, webhooks) - Fluent event builder API - Offline queue with automatic retry - Real-time notifications via SignalR --- .gitignore | 6 + Class1.cs | 7 + IronNotify.Client.csproj | 25 +++ LICENSE | 21 +++ NotifyClient.cs | 335 ++++++++++++++++++++++++++++++++++ NotifyRealTimeClient.cs | 375 +++++++++++++++++++++++++++++++++++++++ OfflineQueue.cs | 249 ++++++++++++++++++++++++++ README.md | 206 +++++++++++++++++++++ 8 files changed, 1224 insertions(+) create mode 100644 .gitignore create mode 100755 Class1.cs create mode 100755 IronNotify.Client.csproj create mode 100644 LICENSE create mode 100755 NotifyClient.cs create mode 100644 NotifyRealTimeClient.cs create mode 100644 OfflineQueue.cs create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..776e6f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +*.user +*.suo +.vs/ +*.DotSettings.user diff --git a/Class1.cs b/Class1.cs new file mode 100755 index 0000000..cba4c84 --- /dev/null +++ b/Class1.cs @@ -0,0 +1,7 @@ +namespace IronNotify.Client +{ + public class Class1 + { + + } +} diff --git a/IronNotify.Client.csproj b/IronNotify.Client.csproj new file mode 100755 index 0000000..1e01db8 --- /dev/null +++ b/IronNotify.Client.csproj @@ -0,0 +1,25 @@ + + + + net8.0;net9.0 + enable + enable + + + IronNotify.Client + 1.0.0 + David H Friedel Jr + MarketAlly + Client SDK for IronNotify - Event Notification Platform. Send events, alerts, and notifications from your applications. + notifications;events;alerts;monitoring;webhooks + MIT + https://github.com/ironservices/ironnotify-client + https://www.ironnotify.com + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..79f68de --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 IronServices + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NotifyClient.cs b/NotifyClient.cs new file mode 100755 index 0000000..e655162 --- /dev/null +++ b/NotifyClient.cs @@ -0,0 +1,335 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IronNotify.Client; + +public class NotifyClient : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly NotifyClientOptions _options; + private readonly NotifyOfflineQueue? _offlineQueue; + private bool _disposed; + + /// + /// Gets the offline queue for accessing queued items. + /// + public NotifyOfflineQueue? OfflineQueue => _offlineQueue; + + public NotifyClient(NotifyClientOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + + if (string.IsNullOrEmpty(options.ApiKey)) + throw new ArgumentException("API key is required", nameof(options)); + + _httpClient = new HttpClient + { + BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/") + }; + + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", options.ApiKey); + _httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + + if (options.Timeout.HasValue) + _httpClient.Timeout = options.Timeout.Value; + + // Initialize offline queue if enabled + if (options.EnableOfflineQueue) + { + _offlineQueue = new NotifyOfflineQueue( + SendQueuedEventAsync, + options.OfflineQueueDirectory, + options.MaxOfflineQueueSize, + enableAutoRetry: true); + } + } + + private async Task SendQueuedEventAsync(QueuedEvent queuedEvent) + { + var result = await NotifyAsync(queuedEvent.Request, skipQueue: true); + return result.Success; + } + + public NotifyClient(string apiKey, string? appSlug = null) + : this(new NotifyClientOptions { ApiKey = apiKey, DefaultAppSlug = appSlug }) + { + } + + /// + /// Send an event notification + /// + public Task NotifyAsync(NotifyEventRequest request, CancellationToken cancellationToken = default) + { + return NotifyAsync(request, skipQueue: false, cancellationToken); + } + + private async Task NotifyAsync(NotifyEventRequest request, bool skipQueue, CancellationToken cancellationToken = default) + { + var apiRequest = new + { + appSlug = request.AppSlug ?? _options.DefaultAppSlug, + appId = request.AppId, + eventType = request.EventType, + severity = request.Severity.ToString(), + source = request.Source ?? _options.DefaultSource, + title = request.Title, + message = request.Message, + entityId = request.EntityId, + metadata = request.Metadata, + actions = request.Actions?.Select(a => new + { + actionId = a.ActionId, + label = a.Label, + webhookUrl = a.WebhookUrl + }) + }; + + try + { + var response = await _httpClient.PostAsJsonAsync("api/v1/events", apiRequest, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return new EventResult + { + Success = true, + EventId = result?.Id, + Status = result?.Status + }; + } + + var error = await response.Content.ReadAsStringAsync(cancellationToken); + + // Queue for retry if enabled and not already retrying + if (!skipQueue && _offlineQueue != null) + { + _offlineQueue.Enqueue(request); + } + + return new EventResult + { + Success = false, + Error = error, + Queued = !skipQueue && _offlineQueue != null + }; + } + catch (Exception ex) + { + // Queue for retry on network errors + if (!skipQueue && _offlineQueue != null) + { + _offlineQueue.Enqueue(request); + } + + return new EventResult + { + Success = false, + Error = ex.Message, + Queued = !skipQueue && _offlineQueue != null + }; + } + } + + /// + /// Send a simple event notification + /// + public Task NotifyAsync( + string eventType, + string title, + Severity severity = Severity.Info, + string? message = null, + Dictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return NotifyAsync(new NotifyEventRequest + { + EventType = eventType, + Title = title, + Severity = severity, + Message = message, + Metadata = metadata + }, cancellationToken); + } + + /// + /// Create a fluent event builder + /// + public EventBuilder Event(string eventType) => new(this, eventType); + + public void Dispose() + { + if (!_disposed) + { + _offlineQueue?.Dispose(); + _httpClient.Dispose(); + _disposed = true; + } + } +} + +public class NotifyClientOptions +{ + public string ApiKey { get; set; } = string.Empty; + public string BaseUrl { get; set; } = "https://ironnotify.com"; + public string? DefaultAppSlug { get; set; } + public string? DefaultSource { get; set; } + public TimeSpan? Timeout { get; set; } + + /// + /// Enable offline queue for failed sends. Default is true. + /// + public bool EnableOfflineQueue { get; set; } = true; + + /// + /// Directory to store offline queue. Defaults to LocalApplicationData/IronNotify/Queue. + /// + public string? OfflineQueueDirectory { get; set; } + + /// + /// Maximum items to store in offline queue. Default: 500. + /// + public int MaxOfflineQueueSize { get; set; } = 500; +} + +public class NotifyEventRequest +{ + public Guid? AppId { get; set; } + public string? AppSlug { get; set; } + public string EventType { get; set; } = string.Empty; + public Severity Severity { get; set; } = Severity.Info; + public string? Source { get; set; } + public string Title { get; set; } = string.Empty; + public string? Message { get; set; } + public string? EntityId { get; set; } + public Dictionary? Metadata { get; set; } + public List? Actions { get; set; } +} + +public class EventAction +{ + public string ActionId { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string? WebhookUrl { get; set; } +} + +public enum Severity +{ + Info, + Warning, + High, + Critical +} + +public class EventResult +{ + public bool Success { get; set; } + public Guid? EventId { get; set; } + public string? Status { get; set; } + public string? Error { get; set; } + + /// + /// Whether the event was queued for later retry (when offline queue is enabled). + /// + public bool Queued { get; set; } +} + +internal class EventResponse +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; +} + +/// +/// Fluent builder for creating events +/// +public class EventBuilder +{ + private readonly NotifyClient _client; + private readonly NotifyEventRequest _request; + + internal EventBuilder(NotifyClient client, string eventType) + { + _client = client; + _request = new NotifyEventRequest { EventType = eventType }; + } + + public EventBuilder WithSeverity(Severity severity) + { + _request.Severity = severity; + return this; + } + + public EventBuilder WithTitle(string title) + { + _request.Title = title; + return this; + } + + public EventBuilder WithMessage(string message) + { + _request.Message = message; + return this; + } + + public EventBuilder WithSource(string source) + { + _request.Source = source; + return this; + } + + public EventBuilder WithEntityId(string entityId) + { + _request.EntityId = entityId; + return this; + } + + public EventBuilder WithApp(string slug) + { + _request.AppSlug = slug; + return this; + } + + public EventBuilder WithApp(Guid appId) + { + _request.AppId = appId; + return this; + } + + public EventBuilder WithMetadata(string key, object value) + { + _request.Metadata ??= new Dictionary(); + _request.Metadata[key] = value; + return this; + } + + public EventBuilder WithMetadata(Dictionary metadata) + { + _request.Metadata = metadata; + return this; + } + + public EventBuilder WithAction(string actionId, string label, string? webhookUrl = null) + { + _request.Actions ??= new List(); + _request.Actions.Add(new EventAction + { + ActionId = actionId, + Label = label, + WebhookUrl = webhookUrl + }); + return this; + } + + public Task SendAsync(CancellationToken cancellationToken = default) + { + return _client.NotifyAsync(_request, cancellationToken); + } +} diff --git a/NotifyRealTimeClient.cs b/NotifyRealTimeClient.cs new file mode 100644 index 0000000..643e579 --- /dev/null +++ b/NotifyRealTimeClient.cs @@ -0,0 +1,375 @@ +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 diff --git a/OfflineQueue.cs b/OfflineQueue.cs new file mode 100644 index 0000000..3dafee4 --- /dev/null +++ b/OfflineQueue.cs @@ -0,0 +1,249 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IronNotify.Client; + +/// +/// File-based offline queue for notification events. +/// Persists failed sends to disk and retries with exponential backoff. +/// +public class NotifyOfflineQueue : IDisposable +{ + private readonly string _queueFilePath; + private readonly int _maxQueueSize; + private readonly object _fileLock = new(); + private readonly SemaphoreSlim _retrySemaphore = new(1, 1); + private readonly Timer? _retryTimer; + private readonly Func> _sendFunc; + private int _retryAttempt; + private bool _disposed; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + /// + /// Creates a new offline queue. + /// + /// Function to send an event to server. Returns true on success. + /// Directory to store queue file. Defaults to app data. + /// Maximum items to store. Oldest items dropped when exceeded. + /// Whether to automatically retry sending queued items. + public NotifyOfflineQueue( + Func> sendFunc, + string? queueDirectory = null, + int maxQueueSize = 500, + bool enableAutoRetry = true) + { + _sendFunc = sendFunc; + _maxQueueSize = maxQueueSize; + + var directory = queueDirectory ?? GetDefaultQueueDirectory(); + Directory.CreateDirectory(directory); + _queueFilePath = Path.Combine(directory, "notify_queue.json"); + + if (enableAutoRetry) + { + // Start retry timer - initial delay 30 seconds, then every 60 seconds + _retryTimer = new Timer( + _ => _ = RetryQueuedItemsAsync(), + null, + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60)); + } + } + + /// + /// Number of items currently in the queue. + /// + public int Count + { + get + { + lock (_fileLock) + { + var items = LoadQueue(); + return items.Count; + } + } + } + + /// + /// Enqueue an event that failed to send. + /// + public void Enqueue(NotifyEventRequest request) + { + lock (_fileLock) + { + var queue = LoadQueue(); + queue.Add(new QueuedEvent + { + QueuedAt = DateTime.UtcNow, + Request = request + }); + + // Trim to max size (remove oldest) + while (queue.Count > _maxQueueSize) + { + queue.RemoveAt(0); + } + + SaveQueue(queue); + } + } + + /// + /// Try to send all queued items. + /// + public async Task RetryQueuedItemsAsync() + { + if (!await _retrySemaphore.WaitAsync(0)) + { + // Already retrying + return 0; + } + + int sentCount = 0; + + try + { + List items; + lock (_fileLock) + { + items = LoadQueue(); + } + + if (items.Count == 0) + { + _retryAttempt = 0; + return 0; + } + + var remaining = new List(); + + foreach (var item in items) + { + var success = await _sendFunc(item); + if (success) + { + sentCount++; + } + else + { + remaining.Add(item); + } + } + + // Save remaining items + lock (_fileLock) + { + SaveQueue(remaining); + } + + if (remaining.Count == 0) + { + _retryAttempt = 0; + } + else + { + _retryAttempt++; + } + + return sentCount; + } + catch + { + _retryAttempt++; + return 0; + } + finally + { + _retrySemaphore.Release(); + } + } + + /// + /// Clear all queued items without sending. + /// + public void Clear() + { + lock (_fileLock) + { + SaveQueue(new List()); + } + } + + /// + /// Get all queued items (for display/export). + /// + public List GetQueuedItems() + { + lock (_fileLock) + { + return LoadQueue(); + } + } + + private List LoadQueue() + { + try + { + if (!File.Exists(_queueFilePath)) + { + return new List(); + } + + var json = File.ReadAllText(_queueFilePath); + return JsonSerializer.Deserialize>(json, JsonOptions) + ?? new List(); + } + catch + { + return new List(); + } + } + + private void SaveQueue(List items) + { + try + { + var json = JsonSerializer.Serialize(items, JsonOptions); + File.WriteAllText(_queueFilePath, json); + } + catch + { + // Ignore write errors + } + } + + private static string GetDefaultQueueDirectory() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "IronNotify", + "Queue"); + } + + public void Dispose() + { + if (!_disposed) + { + _retryTimer?.Dispose(); + _retrySemaphore.Dispose(); + _disposed = true; + } + } +} + +/// +/// A queued notification event with metadata. +/// +public class QueuedEvent +{ + public DateTime QueuedAt { get; set; } + public int RetryCount { get; set; } + public NotifyEventRequest Request { get; set; } = new(); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..07e3bcf --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# IronNotify.Client + +Event notification SDK for .NET applications. Send alerts via push, email, SMS, webhooks, and in-app notifications with offline queue support. + +[![NuGet](https://img.shields.io/nuget/v/IronNotify.Client.svg)](https://www.nuget.org/packages/IronNotify.Client) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +```bash +dotnet add package IronNotify.Client +``` + +## Quick Start + +### Simple Usage + +```csharp +using IronNotify.Client; + +// Create client with API key +var client = new NotifyClient("your-api-key", appSlug: "my-app"); + +// Send an event +var result = await client.NotifyAsync( + eventType: "order.completed", + title: "New Order Received", + severity: Severity.Info, + message: "Order #12345 has been placed" +); + +if (result.Success) +{ + Console.WriteLine($"Event sent! ID: {result.EventId}"); +} +``` + +### Fluent Builder + +```csharp +var result = await client + .Event("payment.failed") + .WithSeverity(Severity.High) + .WithTitle("Payment Failed") + .WithMessage("Customer payment was declined") + .WithEntityId("order-12345") + .WithMetadata("amount", 99.99) + .WithMetadata("currency", "USD") + .WithAction("retry", "Retry Payment", webhookUrl: "https://api.example.com/retry") + .SendAsync(); +``` + +## Configuration Options + +```csharp +var client = new NotifyClient(new NotifyClientOptions +{ + // Required + ApiKey = "your-api-key", + + // Optional + DefaultAppSlug = "my-app", + DefaultSource = "backend-service", + BaseUrl = "https://ironnotify.com", + Timeout = TimeSpan.FromSeconds(30), + + // Offline queue (enabled by default) + EnableOfflineQueue = true, + MaxOfflineQueueSize = 500, + OfflineQueueDirectory = null // Uses LocalApplicationData by default +}); +``` + +## Severity Levels + +```csharp +Severity.Info // Informational events +Severity.Warning // Warnings that may need attention +Severity.High // Important events requiring action +Severity.Critical // Critical events requiring immediate attention +``` + +## Metadata + +Add custom key-value pairs to events: + +```csharp +await client.NotifyAsync(new NotifyEventRequest +{ + EventType = "user.signup", + Title = "New User Registration", + Severity = Severity.Info, + Metadata = new Dictionary + { + ["userId"] = "user-123", + ["plan"] = "pro", + ["referrer"] = "google" + } +}); +``` + +## Actions + +Add clickable actions to notifications: + +```csharp +await client + .Event("server.down") + .WithSeverity(Severity.Critical) + .WithTitle("Server Unreachable") + .WithAction("restart", "Restart Server", webhookUrl: "https://api.example.com/restart") + .WithAction("acknowledge", "Acknowledge") + .SendAsync(); +``` + +## Offline Queue + +Events are automatically queued when the network is unavailable: + +```csharp +var result = await client.NotifyAsync("event.type", "Title"); + +if (result.Queued) +{ + Console.WriteLine("Event queued for retry when online"); +} + +// Check queue status +if (client.OfflineQueue != null) +{ + Console.WriteLine($"Queued items: {client.OfflineQueue.Count}"); +} +``` + +The offline queue: +- Persists events to disk +- Automatically retries when connectivity is restored +- Respects `MaxOfflineQueueSize` limit +- Works across app restarts + +## Real-Time Notifications + +For receiving real-time notifications, use `NotifyRealTimeClient`: + +```csharp +using IronNotify.Client; + +var realtime = new NotifyRealTimeClient( + hubUrl: "https://ironnotify.com/hubs/events", + apiKey: "your-api-key" +); + +// Subscribe to events +realtime.OnEventReceived += (sender, evt) => +{ + Console.WriteLine($"Received: {evt.Title} ({evt.Severity})"); +}; + +// Connect +await realtime.ConnectAsync(); + +// Join app channel +await realtime.JoinAppAsync("my-app"); + +// Disconnect when done +await realtime.DisconnectAsync(); +``` + +## Dependency Injection + +```csharp +// Register in DI container +services.AddSingleton(sp => + new NotifyClient(new NotifyClientOptions + { + ApiKey = configuration["IronNotify:ApiKey"], + DefaultAppSlug = configuration["IronNotify:AppSlug"] + })); +``` + +## Common Event Types + +```csharp +// User events +"user.signup", "user.login", "user.password_reset" + +// Order events +"order.created", "order.completed", "order.cancelled" + +// Payment events +"payment.succeeded", "payment.failed", "payment.refunded" + +// System events +"server.error", "deployment.completed", "backup.finished" +``` + +## Links + +- [Documentation](https://www.ironnotify.com/docs) +- [Dashboard](https://www.ironnotify.com) +- [API Reference](https://www.ironnotify.com/docs/api) +- [Support](https://www.ironnotify.com/app/tickets) + +## License + +MIT License - see [LICENSE](LICENSE) for details.