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>
179 lines
6.2 KiB
Go
179 lines
6.2 KiB
Go
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
|
|
// der Betreiber pflegt den plattformweiten Standard
|
|
// (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
|
|
// übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die
|
|
// feste, bekannte Liste der vom System versendeten E-Mails — aktuell
|
|
// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt.
|
|
package web
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
type emailVorlageTypInfo struct {
|
|
Typ string
|
|
Label string
|
|
Platzhalter string
|
|
}
|
|
|
|
var emailVorlageTypen = []emailVorlageTypInfo{
|
|
{
|
|
Typ: "passwort_zuruecksetzen",
|
|
Label: "Passwort zurücksetzen",
|
|
Platzhalter: "{{link}} — der Zurücksetzen-Link, wird beim Versand eingesetzt.",
|
|
},
|
|
}
|
|
|
|
func istGueltigerEmailVorlageTyp(typ string) bool {
|
|
for _, t := range emailVorlageTypen {
|
|
if t.Typ == typ {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer
|
|
// Vorlage — aktuell nur {{link}} (Passwort-Zurücksetzen-Link).
|
|
func renderEmailVorlage(text, link string) string {
|
|
return strings.ReplaceAll(text, "{{link}}", link)
|
|
}
|
|
|
|
// ─── Mandant (Ebene 4, admin-only): eigene Übersteuerung ───────────────
|
|
|
|
type emailVorlageZeile struct {
|
|
Typ string
|
|
Label string
|
|
Platzhalter string
|
|
Betreff string
|
|
Text string
|
|
IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt
|
|
}
|
|
|
|
type emailVorlagenListeData struct {
|
|
Title string
|
|
Nav navData
|
|
Zeilen []emailVorlageZeile
|
|
Error string
|
|
Gespeichert bool
|
|
ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
|
|
}
|
|
|
|
func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
|
|
accountID := currentUser(r).AccountID
|
|
data := emailVorlagenListeData{
|
|
Title: "E-Mail-Vorlagen", Nav: navFor(r),
|
|
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
|
|
ActionBase: "/verwaltung/email-vorlagen",
|
|
}
|
|
for _, t := range emailVorlageTypen {
|
|
resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, t.Typ)
|
|
if err != nil {
|
|
http.Error(w, "E-Mail-Vorlagen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
eigene, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
|
|
istUebersteuert := err == nil && eigene.ID == resolved.ID
|
|
data.Zeilen = append(data.Zeilen, emailVorlageZeile{
|
|
Typ: t.Typ, Label: t.Label, Platzhalter: t.Platzhalter,
|
|
Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: istUebersteuert,
|
|
})
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
typ := r.FormValue("typ")
|
|
if !istGueltigerEmailVorlageTyp(typ) {
|
|
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest)
|
|
return
|
|
}
|
|
accountID := currentUser(r).AccountID
|
|
betreff := strings.TrimSpace(r.FormValue("betreff"))
|
|
text := strings.TrimSpace(r.FormValue("text"))
|
|
if betreff == "" || text == "" {
|
|
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, err := s.store.UpsertEmailVorlage(r.Context(), &accountID, typ, betreff, text); err != nil {
|
|
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/verwaltung/email-vorlagen?gespeichert=1", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
typ := r.FormValue("typ")
|
|
if !istGueltigerEmailVorlageTyp(typ) {
|
|
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest)
|
|
return
|
|
}
|
|
accountID := currentUser(r).AccountID
|
|
if err := s.store.DeleteEmailVorlage(r.Context(), &accountID, typ); err != nil && !errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/verwaltung/email-vorlagen?gespeichert=1", http.StatusSeeOther)
|
|
}
|
|
|
|
// ─── Betreiber (Ebene 5): plattformweiter Standard ─────────────────────
|
|
|
|
func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
|
|
data := emailVorlagenListeData{
|
|
Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r),
|
|
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
|
|
ActionBase: "/betreiber/email-vorlagen",
|
|
}
|
|
for _, t := range emailVorlageTypen {
|
|
v, err := s.store.GetEmailVorlage(r.Context(), nil, t.Typ)
|
|
if err != nil {
|
|
http.Error(w, "E-Mail-Vorlagen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data.Zeilen = append(data.Zeilen, emailVorlageZeile{
|
|
Typ: t.Typ, Label: t.Label, Platzhalter: t.Platzhalter,
|
|
Betreff: v.Betreff, Text: v.Text,
|
|
})
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
typ := r.FormValue("typ")
|
|
if !istGueltigerEmailVorlageTyp(typ) {
|
|
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest)
|
|
return
|
|
}
|
|
betreff := strings.TrimSpace(r.FormValue("betreff"))
|
|
text := strings.TrimSpace(r.FormValue("text"))
|
|
if betreff == "" || text == "" {
|
|
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, err := s.store.UpsertEmailVorlage(r.Context(), nil, typ, betreff, text); err != nil {
|
|
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/betreiber/email-vorlagen?gespeichert=1", http.StatusSeeOther)
|
|
}
|