- Rename Go module switchboard-core → armature (155+ files) - Rename Docker image → gobha/armature - Rename K8s resources, secrets, deployments - Rename Prometheus metrics switchboard_* → armature_* - Rename env vars SWITCHBOARD_ADMIN_* → ARMATURE_ADMIN_* - Rename DB names switchboard_core* → armature* - Update all frontend branding, notification templates, docs - Update CI scripts, e2e tests, Keycloak realm, nginx conf - Rename scripts/switchboard-ca.sh → scripts/armature-ca.sh - Rename k8s/switchboard.yaml → k8s/armature.yaml - Rename chart alerting/dashboard files - Fix: DockerHub push uses env: binding for secret injection - Helm chart updated (name, labels, template functions, dashboard, alerting) - Replace favicon/icon assets with Armature brand No functional changes. Pure mechanical rename + CI fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
204 lines
5.6 KiB
Go
204 lines
5.6 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"armature/events"
|
|
"armature/models"
|
|
"armature/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: "Armature",
|
|
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)
|
|
}
|
|
}
|