docs(website): the template editor as built (engagement Phase 5b)

ENGAGEMENT.md gains an "As built - 5b" section and BACKEND_DESIGN.md the eight
routes under /admin/engagement.

The scope decision is recorded first because the plan contradicted itself: the
Phase 5 body names only the editor, while Q4's answer and 6.2 both promise
"Triggers, Templates and the send log" in Phase 5. All three shipped - leaving
either out would have left the nav group half-built and G15 open with the rows
already on disk.

Five more decisions, each because the tree said something the plan did not: the
preview is rendered server-side and framed; `status` is now enforced by
renderByKey; a test send is logged under a synthetic trigger rather than making
the column nullable; a template a rule uses refuses deletion with a 409; and
duplicate is the only creation path.

Also records the correction that changed the most code - 4.6.2 says duplicate is
how a protected template is customized, the schema says "Editable, NOT deletable",
and the org lead's ruling is the schema's - and the three things only building it
found, including the Phase 4a template-key pattern that could not match any key
this system uses.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 18:14:12 -05:00
parent c03dfc14ba
commit 556124562b
2 changed files with 112 additions and 10 deletions

View File

@@ -180,16 +180,19 @@ server/
from a manifest URL, enable,
disable, uninstall, purge, restart
and the source allowlist
engagement.router.js (14) /admin/engagement — adminOnly,
engagement.router.js (22) /admin/engagement — adminOnly,
the declared event catalog (three
table-free reads, served from the
module registries) plus the rules
and audience segments an operator
configures over it, and the
count-only reach preview. Templates
and the send log land under this
same prefix in ENGAGEMENT.md
Phase 5
configures over it, the count-only
reach preview, the message
templates and their sandboxed
preview / test send, and the send
log (G15). Two of these are POSTs
that write nothing: preview and
test-send act on the draft in the
request, not the stored row
email.router.js (4) /admin/email — outbound mail:
transport + credentials + send
test — adminOnly. The two
@@ -1306,6 +1309,14 @@ file a route sits in — that is the property the route manifest freezes.
| PATCH | `/engagement/rules/:id/enabled` | flip that column and no other, **without re-validating the rule**. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating `PUT` refuses to save. Turning one on is safe unvalidated because the engine re-checks the ceiling at send time |
| GET · POST | `/engagement/segments` | list every saved audience segment annotated with dormancy (and which audience ids are missing), or save a new one. The stored `ceiling` is **derived** as the narrowest in the expression and is never taken from the caller; `not` is legal only as a child of `and`; two incomparable ceilings have no meet and the composition is refused rather than guessed. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
| PUT · DELETE | `/engagement/segments/:id` | update (re-deriving the ceiling) or delete. **`409` while any rule still points at it**, with the count in the message. No foreign key does this on purpose: `CASCADE` would delete an operator's rules and `SET NULL` would silently fall each rule back to its plain `audience` column, which reaches a *different set of people* |
| GET | `/engagement/templates` | every message template, each annotated with three separately-meaningful warnings: `dormant` (pinned to a trigger no installed module declares, so its variables cannot be checked and nothing will send it), `triggerBehind` (the module is installed but its declaration has moved on past the version this template was authored against) and `seedBehind` (a newer shipped default exists and was **not** applied, because a person had edited this row). `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.6.2 |
| GET | `/engagement/templates/:id` | one template plus `variables` — the palette the editor offers, resolved from the trigger declaration or, for a template tied to no trigger, from the shipped seed, merged with the ambient variables every template may use. Served with the row so the editor never guesses what is legal |
| PUT | `/engagement/templates/:id` | edit any template, **including a shipped default, in place**: the save sets `customized = 1`, which is what stops the next seed bump from taking the edit back. `key` and `channel` are **immutable and the attempt is refused rather than ignored**`mailer` renders by key, so a rename would break the message it names with no error anywhere. Two refusals are the point of the route: a token (or an `email.itemList` naming a bare variable) referencing something the trigger does not declare is refused **with the variable named**, and a `published` template whose plain-text part renders empty is refused — checked by *rendering* with the declared examples, because whether a text part exists depends on what each block's `toText` does with these props |
| POST | `/engagement/templates/:id/duplicate` | the **only** way a template that is not a shipped seed comes into being, so every template on a deployment descends from one that renders. The copy always starts as a `draft`, is never `protected`, and **inherits the source's `seed_key`** — that is what carries its variable palette, not bookkeeping: a seedless, triggerless copy would resolve to the ambient variables alone and be refused for the tokens it was copied with. `409` on a taken key |
| DELETE | `/engagement/templates/:id` | `409` for a `protected` template — the system breaks without a password-reset body, so those are editable and not deletable — and `409` while any rule's `template_keys` points at the key, **naming the rules**. The same answer a segment in use gets, for the same reason: the alternative is a rule that silently stops producing mail |
| POST | `/engagement/templates/:id/preview` | renders the body **in the request**, not the stored row, using each variable's declared `example` — which is why `example` is a required part of a trigger declaration rather than documentation. A `POST` that writes nothing: an editor that could only preview what was already saved would make saving the way to find out whether a change was right. Returns both parts as JSON strings; the client renders the HTML inside `<iframe sandbox="" srcdoc>` with **no `allow-scripts`**. Serving it as a document from this origin would run operator-authored HTML under the site's own CSP with access to its cookies |
| POST | `/engagement/templates/:id/test-send` | sends what is on screen, saved or not, through the configured transport, and records the attempt in `engagement_sends` **including when it fails** — the outcome an operator most needs a record of. `trigger_id` is `NOT NULL` and a transactional template has no trigger, so the row is logged under the synthetic **`core.admin.test-send`**, which is deliberately not a registered trigger. It does not consult channel preferences or the suppression list: the address is typed by an admin about their own deployment and is not derived from a user. `409` when mail is unconfigured, `502` when the relay refuses |
| GET | `/engagement/sends` | the send log, newest first, paged (`limit` 1200, `offset`) and filterable by `triggerId`, `ruleId`, `userId` and `status`, with a `total` matching the same filters. **G15's answer.** `address_hash` is stored but **never returned**: the log keeps it so a bounce can be correlated back to a recipient (Phase 9) without becoming a second address book, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment |
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust |