145 lines
4.9 KiB
Go
145 lines
4.9 KiB
Go
// 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
|
|
}
|