package notifications import ( "context" "encoding/json" "log" "sync" "time" "switchboard-core/events" "switchboard-core/models" "switchboard-core/store" ) // ── Package-level singleton ───────────────── // Allows notification sources (KB ingester, group handler, etc.) // to access the service without threading it through constructors. var defaultService *Service // SetDefault registers the global notification service instance. // Called once at startup from main.go. func SetDefault(svc *Service) { defaultService = svc } // Default returns the global notification service instance, or nil if // not configured (e.g. in unmanaged mode without a database). func Default() *Service { return defaultService } // Service handles notification creation, persistence, and real-time delivery. // All notification sources should go through this service — never insert directly. type Service struct { store store.NotificationStore prefStore store.NotificationPreferenceStore userStore store.UserStore hub *events.Hub // Email transport (nil = email disabled) email *EmailTransport instanceName string // Retention cleanup retentionDays int stopCleanup chan struct{} cleanupWg sync.WaitGroup } // NewService creates a notification service bound to the given store and WebSocket hub. func NewService(s store.NotificationStore, hub *events.Hub) *Service { return &Service{ store: s, hub: hub, instanceName: "Switchboard Core", retentionDays: 90, stopCleanup: make(chan struct{}), } } // WithPrefs attaches the preference store for per-user delivery control. func (s *Service) WithPrefs(ps store.NotificationPreferenceStore) *Service { s.prefStore = ps return s } // WithUsers attaches the user store for email address lookup. func (s *Service) WithUsers(us store.UserStore) *Service { s.userStore = us return s } // WithEmail enables email transport. func (s *Service) WithEmail(transport *EmailTransport, instanceName string) *Service { s.email = transport if instanceName != "" { s.instanceName = instanceName } return s } // WithRetention sets the retention period in days. Notifications older than // this are pruned by the background cleanup goroutine. Default: 90 days. func (s *Service) WithRetention(days int) *Service { if days > 0 { s.retentionDays = days } return s } // Notify routes a notification through the user's delivery preferences: // in-app (persist + WebSocket) and/or email. Falls back to system defaults // if no user preferences are set. func (s *Service) Notify(ctx context.Context, n *models.Notification) error { prefs := s.resolvePrefs(ctx, n.UserID, n.Type) // In-app: persist + WebSocket push if prefs.InApp { if err := s.store.Create(ctx, n); err != nil { return err } if s.hub != nil { payload, _ := json.Marshal(n) s.hub.PublishToUser(n.UserID, events.Event{ Label: "notification.new", Payload: payload, Ts: time.Now().UnixMilli(), }) } } // Email: async send (don't block the caller) if prefs.Email && s.email != nil && s.userStore != nil { user, err := s.userStore.GetByID(ctx, n.UserID) if err == nil && user != nil && user.Email != "" { subject := SubjectForNotification(n, s.instanceName) htmlBody := RenderHTML(n, s.instanceName) textBody := RenderText(n, s.instanceName) s.email.SendAsync(user.Email, subject, htmlBody, textBody) } } return nil } // NotifyMany fans out a notification template to multiple users. // Each user gets their own copy. Errors are logged but don't fail the batch. func (s *Service) NotifyMany(ctx context.Context, userIDs []string, template models.Notification) { for _, uid := range userIDs { n := template n.ID = "" // let store generate n.UserID = uid if err := s.Notify(ctx, &n); err != nil { log.Printf("[notifications] failed to notify user %s: %v", uid, err) } } } // resolvePrefs returns the effective delivery preferences for a user + type. // Resolution chain: specific type → user '*' default → system default. func (s *Service) resolvePrefs(ctx context.Context, userID, notifType string) models.ResolvedPreference { if s.prefStore == nil { return models.SystemDefaultPreference } // 1. Specific type preference pref, err := s.prefStore.Get(ctx, userID, notifType) if err == nil && pref != nil { return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email} } // 2. User's wildcard default pref, err = s.prefStore.Get(ctx, userID, "*") if err == nil && pref != nil { return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email} } // 3. System default return models.SystemDefaultPreference } // StartCleanup launches a background goroutine that prunes old notifications daily. func (s *Service) StartCleanup() { s.cleanupWg.Add(1) go func() { defer s.cleanupWg.Done() ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() // Run once on startup (after a short delay to avoid contention) time.Sleep(5 * time.Minute) s.runCleanup() for { select { case <-ticker.C: s.runCleanup() case <-s.stopCleanup: return } } }() } // StopCleanup signals the cleanup goroutine to stop and waits for it. func (s *Service) StopCleanup() { close(s.stopCleanup) s.cleanupWg.Wait() } func (s *Service) runCleanup() { cutoff := time.Now().AddDate(0, 0, -s.retentionDays) deleted, err := s.store.DeleteOlderThan(context.Background(), cutoff) if err != nil { log.Printf("[notifications] cleanup error: %v", err) return } if deleted > 0 { log.Printf("[notifications] cleaned up %d notifications older than %d days", deleted, s.retentionDays) } }