CFG-04: backend teil 1 — internal/notifyprefs (praeferenz-store + enqueueifallowed-filter vor dispatcher)

This commit is contained in:
sysops
2026-08-28 23:47:46 +02:00
parent 034865f5a0
commit 2df3f93373
3 changed files with 162 additions and 0 deletions
+144
View File
@@ -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
}
@@ -0,0 +1 @@
DROP TABLE notification_preferences;
@@ -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);