diff --git a/CLAUDE.md b/CLAUDE.md index 76c4719..f13ad15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -640,9 +640,15 @@ sein — für E-Mails, die an die eigenen Mitarbeiter einer Firma gehen, durch den Mandanten selbst; für E-Mails, die die Plattform an Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter -Standard (`GET/POST /betreiber/email-vorlagen`, nur Betreiber), gesetzt -= mandantenspezifische Übersteuerung (`GET/POST /verwaltung/email- -vorlagen`, nur Admin). `ResolveEmailVorlage` liefert die eigene +Standard (`/betreiber/email-vorlagen`, nur Betreiber), gesetzt = +mandantenspezifische Übersteuerung (`/verwaltung/email-vorlagen`, nur +Admin) — jeweils ein eigener Nav-Punkt (nicht als weiterer Button auf +einer bestehenden Seite), mit Liste + eigener Bearbeiten-Seite je Typ +(`GET .../email-vorlagen` Liste, `GET/POST .../email-vorlagen/{typ}` +Bearbeiten) statt eines einzigen, mit allen Formularen gestapelten +Screens — auf ausdrücklichen Nutzerwunsch, da mit der Zeit weitere +Benachrichtigungstypen dazukommen sollen und eine gestapelte Liste dann +unübersichtlich würde. `ResolveEmailVorlage` liefert die eigene Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein Mandant, der nie etwas anpasst, bekommt automatisch jede künftige Änderung des Plattform-Standards. `emailVorlageTypen` in diff --git a/internal/web/email_vorlage_handlers.go b/internal/web/email_vorlage_handlers.go index de5d9b6..178c15a 100644 --- a/internal/web/email_vorlage_handlers.go +++ b/internal/web/email_vorlage_handlers.go @@ -3,7 +3,10 @@ // (/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. +// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt, +// aber bewusst als Liste-mit-Bearbeiten-Seite gebaut (wie Werkzeugkatalog/ +// Nutzerverwaltung), nicht als eine Seite mit allen Formularen gestapelt +// — mit der Zeit kommen weitere Benachrichtigungstypen dazu. package web import ( @@ -28,13 +31,13 @@ var emailVorlageTypen = []emailVorlageTypInfo{ }, } -func istGueltigerEmailVorlageTyp(typ string) bool { +func findEmailVorlageTyp(typ string) (emailVorlageTypInfo, bool) { for _, t := range emailVorlageTypen { if t.Typ == typ { - return true + return t, true } } - return false + return emailVorlageTypInfo{}, false } // renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer @@ -48,54 +51,74 @@ func renderEmailVorlage(text, link string) string { 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 + Title string + Nav navData + Zeilen []emailVorlageZeile + 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", - } + data := emailVorlagenListeData{Title: "E-Mail-Vorlagen", Nav: navFor(r), 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, - }) + _, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ) + data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label, IstUebersteuert: err == nil}) } 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) +type emailVorlageBearbeitenData struct { + Title string + Nav navData + Typ string + Label string + Platzhalter string + Betreff string + Text string + IstUebersteuert bool + ActionBase string + Error string + Gespeichert bool +} + +func (s *Server) handleEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) { + typ := r.PathValue("typ") + info, ok := findEmailVorlageTyp(typ) + if !ok { + http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound) return } - typ := r.FormValue("typ") - if !istGueltigerEmailVorlageTyp(typ) { - http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest) + accountID := currentUser(r).AccountID + resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, typ) + if err != nil { + http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + _, uebersteuert := s.store.GetEmailVorlage(r.Context(), &accountID, typ) + data := emailVorlageBearbeitenData{ + Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter, + Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: uebersteuert == nil, + ActionBase: "/verwaltung/email-vorlagen", Gespeichert: r.URL.Query().Get("gespeichert") == "1", + } + if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) { + typ := r.PathValue("typ") + if _, ok := findEmailVorlageTyp(typ); !ok { + http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) return } accountID := currentUser(r).AccountID @@ -109,17 +132,13 @@ func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Requ http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } - http.Redirect(w, r, "/verwaltung/email-vorlagen?gespeichert=1", http.StatusSeeOther) + http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?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) + typ := r.PathValue("typ") + if _, ok := findEmailVorlageTyp(typ); !ok { + http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound) return } accountID := currentUser(r).AccountID @@ -127,41 +146,51 @@ func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http. http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } - http.Redirect(w, r, "/verwaltung/email-vorlagen?gespeichert=1", http.StatusSeeOther) + http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?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", - } + data := emailVorlagenListeData{Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r), 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, - }) + data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label}) } 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) +func (s *Server) handleBetreiberEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) { + typ := r.PathValue("typ") + info, ok := findEmailVorlageTyp(typ) + if !ok { + http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound) return } - typ := r.FormValue("typ") - if !istGueltigerEmailVorlageTyp(typ) { - http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest) + v, err := s.store.GetEmailVorlage(r.Context(), nil, typ) + if err != nil { + http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := emailVorlageBearbeitenData{ + Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter, + Betreff: v.Betreff, Text: v.Text, ActionBase: "/betreiber/email-vorlagen", + Gespeichert: r.URL.Query().Get("gespeichert") == "1", + } + if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) { + typ := r.PathValue("typ") + if _, ok := findEmailVorlageTyp(typ); !ok { + http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) return } betreff := strings.TrimSpace(r.FormValue("betreff")) @@ -174,5 +203,5 @@ func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r * http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } - http.Redirect(w, r, "/betreiber/email-vorlagen?gespeichert=1", http.StatusSeeOther) + http.Redirect(w, r, "/betreiber/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther) } diff --git a/internal/web/email_vorlage_handlers_test.go b/internal/web/email_vorlage_handlers_test.go index d41b313..49d6680 100644 --- a/internal/web/email_vorlage_handlers_test.go +++ b/internal/web/email_vorlage_handlers_test.go @@ -13,22 +13,30 @@ func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) { s := newServer(t, fs) cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") - getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen") + listResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen") + if listResp.Code != http.StatusOK { + t.Fatalf("Liste-Status = %d, want 200", listResp.Code) + } + if !strings.Contains(listResp.Body.String(), "/verwaltung/email-vorlagen/passwort_zuruecksetzen") { + t.Fatalf("erwartet Link zur Bearbeiten-Seite, body: %s", listResp.Body.String()) + } + + getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen") if getResp.Code != http.StatusOK { - t.Fatalf("GET status = %d, want 200", getResp.Code) + t.Fatalf("GET Bearbeiten-Seite status = %d, want 200", getResp.Code) } if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") { t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String()) } - saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen", url.Values{ - "typ": {"passwort_zuruecksetzen"}, "betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"}, + saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{ + "betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"}, }) if saveResp.Code != http.StatusSeeOther { t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String()) } - afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen") + afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen") body := afterResp.Body.String() if !strings.Contains(body, "Mein eigener Betreff") { t.Fatalf("gespeicherter Betreff fehlt, body: %s", body) @@ -37,11 +45,11 @@ func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) { t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body) } - resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/zuruecksetzen", url.Values{"typ": {"passwort_zuruecksetzen"}}) + resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen/zuruecksetzen", url.Values{}) if resetResp.Code != http.StatusSeeOther { t.Fatalf("reset status = %d, want 303", resetResp.Code) } - finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen") + finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen") if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") { t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv") } @@ -52,14 +60,14 @@ func TestBetreiberKannPlattformStandardBearbeiten(t *testing.T) { s := newServer(t, fs) cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber") - saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen", url.Values{ - "typ": {"passwort_zuruecksetzen"}, "betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"}, + saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen", url.Values{ + "betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"}, }) if saveResp.Code != http.StatusSeeOther { t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String()) } - getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen") + getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen") if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") { t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String()) } @@ -81,8 +89,8 @@ func TestPasswortVergessenNutztMandantenspezifischeVorlage(t *testing.T) { s, fm := newServerWithMailer(t, fs) cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin") - if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen", url.Values{ - "typ": {"passwort_zuruecksetzen"}, "betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."}, + if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{ + "betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."}, }); resp.Code != http.StatusSeeOther { t.Fatalf("save status = %d, want 303", resp.Code) } diff --git a/internal/web/server.go b/internal/web/server.go index 4c91103..e069760 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -167,10 +167,12 @@ func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, erro mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe)) mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern)) mux.HandleFunc("GET /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe)) - mux.HandleFunc("POST /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlageSpeichern)) - mux.HandleFunc("POST /verwaltung/email-vorlagen/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen)) + mux.HandleFunc("GET /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageBearbeitenForm)) + mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageSpeichern)) + mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen)) mux.HandleFunc("GET /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe)) - mux.HandleFunc("POST /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern)) + mux.HandleFunc("GET /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageBearbeitenForm)) + mux.HandleFunc("POST /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern)) mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm) mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen) mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm) diff --git a/internal/web/templates/email_vorlage_bearbeiten.html b/internal/web/templates/email_vorlage_bearbeiten.html new file mode 100644 index 0000000..1287199 --- /dev/null +++ b/internal/web/templates/email_vorlage_bearbeiten.html @@ -0,0 +1,49 @@ +{{define "email-vorlage-bearbeiten"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← E-Mail-Vorlagen

