Changeset 0.20.0 (#85)
This commit is contained in:
203
server/notifications/service.go
Normal file
203
server/notifications/service.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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: "Chat Switchboard",
|
||||
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.SendToUser(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user