feat: pro Mandant editierbare E-Mail-Vorlagen
Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform- Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding. Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu aktualisieren. Fix: zwei partielle Unique-Indizes statt eines gemeinsamen Constraints, mit Regressionstest abgesichert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
116
internal/store/email_vorlage.go
Normal file
116
internal/store/email_vorlage.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
|
||||
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
|
||||
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
|
||||
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type EmailVorlage struct {
|
||||
ID string
|
||||
AccountID *string
|
||||
Typ string
|
||||
Betreff string
|
||||
Text string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
|
||||
// accountID nil schreibt den plattformweiten Standard (nur für den
|
||||
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
|
||||
//
|
||||
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
|
||||
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
|
||||
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
|
||||
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
|
||||
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
|
||||
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
|
||||
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
var err error
|
||||
if accountID == nil {
|
||||
err = s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||
VALUES (NULL, $1, $2, $3)
|
||||
ON CONFLICT (typ) WHERE account_id IS NULL
|
||||
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
|
||||
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
} else {
|
||||
err = s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
|
||||
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
|
||||
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
|
||||
// accountID nil sucht den plattformweiten Standard.
|
||||
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return EmailVorlage{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
|
||||
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
|
||||
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
|
||||
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
|
||||
// Plattform-Standard wird per Migration angelegt).
|
||||
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
|
||||
ORDER BY account_id NULLS LAST
|
||||
LIMIT 1
|
||||
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return EmailVorlage{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
|
||||
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
|
||||
// wieder auf den plattformweiten Standard zurück), beim Betreiber
|
||||
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
|
||||
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
|
||||
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `
|
||||
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||
`, accountID, typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete email vorlage: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user