+ +{{if .Error}}

{{.Error}}

{{end}} +{{if .Gespeichert}}

Gespeichert.

{{end}} +
+
+
+ {{.Label}}{{if .IstUebersteuert}} (eigene Vorlage){{else if eq .ActionBase "/verwaltung/email-vorlagen"}} (Plattform-Standard, nicht angepasst){{end}} +
+
+ + +
+
+ + +
+
+
+
+ +
+
+{{if and .IstUebersteuert (eq .ActionBase "/verwaltung/email-vorlagen")}} +
+
+ +
+
+{{end}} +
+
+ + +{{end}} diff --git a/internal/web/templates/email_vorlagen_liste.html b/internal/web/templates/email_vorlagen_liste.html index 4ad1ba3..aa91b11 100644 --- a/internal/web/templates/email_vorlagen_liste.html +++ b/internal/web/templates/email_vorlagen_liste.html @@ -7,46 +7,22 @@ -{{if .Error}}

{{.Error}}

{{end}} -{{if .Gespeichert}}

Gespeichert.

{{end}} -{{range .Zeilen}} -
-
- -
- {{.Label}}{{if .IstUebersteuert}} (eigene Vorlage){{else if eq $.ActionBase "/verwaltung/email-vorlagen"}} (Plattform-Standard, nicht angepasst){{end}} -

Verfügbare Platzhalter: {{.Platzhalter}}

-
-
- - -
-
- - -
-
-
-
- -
-
-{{if and .IstUebersteuert (eq $.ActionBase "/verwaltung/email-vorlagen")}} -
-
- - -
-
-{{end}} -
+{{if not .Zeilen}} +

Es gibt noch keine vom System versendeten E-Mails.

+{{else}} + {{end}}