From 9e216a4c44aef46f74ba05d57b63c5c17f02bad8 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 29 Apr 2026 19:06:15 +0200 Subject: [PATCH 1/3] =?UTF-8?q?docs(admin):=20design=20=E2=80=94=20admin?= =?UTF-8?q?=20email-templates=20editor=20(t-paliad-072)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB-backed templates with embedded fallback, per-language split, full edit/preview/version/restore loop. Subject moves from Go-built strings to template-rendered. Five open questions for m parked at §8 — most loaded: should base.html be editable or read-only. --- docs/design-email-templates-2026-04-29.md | 611 ++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 docs/design-email-templates-2026-04-29.md diff --git a/docs/design-email-templates-2026-04-29.md b/docs/design-email-templates-2026-04-29.md new file mode 100644 index 0000000..2b69ba1 --- /dev/null +++ b/docs/design-email-templates-2026-04-29.md @@ -0,0 +1,611 @@ +# Admin Email-Templates editor — design + +**Task:** t-paliad-072 (ritchie, inventor) +**Date:** 2026-04-29 +**Status:** design — awaiting m's go/no-go before coder shift + +## Problem statement + +Today the three email templates Paliad sends (`invitation`, `deadline_digest`, +plus the shared `base.html` wrapper) live in `internal/templates/email/*.html`, +embedded into the binary at build time and rendered by `MailService` +(`internal/services/mail_service.go`). Editing copy — even fixing a typo in +the German `Heute fällig` heading — requires a code change, PR, merge to main, +Dokploy redeploy. + +The `/admin` landing page already advertises an "Email-Templates" card as +"Kommt bald" (`frontend/src/admin.tsx:36-42`). This task fills it in: an +admin can read each template, edit subject + body, preview against sample +data, save without a deploy, and roll back if a save was wrong. + +--- + +## 1. Storage decision + +**Decision: DB-backed, with the embedded files as the fallback default.** + +The task brief recommends DB unless m explicitly wants filesystem-only, and +the whole rationale for surfacing a card on `/admin` is in-place editing. A +filesystem-only "preview + variable docs" page would be a different feature +(more like `/admin/email-templates/docs`) and doesn't fit the card we +promised. + +The embedded files **stay**. They are: + +1. The seed source (initial DB rows are populated from them). +2. The render-time fallback when a DB row is missing or malformed (so a + broken save can never wedge an entire send path — see §3). +3. The "Reset to default" target (always available, always parseable). + +### Schema + +Two new tables in a single migration `026_email_templates.up.sql`. RLS +enabled with no policies — service-only access, same pattern as +`paliad.invitations` / `paliad.reminder_log`. + +```sql +-- Active template body per (key, lang). Exactly one row per pair, kept +-- current by UPSERT on save. Absence == use embedded fallback. +CREATE TABLE paliad.email_templates ( + key text NOT NULL, + lang text NOT NULL CHECK (lang IN ('de', 'en')), + subject text NOT NULL, -- text/template source + body text NOT NULL, -- html/template source ({{define "content"}}) + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + PRIMARY KEY (key, lang) +); + +-- Append-only version log. Captures every save (and every reset). The +-- service garbage-collects to the most recent VERSION_RETENTION rows per +-- (key, lang) inside the same transaction as the save. +CREATE TABLE paliad.email_template_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + key text NOT NULL, + lang text NOT NULL CHECK (lang IN ('de', 'en')), + subject text NOT NULL, + body text NOT NULL, + saved_at timestamptz NOT NULL DEFAULT now(), + saved_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + note text NOT NULL DEFAULT '' -- '', 'reset', 'restore from ' +); + +CREATE INDEX email_template_versions_key_lang_idx + ON paliad.email_template_versions (key, lang, saved_at DESC); + +ALTER TABLE paliad.email_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE paliad.email_template_versions ENABLE ROW LEVEL SECURITY; +``` + +`key` is one of `invitation`, `deadline_digest`, `base` — the existing +template-name set. `lang` is `de` or `en`. The pair `('base', 'de')` is the +shared wrapper (it has lang-conditional bits in it today, but those become +language-specific copies after the split — see §1.3). + +`VERSION_RETENTION = 20` per (key, lang). After 20 the oldest is deleted on +save. Trade-off: 20 saves per language per template means at most +3 templates × 2 languages × 20 = 120 rows total in steady state. Negligible +storage; gives admins room to recover from "I edited this five times in a row +to get the wording right and then realised the third version was correct". + +### Migration plan + +1. **Migration 026 (this PR)** — creates both tables. Does **not** seed any + rows. First-render-after-deploy reads embedded fallbacks; first save from + the editor inserts the active row. +2. **Embedded file split (this PR)** — replace each existing + `.html` with `.de.html` and `.en.html`. The current + bilingual files use `{{if eq .Lang "en"}}…{{else}}…{{end}}` blocks; we + split each branch into its own file. After this migration **no template + contains a `Lang` conditional** — language is selected by file (or DB + row) lookup, not by an in-template branch. This makes the editor UX + simple ("you're editing the German invitation" — one file, no nested + conditionals to confuse the reader). +3. **MailService refactor** — `RenderTemplate` looks up `EmailTemplateService.GetActive(key, lang)` first; on miss reads the embedded `..html`. `base` is loaded the same way (so the wrapper is editable). The render itself stays `html/template` over the cloned base. +4. **Subject becomes data, not code.** The hard-coded `inviteSubject` and `buildDigestSubject` in Go go away. Each template's DB row has a `subject` column whose contents are a `text/template` source (not `html/template` — subject lines aren't HTML). Caller passes the same `Data` map; service renders subject and body from the same payload. + + *Trade-off*: today's subject logic for `deadline_digest` is conditional + ("SYSTEMAUSFALL" vs "URGENT" vs plain count). It moves into the template + syntax verbatim. Admins editing the subject see the conditional clearly + and can adjust the framing. **Mitigation against admin breakage**: save + validates the subject template parses cleanly *and* renders without error + against the same sample data the body preview uses (§3.4). + +### Why not split DE/EN at the file level only (no DB)? + +Considered. Rejected because the rejected version is "the editor card is a +preview + docs page, no editing". That removes the only feature the card was +named after. If m vetoes DB-backed editing, the fallback is to swap this card +for "Email-Templates (Vorschau + Variablen)" — but that's a different +product decision and I'd want to confirm before building toward it. + +--- + +## 2. Editor UX + +### Page layout + +`GET /admin/email-templates` — gated identically to `/admin/team`: +`auth.RequireAdminFunc(users, gateOnboarded(handleAdminEmailTemplatesPage))`. + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Sidebar │ +│ │ +│ Email-Templates [Vorschau ↻]│ +│ Vorlagen für Einladungen, Erinnerungen und Layout-Wrapper. │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Einladung │ │ Fristen- │ │ Basis │ │ +│ │ invitation │ │ Sammelmail │ │ base │ │ +│ │ │ │ deadline_… │ │ Layout- │ │ +│ │ Zuletzt: │ │ │ │ Wrapper │ │ +│ │ 2026-04-12 │ │ Standard │ │ Standard │ │ +│ │ Standard │ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +Three template cards. Each card shows: human title, internal key, +"Zuletzt geändert" date (or "Standard" if no DB override), language badges +(DE/EN — clicking enters the editor for that language). + +### Editor view + +`/admin/email-templates/{key}?lang=de` (lang query, defaults to `de`). + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ ← Zurück Einladung — Deutsch [DE] [EN] │ +│ │ +│ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ │ +│ │ Betreff │ │ VORSCHAU │ │ +│ │ ┌───────────────────────────┐ │ │ ┌─────────────────────────┐ │ │ +│ │ │ Einladung von {{.Inviter…│ │ │ │