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

@@ -25,6 +25,7 @@ const ceilings = require('../../modules/ceilings')
const channels = require('../../engagement/channels')
const segmentExpressions = require('../../engagement/segments')
const conditions = require('../../engagement/conditions')
const templates = require('../../engagement/templates')
// A day. Longer than this and "cooldown" is really "send once", which a rule
// expresses by being disabled rather than by a decade-long interval.
@@ -77,6 +78,14 @@ async function validate(input, { existing = null } = {}) {
// KEYS are checked for shape and not for existence - a rule may legitimately
// name a template that has not been authored yet, and Phase 5's editor is where
// that becomes resolvable.
//
// **The shape check was wrong until Phase 5b, and wrong in the way that matters:**
// it required `/^[a-z0-9][a-z0-9-]{0,63}$/`, which has no dot, while every
// template key that exists is dotted (`notify.event`, `auth.password-reset`).
// Written before templates existed, it could not match one, so no rule could name
// any real template - which is precisely the workflow S4.6.2's duplicate action
// exists to serve. It now uses the templates model's own pattern, so the two
// cannot disagree about what a key is.
const templateKeys = {}
if (raw.templateKeys !== undefined && !isPlainObject(raw.templateKeys)) {
errors.push('templateKeys must be an object of { channel: templateKey }')
@@ -86,7 +95,7 @@ async function validate(input, { existing = null } = {}) {
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
continue
}
if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(key)) {
if (typeof key !== 'string' || key.length > templates.MAX_KEY || !templates.KEY_RE.test(key)) {
errors.push(`templateKeys.${channel} is not a valid template key`)
continue
}