// Package notify implementiert Core CFG-02: den zentralen Benachrichtigungs- // Dispatcher, ueber den beliebige Module Benachrichtigungen ausloesen — // Warteschlange, Wiederholungslogik, Kanal-Abstraktion. Die tatsaechlichen // Kanaele (E-Mail/In-App) sind CFG-03, hier gibt es nur die Sender- // Schnittstelle als Vorbereitung. package notify import ( "context" "encoding/json" "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" ) // DefaultMaxAttempts begrenzt Wiederholungsversuche (Akzeptanzkriterium 2) — // nach dieser Anzahl gibt der Dispatcher kontrolliert auf (status=failed) // statt endlos zu wiederholen. const DefaultMaxAttempts = 5 // DefaultRetryBackoff ist die Basis-Wartezeit zwischen Wiederholungen, // linear mit der Versuchsnummer skaliert. const DefaultRetryBackoff = 200 * time.Millisecond type Notification struct { ID string Channel string Recipient string Payload map[string]any Attempts int } // Sender ist die schmale Schnittstelle, die ein konkreter Kanal (CFG-03) // implementiert. Der Dispatcher selbst weiss nichts ueber E-Mail/In-App. type Sender interface { Send(ctx context.Context, n Notification) error } // Dispatcher ist die EINE Schnittstelle, ueber die Module Benachrichtigungen // ausloesen — kein Modul baut eigenen Versandcode (Akzeptanzkriterium 1). type Dispatcher struct { pool *pgxpool.Pool maxAttempts int retryBackoff time.Duration } func NewDispatcher(pool *pgxpool.Pool) *Dispatcher { return &Dispatcher{pool: pool, maxAttempts: DefaultMaxAttempts, retryBackoff: DefaultRetryBackoff} } // WithRetryPolicy erlaubt Tests/Betrieb, Versuchsanzahl und Backoff // anzupassen, ohne die Default-Policy im Produktionscode zu veraendern. func (d *Dispatcher) WithRetryPolicy(maxAttempts int, backoff time.Duration) *Dispatcher { return &Dispatcher{pool: d.pool, maxAttempts: maxAttempts, retryBackoff: backoff} } // Enqueue reiht eine Benachrichtigung in die Postgres-Warteschlange ein und // kehrt sofort zurueck — die Zeile ueberlebt jeden Neustart des Dispatcher- // Prozesses unveraendert (Akzeptanzkriterium 3), da sie ausschliesslich in // der Datenbank existiert, nicht im Prozessspeicher. func (d *Dispatcher) Enqueue(ctx context.Context, channel, recipient string, payload map[string]any) (string, error) { if payload == nil { payload = map[string]any{} } payloadJSON, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("payload serialisieren: %w", err) } var id string err = d.pool.QueryRow(ctx, ` INSERT INTO notification_jobs (channel, recipient, payload, max_attempts) VALUES ($1, $2, $3, $4) RETURNING id `, channel, recipient, payloadJSON, d.maxAttempts).Scan(&id) if err != nil { return "", fmt.Errorf("benachrichtigung einreihen: %w", err) } return id, nil }