Files
paliad/internal/handlers/email_templates.go
m 460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00

270 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// email_templates.go — backing endpoints for /admin/email-templates
// (t-paliad-072). Routes are registered behind RequireAdminFunc in
// handlers.go, so handlers assume the caller is global_admin and only the
// operation itself needs validation.
//
// All routes require DATABASE_URL — the editor only makes sense when there's
// somewhere to persist saves. Reads still serve embedded defaults via
// EmailTemplateService.GetActive when no DB row exists, but the editor as a
// surface is gated by requireDB just like /admin/team.
// emailTemplateSummary is one row in the list-templates response. Each (key,
// lang) pair gets its own summary so the editor's three-card list can show
// "Standard" or "Zuletzt geändert: <date>" per language.
type emailTemplateSummary struct {
Key string `json:"key"`
Lang string `json:"lang"`
IsDefault bool `json:"is_default"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
UpdatedBy *uuid.UUID `json:"updated_by,omitempty"`
}
// GET /api/admin/email-templates — summaries for every (canonical key × lang)
// pair, in canonical order. Used by the list page to render the per-template
// cards.
func handleAdminListEmailTemplates(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
out := make([]emailTemplateSummary, 0, len(services.CanonicalEmailTemplateKeys)*len(services.EmailTemplateLanguages))
for _, key := range services.CanonicalEmailTemplateKeys {
for _, lang := range services.EmailTemplateLanguages {
row, err := dbSvc.emailTemplate.GetActive(r.Context(), key, lang)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
out = append(out, emailTemplateSummary{
Key: key,
Lang: lang,
IsDefault: row.IsDefault,
UpdatedAt: row.UpdatedAt,
UpdatedBy: row.UpdatedBy,
})
}
}
writeJSON(w, http.StatusOK, out)
}
// GET /api/admin/email-templates/{key}/{lang} — full active row (subject +
// body + IsDefault + updated_at). Editor uses this to populate the form.
func handleAdminGetEmailTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
key := r.PathValue("key")
lang := r.PathValue("lang")
row, err := dbSvc.emailTemplate.GetActive(r.Context(), key, lang)
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// GET /api/admin/email-templates/{key}/variables — the variable contract for
// a key. Lang-agnostic (sample fields for both languages are in the payload).
func handleAdminEmailTemplateVariables(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
key := r.PathValue("key")
if !services.IsCanonicalKey(key) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": services.ErrTemplateUnknownKey.Error()})
return
}
writeJSON(w, http.StatusOK, services.EmailTemplateVariables(key))
}
// previewRequest is the JSON shape for POST .../{key}/{lang}/preview.
type previewRequest struct {
Subject string `json:"subject"`
Body string `json:"body"`
// Slot is honoured for deadline_digest only ("morning" or "evening").
Slot string `json:"slot,omitempty"`
}
type previewResponse struct {
Subject string `json:"subject_rendered"`
HTML string `json:"html_rendered"`
}
// POST /api/admin/email-templates/{key}/{lang}/preview — render proposed
// subject + body against sample data, no persistence. 422 on parse error so
// the editor can surface the message inline.
func handleAdminPreviewEmailTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
key := r.PathValue("key")
lang := r.PathValue("lang")
if !services.IsCanonicalKey(key) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": services.ErrTemplateUnknownKey.Error()})
return
}
if lang != "de" && lang != "en" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": services.ErrTemplateUnknownLang.Error()})
return
}
var in previewRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if err := services.ValidateTemplate(key, in.Subject, in.Body); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
sample := services.EmailTemplateSampleData(key, lang, in.Slot)
subj, html, err := dbSvc.mail.RenderPreview(key, lang, in.Subject, in.Body, sample)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, previewResponse{Subject: subj, HTML: html})
}
// saveRequest is the JSON shape for PUT .../{key}/{lang}.
type saveRequest struct {
Subject string `json:"subject"`
Body string `json:"body"`
Note string `json:"note,omitempty"`
}
// PUT /api/admin/email-templates/{key}/{lang} — validate, upsert active row,
// append a version. Returns the new version row (incl. id). 422 on bad
// templates surfaces inline in the editor.
func handleAdminSaveEmailTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
key := r.PathValue("key")
lang := r.PathValue("lang")
var in saveRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
ver, err := dbSvc.emailTemplate.Save(r.Context(), services.SaveInput{
Key: key,
Lang: lang,
Subject: in.Subject,
Body: in.Body,
Note: in.Note,
SavedBy: uid,
})
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, ver)
}
// POST /api/admin/email-templates/{key}/{lang}/reset — drop the active row
// and append a 'reset' version. Subsequent renders fall through to the
// embedded default.
func handleAdminResetEmailTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
ver, err := dbSvc.emailTemplate.Reset(r.Context(), r.PathValue("key"), r.PathValue("lang"), uid)
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, ver)
}
// GET /api/admin/email-templates/{key}/{lang}/versions — most-recent-first
// list, capped at EmailTemplateVersionRetention.
func handleAdminListEmailTemplateVersions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
rows, err := dbSvc.emailTemplate.ListVersions(r.Context(), r.PathValue("key"), r.PathValue("lang"))
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/admin/email-templates/{key}/{lang}/restore/{version_id} —
// copy a historical version back into the active row. Always appends a
// fresh version row that records the restore source.
func handleAdminRestoreEmailTemplateVersion(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
versionID, err := uuid.Parse(r.PathValue("version_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version id"})
return
}
ver, err := dbSvc.emailTemplate.RestoreVersion(r.Context(),
r.PathValue("key"), r.PathValue("lang"), versionID, uid)
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, ver)
}
// handleAdminEmailTemplatesPage serves the SPA shell for the list page.
func handleAdminEmailTemplatesPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-email-templates.html")
}
// handleAdminEmailTemplatesEditPage serves the SPA shell for the editor.
// Same shell for every key — the client reads {key} from the URL and fetches
// the active row.
func handleAdminEmailTemplatesEditPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-email-templates-edit.html")
}
// writeEmailTemplateError maps EmailTemplateService sentinel errors to
// status codes the editor can react to. Anything else is 500.
func writeEmailTemplateError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrTemplateUnknownKey),
errors.Is(err, services.ErrTemplateVersionNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrTemplateUnknownLang):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrTemplateBodySyntax),
errors.Is(err, services.ErrTemplateSubjectSyntax),
errors.Is(err, services.ErrTemplateMissingContent),
errors.Is(err, services.ErrTemplateMissingBaseBlock):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrTemplateStoreUnavailable):
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}