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)