mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
Previous version preferred a channel-specific row but fell through to channel-agnostic when no match existed — so an SMS request could silently render an email template. Tighten so the lookup key is (tenant, name) when channel="" and (tenant, name, channel) when set, with no crossover. SMS in == SMS out; if there's no SMS template, error loud. Also collapses two near-identical FindRecordsByFilter calls into one with a conditional clause — single source of truth for the query.
150 lines
5.1 KiB
Go
150 lines
5.1 KiB
Go
// Package template renders Notify templates and manages the lifecycle.
|
|
//
|
|
// Lifecycle: draft → pending_approval → approved → published → archived.
|
|
// Only `published` templates can be invoked from a SendRequest. The
|
|
// approval transition (pending_approval → approved) requires a privileged
|
|
// caller (X-Roles claim contains "notify-approver").
|
|
//
|
|
// Rendering uses Go's text/template with a small custom function map.
|
|
// The library is text/template (not html/template) because notify
|
|
// payloads include SMS and voice scripts that have no HTML semantics;
|
|
// callers sending email templates with HTML are responsible for
|
|
// escaping their inputs.
|
|
package template
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
texttemplate "text/template"
|
|
|
|
"github.com/hanzoai/base"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/dbx"
|
|
|
|
"github.com/hanzoai/notify/internal/schema"
|
|
)
|
|
|
|
// Resolved is the result of rendering a published Template against a
|
|
// caller-supplied variable map.
|
|
type Resolved struct {
|
|
Subject string
|
|
Body string
|
|
Channel string
|
|
}
|
|
|
|
// LoadPublished fetches the latest published version of (tenant, name).
|
|
//
|
|
// The lookup key is (tenant, name) when channel is empty, and
|
|
// (tenant, name, channel) when channel is non-empty. There is no
|
|
// silent fall-through between the two: an SMS request for which no
|
|
// SMS-channel template exists is an error, not "give me the email
|
|
// version anyway". This keeps the wire contract honest — channel
|
|
// in == channel out.
|
|
//
|
|
// Returns a "not found" error when no row matches; the routes layer
|
|
// maps that to HTTP 400.
|
|
func LoadPublished(app core.App, tenant, idOrName, channel string) (*core.Record, error) {
|
|
if tenant == "" || idOrName == "" {
|
|
return nil, errors.New("template: tenant and id are required")
|
|
}
|
|
// Direct hit by row id wins regardless of channel — explicit pin
|
|
// from the caller, validated against tenant + published status.
|
|
if rec, _ := app.FindRecordById(schema.Templates, idOrName); rec != nil &&
|
|
rec.GetString("tenant") == tenant &&
|
|
rec.GetString("status") == schema.TemplateStatusPublished {
|
|
return rec, nil
|
|
}
|
|
filter := "tenant = {:tenant} && name = {:name} && status = {:status}"
|
|
params := dbx.Params{
|
|
"tenant": tenant,
|
|
"name": idOrName,
|
|
"status": schema.TemplateStatusPublished,
|
|
}
|
|
if channel != "" {
|
|
filter += " && channel = {:channel}"
|
|
params["channel"] = channel
|
|
}
|
|
rows, err := app.FindRecordsByFilter(schema.Templates, filter, "-version", 1, 0, params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("template: lookup: %w", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
if channel != "" {
|
|
return nil, fmt.Errorf("template: no published %q template on %s for tenant %s", idOrName, channel, tenant)
|
|
}
|
|
return nil, fmt.Errorf("template: no published template %q for tenant %s", idOrName, tenant)
|
|
}
|
|
return rows[0], nil
|
|
}
|
|
|
|
// Render renders the template's subject + body against vars.
|
|
func Render(rec *core.Record, vars map[string]any) (*Resolved, error) {
|
|
subject, err := renderText("subject", rec.GetString("subject"), vars)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("template: render subject: %w", err)
|
|
}
|
|
body, err := renderText("body", rec.GetString("body"), vars)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("template: render body: %w", err)
|
|
}
|
|
return &Resolved{
|
|
Subject: subject,
|
|
Body: body,
|
|
Channel: rec.GetString("channel"),
|
|
}, nil
|
|
}
|
|
|
|
// renderText is the inner Go-template call. Errors surface to the
|
|
// route handler as 400 — a template that won't parse is a tenant
|
|
// authoring bug, not a server bug.
|
|
func renderText(name, src string, vars map[string]any) (string, error) {
|
|
if src == "" {
|
|
return "", nil
|
|
}
|
|
t, err := texttemplate.New(name).
|
|
Option("missingkey=zero").
|
|
Parse(src)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.Execute(&buf, vars); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// Transition moves a template from one lifecycle state to another.
|
|
// Returns an error if the transition is not allowed. Callers must check
|
|
// authorization separately — this function does not consult X-Roles.
|
|
func Transition(app *base.Base, rec *core.Record, next string) error {
|
|
cur := rec.GetString("status")
|
|
if !validTransition(cur, next) {
|
|
return fmt.Errorf("template: invalid transition %s → %s", cur, next)
|
|
}
|
|
rec.Set("status", next)
|
|
if err := app.Save(rec); err != nil {
|
|
return fmt.Errorf("template: save: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validTransition encodes the legal state machine. Each row is from →
|
|
// allowed-to slice. Closed-world: anything not listed is forbidden.
|
|
func validTransition(from, to string) bool {
|
|
allowed := map[string][]string{
|
|
schema.TemplateStatusDraft: {schema.TemplateStatusPendingApproval, schema.TemplateStatusArchived},
|
|
schema.TemplateStatusPendingApproval: {schema.TemplateStatusApproved, schema.TemplateStatusDraft, schema.TemplateStatusArchived},
|
|
schema.TemplateStatusApproved: {schema.TemplateStatusPublished, schema.TemplateStatusArchived, schema.TemplateStatusDraft},
|
|
schema.TemplateStatusPublished: {schema.TemplateStatusArchived},
|
|
schema.TemplateStatusArchived: {schema.TemplateStatusDraft},
|
|
}
|
|
for _, ok := range allowed[from] {
|
|
if ok == to {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|