Basic impl added

This commit is contained in:
2025-10-16 21:22:51 -07:00
parent 097ca99788
commit 9087a710e5
43 changed files with 7616 additions and 107 deletions
+211
View File
@@ -0,0 +1,211 @@
package config
import (
"fmt"
"strings"
"github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/notifier"
"github.com/spf13/viper"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Queue domain.QueueConfig `mapstructure:"queue"`
Notifiers NotifiersConfig `mapstructure:"notifiers"`
Logging LoggingConfig `mapstructure:"logging"`
Metrics MetricsConfig `mapstructure:"metrics"`
HealthCheck HealthCheckConfig `mapstructure:"health_check"`
}
// ServerConfig contains server configuration
type ServerConfig struct {
GRPCPort int `mapstructure:"grpc_port"`
RESTPort int `mapstructure:"rest_port"`
Host string `mapstructure:"host"`
Mode string `mapstructure:"mode"` // "both", "grpc", "rest"
}
// NotifiersConfig contains configuration for all notifier types
type NotifiersConfig struct {
SMTP *notifier.SMTPConfig `mapstructure:"smtp"`
Slack *notifier.SlackConfig `mapstructure:"slack"`
Ntfy *notifier.NtfyConfig `mapstructure:"ntfy"`
Stdout bool `mapstructure:"stdout"` // Enable stdout notifier
}
// LoggingConfig contains logging configuration
type LoggingConfig struct {
Level string `mapstructure:"level"` // debug, info, warn, error
Format string `mapstructure:"format"` // json, text
OutputPath string `mapstructure:"output_path"` // stdout, stderr, or file path
}
// MetricsConfig contains metrics/observability configuration
type MetricsConfig struct {
Enabled bool `mapstructure:"enabled"`
Port int `mapstructure:"port"`
Path string `mapstructure:"path"`
PrometheusEnabled bool `mapstructure:"prometheus_enabled"`
}
// HealthCheckConfig contains health check configuration
type HealthCheckConfig struct {
Enabled bool `mapstructure:"enabled"`
Port int `mapstructure:"port"`
Path string `mapstructure:"path"`
Interval int `mapstructure:"interval"` // seconds
}
// Load loads configuration from file and environment variables
func Load(configPath string) (*Config, error) {
v := viper.New()
// Set default values
setDefaults(v)
// Configure viper
v.SetConfigName("config")
v.SetConfigType("yaml")
if configPath != "" {
v.AddConfigPath(configPath)
}
// Also look in common locations
v.AddConfigPath(".")
v.AddConfigPath("./config")
v.AddConfigPath("/etc/notifier")
v.AddConfigPath("$HOME/.notifier")
// Environment variable support
v.SetEnvPrefix("NOTIFIER")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// Read config file
if err := v.ReadInConfig(); err != nil {
// Config file is optional if environment variables are set
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
}
var config Config
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Validate configuration
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return &config, nil
}
// setDefaults sets default configuration values
func setDefaults(v *viper.Viper) {
// Server defaults
v.SetDefault("server.grpc_port", 50051)
v.SetDefault("server.rest_port", 8080)
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.mode", "both")
// Queue defaults
v.SetDefault("queue.type", "local")
v.SetDefault("queue.max_size", 10000)
v.SetDefault("queue.worker_count", 10)
v.SetDefault("queue.retry_attempts", 3)
v.SetDefault("queue.retry_backoff", "exponential")
// Local queue defaults
v.SetDefault("queue.local.buffer_size", 1000)
v.SetDefault("queue.local.persist_to_disk", false)
// Logging defaults
v.SetDefault("logging.level", "info")
v.SetDefault("logging.format", "json")
v.SetDefault("logging.output_path", "stdout")
// Metrics defaults
v.SetDefault("metrics.enabled", true)
v.SetDefault("metrics.port", 9090)
v.SetDefault("metrics.path", "/metrics")
v.SetDefault("metrics.prometheus_enabled", true)
// Health check defaults
v.SetDefault("health_check.enabled", true)
v.SetDefault("health_check.port", 8081)
v.SetDefault("health_check.path", "/health")
v.SetDefault("health_check.interval", 30)
// Notifier defaults
v.SetDefault("notifiers.stdout", true)
v.SetDefault("notifiers.smtp.port", 587)
v.SetDefault("notifiers.smtp.use_tls", true)
v.SetDefault("notifiers.ntfy.server_url", "https://ntfy.sh")
}
// Validate validates the configuration
func (c *Config) Validate() error {
// Validate server config
if c.Server.GRPCPort < 1 || c.Server.GRPCPort > 65535 {
return fmt.Errorf("invalid gRPC port: %d", c.Server.GRPCPort)
}
if c.Server.RESTPort < 1 || c.Server.RESTPort > 65535 {
return fmt.Errorf("invalid REST port: %d", c.Server.RESTPort)
}
validModes := map[string]bool{"both": true, "grpc": true, "rest": true}
if !validModes[c.Server.Mode] {
return fmt.Errorf("invalid server mode: %s (must be both, grpc, or rest)", c.Server.Mode)
}
// Validate queue config
validQueueTypes := map[string]bool{"local": true, "kafka": true}
if !validQueueTypes[c.Queue.Type] {
return fmt.Errorf("invalid queue type: %s (must be local or kafka)", c.Queue.Type)
}
if c.Queue.Type == "kafka" && c.Queue.Kafka == nil {
return fmt.Errorf("Kafka queue type selected but no Kafka configuration provided")
}
// Validate at least one notifier is configured
if !c.HasAnyNotifier() {
return fmt.Errorf("at least one notifier must be configured")
}
return nil
}
// HasAnyNotifier checks if at least one notifier is configured
func (c *Config) HasAnyNotifier() bool {
return c.Notifiers.Stdout ||
c.Notifiers.SMTP != nil ||
c.Notifiers.Slack != nil ||
c.Notifiers.Ntfy != nil
}
// GetEnabledNotifiers returns a list of enabled notifier types
func (c *Config) GetEnabledNotifiers() []domain.NotificationType {
var enabled []domain.NotificationType
if c.Notifiers.Stdout {
enabled = append(enabled, domain.TypeStdout)
}
if c.Notifiers.SMTP != nil {
enabled = append(enabled, domain.TypeEmail)
}
if c.Notifiers.Slack != nil {
enabled = append(enabled, domain.TypeSlack)
}
if c.Notifiers.Ntfy != nil {
enabled = append(enabled, domain.TypeNtfy)
}
return enabled
}
+115
View File
@@ -0,0 +1,115 @@
package domain
import (
"time"
)
// Priority defines the urgency level of a notification
type Priority int
const (
PriorityLow Priority = iota
PriorityNormal
PriorityHigh
PriorityCritical
)
// NotificationType defines the channel through which to send the notification
type NotificationType string
const (
TypeEmail NotificationType = "email"
TypeSlack NotificationType = "slack"
TypeNtfy NotificationType = "ntfy"
TypeStdout NotificationType = "stdout"
)
// NotificationStatus represents the current state of a notification
type NotificationStatus string
const (
StatusPending NotificationStatus = "pending"
StatusQueued NotificationStatus = "queued"
StatusProcessing NotificationStatus = "processing"
StatusSent NotificationStatus = "sent"
StatusFailed NotificationStatus = "failed"
StatusRetrying NotificationStatus = "retrying"
)
// Notification represents a notification message with metadata
type Notification struct {
// ID is a unique identifier for the notification
ID string `json:"id"`
// Type specifies which notifier should handle this notification
Type NotificationType `json:"type"`
// Priority determines urgency and retry behavior
Priority Priority `json:"priority"`
// Status tracks the current state of the notification
Status NotificationStatus `json:"status"`
// Subject is the notification subject/title (used for email, slack, ntfy)
Subject string `json:"subject"`
// Body is the main content of the notification
Body string `json:"body"`
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.)
Recipients []string `json:"recipients"`
// Metadata contains additional provider-specific data
Metadata map[string]interface{} `json:"metadata,omitempty"`
// CreatedAt is when the notification was created
CreatedAt time.Time `json:"created_at"`
// ScheduledFor allows delayed sending (optional)
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
// SentAt is when the notification was successfully sent
SentAt *time.Time `json:"sent_at,omitempty"`
// RetryCount tracks how many times sending has been attempted
RetryCount int `json:"retry_count"`
// MaxRetries defines the maximum retry attempts
MaxRetries int `json:"max_retries"`
// LastError stores the most recent error message if failed
LastError string `json:"last_error,omitempty"`
}
// NotificationResult represents the outcome of sending a notification
type NotificationResult struct {
// NotificationID references the original notification
NotificationID string `json:"notification_id"`
// Success indicates if the notification was sent successfully
Success bool `json:"success"`
// Message provides additional context about the result
Message string `json:"message,omitempty"`
// Error contains error details if the notification failed
Error string `json:"error,omitempty"`
// SentAt is when the notification was sent
SentAt time.Time `json:"sent_at"`
// ProviderResponse contains raw response data from the notification provider
ProviderResponse map[string]interface{} `json:"provider_response,omitempty"`
}
// NotificationFilter is used for querying notifications
type NotificationFilter struct {
IDs []string `json:"ids,omitempty"`
Types []NotificationType `json:"types,omitempty"`
Statuses []NotificationStatus `json:"statuses,omitempty"`
Recipients []string `json:"recipients,omitempty"`
CreatedAfter *time.Time `json:"created_after,omitempty"`
CreatedBefore *time.Time `json:"created_before,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
+67
View File
@@ -0,0 +1,67 @@
package domain
import (
"context"
)
// Notifier is the core interface that all notification implementations must satisfy
type Notifier interface {
// Send sends a notification and returns the result
Send(ctx context.Context, notification *Notification) (*NotificationResult, error)
// Type returns the notification type this notifier handles
Type() NotificationType
// Validate checks if a notification can be sent with this notifier
Validate(notification *Notification) error
// Close performs cleanup when the notifier is no longer needed
Close() error
}
// NotifierFactory creates notifier instances based on configuration
type NotifierFactory interface {
// Create creates a notifier for the given type
Create(notificationType NotificationType) (Notifier, error)
// RegisterNotifier registers a custom notifier implementation
RegisterNotifier(notificationType NotificationType, notifier Notifier) error
// SupportedTypes returns all supported notification types
SupportedTypes() []NotificationType
}
// NotificationService is the high-level service interface for managing notifications
type NotificationService interface {
// Send queues a notification for delivery
Send(ctx context.Context, notification *Notification) (*NotificationResult, error)
// SendBatch queues multiple notifications for delivery
SendBatch(ctx context.Context, notifications []*Notification) ([]*NotificationResult, error)
// GetNotification retrieves a notification by ID
GetNotification(ctx context.Context, id string) (*Notification, error)
// ListNotifications retrieves notifications matching the filter
ListNotifications(ctx context.Context, filter *NotificationFilter) ([]*Notification, error)
// CancelNotification cancels a pending notification
CancelNotification(ctx context.Context, id string) error
// RetryNotification retries a failed notification
RetryNotification(ctx context.Context, id string) (*NotificationResult, error)
// GetStats returns notification statistics
GetStats(ctx context.Context) (*NotificationStats, error)
}
// NotificationStats contains statistics about notification processing
type NotificationStats struct {
TotalSent int64 `json:"total_sent"`
TotalFailed int64 `json:"total_failed"`
TotalPending int64 `json:"total_pending"`
TotalQueued int64 `json:"total_queued"`
ByType map[string]int64 `json:"by_type"`
ByStatus map[string]int64 `json:"by_status"`
AverageLatency float64 `json:"average_latency_ms"`
}
+111
View File
@@ -0,0 +1,111 @@
package domain
import (
"context"
)
// QueueMessage wraps a notification with queue-specific metadata
type QueueMessage struct {
// ID is a unique identifier for this queue message
ID string `json:"id"`
// Notification is the actual notification to be sent
Notification *Notification `json:"notification"`
// Attempt is the current delivery attempt number
Attempt int `json:"attempt"`
// EnqueuedAt is when the message was added to the queue
EnqueuedAt int64 `json:"enqueued_at"`
}
// Queue defines the interface for a notification queue
type Queue interface {
// Enqueue adds a notification to the queue
Enqueue(ctx context.Context, notification *Notification) error
// EnqueueBatch adds multiple notifications to the queue
EnqueueBatch(ctx context.Context, notifications []*Notification) error
// Dequeue retrieves the next notification from the queue
// Returns nil if the queue is empty
Dequeue(ctx context.Context) (*QueueMessage, error)
// Ack acknowledges successful processing of a message
Ack(ctx context.Context, messageID string) error
// Nack indicates processing failure and may requeue the message
Nack(ctx context.Context, messageID string, requeue bool) error
// Size returns the current number of messages in the queue
Size(ctx context.Context) (int64, error)
// Purge removes all messages from the queue
Purge(ctx context.Context) error
// Close cleanly shuts down the queue
Close() error
// HealthCheck verifies the queue is operational
HealthCheck(ctx context.Context) error
}
// QueueConfig contains configuration for queue implementations
type QueueConfig struct {
// Type specifies the queue implementation (local, kafka, etc.)
Type string `mapstructure:"type"`
// MaxSize is the maximum number of messages the queue can hold
MaxSize int64 `mapstructure:"max_size"`
// WorkerCount is the number of concurrent workers processing the queue
WorkerCount int `mapstructure:"worker_count"`
// RetryAttempts is the number of times to retry failed notifications
RetryAttempts int `mapstructure:"retry_attempts"`
// RetryBackoff is the backoff strategy for retries (exponential, linear, fixed)
RetryBackoff string `mapstructure:"retry_backoff"`
// Local queue specific config
Local *LocalQueueConfig `mapstructure:"local,omitempty"`
// Kafka specific config
Kafka *KafkaQueueConfig `mapstructure:"kafka,omitempty"`
}
// LocalQueueConfig contains configuration for the in-memory queue
type LocalQueueConfig struct {
// BufferSize is the channel buffer size
BufferSize int `mapstructure:"buffer_size"`
// PersistToDisk enables writing queue state to disk for recovery
PersistToDisk bool `mapstructure:"persist_to_disk"`
// PersistPath is where to store the queue state
PersistPath string `mapstructure:"persist_path"`
}
// KafkaQueueConfig contains configuration for Kafka queue
type KafkaQueueConfig struct {
// Brokers is the list of Kafka broker addresses
Brokers []string `mapstructure:"brokers"`
// Topic is the Kafka topic for notifications
Topic string `mapstructure:"topic"`
// ConsumerGroup is the Kafka consumer group ID
ConsumerGroup string `mapstructure:"consumer_group"`
// PartitionCount is the number of partitions for the topic
PartitionCount int `mapstructure:"partition_count"`
// ReplicationFactor is the replication factor for the topic
ReplicationFactor int `mapstructure:"replication_factor"`
// EnableIdempotence ensures exactly-once delivery semantics
EnableIdempotence bool `mapstructure:"enable_idempotence"`
// CompressionType defines compression (none, gzip, snappy, lz4, zstd)
CompressionType string `mapstructure:"compression_type"`
}
+107
View File
@@ -0,0 +1,107 @@
package notifier
import (
"context"
"fmt"
"sync"
"github.com/igodwin/notifier/internal/domain"
)
// Factory creates and manages notifier instances
type Factory struct {
notifiers map[domain.NotificationType]domain.Notifier
mu sync.RWMutex
}
// NewFactory creates a new notifier factory
func NewFactory() *Factory {
return &Factory{
notifiers: make(map[domain.NotificationType]domain.Notifier),
}
}
// Create creates a notifier for the given type
func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notifier, error) {
f.mu.RLock()
defer f.mu.RUnlock()
notifier, exists := f.notifiers[notificationType]
if !exists {
return nil, fmt.Errorf("unsupported notification type: %s", notificationType)
}
return notifier, nil
}
// RegisterNotifier registers a custom notifier implementation
func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, notifier domain.Notifier) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.notifiers[notificationType]; exists {
return fmt.Errorf("notifier already registered for type: %s", notificationType)
}
f.notifiers[notificationType] = notifier
return nil
}
// SupportedTypes returns all supported notification types
func (f *Factory) SupportedTypes() []domain.NotificationType {
f.mu.RLock()
defer f.mu.RUnlock()
types := make([]domain.NotificationType, 0, len(f.notifiers))
for t := range f.notifiers {
types = append(types, t)
}
return types
}
// BaseNotifier provides common functionality for all notifiers
type BaseNotifier struct {
notificationType domain.NotificationType
}
// Type returns the notification type
func (b *BaseNotifier) Type() domain.NotificationType {
return b.notificationType
}
// Validate performs basic validation common to all notifiers
func (b *BaseNotifier) Validate(notification *domain.Notification) error {
if notification == nil {
return fmt.Errorf("notification is nil")
}
if len(notification.Recipients) == 0 {
return fmt.Errorf("notification has no recipients")
}
if notification.Type != b.notificationType {
return fmt.Errorf("notification type mismatch: expected %s, got %s", b.notificationType, notification.Type)
}
return nil
}
// Close performs cleanup (default implementation does nothing)
func (b *BaseNotifier) Close() error {
return nil
}
// ValidateContext checks if the context is valid
func ValidateContext(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("context is nil")
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
+261
View File
@@ -0,0 +1,261 @@
package notifier
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// NtfyConfig contains ntfy.sh configuration
type NtfyConfig struct {
// ServerURL is the ntfy server URL (default: https://ntfy.sh)
ServerURL string `mapstructure:"server_url"`
// Token is the access token for authentication (preferred method)
// Supports both regular tokens (tk_...) and publish tokens
Token string `mapstructure:"token"`
// Username for basic authentication (alternative to token)
Username string `mapstructure:"username"`
// Password for basic authentication (alternative to token)
Password string `mapstructure:"password"`
// DefaultTopic is the default topic if not specified in notification
DefaultTopic string `mapstructure:"default_topic"`
// InsecureSkipVerify skips TLS verification (for self-hosted servers with self-signed certs)
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
}
// NtfyNotifier sends notifications via ntfy.sh
type NtfyNotifier struct {
BaseNotifier
config *NtfyConfig
httpClient *http.Client
}
// ntfyRequest represents the ntfy API request format
type ntfyRequest struct {
Topic string `json:"topic"`
Message string `json:"message"`
Title string `json:"title,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Attach string `json:"attach,omitempty"`
Actions []ntfyAction `json:"actions,omitempty"`
Icon string `json:"icon,omitempty"`
Delay string `json:"delay,omitempty"`
Email string `json:"email,omitempty"`
}
// ntfyAction represents an action button in ntfy
type ntfyAction struct {
Action string `json:"action"`
Label string `json:"label"`
URL string `json:"url,omitempty"`
Body string `json:"body,omitempty"`
Clear bool `json:"clear,omitempty"`
}
// NewNtfyNotifier creates a new ntfy notifier
func NewNtfyNotifier(config *NtfyConfig) (*NtfyNotifier, error) {
if config == nil {
return nil, fmt.Errorf("ntfy config is required")
}
if config.ServerURL == "" {
config.ServerURL = "https://ntfy.sh" // Default public ntfy server
}
// Create HTTP client with optional TLS skip verify
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
if config.InsecureSkipVerify {
// For self-hosted servers with self-signed certificates
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
httpClient.Transport = transport
}
return &NtfyNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeNtfy,
},
config: config,
httpClient: httpClient,
}, nil
}
// Send sends a notification via ntfy
func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := n.Validate(notification); err != nil {
return nil, err
}
// For ntfy, recipients are topics
recipients := notification.Recipients
if len(recipients) == 0 && n.config.DefaultTopic != "" {
recipients = []string{n.config.DefaultTopic}
}
for _, topic := range recipients {
req := ntfyRequest{
Topic: topic,
Message: notification.Body,
Title: notification.Subject,
Priority: n.mapPriority(notification.Priority),
}
// Add custom tags from metadata
if tags, ok := notification.Metadata["tags"].([]interface{}); ok {
for _, tag := range tags {
if tagStr, ok := tag.(string); ok {
req.Tags = append(req.Tags, tagStr)
}
}
}
// Add click action from metadata
if click, ok := notification.Metadata["click"].(string); ok {
req.Click = click
}
// Add attachment from metadata
if attach, ok := notification.Metadata["attach"].(string); ok {
req.Attach = attach
}
// Add icon from metadata
if icon, ok := notification.Metadata["icon"].(string); ok {
req.Icon = icon
}
// Add delay from metadata (e.g., "30s", "1m", "1h")
if delay, ok := notification.Metadata["delay"].(string); ok {
req.Delay = delay
}
// Add email from metadata (for email notifications)
if email, ok := notification.Metadata["email"].(string); ok {
req.Email = email
}
// Add actions from metadata
if actions, ok := notification.Metadata["actions"].([]interface{}); ok {
for _, action := range actions {
if actionMap, ok := action.(map[string]interface{}); ok {
ntfyAct := ntfyAction{}
if actionType, ok := actionMap["action"].(string); ok {
ntfyAct.Action = actionType
}
if label, ok := actionMap["label"].(string); ok {
ntfyAct.Label = label
}
if url, ok := actionMap["url"].(string); ok {
ntfyAct.URL = url
}
if body, ok := actionMap["body"].(string); ok {
ntfyAct.Body = body
}
if clear, ok := actionMap["clear"].(bool); ok {
ntfyAct.Clear = clear
}
req.Actions = append(req.Actions, ntfyAct)
}
}
}
if err := n.sendToTopic(ctx, &req); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, err
}
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Notification sent to %d topics", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"server": n.config.ServerURL,
"topics": notification.Recipients,
},
}, nil
}
// sendToTopic sends a notification to a specific ntfy topic
func (n *NtfyNotifier) sendToTopic(ctx context.Context, req *ntfyRequest) error {
url := fmt.Sprintf("%s", n.config.ServerURL)
jsonData, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal ntfy request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// Add authentication if configured
if n.config.Token != "" {
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", n.config.Token))
} else if n.config.Username != "" && n.config.Password != "" {
httpReq.SetBasicAuth(n.config.Username, n.config.Password)
}
resp, err := n.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to send ntfy notification: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy server returned status: %d", resp.StatusCode)
}
return nil
}
// mapPriority maps domain priority to ntfy priority (1-5)
func (n *NtfyNotifier) mapPriority(priority domain.Priority) int {
switch priority {
case domain.PriorityLow:
return 2
case domain.PriorityNormal:
return 3
case domain.PriorityHigh:
return 4
case domain.PriorityCritical:
return 5
default:
return 3
}
}
// Close closes the HTTP client
func (n *NtfyNotifier) Close() error {
n.httpClient.CloseIdleConnections()
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package notifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// SlackConfig contains Slack webhook configuration
type SlackConfig struct {
WebhookURL string `mapstructure:"webhook_url"`
Token string `mapstructure:"token"`
Channel string `mapstructure:"channel"`
Username string `mapstructure:"username"`
IconEmoji string `mapstructure:"icon_emoji"`
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
}
// SlackNotifier sends notifications to Slack
type SlackNotifier struct {
BaseNotifier
config *SlackConfig
httpClient *http.Client
}
// slackMessage represents the Slack API request format
type slackMessage struct {
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
Text string `json:"text,omitempty"`
Blocks []slackBlock `json:"blocks,omitempty"`
Markdown bool `json:"mrkdwn,omitempty"`
}
// slackBlock represents a Slack block element
type slackBlock struct {
Type string `json:"type"`
Text *slackTextBlock `json:"text,omitempty"`
}
// slackTextBlock represents a text element in a Slack block
type slackTextBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
// NewSlackNotifier creates a new Slack notifier
func NewSlackNotifier(config *SlackConfig) (*SlackNotifier, error) {
if config == nil {
return nil, fmt.Errorf("Slack config is required")
}
// Either webhook URL or token is required
if config.WebhookURL == "" && config.Token == "" && len(config.Webhooks) == 0 {
return nil, fmt.Errorf("Slack webhook URL, token, or channel webhooks are required")
}
return &SlackNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeSlack,
},
config: config,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
// Send sends a notification to Slack
func (s *SlackNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
// For Slack, recipients are channel names or webhook URLs
for _, recipient := range notification.Recipients {
msg := s.buildMessage(notification, recipient)
webhookURL := s.getWebhookURL(recipient)
if err := s.sendToSlack(ctx, webhookURL, msg); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, err
}
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Slack notification sent to %d channels", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"channels": notification.Recipients,
},
}, nil
}
// buildMessage constructs a Slack message with rich formatting
func (s *SlackNotifier) buildMessage(notification *domain.Notification, channel string) *slackMessage {
msg := &slackMessage{
Channel: channel,
Username: s.config.Username,
IconEmoji: s.config.IconEmoji,
Markdown: true,
}
// Use blocks for rich formatting if both subject and body exist
if notification.Subject != "" && notification.Body != "" {
msg.Blocks = []slackBlock{
{
Type: "header",
Text: &slackTextBlock{
Type: "plain_text",
Text: notification.Subject,
},
},
{
Type: "section",
Text: &slackTextBlock{
Type: "mrkdwn",
Text: notification.Body,
},
},
}
} else {
// Fallback to simple text
if notification.Subject != "" {
msg.Text = fmt.Sprintf("*%s*\n%s", notification.Subject, notification.Body)
} else {
msg.Text = notification.Body
}
}
// Add priority indicator for high priority notifications
if notification.Priority >= domain.PriorityHigh {
priorityEmoji := ":warning:"
if notification.Priority == domain.PriorityCritical {
priorityEmoji = ":rotating_light:"
}
msg.Blocks = append([]slackBlock{
{
Type: "context",
Text: &slackTextBlock{
Type: "mrkdwn",
Text: fmt.Sprintf("%s *Priority: %d*", priorityEmoji, notification.Priority),
},
},
}, msg.Blocks...)
}
return msg
}
// getWebhookURL returns the webhook URL for a specific channel
func (s *SlackNotifier) getWebhookURL(channel string) string {
// Check for channel-specific webhook
if webhook, ok := s.config.Webhooks[channel]; ok {
return webhook
}
// Fall back to default webhook URL
return s.config.WebhookURL
}
// sendToSlack sends the message to Slack via webhook
func (s *SlackNotifier) sendToSlack(ctx context.Context, webhookURL string, msg *slackMessage) error {
jsonData, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal Slack message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Add token authentication if configured
if s.config.Token != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.Token))
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send Slack notification: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Slack API returned status: %d", resp.StatusCode)
}
return nil
}
// Close closes the HTTP client
func (s *SlackNotifier) Close() error {
s.httpClient.CloseIdleConnections()
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package notifier
import (
"context"
"fmt"
"net/smtp"
"strings"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// SMTPConfig contains SMTP server configuration
type SMTPConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
From string `mapstructure:"from"`
UseTLS bool `mapstructure:"use_tls"`
}
// SMTPNotifier sends notifications via email using SMTP
type SMTPNotifier struct {
BaseNotifier
config *SMTPConfig
}
// NewSMTPNotifier creates a new SMTP notifier
func NewSMTPNotifier(config *SMTPConfig) (*SMTPNotifier, error) {
if config == nil {
return nil, fmt.Errorf("SMTP config is required")
}
if config.Host == "" {
return nil, fmt.Errorf("SMTP host is required")
}
if config.Port == 0 {
config.Port = 587 // Default SMTP submission port
}
if config.From == "" {
return nil, fmt.Errorf("SMTP from address is required")
}
return &SMTPNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeEmail,
},
config: config,
}, nil
}
// Send sends a notification via email
func (s *SMTPNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
// Validate email recipients
for _, recipient := range notification.Recipients {
if !strings.Contains(recipient, "@") {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: fmt.Sprintf("invalid email address: %s", recipient),
SentAt: time.Now(),
}, fmt.Errorf("invalid email address: %s", recipient)
}
}
// Build email message
message := s.buildMessage(notification)
// Send email
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
err := smtp.SendMail(addr, auth, s.config.From, notification.Recipients, []byte(message))
if err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, fmt.Errorf("failed to send email: %w", err)
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Email sent to %d recipients", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"smtp_server": addr,
"from": s.config.From,
"to": notification.Recipients,
},
}, nil
}
// buildMessage constructs the email message with headers
func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("From: %s\r\n", s.config.From))
builder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(notification.Recipients, ", ")))
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
builder.WriteString("MIME-Version: 1.0\r\n")
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("\r\n")
builder.WriteString(notification.Body)
return builder.String()
}
// Validate checks if the notification is valid for SMTP
func (s *SMTPNotifier) Validate(notification *domain.Notification) error {
if err := s.BaseNotifier.Validate(notification); err != nil {
return err
}
if notification.Subject == "" {
return fmt.Errorf("email subject is required")
}
if notification.Body == "" {
return fmt.Errorf("email body is required")
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package notifier
import (
"context"
"fmt"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// StdoutNotifier sends notifications to stdout (useful for debugging)
type StdoutNotifier struct {
BaseNotifier
}
// NewStdoutNotifier creates a new stdout notifier
func NewStdoutNotifier() *StdoutNotifier {
return &StdoutNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeStdout,
},
}
}
// Send sends a notification to stdout
func (s *StdoutNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
fmt.Println("========================================")
fmt.Printf("Notification ID: %s\n", notification.ID)
fmt.Printf("Type: %s\n", notification.Type)
fmt.Printf("Priority: %d\n", notification.Priority)
fmt.Printf("Recipients: %v\n", notification.Recipients)
fmt.Printf("Subject: %s\n", notification.Subject)
fmt.Printf("Body:\n%s\n", notification.Body)
fmt.Println("========================================")
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: "Notification printed to stdout",
SentAt: time.Now(),
}, nil
}
+297
View File
@@ -0,0 +1,297 @@
package queue
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/igodwin/notifier/internal/domain"
"github.com/google/uuid"
)
// LocalQueue is an in-memory queue implementation
type LocalQueue struct {
queue chan *domain.QueueMessage
messages map[string]*domain.QueueMessage
mu sync.RWMutex
config *domain.LocalQueueConfig
persistToDisk bool
persistPath string
closed bool
closeChan chan struct{}
}
// NewLocalQueue creates a new local queue instance
func NewLocalQueue(config *domain.LocalQueueConfig) (*LocalQueue, error) {
if config == nil {
config = &domain.LocalQueueConfig{
BufferSize: 1000,
PersistToDisk: false,
}
}
lq := &LocalQueue{
queue: make(chan *domain.QueueMessage, config.BufferSize),
messages: make(map[string]*domain.QueueMessage),
config: config,
persistToDisk: config.PersistToDisk,
persistPath: config.PersistPath,
closeChan: make(chan struct{}),
}
// Load persisted messages if enabled
if lq.persistToDisk && lq.persistPath != "" {
if err := lq.loadFromDisk(); err != nil {
return nil, fmt.Errorf("failed to load persisted queue: %w", err)
}
}
return lq, nil
}
// Enqueue adds a notification to the queue
func (lq *LocalQueue) Enqueue(ctx context.Context, notification *domain.Notification) error {
lq.mu.Lock()
defer lq.mu.Unlock()
if lq.closed {
return fmt.Errorf("queue is closed")
}
msg := &domain.QueueMessage{
ID: uuid.New().String(),
Notification: notification,
Attempt: 0,
EnqueuedAt: time.Now().Unix(),
}
select {
case lq.queue <- msg:
lq.messages[msg.ID] = msg
notification.Status = domain.StatusQueued
if lq.persistToDisk {
return lq.persistToDiskSync()
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-lq.closeChan:
return fmt.Errorf("queue is closed")
}
}
// EnqueueBatch adds multiple notifications to the queue
func (lq *LocalQueue) EnqueueBatch(ctx context.Context, notifications []*domain.Notification) error {
lq.mu.Lock()
defer lq.mu.Unlock()
if lq.closed {
return fmt.Errorf("queue is closed")
}
for _, notification := range notifications {
msg := &domain.QueueMessage{
ID: uuid.New().String(),
Notification: notification,
Attempt: 0,
EnqueuedAt: time.Now().Unix(),
}
select {
case lq.queue <- msg:
lq.messages[msg.ID] = msg
notification.Status = domain.StatusQueued
case <-ctx.Done():
return ctx.Err()
case <-lq.closeChan:
return fmt.Errorf("queue is closed")
}
}
if lq.persistToDisk {
return lq.persistToDiskSync()
}
return nil
}
// Dequeue retrieves the next notification from the queue
func (lq *LocalQueue) Dequeue(ctx context.Context) (*domain.QueueMessage, error) {
if lq.closed {
return nil, fmt.Errorf("queue is closed")
}
select {
case msg := <-lq.queue:
lq.mu.Lock()
msg.Attempt++
msg.Notification.Status = domain.StatusProcessing
lq.mu.Unlock()
return msg, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-lq.closeChan:
return nil, fmt.Errorf("queue is closed")
}
}
// Ack acknowledges successful processing of a message
func (lq *LocalQueue) Ack(ctx context.Context, messageID string) error {
lq.mu.Lock()
defer lq.mu.Unlock()
if msg, exists := lq.messages[messageID]; exists {
msg.Notification.Status = domain.StatusSent
delete(lq.messages, messageID)
if lq.persistToDisk {
return lq.persistToDiskSync()
}
}
return nil
}
// Nack indicates processing failure and may requeue the message
func (lq *LocalQueue) Nack(ctx context.Context, messageID string, requeue bool) error {
lq.mu.Lock()
defer lq.mu.Unlock()
msg, exists := lq.messages[messageID]
if !exists {
return fmt.Errorf("message not found: %s", messageID)
}
if requeue {
msg.Notification.Status = domain.StatusRetrying
select {
case lq.queue <- msg:
if lq.persistToDisk {
return lq.persistToDiskSync()
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-lq.closeChan:
return fmt.Errorf("queue is closed")
}
} else {
msg.Notification.Status = domain.StatusFailed
delete(lq.messages, messageID)
if lq.persistToDisk {
return lq.persistToDiskSync()
}
}
return nil
}
// Size returns the current number of messages in the queue
func (lq *LocalQueue) Size(ctx context.Context) (int64, error) {
lq.mu.RLock()
defer lq.mu.RUnlock()
return int64(len(lq.queue)), nil
}
// Purge removes all messages from the queue
func (lq *LocalQueue) Purge(ctx context.Context) error {
lq.mu.Lock()
defer lq.mu.Unlock()
// Drain the channel
for len(lq.queue) > 0 {
<-lq.queue
}
lq.messages = make(map[string]*domain.QueueMessage)
if lq.persistToDisk {
return lq.persistToDiskSync()
}
return nil
}
// Close cleanly shuts down the queue
func (lq *LocalQueue) Close() error {
lq.mu.Lock()
defer lq.mu.Unlock()
if lq.closed {
return nil
}
lq.closed = true
close(lq.closeChan)
if lq.persistToDisk {
if err := lq.persistToDiskSync(); err != nil {
return err
}
}
close(lq.queue)
return nil
}
// HealthCheck verifies the queue is operational
func (lq *LocalQueue) HealthCheck(ctx context.Context) error {
lq.mu.RLock()
defer lq.mu.RUnlock()
if lq.closed {
return fmt.Errorf("queue is closed")
}
return nil
}
// persistToDiskSync persists the queue state to disk (must be called with lock held)
func (lq *LocalQueue) persistToDiskSync() error {
if !lq.persistToDisk || lq.persistPath == "" {
return nil
}
data, err := json.Marshal(lq.messages)
if err != nil {
return fmt.Errorf("failed to marshal queue state: %w", err)
}
if err := os.WriteFile(lq.persistPath, data, 0644); err != nil {
return fmt.Errorf("failed to write queue state: %w", err)
}
return nil
}
// loadFromDisk loads the queue state from disk
func (lq *LocalQueue) loadFromDisk() error {
if lq.persistPath == "" {
return nil
}
data, err := os.ReadFile(lq.persistPath)
if err != nil {
if os.IsNotExist(err) {
return nil // No persisted state yet
}
return fmt.Errorf("failed to read queue state: %w", err)
}
var messages map[string]*domain.QueueMessage
if err := json.Unmarshal(data, &messages); err != nil {
return fmt.Errorf("failed to unmarshal queue state: %w", err)
}
// Re-enqueue persisted messages
for _, msg := range messages {
lq.queue <- msg
lq.messages[msg.ID] = msg
}
return nil
}
+383
View File
@@ -0,0 +1,383 @@
package service
import (
"context"
"fmt"
"sync"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// NotificationService implements the domain.NotificationService interface
type NotificationService struct {
factory domain.NotifierFactory
queue domain.Queue
notifications map[string]*domain.Notification
mu sync.RWMutex
workerCount int
stopChan chan struct{}
wg sync.WaitGroup
}
// NewNotificationService creates a new notification service
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int) *NotificationService {
if workerCount <= 0 {
workerCount = 10
}
return &NotificationService{
factory: factory,
queue: queue,
notifications: make(map[string]*domain.Notification),
workerCount: workerCount,
stopChan: make(chan struct{}),
}
}
// Start starts the worker pool
func (s *NotificationService) Start(ctx context.Context) error {
for i := 0; i < s.workerCount; i++ {
s.wg.Add(1)
go s.worker(ctx, i)
}
return nil
}
// Stop stops the service gracefully
func (s *NotificationService) Stop() error {
close(s.stopChan)
s.wg.Wait()
return s.queue.Close()
}
// worker processes notifications from the queue
func (s *NotificationService) worker(ctx context.Context, id int) {
defer s.wg.Done()
for {
select {
case <-s.stopChan:
return
case <-ctx.Done():
return
default:
// Try to dequeue with timeout
workerCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
msg, err := s.queue.Dequeue(workerCtx)
cancel()
if err != nil {
if err == context.DeadlineExceeded {
continue
}
time.Sleep(100 * time.Millisecond)
continue
}
if msg == nil {
time.Sleep(100 * time.Millisecond)
continue
}
// Process the notification
s.processNotification(ctx, msg)
}
}
}
// processNotification sends a notification and handles the result
func (s *NotificationService) processNotification(ctx context.Context, msg *domain.QueueMessage) {
notification := msg.Notification
// Get the appropriate notifier
notifier, err := s.factory.Create(notification.Type)
if err != nil {
notification.Status = domain.StatusFailed
notification.LastError = fmt.Sprintf("failed to create notifier: %v", err)
s.queue.Nack(ctx, msg.ID, false)
s.updateNotification(notification)
return
}
// Send the notification
result, err := notifier.Send(ctx, notification)
if err != nil || !result.Success {
notification.RetryCount++
notification.LastError = result.Error
if err != nil {
notification.LastError = err.Error()
}
// Check if we should retry
if notification.RetryCount < notification.MaxRetries {
notification.Status = domain.StatusRetrying
s.queue.Nack(ctx, msg.ID, true) // Requeue
} else {
notification.Status = domain.StatusFailed
s.queue.Nack(ctx, msg.ID, false) // Don't requeue
}
} else {
notification.Status = domain.StatusSent
now := time.Now()
notification.SentAt = &now
s.queue.Ack(ctx, msg.ID)
}
s.updateNotification(notification)
}
// Send queues a notification for delivery
func (s *NotificationService) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
// Store the notification
s.storeNotification(notification)
// Enqueue for processing
if err := s.queue.Enqueue(ctx, notification); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: fmt.Sprintf("failed to enqueue: %v", err),
SentAt: time.Now(),
}, err
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: "notification queued successfully",
SentAt: time.Now(),
}, nil
}
// SendBatch queues multiple notifications for delivery
func (s *NotificationService) SendBatch(ctx context.Context, notifications []*domain.Notification) ([]*domain.NotificationResult, error) {
results := make([]*domain.NotificationResult, 0, len(notifications))
// Store all notifications
for _, notification := range notifications {
s.storeNotification(notification)
}
// Enqueue batch
if err := s.queue.EnqueueBatch(ctx, notifications); err != nil {
return nil, fmt.Errorf("failed to enqueue batch: %w", err)
}
// Create results
for _, notification := range notifications {
results = append(results, &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: "notification queued successfully",
SentAt: time.Now(),
})
}
return results, nil
}
// GetNotification retrieves a notification by ID
func (s *NotificationService) GetNotification(ctx context.Context, id string) (*domain.Notification, error) {
s.mu.RLock()
defer s.mu.RUnlock()
notification, exists := s.notifications[id]
if !exists {
return nil, fmt.Errorf("notification not found: %s", id)
}
return notification, nil
}
// ListNotifications retrieves notifications matching the filter
func (s *NotificationService) ListNotifications(ctx context.Context, filter *domain.NotificationFilter) ([]*domain.Notification, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Simple in-memory filtering
var results []*domain.Notification
for _, notification := range s.notifications {
if s.matchesFilter(notification, filter) {
results = append(results, notification)
}
}
// Apply limit and offset
if filter.Offset > 0 && filter.Offset < len(results) {
results = results[filter.Offset:]
}
if filter.Limit > 0 && filter.Limit < len(results) {
results = results[:filter.Limit]
}
return results, nil
}
// CancelNotification cancels a pending notification
func (s *NotificationService) CancelNotification(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
notification, exists := s.notifications[id]
if !exists {
return fmt.Errorf("notification not found: %s", id)
}
if notification.Status == domain.StatusSent {
return fmt.Errorf("notification already sent")
}
notification.Status = domain.StatusFailed
notification.LastError = "cancelled by user"
return nil
}
// RetryNotification retries a failed notification
func (s *NotificationService) RetryNotification(ctx context.Context, id string) (*domain.NotificationResult, error) {
notification, err := s.GetNotification(ctx, id)
if err != nil {
return nil, err
}
if notification.Status == domain.StatusSent {
return &domain.NotificationResult{
NotificationID: id,
Success: false,
Error: "notification already sent",
SentAt: time.Now(),
}, fmt.Errorf("notification already sent")
}
// Reset retry count and status
notification.RetryCount = 0
notification.Status = domain.StatusPending
// Re-enqueue
return s.Send(ctx, notification)
}
// GetStats returns notification statistics
func (s *NotificationService) GetStats(ctx context.Context) (*domain.NotificationStats, error) {
s.mu.RLock()
defer s.mu.RUnlock()
stats := &domain.NotificationStats{
ByType: make(map[string]int64),
ByStatus: make(map[string]int64),
}
for _, notification := range s.notifications {
switch notification.Status {
case domain.StatusSent:
stats.TotalSent++
case domain.StatusFailed:
stats.TotalFailed++
case domain.StatusPending:
stats.TotalPending++
case domain.StatusQueued:
stats.TotalQueued++
}
stats.ByType[string(notification.Type)]++
stats.ByStatus[string(notification.Status)]++
}
return stats, nil
}
// storeNotification stores a notification in memory
func (s *NotificationService) storeNotification(notification *domain.Notification) {
s.mu.Lock()
defer s.mu.Unlock()
s.notifications[notification.ID] = notification
}
// updateNotification updates a notification in memory
func (s *NotificationService) updateNotification(notification *domain.Notification) {
s.mu.Lock()
defer s.mu.Unlock()
s.notifications[notification.ID] = notification
}
// matchesFilter checks if a notification matches the filter
func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool {
if filter == nil {
return true
}
// Check IDs
if len(filter.IDs) > 0 {
found := false
for _, id := range filter.IDs {
if notification.ID == id {
found = true
break
}
}
if !found {
return false
}
}
// Check types
if len(filter.Types) > 0 {
found := false
for _, t := range filter.Types {
if notification.Type == t {
found = true
break
}
}
if !found {
return false
}
}
// Check statuses
if len(filter.Statuses) > 0 {
found := false
for _, s := range filter.Statuses {
if notification.Status == s {
found = true
break
}
}
if !found {
return false
}
}
// Check recipients
if len(filter.Recipients) > 0 {
found := false
for _, fr := range filter.Recipients {
for _, nr := range notification.Recipients {
if fr == nr {
found = true
break
}
}
if found {
break
}
}
if !found {
return false
}
}
// Check time ranges
if filter.CreatedAfter != nil && notification.CreatedAt.Before(*filter.CreatedAfter) {
return false
}
if filter.CreatedBefore != nil && notification.CreatedAt.After(*filter.CreatedBefore) {
return false
}
return true
}