commit 058d50f99b56f28421ec075eb94c687d4ec051fb Author: David Friedel Date: Thu Dec 25 09:08:13 2025 +0000 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 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.