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>
117 lines
4.5 KiB
Go
117 lines
4.5 KiB
Go
// 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
|
|
}
|