Files
paliad/internal/services/email_template_variables.go
m 0e3411c40b feat(admin): /admin/email-templates editor (t-paliad-072)
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.

Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
  and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
  language file when no DB row, Save validates parse + structural
  invariants and writes a version, Reset deletes the active row, Restore
  copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
  back to the embedded default if the active row is malformed at parse
  time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
  text/template strings stored in the (key, lang) row. Default subjects
  ship with a {{/* keep this phrasing */}} comment pointing at the
  reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
  + .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
  No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
  RequireAdminFunc(users) admin middleware, same shape as /admin/team.

Frontend:
- /admin/email-templates list page — three cards (one per template),
  each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
  textarea + variable docs + actions on the left, sandboxed iframe
  preview + version log on the right. 500 ms debounced live preview;
  save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.

Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.

Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean

Design: docs/design-email-templates-2026-04-29.md.
2026-04-29 22:09:39 +02:00

122 lines
5.5 KiB
Go

// Variable contracts for the /admin/email-templates editor. Surfaces in the
// editor sidebar so an admin sees what `{{.Foo}}` placeholders are
// available and what each renders to with sample data. Single source of
// truth for both the docs and the sample data.
package services
// EmailTemplateVariable describes one placeholder a template may reference.
// Type is informational ("string", "[]Row", "bool", etc.); the editor uses
// it for rendering, not for validation.
type EmailTemplateVariable struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
SampleDE string `json:"sample_de"`
SampleEN string `json:"sample_en"`
}
// EmailTemplateVariables returns the variable contract for (key). The list
// is identical across languages; the SampleDE/SampleEN fields differ.
func EmailTemplateVariables(key string) []EmailTemplateVariable {
switch key {
case EmailTemplateKeyInvitation:
return invitationVariables
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestVariables
case EmailTemplateKeyBase:
return baseVariables
default:
return nil
}
}
var invitationVariables = []EmailTemplateVariable{
{Name: ".InviterName", Type: "string",
Description: "Anzeigename der einladenden Person.",
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
{Name: ".InviterEmail", Type: "string",
Description: "E-Mail-Adresse der einladenden Person.",
SampleDE: "maria.schmidt@hlc.com", SampleEN: "maria.schmidt@hlc.com"},
{Name: ".ToEmail", Type: "string",
Description: "Empfänger:in der Einladung.",
SampleDE: "neu.kollege@hlc.de", SampleEN: "new.colleague@hlc.com"},
{Name: ".Message", Type: "string (optional)",
Description: "Persönliche Nachricht der einladenden Person. Der Body sollte den Block mit {{if .Message}}…{{end}} umschliessen.",
SampleDE: "Hallo Kolleg:in — schau es dir an.", SampleEN: "Hi — have a look."},
{Name: ".RegisterURL", Type: "string",
Description: "Link zum Login/Registrierungs-Endpunkt.",
SampleDE: "https://paliad.de/login", SampleEN: "https://paliad.de/login"},
{Name: ".Firm", Type: "string",
Description: "Firmenname (FIRM_NAME). Wird im Body und Footer verwendet.",
SampleDE: "HLC", SampleEN: "HLC"},
}
var deadlineDigestVariables = []EmailTemplateVariable{
{Name: ".Slot", Type: "string",
Description: "Trigger-Slot: \"morning\" oder \"evening\". Body verwendet typischerweise .IsEvening.",
SampleDE: "morning", SampleEN: "morning"},
{Name: ".IsEvening", Type: "bool",
Description: "True im Abend-Slot — steuert die DRINGEND/URGENT-Headline.",
SampleDE: "false", SampleEN: "false"},
{Name: ".Overdue", Type: "[]Row",
Description: "Überfällige Fristen. Iteriere mit {{range .Overdue}}…{{end}}.",
SampleDE: "1 Eintrag", SampleEN: "1 entry"},
{Name: ".OverdueCount", Type: "int",
Description: "Länge von .Overdue, vorgerechnet für Überschriften.",
SampleDE: "1", SampleEN: "1"},
{Name: ".DueToday", Type: "[]Row",
Description: "Heute fällige Fristen.",
SampleDE: "2 Einträge", SampleEN: "2 entries"},
{Name: ".DueTodayCount", Type: "int",
Description: "Länge von .DueToday.",
SampleDE: "2", SampleEN: "2"},
{Name: ".DueWarning", Type: "[]Row",
Description: "Innerhalb der Vorwarnung fällige Fristen (typisch: ≤ 7 Tage).",
SampleDE: "1 Eintrag", SampleEN: "1 entry"},
{Name: ".DueWarningCount", Type: "int",
Description: "Länge von .DueWarning.",
SampleDE: "1", SampleEN: "1"},
{Name: ".OpenTotal", Type: "int",
Description: "Summe von .DueTodayCount + .DueWarningCount, vorgerechnet für die Betreffzeile.",
SampleDE: "3", SampleEN: "3"},
{Name: ".DeadlinesURL", Type: "string",
Description: "Ziel des „Alle Fristen / All deadlines\"-Buttons.",
SampleDE: "https://paliad.de/deadlines", SampleEN: "https://paliad.de/deadlines"},
{Name: ".Firm", Type: "string",
Description: "Firmenname (FIRM_NAME).",
SampleDE: "HLC", SampleEN: "HLC"},
{Name: "Row.DueDate", Type: "string (ISO)",
Description: "Fälligkeitsdatum, ISO YYYY-MM-DD.",
SampleDE: "2026-04-29", SampleEN: "2026-04-29"},
{Name: "Row.Title", Type: "string",
Description: "Frist-Titel.",
SampleDE: "Klageerwiderung einreichen", SampleEN: "File reply to complaint"},
{Name: "Row.ProjectReference", Type: "string",
Description: "Akten-/Projekt-Aktenzeichen.",
SampleDE: "HL-2025-0011", SampleEN: "HL-2025-0011"},
{Name: "Row.ProjectTitle", Type: "string (optional)",
Description: "Projekt-Titel; kann leer sein.",
SampleDE: "Gamma AG vs Delta Inc", SampleEN: "Gamma AG vs Delta Inc"},
{Name: "Row.OwnerName", Type: "string",
Description: "Eigentümer:in der Frist.",
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
{Name: "Row.IsOtherOwner", Type: "bool",
Description: "True wenn die Frist nicht der/dem Empfänger:in gehört (zeigt Eigentümer-Hinweis).",
SampleDE: "true", SampleEN: "true"},
{Name: "Row.URL", Type: "string",
Description: "Direktlink zur Frist-Detailseite.",
SampleDE: "https://paliad.de/deadlines/<uuid>", SampleEN: "https://paliad.de/deadlines/<uuid>"},
}
var baseVariables = []EmailTemplateVariable{
{Name: ".Lang", Type: "string",
Description: "Sprache der Mail. Wird in <html lang=\"…\"> eingesetzt.",
SampleDE: "de", SampleEN: "en"},
{Name: ".Subject", Type: "string",
Description: "Vom Inhalts-Template übergebene Betreffzeile. Erscheint im <title>-Tag.",
SampleDE: "Beispielbetreff", SampleEN: "Example subject"},
{Name: ".Firm", Type: "string",
Description: "Firmenname (FIRM_NAME).",
SampleDE: "HLC", SampleEN: "HLC"},
}