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.
270 lines
9.1 KiB
Go
270 lines
9.1 KiB
Go
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()})
|
||
}
|
||
}
|