222 lines
6.4 KiB
Go
222 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
|
)
|
|
|
|
// emailTemplateInfo is the metadata returned by the list endpoint.
|
|
type emailTemplateInfo struct {
|
|
Name string `json:"name"`
|
|
Embedded bool `json:"embedded,omitempty"`
|
|
}
|
|
|
|
// emailTemplateHandler serves CRUD operations for email templates stored in the
|
|
// ORDER_EMAIL_TEMPLATES directory. Embedded templates are read-only; on-disk
|
|
// copies override them and can be edited via PUT.
|
|
type emailTemplateHandler struct {
|
|
dir string // the on-disk templates directory, from ORDER_EMAIL_TEMPLATES env
|
|
}
|
|
|
|
func newEmailTemplateHandler(dir string) *emailTemplateHandler {
|
|
if dir == "" {
|
|
dir = "data/order-email-templates"
|
|
}
|
|
return &emailTemplateHandler{dir: dir}
|
|
}
|
|
|
|
// list returns all known email templates (embedded + on-disk) with their source.
|
|
func (h *emailTemplateHandler) list(w http.ResponseWriter, r *http.Request) {
|
|
embedded := knownEmbeddedTemplates()
|
|
onDisk := h.listOnDisk()
|
|
|
|
seen := map[string]bool{}
|
|
var infos []emailTemplateInfo
|
|
|
|
// List on-disk first (they take precedence).
|
|
for _, name := range onDisk {
|
|
infos = append(infos, emailTemplateInfo{Name: name})
|
|
seen[name] = true
|
|
}
|
|
// Then add embedded templates not overridden by disk.
|
|
for _, name := range embedded {
|
|
if !seen[name] {
|
|
infos = append(infos, emailTemplateInfo{Name: name, Embedded: true})
|
|
seen[name] = true
|
|
}
|
|
}
|
|
|
|
if infos == nil {
|
|
infos = []emailTemplateInfo{}
|
|
}
|
|
writeJSON(w, http.StatusOK, infos)
|
|
}
|
|
|
|
// get returns the template content for the given name.
|
|
func (h *emailTemplateHandler) get(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if name == "" {
|
|
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
|
|
return
|
|
}
|
|
store, err := order.LoadEmailTemplates(h.dir)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
|
|
return
|
|
}
|
|
tmpl, err := store.Get(name)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tmpl)
|
|
}
|
|
|
|
// save persists a template to the on-disk directory. Returns the saved template.
|
|
func (h *emailTemplateHandler) save(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if name == "" || !validEmailTemplateName(name) {
|
|
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template name %q", name))
|
|
return
|
|
}
|
|
var tmpl order.EmailTemplate
|
|
if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil {
|
|
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template body: %w", err))
|
|
return
|
|
}
|
|
if err := tmpl.Validate(name); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if err := os.MkdirAll(h.dir, 0o755); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, fmt.Errorf("create templates dir: %w", err))
|
|
return
|
|
}
|
|
data, err := json.MarshalIndent(tmpl, "", " ")
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, fmt.Errorf("marshal template: %w", err))
|
|
return
|
|
}
|
|
path := filepath.Join(h.dir, name+".json")
|
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, fmt.Errorf("write template: %w", err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tmpl)
|
|
}
|
|
|
|
// delete removes an on-disk template. Embedded templates cannot be deleted.
|
|
func (h *emailTemplateHandler) delete(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if name == "" {
|
|
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
|
|
return
|
|
}
|
|
// Check if it's embedded (read-only).
|
|
if isEmbeddedTemplate(name) {
|
|
writeErr(w, http.StatusForbidden, fmt.Errorf("template %q is built-in and cannot be deleted; save an override instead", name))
|
|
return
|
|
}
|
|
path := filepath.Join(h.dir, name+".json")
|
|
if err := os.Remove(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found on disk", name))
|
|
return
|
|
}
|
|
writeErr(w, http.StatusInternalServerError, fmt.Errorf("delete template: %w", err))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// preview renders a template with sample data and returns the result.
|
|
func (h *emailTemplateHandler) preview(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if name == "" {
|
|
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
|
|
return
|
|
}
|
|
store, err := order.LoadEmailTemplates(h.dir)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
|
|
return
|
|
}
|
|
tmpl, err := store.Get(name)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
|
|
return
|
|
}
|
|
|
|
// Build sample data for preview.
|
|
sampleData := order.SampleTemplateData()
|
|
subject, _ := order.RenderTextTemplate("preview-subject", tmpl.Subject, sampleData)
|
|
textBody, _ := order.RenderTextTemplate("preview-text", tmpl.Text, sampleData)
|
|
htmlBody, _ := order.RenderOptionalHTMLTemplate("preview-html", tmpl.HTML, sampleData)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"subject": subject,
|
|
"text": textBody,
|
|
"html": htmlBody,
|
|
})
|
|
}
|
|
|
|
// listOnDisk returns template names from the on-disk directory.
|
|
func (h *emailTemplateHandler) listOnDisk() []string {
|
|
matches, err := filepath.Glob(filepath.Join(h.dir, "*.json"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var names []string
|
|
for _, m := range matches {
|
|
name := strings.TrimSuffix(filepath.Base(m), ".json")
|
|
if validEmailTemplateName(name) {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// knownEmbeddedTemplates returns the names of templates embedded in the binary.
|
|
// This must be kept in sync with the files in pkg/order/email_templates/.
|
|
func knownEmbeddedTemplates() []string {
|
|
return []string{
|
|
"fulfillment-shipped",
|
|
"order-confirmation",
|
|
"payment-receipt",
|
|
"shipment-delivered",
|
|
"return-confirmed",
|
|
"refund-issued",
|
|
}
|
|
}
|
|
|
|
// isEmbeddedTemplate reports whether name matches one of the embedded templates.
|
|
func isEmbeddedTemplate(name string) bool {
|
|
for _, e := range knownEmbeddedTemplates() {
|
|
if e == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// validEmailTemplateName validates a template name (alphanumeric + hyphens).
|
|
func validEmailTemplateName(s string) bool {
|
|
if s == "" || len(s) > 64 {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if !(r == '-' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|