// Package notifyprefs implementiert Core CFG-04: Benachrichtigungspräferenzen // je Benutzer, Ereignistyp und Kanal. Duenner Client von CFG-02/03 — keine // eigene Zustelllogik, nur ein Filter DAVOR, ob eine Benachrichtigung // überhaupt in die Warteschlange (internal/notify.Dispatcher) eingereiht wird. package notifyprefs import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/notify" ) type Preference struct { TenantSlug string UserID string EventType string Channel string Enabled bool } // Store verwaltet Benachrichtigungspraeferenzen in der zentralen Registry-DB // (gleiche Ablageebene wie CFG-03s notification_templates/in_app_notifications). type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // Set setzt die Praeferenz eines Benutzers fuer einen Ereignistyp+Kanal // (Akzeptanzkriterium 1). Wirkt sofort — es gibt keinen Cache dazwischen, // jede Pruefung (IsEnabled) liest direkt aus der DB (Akzeptanzkriterium 2). func (s *Store) Set(ctx context.Context, tenantSlug, userID, eventType, channel string, enabled bool) error { if tenantSlug == "" || userID == "" || eventType == "" || channel == "" { return errors.New("notifyprefs: tenantSlug, userID, eventType und channel duerfen nicht leer sein") } _, err := s.pool.Exec(ctx, ` INSERT INTO notification_preferences (tenant_slug, user_id, event_type, channel, enabled) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tenant_slug, user_id, event_type, channel) DO UPDATE SET enabled = $5, updated_at = now() `, tenantSlug, userID, eventType, channel, enabled) if err != nil { return fmt.Errorf("praeferenz speichern: %w", err) } return nil } // IsEnabled prueft, ob ein Kanal fuer einen Ereignistyp aktiv ist. Ohne // explizite Praeferenz gilt der Kanal als AKTIVIERT (Opt-out-Default, // siehe Migration). func (s *Store) IsEnabled(ctx context.Context, tenantSlug, userID, eventType, channel string) (bool, error) { var enabled bool err := s.pool.QueryRow(ctx, ` SELECT enabled FROM notification_preferences WHERE tenant_slug = $1 AND user_id = $2 AND event_type = $3 AND channel = $4 `, tenantSlug, userID, eventType, channel).Scan(&enabled) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return true, nil } return false, fmt.Errorf("praeferenz lesen: %w", err) } return enabled, nil } // ListForUser liefert alle expliziten Praeferenzen eines Benutzers // (Akzeptanzkriterium 1: eigene Einstellungen einsehen/aendern). func (s *Store) ListForUser(ctx context.Context, tenantSlug, userID string) ([]Preference, error) { rows, err := s.pool.Query(ctx, ` SELECT event_type, channel, enabled FROM notification_preferences WHERE tenant_slug = $1 AND user_id = $2 ORDER BY event_type, channel `, tenantSlug, userID) if err != nil { return nil, fmt.Errorf("praeferenzen auflisten: %w", err) } defer rows.Close() var out []Preference for rows.Next() { p := Preference{TenantSlug: tenantSlug, UserID: userID} if err := rows.Scan(&p.EventType, &p.Channel, &p.Enabled); err != nil { return nil, fmt.Errorf("praeferenz lesen: %w", err) } out = append(out, p) } return out, rows.Err() } // ListForTenant liefert ALLE expliziten Praeferenzen aller Benutzer eines // Mandanten (Akzeptanzkriterium 3: Tenant-Admin-Übersicht). func (s *Store) ListForTenant(ctx context.Context, tenantSlug string) ([]Preference, error) { rows, err := s.pool.Query(ctx, ` SELECT user_id, event_type, channel, enabled FROM notification_preferences WHERE tenant_slug = $1 ORDER BY user_id, event_type, channel `, tenantSlug) if err != nil { return nil, fmt.Errorf("mandanten-praeferenzen auflisten: %w", err) } defer rows.Close() var out []Preference for rows.Next() { p := Preference{TenantSlug: tenantSlug} if err := rows.Scan(&p.UserID, &p.EventType, &p.Channel, &p.Enabled); err != nil { return nil, fmt.Errorf("praeferenz lesen: %w", err) } out = append(out, p) } return out, rows.Err() } // EnqueueIfAllowed ist der einzige vorgesehene Weg, wie ein Modul eine // Benachrichtigung fuer einen konkreten Benutzer+Ereignistyp auslöst: prueft // zuerst die Praeferenz, reiht NUR bei Aktivierung tatsaechlich in // internal/notify.Dispatcher ein (Akzeptanzkriterium 1 / Pruefung 1 — ein // deaktivierter Kanal erzeugt nachweislich KEINE notification_jobs-Zeile, // nicht nur eine ignorierte). skipped=true bedeutet: bewusst nicht zugestellt, // kein Fehler. func EnqueueIfAllowed( ctx context.Context, prefs *Store, dispatcher *notify.Dispatcher, tenantSlug, userID, eventType, channel, recipient string, payload map[string]any, ) (jobID string, skipped bool, err error) { enabled, err := prefs.IsEnabled(ctx, tenantSlug, userID, eventType, channel) if err != nil { return "", false, err } if !enabled { return "", true, nil } jobID, err = dispatcher.Enqueue(ctx, channel, recipient, payload) return jobID, false, err }