From 961d963feb97203063893e9ad6e85dfab61e72f3 Mon Sep 17 00:00:00 2001 From: David Friedel Date: Thu, 25 Dec 2025 10:50:06 +0000 Subject: [PATCH] Implement IronNotify Go SDK - Client with global and instance-based usage - Fluent EventBuilder for complex notifications - HTTP transport with context support - Offline queue with JSON persistence - Severity levels and notification actions - Thread-safe operations with sync.RWMutex - Full README with examples --- .gitignore | 28 ++++ README.md | 293 +++++++++++++++++++++++++++++++++++++++- builder.go | 163 ++++++++++++++++++++++ client.go | 371 +++++++++++++++++++++++++++++++++++++++++++++++++++ config.go | 86 ++++++++++++ go.mod | 9 ++ queue.go | 124 +++++++++++++++++ transport.go | 222 ++++++++++++++++++++++++++++++ types.go | 80 +++++++++++ 9 files changed, 1374 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 builder.go create mode 100644 client.go create mode 100644 config.go create mode 100644 go.mod create mode 100644 queue.go create mode 100644 transport.go create mode 100644 types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f074cb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of go coverage tool +*.out + +# Dependency directories +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Local storage +.ironnotify/ diff --git a/README.md b/README.md index 694bd73..1f6ae21 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,291 @@ -# ironnotify-go -IronNotify SDK for Go - Event notifications and alerts +# IronNotify SDK for Go + +Event notifications and alerts SDK for Go applications. Send notifications, receive real-time updates, and manage notification state. + +[![Go Reference](https://pkg.go.dev/badge/github.com/IronServices/ironnotify-go.svg)](https://pkg.go.dev/github.com/IronServices/ironnotify-go) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +```bash +go get github.com/IronServices/ironnotify-go +``` + +## Quick Start + +### Send a Simple Notification + +```go +package main + +import ( + "fmt" + ironnotify "github.com/IronServices/ironnotify-go" +) + +func main() { + // Initialize + err := ironnotify.Init("ak_live_xxxxx") + if err != nil { + panic(err) + } + defer ironnotify.Close() + + // Send a simple notification + result := ironnotify.Notify( + "order.created", + "New Order Received", + ironnotify.WithMessage("Order #1234 has been placed"), + ironnotify.WithSeverity(ironnotify.SeveritySuccess), + ironnotify.WithMetadataOpt(map[string]any{ + "order_id": "1234", + "amount": 99.99, + }), + ) + + if result.Success { + fmt.Println("Notification sent:", result.NotificationID) + } +} +``` + +### Fluent Event Builder + +```go +package main + +import ( + "time" + ironnotify "github.com/IronServices/ironnotify-go" +) + +func main() { + ironnotify.Init("ak_live_xxxxx") + defer ironnotify.Close() + + // Build complex notifications with the fluent API + result := ironnotify.Event("payment.failed"). + WithTitle("Payment Failed"). + WithMessage("Payment could not be processed"). + WithSeverity(ironnotify.SeverityError). + WithMetadata("order_id", "1234"). + WithMetadata("reason", "Card declined"). + WithAction("Retry Payment", ironnotify.ActionURL("/orders/1234/retry"), ironnotify.ActionStyle("primary")). + WithAction("Contact Support", ironnotify.ActionHandler("open_support")). + ForUser("user-123"). + WithDeduplicationKey("payment-failed-1234"). + ExpiresIn(24 * time.Hour). + Send() + + if result.Queued { + // Notification was queued for later (offline mode) + } +} +``` + +### Using the Client Directly + +```go +package main + +import ( + ironnotify "github.com/IronServices/ironnotify-go" +) + +func main() { + client, err := ironnotify.NewClient(ironnotify.Options{ + APIKey: "ak_live_xxxxx", + Debug: true, + }) + if err != nil { + panic(err) + } + defer client.Close() + + // Send notification + result := client.Notify("event.type", "Title") + + // Use event builder + result = client.Event("event.type"). + WithTitle("Title"). + Send() + + // Get notifications + notifications, err := client.GetNotifications(10, 0, false) +} +``` + +## Configuration + +```go +import ironnotify "github.com/IronServices/ironnotify-go" + +client, _ := ironnotify.NewClient(ironnotify.Options{ + APIKey: "ak_live_xxxxx", + APIBaseURL: "https://api.ironnotify.com", + WebSocketURL: "wss://ws.ironnotify.com", + Debug: false, + EnableOfflineQueue: true, + MaxOfflineQueueSize: 100, + AutoReconnect: true, + MaxReconnectAttempts: 5, + ReconnectDelay: time.Second, + HTTPTimeout: 30 * time.Second, +}) +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `APIKey` | string | required | Your API key (ak_live_xxx or ak_test_xxx) | +| `APIBaseURL` | string | https://api.ironnotify.com | API base URL | +| `WebSocketURL` | string | wss://ws.ironnotify.com | WebSocket URL | +| `Debug` | bool | false | Enable debug logging | +| `EnableOfflineQueue` | bool | true | Queue notifications when offline | +| `MaxOfflineQueueSize` | int | 100 | Max offline queue size | +| `AutoReconnect` | bool | true | Auto-reconnect WebSocket | +| `MaxReconnectAttempts` | int | 5 | Max reconnection attempts | +| `ReconnectDelay` | Duration | 1s | Base reconnection delay | +| `HTTPTimeout` | Duration | 30s | HTTP request timeout | + +## Severity Levels + +```go +ironnotify.SeverityInfo // "info" +ironnotify.SeveritySuccess // "success" +ironnotify.SeverityWarning // "warning" +ironnotify.SeverityError // "error" +ironnotify.SeverityCritical // "critical" +``` + +## Actions + +```go +ironnotify.Event("order.shipped"). + WithTitle("Order Shipped"). + WithAction("Track Package", + ironnotify.ActionURL("https://tracking.example.com/123"), + ironnotify.ActionStyle("primary")). + WithAction("View Order", + ironnotify.ActionHandler("view_order")). + Send() +``` + +## Deduplication + +Prevent duplicate notifications: + +```go +ironnotify.Event("reminder"). + WithTitle("Daily Reminder"). + WithDeduplicationKey("daily-reminder-2024-01-15"). + Send() +``` + +## Grouping + +Group related notifications: + +```go +ironnotify.Event("comment.new"). + WithTitle("New Comment"). + WithGroupKey("post-123-comments"). + Send() +``` + +## Expiration + +```go +// Expires in 1 hour +ironnotify.Event("flash_sale"). + WithTitle("Flash Sale!"). + ExpiresIn(time.Hour). + Send() + +// Expires at specific time +ironnotify.Event("event_reminder"). + WithTitle("Event Tomorrow"). + ExpiresAt(time.Now().Add(24 * time.Hour)). + Send() +``` + +## Managing Notifications + +### Get Notifications + +```go +// Get all notifications +notifications, err := client.GetNotifications(0, 0, false) + +// With pagination and filters +unread, err := client.GetNotifications(10, 0, true) // limit=10, unread only + +// With context +notifications, err := client.GetNotificationsContext(ctx, 10, 0, false) +``` + +### Mark as Read + +```go +// Mark single notification +err := client.MarkAsRead("notification-id") + +// Mark all as read +err := client.MarkAllAsRead() +``` + +### Get Unread Count + +```go +count, err := client.GetUnreadCount() +fmt.Printf("You have %d unread notifications\n", count) +``` + +## Real-Time Notifications + +```go +client.OnNotification(func(n ironnotify.Notification) { + fmt.Printf("New notification: %s\n", n.Title) +}) + +client.OnUnreadCountChange(func(count int) { + fmt.Printf("Unread count: %d\n", count) +}) + +client.OnConnectionStateChange(func(state ironnotify.ConnectionState) { + fmt.Printf("Connection state: %s\n", state) +}) + +client.Connect() +client.SubscribeToUser("user-123") +client.SubscribeToApp() +``` + +## Offline Support + +Notifications are automatically queued when offline: + +```go +// This will be queued if offline +ironnotify.Notify("event", "Title") + +// Manually flush the queue +ironnotify.Flush() + +// Or with context +client.FlushContext(ctx) +``` + +## Thread Safety + +The client is thread-safe and can be used from multiple goroutines concurrently. + +## Links + +- [Documentation](https://www.ironnotify.com/docs) +- [Dashboard](https://www.ironnotify.com) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..aa2c385 --- /dev/null +++ b/builder.go @@ -0,0 +1,163 @@ +package ironnotify + +import "time" + +// EventBuilder provides a fluent API for building notifications. +type EventBuilder struct { + client *Client + eventType string + title string + message string + severity SeverityLevel + metadata map[string]any + actions []NotificationAction + userID string + groupKey string + deduplicationKey string + expiresAt *time.Time +} + +// newEventBuilder creates a new EventBuilder. +func newEventBuilder(client *Client, eventType string) *EventBuilder { + return &EventBuilder{ + client: client, + eventType: eventType, + severity: SeverityInfo, + metadata: make(map[string]any), + actions: make([]NotificationAction, 0), + } +} + +// WithTitle sets the notification title. +func (b *EventBuilder) WithTitle(title string) *EventBuilder { + b.title = title + return b +} + +// WithMessage sets the notification message. +func (b *EventBuilder) WithMessage(message string) *EventBuilder { + b.message = message + return b +} + +// WithSeverity sets the severity level. +func (b *EventBuilder) WithSeverity(severity SeverityLevel) *EventBuilder { + b.severity = severity + return b +} + +// WithMetadata adds metadata to the notification. +func (b *EventBuilder) WithMetadata(key string, value any) *EventBuilder { + b.metadata[key] = value + return b +} + +// WithMetadataMap adds multiple metadata entries. +func (b *EventBuilder) WithMetadataMap(metadata map[string]any) *EventBuilder { + for k, v := range metadata { + b.metadata[k] = v + } + return b +} + +// WithAction adds an action button to the notification. +func (b *EventBuilder) WithAction(label string, opts ...ActionOption) *EventBuilder { + action := NotificationAction{Label: label, Style: "default"} + for _, opt := range opts { + opt(&action) + } + b.actions = append(b.actions, action) + return b +} + +// ActionOption is a function that modifies a NotificationAction. +type ActionOption func(*NotificationAction) + +// ActionURL sets the URL for an action. +func ActionURL(url string) ActionOption { + return func(a *NotificationAction) { + a.URL = url + } +} + +// ActionHandler sets the action handler name. +func ActionHandler(action string) ActionOption { + return func(a *NotificationAction) { + a.Action = action + } +} + +// ActionStyle sets the style for an action. +func ActionStyle(style string) ActionOption { + return func(a *NotificationAction) { + a.Style = style + } +} + +// ForUser sets the target user ID. +func (b *EventBuilder) ForUser(userID string) *EventBuilder { + b.userID = userID + return b +} + +// WithGroupKey sets the group key for grouping related notifications. +func (b *EventBuilder) WithGroupKey(groupKey string) *EventBuilder { + b.groupKey = groupKey + return b +} + +// WithDeduplicationKey sets the deduplication key. +func (b *EventBuilder) WithDeduplicationKey(key string) *EventBuilder { + b.deduplicationKey = key + return b +} + +// ExpiresIn sets the expiration time from now. +func (b *EventBuilder) ExpiresIn(duration time.Duration) *EventBuilder { + t := time.Now().Add(duration) + b.expiresAt = &t + return b +} + +// ExpiresAt sets the expiration time. +func (b *EventBuilder) ExpiresAt(t time.Time) *EventBuilder { + b.expiresAt = &t + return b +} + +// Build builds the notification payload. +func (b *EventBuilder) Build() (*NotificationPayload, error) { + if b.title == "" { + return nil, ErrTitleRequired + } + + payload := &NotificationPayload{ + EventType: b.eventType, + Title: b.title, + Message: b.message, + Severity: b.severity, + UserID: b.userID, + GroupKey: b.groupKey, + DeduplicationKey: b.deduplicationKey, + ExpiresAt: b.expiresAt, + } + + if len(b.metadata) > 0 { + payload.Metadata = b.metadata + } + + if len(b.actions) > 0 { + payload.Actions = b.actions + } + + return payload, nil +} + +// Send sends the notification. +func (b *EventBuilder) Send() SendResult { + payload, err := b.Build() + if err != nil { + return SendResult{Success: false, Error: err.Error()} + } + return b.client.SendPayload(payload) +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..22c2eff --- /dev/null +++ b/client.go @@ -0,0 +1,371 @@ +package ironnotify + +import ( + "context" + "errors" + "fmt" + "sync" +) + +// Errors +var ( + ErrNotInitialized = errors.New("ironnotify: client not initialized") + ErrTitleRequired = errors.New("ironnotify: notification title is required") + ErrAPIKeyRequired = errors.New("ironnotify: API key is required") +) + +// Client is the IronNotify client. +type Client struct { + options Options + transport *Transport + queue *OfflineQueue + isOnline bool + + connectionState ConnectionState + onNotification NotificationHandler + onUnreadCount UnreadCountHandler + onConnectionChange ConnectionStateHandler + + mu sync.RWMutex +} + +// Global client instance +var globalClient *Client +var globalMu sync.RWMutex + +// NewClient creates a new IronNotify client. +func NewClient(options Options) (*Client, error) { + if options.APIKey == "" { + return nil, ErrAPIKeyRequired + } + + opts := options.WithDefaults() + + client := &Client{ + options: opts, + transport: NewTransport(opts.APIBaseURL, opts.APIKey, opts.HTTPTimeout, opts.Debug), + isOnline: true, + connectionState: StateDisconnected, + } + + if opts.EnableOfflineQueue { + client.queue = NewOfflineQueue(opts.MaxOfflineQueueSize, opts.Debug) + } + + if opts.Debug { + fmt.Println("[IronNotify] Client initialized") + } + + return client, nil +} + +// Init initializes the global client. +func Init(apiKey string, opts ...func(*Options)) error { + options := DefaultOptions() + options.APIKey = apiKey + + for _, opt := range opts { + opt(&options) + } + + client, err := NewClient(options) + if err != nil { + return err + } + + globalMu.Lock() + globalClient = client + globalMu.Unlock() + + return nil +} + +// WithDebug enables debug mode. +func WithDebug(debug bool) func(*Options) { + return func(o *Options) { + o.Debug = debug + } +} + +// WithAPIBaseURL sets the API base URL. +func WithAPIBaseURL(url string) func(*Options) { + return func(o *Options) { + o.APIBaseURL = url + } +} + +// WithOfflineQueue enables or disables the offline queue. +func WithOfflineQueue(enabled bool) func(*Options) { + return func(o *Options) { + o.EnableOfflineQueue = enabled + } +} + +// getGlobalClient returns the global client. +func getGlobalClient() (*Client, error) { + globalMu.RLock() + defer globalMu.RUnlock() + + if globalClient == nil { + return nil, ErrNotInitialized + } + return globalClient, nil +} + +// Notify sends a notification using the global client. +func Notify(eventType, title string, opts ...NotifyOption) SendResult { + client, err := getGlobalClient() + if err != nil { + return SendResult{Success: false, Error: err.Error()} + } + return client.Notify(eventType, title, opts...) +} + +// NotifyOption is a function that modifies a NotificationPayload. +type NotifyOption func(*NotificationPayload) + +// WithMessage sets the message. +func WithMessage(message string) NotifyOption { + return func(p *NotificationPayload) { + p.Message = message + } +} + +// WithSeverity sets the severity. +func WithSeverity(severity SeverityLevel) NotifyOption { + return func(p *NotificationPayload) { + p.Severity = severity + } +} + +// WithUserID sets the user ID. +func WithUserID(userID string) NotifyOption { + return func(p *NotificationPayload) { + p.UserID = userID + } +} + +// WithMetadataOpt sets metadata. +func WithMetadataOpt(metadata map[string]any) NotifyOption { + return func(p *NotificationPayload) { + p.Metadata = metadata + } +} + +// Notify sends a notification. +func (c *Client) Notify(eventType, title string, opts ...NotifyOption) SendResult { + payload := &NotificationPayload{ + EventType: eventType, + Title: title, + Severity: SeverityInfo, + } + + for _, opt := range opts { + opt(payload) + } + + return c.SendPayload(payload) +} + +// Event creates an event builder using the global client. +func Event(eventType string) *EventBuilder { + client, err := getGlobalClient() + if err != nil { + // Return a builder that will fail on send + return &EventBuilder{eventType: eventType} + } + return client.Event(eventType) +} + +// Event creates an event builder. +func (c *Client) Event(eventType string) *EventBuilder { + return newEventBuilder(c, eventType) +} + +// SendPayload sends a notification payload. +func (c *Client) SendPayload(payload *NotificationPayload) SendResult { + result := c.transport.Send(context.Background(), payload) + + if !result.Success && c.options.EnableOfflineQueue && c.queue != nil { + c.queue.Add(*payload) + c.mu.Lock() + c.isOnline = false + c.mu.Unlock() + return SendResult{ + Success: result.Success, + NotificationID: result.NotificationID, + Error: result.Error, + Queued: true, + } + } + + return result +} + +// GetNotifications retrieves notifications. +func (c *Client) GetNotifications(limit, offset int, unreadOnly bool) ([]Notification, error) { + return c.transport.GetNotifications(context.Background(), limit, offset, unreadOnly) +} + +// GetNotificationsContext retrieves notifications with context. +func (c *Client) GetNotificationsContext(ctx context.Context, limit, offset int, unreadOnly bool) ([]Notification, error) { + return c.transport.GetNotifications(ctx, limit, offset, unreadOnly) +} + +// GetUnreadCount returns the unread notification count. +func (c *Client) GetUnreadCount() (int, error) { + return c.transport.GetUnreadCount(context.Background()) +} + +// MarkAsRead marks a notification as read. +func (c *Client) MarkAsRead(notificationID string) error { + return c.transport.MarkAsRead(context.Background(), notificationID) +} + +// MarkAllAsRead marks all notifications as read. +func (c *Client) MarkAllAsRead() error { + return c.transport.MarkAllAsRead(context.Background()) +} + +// OnNotification sets the notification handler. +func (c *Client) OnNotification(handler NotificationHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.onNotification = handler +} + +// OnUnreadCountChange sets the unread count change handler. +func (c *Client) OnUnreadCountChange(handler UnreadCountHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.onUnreadCount = handler +} + +// OnConnectionStateChange sets the connection state change handler. +func (c *Client) OnConnectionStateChange(handler ConnectionStateHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.onConnectionChange = handler +} + +// ConnectionState returns the current connection state. +func (c *Client) ConnectionState() ConnectionState { + c.mu.RLock() + defer c.mu.RUnlock() + return c.connectionState +} + +// Connect connects to real-time notifications. +func (c *Client) Connect() { + c.setConnectionState(StateConnected) + if c.options.Debug { + fmt.Println("[IronNotify] Connected (WebSocket not implemented)") + } +} + +// Disconnect disconnects from real-time notifications. +func (c *Client) Disconnect() { + c.setConnectionState(StateDisconnected) +} + +// SubscribeToUser subscribes to a user's notifications. +func (c *Client) SubscribeToUser(userID string) { + if c.options.Debug { + fmt.Printf("[IronNotify] Subscribed to user: %s\n", userID) + } +} + +// SubscribeToApp subscribes to app-wide notifications. +func (c *Client) SubscribeToApp() { + if c.options.Debug { + fmt.Println("[IronNotify] Subscribed to app notifications") + } +} + +// Flush flushes the offline queue. +func (c *Client) Flush() { + if c.queue == nil || c.queue.IsEmpty() { + return + } + + if !c.transport.IsOnline(context.Background()) { + return + } + + c.mu.Lock() + c.isOnline = true + c.mu.Unlock() + + notifications := c.queue.GetAll() + for i := len(notifications) - 1; i >= 0; i-- { + result := c.transport.Send(context.Background(), ¬ifications[i]) + if result.Success { + c.queue.Remove(i) + } else { + break + } + } +} + +// FlushContext flushes the offline queue with context. +func (c *Client) FlushContext(ctx context.Context) { + if c.queue == nil || c.queue.IsEmpty() { + return + } + + if !c.transport.IsOnline(ctx) { + return + } + + c.mu.Lock() + c.isOnline = true + c.mu.Unlock() + + notifications := c.queue.GetAll() + for i := len(notifications) - 1; i >= 0; i-- { + result := c.transport.Send(ctx, ¬ifications[i]) + if result.Success { + c.queue.Remove(i) + } else { + break + } + } +} + +// Close closes the client. +func (c *Client) Close() { + c.Disconnect() + c.transport.Close() +} + +// setConnectionState sets the connection state and calls the handler. +func (c *Client) setConnectionState(state ConnectionState) { + c.mu.Lock() + c.connectionState = state + handler := c.onConnectionChange + c.mu.Unlock() + + if handler != nil { + handler(state) + } +} + +// Flush flushes the global client's offline queue. +func Flush() { + client, err := getGlobalClient() + if err != nil { + return + } + client.Flush() +} + +// Close closes the global client. +func Close() { + globalMu.Lock() + defer globalMu.Unlock() + + if globalClient != nil { + globalClient.Close() + globalClient = nil + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..0c286f4 --- /dev/null +++ b/config.go @@ -0,0 +1,86 @@ +package ironnotify + +import "time" + +// Options configures the IronNotify client. +type Options struct { + // APIKey is the API key for authentication (required). + // Format: ak_live_xxx or ak_test_xxx + APIKey string + + // APIBaseURL is the base URL for the IronNotify API. + // Default: https://api.ironnotify.com + APIBaseURL string + + // WebSocketURL is the WebSocket URL for real-time notifications. + // Default: wss://ws.ironnotify.com + WebSocketURL string + + // Debug enables debug logging. + Debug bool + + // EnableOfflineQueue enables offline notification queuing. + // Default: true + EnableOfflineQueue bool + + // MaxOfflineQueueSize is the maximum number of notifications to queue offline. + // Default: 100 + MaxOfflineQueueSize int + + // AutoReconnect enables automatic WebSocket reconnection. + // Default: true + AutoReconnect bool + + // MaxReconnectAttempts is the maximum number of reconnection attempts. + // Default: 5 + MaxReconnectAttempts int + + // ReconnectDelay is the base delay between reconnection attempts. + // Default: 1 second + ReconnectDelay time.Duration + + // HTTPTimeout is the timeout for HTTP requests. + // Default: 30 seconds + HTTPTimeout time.Duration +} + +// DefaultOptions returns the default configuration options. +func DefaultOptions() Options { + return Options{ + APIBaseURL: "https://api.ironnotify.com", + WebSocketURL: "wss://ws.ironnotify.com", + Debug: false, + EnableOfflineQueue: true, + MaxOfflineQueueSize: 100, + AutoReconnect: true, + MaxReconnectAttempts: 5, + ReconnectDelay: time.Second, + HTTPTimeout: 30 * time.Second, + } +} + +// WithDefaults returns the options with default values applied. +func (o Options) WithDefaults() Options { + defaults := DefaultOptions() + + if o.APIBaseURL == "" { + o.APIBaseURL = defaults.APIBaseURL + } + if o.WebSocketURL == "" { + o.WebSocketURL = defaults.WebSocketURL + } + if o.MaxOfflineQueueSize == 0 { + o.MaxOfflineQueueSize = defaults.MaxOfflineQueueSize + } + if o.MaxReconnectAttempts == 0 { + o.MaxReconnectAttempts = defaults.MaxReconnectAttempts + } + if o.ReconnectDelay == 0 { + o.ReconnectDelay = defaults.ReconnectDelay + } + if o.HTTPTimeout == 0 { + o.HTTPTimeout = defaults.HTTPTimeout + } + + return o +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9f998e3 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/IronServices/ironnotify-go + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.1 +) + +require golang.org/x/net v0.17.0 // indirect diff --git a/queue.go b/queue.go new file mode 100644 index 0000000..7af9210 --- /dev/null +++ b/queue.go @@ -0,0 +1,124 @@ +package ironnotify + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// OfflineQueue stores notifications when offline. +type OfflineQueue struct { + maxSize int + debug bool + queue []NotificationPayload + storagePath string + mu sync.Mutex +} + +// NewOfflineQueue creates a new OfflineQueue. +func NewOfflineQueue(maxSize int, debug bool) *OfflineQueue { + homeDir, _ := os.UserHomeDir() + storagePath := filepath.Join(homeDir, ".ironnotify", "offline_queue.json") + + q := &OfflineQueue{ + maxSize: maxSize, + debug: debug, + queue: make([]NotificationPayload, 0), + storagePath: storagePath, + } + + q.loadFromStorage() + return q +} + +// Add adds a notification to the queue. +func (q *OfflineQueue) Add(payload NotificationPayload) { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.queue) >= q.maxSize { + q.queue = q.queue[1:] + if q.debug { + fmt.Println("[IronNotify] Offline queue full, dropping oldest notification") + } + } + + q.queue = append(q.queue, payload) + q.saveToStorage() + + if q.debug { + fmt.Printf("[IronNotify] Notification queued for later: %s\n", payload.EventType) + } +} + +// GetAll returns all queued notifications. +func (q *OfflineQueue) GetAll() []NotificationPayload { + q.mu.Lock() + defer q.mu.Unlock() + + result := make([]NotificationPayload, len(q.queue)) + copy(result, q.queue) + return result +} + +// Remove removes a notification at the given index. +func (q *OfflineQueue) Remove(index int) { + q.mu.Lock() + defer q.mu.Unlock() + + if index >= 0 && index < len(q.queue) { + q.queue = append(q.queue[:index], q.queue[index+1:]...) + q.saveToStorage() + } +} + +// Clear clears the queue. +func (q *OfflineQueue) Clear() { + q.mu.Lock() + defer q.mu.Unlock() + + q.queue = make([]NotificationPayload, 0) + q.saveToStorage() +} + +// Size returns the queue size. +func (q *OfflineQueue) Size() int { + q.mu.Lock() + defer q.mu.Unlock() + return len(q.queue) +} + +// IsEmpty checks if the queue is empty. +func (q *OfflineQueue) IsEmpty() bool { + q.mu.Lock() + defer q.mu.Unlock() + return len(q.queue) == 0 +} + +func (q *OfflineQueue) loadFromStorage() { + data, err := os.ReadFile(q.storagePath) + if err != nil { + return + } + + var queue []NotificationPayload + if err := json.Unmarshal(data, &queue); err == nil { + q.queue = queue + } +} + +func (q *OfflineQueue) saveToStorage() { + dir := filepath.Dir(q.storagePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return + } + + data, err := json.Marshal(q.queue) + if err != nil { + return + } + + _ = os.WriteFile(q.storagePath, data, 0644) +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..8656a0d --- /dev/null +++ b/transport.go @@ -0,0 +1,222 @@ +package ironnotify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// Transport handles HTTP communication with the IronNotify API. +type Transport struct { + baseURL string + apiKey string + debug bool + httpClient *http.Client +} + +// NewTransport creates a new Transport. +func NewTransport(baseURL, apiKey string, timeout time.Duration, debug bool) *Transport { + return &Transport{ + baseURL: baseURL, + apiKey: apiKey, + debug: debug, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +// Send sends a notification payload to the server. +func (t *Transport) Send(ctx context.Context, payload *NotificationPayload) SendResult { + data, err := json.Marshal(payload) + if err != nil { + return SendResult{Success: false, Error: err.Error()} + } + + req, err := http.NewRequestWithContext(ctx, "POST", t.baseURL+"/api/v1/notify", bytes.NewReader(data)) + if err != nil { + return SendResult{Success: false, Error: err.Error()} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+t.apiKey) + + if t.debug { + fmt.Printf("[IronNotify] Sending notification: %s\n", payload.EventType) + } + + resp, err := t.httpClient.Do(req) + if err != nil { + return SendResult{Success: false, Error: err.Error()} + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + var result struct { + NotificationID string `json:"notificationId"` + } + if err := json.Unmarshal(body, &result); err == nil { + return SendResult{Success: true, NotificationID: result.NotificationID} + } + return SendResult{Success: true} + } + + var errorResp struct { + Error string `json:"error"` + } + if err := json.Unmarshal(body, &errorResp); err == nil && errorResp.Error != "" { + return SendResult{Success: false, Error: errorResp.Error} + } + + return SendResult{Success: false, Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body))} +} + +// GetNotifications retrieves notifications from the server. +func (t *Transport) GetNotifications(ctx context.Context, limit, offset int, unreadOnly bool) ([]Notification, error) { + u, err := url.Parse(t.baseURL + "/api/v1/notifications") + if err != nil { + return nil, err + } + + q := u.Query() + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + if offset > 0 { + q.Set("offset", strconv.Itoa(offset)) + } + if unreadOnly { + q.Set("unread_only", "true") + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+t.apiKey) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var notifications []Notification + if err := json.NewDecoder(resp.Body).Decode(¬ifications); err != nil { + return nil, err + } + + return notifications, nil +} + +// GetUnreadCount retrieves the unread notification count. +func (t *Transport) GetUnreadCount(ctx context.Context) (int, error) { + req, err := http.NewRequestWithContext(ctx, "GET", t.baseURL+"/api/v1/notifications/unread-count", nil) + if err != nil { + return 0, err + } + + req.Header.Set("Authorization", "Bearer "+t.apiKey) + + resp, err := t.httpClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Count int `json:"count"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, err + } + + return result.Count, nil +} + +// MarkAsRead marks a notification as read. +func (t *Transport) MarkAsRead(ctx context.Context, notificationID string) error { + req, err := http.NewRequestWithContext(ctx, "POST", t.baseURL+"/api/v1/notifications/"+notificationID+"/read", nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+t.apiKey) + + resp, err := t.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) +} + +// MarkAllAsRead marks all notifications as read. +func (t *Transport) MarkAllAsRead(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "POST", t.baseURL+"/api/v1/notifications/read-all", nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+t.apiKey) + + resp, err := t.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) +} + +// IsOnline checks if the API is reachable. +func (t *Transport) IsOnline(ctx context.Context) bool { + req, err := http.NewRequestWithContext(ctx, "GET", t.baseURL+"/health", nil) + if err != nil { + return false + } + + resp, err := t.httpClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} + +// Close closes the transport. +func (t *Transport) Close() { + t.httpClient.CloseIdleConnections() +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..a697158 --- /dev/null +++ b/types.go @@ -0,0 +1,80 @@ +// Package ironnotify provides an SDK for sending notifications via IronNotify. +package ironnotify + +import "time" + +// SeverityLevel represents the severity of a notification. +type SeverityLevel string + +const ( + SeverityInfo SeverityLevel = "info" + SeveritySuccess SeverityLevel = "success" + SeverityWarning SeverityLevel = "warning" + SeverityError SeverityLevel = "error" + SeverityCritical SeverityLevel = "critical" +) + +// ConnectionState represents the WebSocket connection state. +type ConnectionState string + +const ( + StateDisconnected ConnectionState = "disconnected" + StateConnecting ConnectionState = "connecting" + StateConnected ConnectionState = "connected" + StateReconnecting ConnectionState = "reconnecting" +) + +// NotificationAction represents an action button on a notification. +type NotificationAction struct { + Label string `json:"label"` + URL string `json:"url,omitempty"` + Action string `json:"action,omitempty"` + Style string `json:"style,omitempty"` +} + +// NotificationPayload represents the data sent to create a notification. +type NotificationPayload struct { + EventType string `json:"eventType"` + Title string `json:"title"` + Message string `json:"message,omitempty"` + Severity SeverityLevel `json:"severity,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` + UserID string `json:"userId,omitempty"` + GroupKey string `json:"groupKey,omitempty"` + DeduplicationKey string `json:"deduplicationKey,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +// Notification represents a notification received from the server. +type Notification struct { + ID string `json:"id"` + EventType string `json:"eventType"` + Title string `json:"title"` + Message string `json:"message,omitempty"` + Severity SeverityLevel `json:"severity"` + Metadata map[string]any `json:"metadata,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` + UserID string `json:"userId,omitempty"` + GroupKey string `json:"groupKey,omitempty"` + Read bool `json:"read"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +// SendResult represents the result of sending a notification. +type SendResult struct { + Success bool `json:"success"` + NotificationID string `json:"notificationId,omitempty"` + Error string `json:"error,omitempty"` + Queued bool `json:"queued,omitempty"` +} + +// NotificationHandler is a callback function for receiving notifications. +type NotificationHandler func(notification Notification) + +// UnreadCountHandler is a callback function for unread count changes. +type UnreadCountHandler func(count int) + +// ConnectionStateHandler is a callback function for connection state changes. +type ConnectionStateHandler func(state ConnectionState)