This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/notifications/service.go
Jeffrey Smith f0dd43144e rebrand: Switchboard Core → Armature
- 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>
2026-03-31 21:39:58 +00:00

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)
}
}