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>
This commit is contained in:
@@ -211,4 +211,124 @@ engagementRouter.delete(
|
||||
controller.deleteSegment,
|
||||
)
|
||||
|
||||
|
||||
// -- Templates (Phase 5b) --------------------------------------------------
|
||||
//
|
||||
// The editor's routes. Two of them are POSTs that write nothing -- preview and
|
||||
// test-send -- because both act on the body in the request rather than on the
|
||||
// stored row: an editor that could only preview what was already saved would make
|
||||
// saving the way to find out whether a change was right.
|
||||
|
||||
engagementRouter.get(
|
||||
'/templates',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'List every message template, annotated'
|
||||
// #swagger.description = 'Each row carries three flags the list renders as warnings. `dormant`: the template is pinned to a trigger no installed module declares, so its variable palette cannot be checked. `triggerBehind`: the module is installed but has moved its declaration on past the version this template was authored against. `seedBehind`: a newer shipped default exists for the seed this row came from, and was NOT applied because a person had edited it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The templates', content: { "application/json": { schema: { type: "object", properties: { templates: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.listTemplates,
|
||||
)
|
||||
|
||||
engagementRouter.get(
|
||||
'/templates/:id',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'One template, with the variables it may reference'
|
||||
// #swagger.description = 'The `variables` array is the editor palette and comes 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. It is served with the row so the editor never guesses what is legal.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The template', content: { "application/json": { schema: { type: "object", properties: { template: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such template', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.getTemplate,
|
||||
)
|
||||
|
||||
engagementRouter.put(
|
||||
'/templates/:id',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Edit a template, including a shipped default'
|
||||
// #swagger.description = 'A seeded template is edited IN PLACE; the save sets `customized = 1`, which is what stops a later seed bump from taking the edit back. `key` and `channel` cannot be changed and a request that tries 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 this route: a token naming a variable the trigger does not declare is refused WITH THE VARIABLE NAMED, and a template published with no plain-text part is refused, because the text part is checked by rendering rather than by inspecting the blocks.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, status: { type: "string", enum: ["draft", "published"] }, triggerId: { type: "string", nullable: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The updated template', content: { "application/json": { schema: { type: "object", properties: { template: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such template', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.updateTemplate,
|
||||
)
|
||||
|
||||
engagementRouter.post(
|
||||
'/templates/:id/duplicate',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Copy a template under a new key'
|
||||
// #swagger.description = '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 whatever the original was, is never protected, and inherits the source seed reference - which is what keeps its variable palette, not bookkeeping.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { key: { type: "string" }, name: { type: "string" }, triggerId: { type: "string", nullable: true } }, required: ["key"] } } } } */
|
||||
/* #swagger.responses[201] = { description: 'The new template', content: { "application/json": { schema: { type: "object", properties: { template: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The key is not a legal template key', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'That key is already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.duplicateTemplate,
|
||||
)
|
||||
|
||||
engagementRouter.delete(
|
||||
'/templates/:id',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Delete a template'
|
||||
// #swagger.description = 'Refused with 409 for a protected template - the system breaks without a password-reset body, so those are editable and not deletable - and refused with 409 while any rule points at the key, naming the rules. The second is the answer a segment in use already gets, for the same reason: the alternative is a rule that silently stops producing mail.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[204] = { description: 'Deleted' } */
|
||||
/* #swagger.responses[409] = { description: 'Protected, or still used by a rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.deleteTemplate,
|
||||
)
|
||||
|
||||
engagementRouter.post(
|
||||
'/templates/:id/preview',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Render the draft on screen, without saving it'
|
||||
// #swagger.description = 'Renders the body in the REQUEST, using the example value each variable declares, so no live game event is needed - which is why `example` is a required part of a trigger declaration rather than documentation. The HTML comes back as a JSON string and the client must render it inside a sandboxed iframe with no allow-scripts: operator-authored HTML served as a document from this origin would run under the site CSP with access to its cookies.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, triggerId: { type: "string", nullable: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Both parts, plus the variable palette and any variable with no value', content: { "application/json": { schema: { type: "object", properties: { subject: { type: "string" }, html: { type: "string" }, text: { type: "string" }, missing: { type: "array", items: { type: "string" } }, variables: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The draft is not renderable; `errors` says why', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.previewTemplate,
|
||||
)
|
||||
|
||||
engagementRouter.post(
|
||||
'/templates/:id/test-send',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Send the draft on screen to one address'
|
||||
// #swagger.description = 'Sends what is on screen, saved or not, through the configured transport, and records the attempt in the send log under a synthetic `core.admin.test-send` trigger - including when it fails, which is the outcome an operator most needs a record of. It deliberately 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.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { to: { type: "string" }, subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, triggerId: { type: "string", nullable: true } }, required: ["to"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No address, or the draft is not renderable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Email is not configured on this deployment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[502] = { description: 'The transport refused the message; the message is the relay reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.testSendTemplate,
|
||||
)
|
||||
|
||||
// -- Send log (Phase 5b) ---------------------------------------------------
|
||||
|
||||
engagementRouter.get(
|
||||
'/sends',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'The send log, newest first'
|
||||
// #swagger.description = 'G15 answered: every terminal delivery outcome, success and failure alike, with the reason. `address_hash` is stored but never returned - the log keeps it so a bounce can be correlated back to a recipient, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['limit'] = { in: 'query', description: 'Page size, 1-200 (default 50)', required: false, schema: { type: 'integer' } }
|
||||
// #swagger.parameters['offset'] = { in: 'query', description: 'Rows to skip', required: false, schema: { type: 'integer' } }
|
||||
// #swagger.parameters['triggerId'] = { in: 'query', description: 'Only sends caused by this trigger', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['ruleId'] = { in: 'query', description: 'Only sends made by this rule', required: false, schema: { type: 'integer' } }
|
||||
// #swagger.parameters['userId'] = { in: 'query', description: 'Only sends to this user', required: false, schema: { type: 'integer' } }
|
||||
// #swagger.parameters['status'] = { in: 'query', description: 'sent, failed, suppressed, bounced or complained', required: false, schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'One page of the log, with the total matching the same filters', content: { "application/json": { schema: { type: "object", properties: { sends: { type: "array", items: { type: "object", additionalProperties: true } }, total: { type: "integer" }, limit: { type: "integer" }, offset: { type: "integer" }, testSendTrigger: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.listSends,
|
||||
)
|
||||
|
||||
module.exports = engagementRouter
|
||||
|
||||
Reference in New Issue
Block a user