Files
website/server/src/engagement/templates.js
wtclaude 3f90070566
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.

The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.

Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.

Three things the plan did not know, found by building it:

  - The undeclared-variable check cannot be a token scan. `email.itemList.variable`
    holds a BARE name, so a digest pointed at `itmes` would have saved clean and
    arrived empty. Blocks now declare `variables(props)`; the editor makes that
    field a select over the trigger's list variables so the typo is unavailable.
  - A duplicate that drops `seed_key` loses its variable palette, so duplicating
    `notify.event` would have been refused for the tokens it was copied with — the
    one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
    is what the seeder actually reads.
  - `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
    version tested it with `.length` — so block validation never ran at all.

Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.

Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:13:57 -05:00

219 lines
9.8 KiB
JavaScript

// ── Templates: resolve, render, seed ───────────────────────────────────────
//
// The seam between a stored `engagement_templates` row and the two body parts a
// transport sends. Everything that needs a database happens here; `emailBlocks/`
// stays pure and synchronous below it.
//
// **A missing row renders the shipped default rather than nothing.** `renderByKey`
// falls back to `templateSeeds.js` whenever the row is absent or its blocks will
// not parse. This is not defensive padding — it is what makes it safe for
// `mailer` to depend on the database for a password-reset body at all. Before the
// first seed runs, after a restore that dropped the table, on a deployment whose
// operator deleted a row by hand: the mail still goes out, in the shipped wording,
// and the `protected` flag stops the last of those from being reachable through
// the API. The same posture `settingsJson` and `resolveThemeTokens` take — a
// stored value that is unusable is treated as absent, never as an error.
const templatesDb = require('../model/engagement/engagementTemplates.db')
const settings = require('../model/settings/settings.model')
const brand = require('../config/brand')
const emailBlocks = require('../emailBlocks')
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
// The trigger registry lives with the module registries, not here — a trigger is
// something a MODULE declares (see engagement/index.js's header).
const { eventTrigger } = require('../modules/registries')
const log = require('../utils/logger')('templates')
const baseUrl = () => (process.env.APP_BASE_URL || brand.url || 'http://localhost:5173').replace(/\/+$/, '')
/**
* The brand values every template may reference, resolved from the same places
* the site's own chrome resolves them (§4.6.1 property 2).
*
* **They are merged OVER the caller's values, not under.** A caller supplies the
* message; the deployment supplies its identity. Letting a caller pass its own
* `siteName` would mean a module — or a bug — could send mail that claims to be
* from somewhere else, which is precisely the thing a recipient cannot check.
*
* Never throws: a settings read that fails degrades to the BRAND_* env values, so
* mail is branded slightly less specifically rather than not sent.
*/
async function ambient() {
let name = brand.name
let logo = brand.logo
let theme = null
try {
name = await settings.getInstanceName()
const shell = await settings.getShellBrand()
logo = shell.logo || brand.logo
theme = shell.theme
} catch (err) {
log.warn('brand resolution failed; falling back to BRAND_* env', { message: err.message })
}
const base = baseUrl()
const absLogo = logo && logo.startsWith('/') ? `${base}${logo}` : logo || ''
return {
values: {
siteName: name,
siteUrl: base,
logoUrl: absLogo,
year: String(new Date().getUTCFullYear()),
},
// resolveThemeTokens speaks CSS custom properties; the renderer speaks colour
// names. One mapping, here, rather than the renderer knowing about CSS.
theme: { accent: theme ? theme['--accent'] : undefined },
baseUrl: base,
}
}
/**
* Which variables a template may reference — the input to Phase 5b's palette and
* to its save-time "undeclared variable" refusal.
*
* Two sources, because a template has two possible origins. One tied to a trigger
* reads §4.3's declaration, which is the authority for anything a module emits.
* One with no trigger — every transactional seed is one; `mailer` renders them by
* key with no rule involved — has no trigger to ask, so its shipped definition
* carries the list. Ambient brand variables are appended to both.
*
* @param {{ trigger_id?: string|null, seed_key?: string|null }} template
* @returns {Array<{name: string, type: string, required: boolean, example: unknown}>}
*/
function variablesFor(template) {
const own = []
if (template && template.trigger_id) {
const declared = eventTrigger(template.trigger_id)
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
} else if (template && template.seed_key) {
const seed = seedByKey(template.seed_key)
if (seed) own.push(...seed.variables)
}
const names = new Set(own.map((v) => v.name))
return [...own, ...AMBIENT_VARIABLES.filter((v) => !names.has(v.name))]
}
/**
* Render one template into its two body parts.
*
* @param {object} template a row, or a seed definition
* @param {Record<string, unknown>} values
* @param {object} resolved the result of ambient()
* @returns {{ subject: string, html: string, text: string, missing: string[] }}
*/
function renderTemplate(template, values, resolved) {
const merged = { ...values, ...resolved.values }
const missing = new Set()
const ctx = emailBlocks.buildContext({
values: merged,
theme: resolved.theme,
baseUrl: resolved.baseUrl,
missing,
})
const rendered = emailBlocks.renderBlocks(template.blocks, ctx)
const subject = template.subject ? ctx.t(template.subject) : ''
// An authored `text_body` REPLACES the generated one (§4.4), and is interpolated
// like any other authored string. It is a per-template override, not an addition.
const text = template.text_body ? ctx.t(template.text_body) : rendered.text
return {
subject,
html: emailBlocks.renderDocument(rendered.html, ctx, subject),
text,
missing: [...missing],
}
}
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null when `key` names no usable row AND no seed — which now includes a
* duplicated (seedless) template still in draft.
*/
async function renderByKey(key, values = {}) {
const resolved = await ambient()
let template = null
try {
template = await templatesDb.getByKey(key)
} catch (err) {
log.warn('template read failed; using the shipped default', { key, message: err.message })
}
// Three ways a row is not the thing to send, and they are one branch on purpose:
// whether the row is absent, structurally unusable, or deliberately unpublished,
// the answer is the shipped default rather than a failed message.
//
// **The `status` arm is the one with teeth** (Phase 5b, decision 3). `status`
// has existed since 5a and nothing read it, so an operator who saved a template
// as a draft kept mailing it — the editor offered a working state that did not
// work. A draft is now exactly what the word means: not what goes out. It falls
// back rather than refusing, for the same reason the other two arms do — no
// state of this table may stop a password reset.
let unusable = null
if (!template) unusable = null
else if (!Array.isArray(template.blocks) || template.blocks.length === 0) unusable = 'unusable'
else if (template.status !== 'published') unusable = 'unpublished'
if (!template || unusable) {
const seed = seedByKey(key)
if (!seed) return null
if (unusable === 'unusable') log.warn('stored template is unusable; using the shipped default', { key })
if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key })
template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
}
return renderTemplate(template, values, resolved)
}
/**
* Ensure every shipped template exists, and bring un-customized rows up to the
* current seed. Idempotent: a second run reports nine skips and writes nothing.
*
* Never throws — it is called from `seedDefaults()` on the boot path, and a
* template that failed to seed costs the shipped default (see the header note),
* not the deployment.
*/
async function seedTemplates() {
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
for (const seed of SEEDS) {
// Validated against the registry before it is stored, even though a seed is
// code rather than input. The alternative is a shipped block array that no
// renderer understands sitting in the table, which reads to an operator as
// their deployment being broken; refusing to write it leaves `renderByKey`'s
// fallback in charge and puts the reason in the boot log.
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
if (!valid) {
log.error('shipped template is invalid and was not seeded', { key: seed.key, errors })
counts.invalid += 1
continue
}
try {
counts[await templatesDb.seedOne(seed)] += 1
} catch (err) {
log.error('template seed failed', { key: seed.key, message: err.message })
}
}
// The third arm of §4.6.1 property 3: a customized row is never touched, and the
// fact that a better default now exists is surfaced instead of applied.
let stale = []
try {
stale = await templatesDb.staleCustomized(SEEDS.map((s) => ({ key: s.key, seedVersion: s.seedVersion })))
} catch {
stale = []
}
if (stale.length) {
log.info('customized templates have a newer shipped default', { keys: stale.map((t) => t.key) })
}
log.info('engagement templates ensured', counts)
return { ...counts, stale: stale.map((t) => t.key) }
}
/**
* The shape of a template key, defined HERE rather than in the templates model
* because two unrelated callers need it and only one of them should own it:
* `engagementTemplates.model` checks it when a duplicate names a new key, and
* `engagementRules.model` checks it when a rule points at one. Phase 4a had its
* own pattern with no dot in it, which could not match any key this system
* actually uses; one definition is what stops that recurring.
*/
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
const MAX_KEY = 96
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl, KEY_RE, MAX_KEY }