diff --git a/internal/notifyprefs/prefs.go b/internal/notifyprefs/prefs.go new file mode 100644 index 0000000..61377ee --- /dev/null +++ b/internal/notifyprefs/prefs.go @@ -0,0 +1,144 @@ +// 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 +} diff --git a/migrations/0007_notification_preferences.down.sql b/migrations/0007_notification_preferences.down.sql new file mode 100644 index 0000000..d31413b --- /dev/null +++ b/migrations/0007_notification_preferences.down.sql @@ -0,0 +1 @@ +DROP TABLE notification_preferences; diff --git a/migrations/0007_notification_preferences.up.sql b/migrations/0007_notification_preferences.up.sql new file mode 100644 index 0000000..1e5a0b4 --- /dev/null +++ b/migrations/0007_notification_preferences.up.sql @@ -0,0 +1,17 @@ +-- CFG-04: Benachrichtigungspraeferenzen je Benutzer, Ereignistyp und Kanal. +-- Lebt wie notification_templates/in_app_notifications (CFG-03) in der +-- Registry-DB — modulübergreifende Konfiguration, keine Mandanten-Geschaeftsdaten. +-- +-- Kein Row = aktiviert (Opt-out-Modell): ein Benutzer verpasst nichts, bis er +-- aktiv einen Kanal/Ereignistyp abschaltet — sicherer Default als Opt-in. +CREATE TABLE notification_preferences ( + tenant_slug TEXT NOT NULL, + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled BOOLEAN NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_slug, user_id, event_type, channel) +); + +CREATE INDEX notification_preferences_tenant_idx ON notification_preferences (tenant_slug);