// 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 "" 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 }