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