internal/channels: konkrete Zustellkanaele fuer CFG-02s Dispatcher.
TemplateStore.Resolve loest Vorlagen pro Tenant auf und faellt auf
GlobalTemplateScope zurueck, wenn ein Tenant keine eigene gesetzt hat
(Akzeptanzkriterium 3). Render nutzt text/template mit
Option("missingkey=error") — ein fehlender Platzhalter bricht das Rendering
MIT FEHLER ab, statt eine unvollstaendige Nachricht zu erzeugen
(Akzeptanzkriterium 1).
EmailSender implementiert notify.Sender: rendert ZUERST die Vorlage, bevor
ueberhaupt eine SMTP-Verbindung aufgebaut wird — schlaegt das Rendering
fehl, wird nie ein Netzwerkzugriff versucht. Ein anschliessend fehl-
schlagender SMTP-Versand liefert einen Fehler, den CFG-02s bereits
getestete Wiederholungslogik verarbeitet (kein zweiter Retry-Mechanismus
hier). InAppSender persistiert In-App-Nachrichten ueber InAppStore
(Akzeptanzkriterium 2, ueber API abrufbar/als gelesen markierbar). Router
waehlt den Kanal anhand Notification.Channel — ein neuer Kanal wird per
Register() ergaenzt, ohne Dispatcher oder Router umzubauen.
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Vorlagenrendering mit fehlenden Platzhaltern bricht kontrolliert ab —
TestRender_MissingPlaceholderAborts und
TestEmailSender_AbortsBeforeSMTPWhenTemplateMissing (Fehler kommt von der
Vorlagenaufloesung, kein SMTP-Verbindungsversuch). PASS.
2. In-App-Benachrichtigung nach Markierung als gelesen korrekt gefuehrt —
TestInAppStore_MarkReadIsReflectedCorrectly. PASS.
3. E-Mail-Versand bei nicht erreichbarem SMTP-Server loest dokumentiertes
Retry-Verhalten ueber CFG-02 aus —
TestEmailSender_TriggersDispatcherRetryOnUnreachableSMTP: echter
EmailSender gegen unerreichbaren Host, ueber notify.Dispatcher
eingereiht, nach ausgeschoepften Wiederholungen status=failed mit
korrekter Versuchszahl. PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
105 lines
3.4 KiB
Go
105 lines
3.4 KiB
Go
// Package channels implementiert Core CFG-03: konkrete Zustellkanaele fuer
|
|
// den CFG-02-Dispatcher (E-Mail, In-App) inklusive Vorlagenverwaltung.
|
|
// Neue Kanaele lassen sich ergaenzen, ohne den Dispatcher selbst
|
|
// anzufassen — jeder Kanal implementiert nur notify.Sender.
|
|
package channels
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"text/template"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// GlobalTemplateScope ist der Fallback-Wert, wenn ein Tenant keine eigene
|
|
// Vorlage konfiguriert hat (Akzeptanzkriterium 3).
|
|
const GlobalTemplateScope = "global"
|
|
|
|
var ErrTemplateNotFound = errors.New("channels: keine vorlage gefunden")
|
|
|
|
type Template struct {
|
|
Subject string
|
|
Body string
|
|
}
|
|
|
|
type TemplateStore struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewTemplateStore(pool *pgxpool.Pool) *TemplateStore {
|
|
return &TemplateStore{pool: pool}
|
|
}
|
|
|
|
// Set legt eine Vorlage fuer einen Tenant (oder GlobalTemplateScope) fest.
|
|
func (s *TemplateStore) Set(ctx context.Context, tenantSlug, key, subject, body string) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO notification_templates (tenant_slug, key, subject, body, updated_at)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
ON CONFLICT (tenant_slug, key) DO UPDATE SET subject = $3, body = $4, updated_at = now()
|
|
`, tenantSlug, key, subject, body)
|
|
if err != nil {
|
|
return fmt.Errorf("vorlage speichern: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resolve liefert die Vorlage eines Tenants, faellt auf GlobalTemplateScope
|
|
// zurueck, wenn der Tenant keine eigene gesetzt hat (Akzeptanzkriterium 3).
|
|
func (s *TemplateStore) Resolve(ctx context.Context, tenantSlug, key string) (Template, error) {
|
|
if tenantSlug != "" && tenantSlug != GlobalTemplateScope {
|
|
if t, err := s.get(ctx, tenantSlug, key); err == nil {
|
|
return t, nil
|
|
} else if !errors.Is(err, ErrTemplateNotFound) {
|
|
return Template{}, err
|
|
}
|
|
}
|
|
return s.get(ctx, GlobalTemplateScope, key)
|
|
}
|
|
|
|
func (s *TemplateStore) get(ctx context.Context, tenantSlug, key string) (Template, error) {
|
|
var t Template
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT subject, body FROM notification_templates WHERE tenant_slug = $1 AND key = $2
|
|
`, tenantSlug, key).Scan(&t.Subject, &t.Body)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Template{}, ErrTemplateNotFound
|
|
}
|
|
return Template{}, fmt.Errorf("vorlage lesen: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// Render fuellt eine Vorlage mit data. Fehlt ein referenzierter Platzhalter
|
|
// in data, bricht das Rendering kontrolliert MIT FEHLER ab, statt eine
|
|
// fehlerhafte/unvollstaendige Nachricht zu erzeugen (Akzeptanzkriterium 1 /
|
|
// Pruefung 1) — text/template mit Option("missingkey=error") liefert dafuer
|
|
// einen Fehler statt stillschweigend "<no value>" einzusetzen.
|
|
func Render(tmpl Template, data map[string]any) (subject, body string, err error) {
|
|
subject, err = renderOne("subject", tmpl.Subject, data)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
body, err = renderOne("body", tmpl.Body, data)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return subject, body, nil
|
|
}
|
|
|
|
func renderOne(name, text string, data map[string]any) (string, error) {
|
|
tmpl, err := template.New(name).Option("missingkey=error").Parse(text)
|
|
if err != nil {
|
|
return "", fmt.Errorf("vorlage %q parsen: %w", name, err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return "", fmt.Errorf("vorlage %q rendern (fehlender platzhalter?): %w", name, err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|