Basic impl added
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user