feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
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

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>
This commit is contained in:
2026-08-29 18:13:57 -05:00
parent 42b40fdec2
commit 3f90070566
27 changed files with 3754 additions and 20 deletions

View File

@@ -125,7 +125,8 @@ function renderTemplate(template, values, resolved) {
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null only when `key` names neither a row nor a seed.
* 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()
@@ -135,10 +136,26 @@ async function renderByKey(key, values = {}) {
} catch (err) {
log.warn('template read failed; using the shipped default', { key, message: err.message })
}
if (!template || !Array.isArray(template.blocks) || template.blocks.length === 0) {
// 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 (template) log.warn('stored template is unusable; using the shipped default', { key })
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)
@@ -187,4 +204,15 @@ async function seedTemplates() {
return { ...counts, stale: stale.map((t) => t.key) }
}
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl }
/**
* 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 }