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:
@@ -44,6 +44,9 @@ import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
||||
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
|
||||
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
|
||||
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
||||
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
||||
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
||||
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
@@ -186,7 +189,7 @@ export default function App() {
|
||||
actions that publish a game-written name is applied per request
|
||||
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
||||
<Route path="teams" element={<TeamsAdmin />} />
|
||||
{/* Engagement (ENGAGEMENT.md Phase 4b). Admin-only, matching the
|
||||
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
|
||||
server: every route under /admin/engagement re-gates to `admin`
|
||||
on top of the group's staff gate, because this is the group that
|
||||
decides who receives mail. */}
|
||||
@@ -201,6 +204,9 @@ export default function App() {
|
||||
<Route index element={<Navigate to="rules" replace />} />
|
||||
<Route path="rules" element={<EngagementRules />} />
|
||||
<Route path="audiences" element={<EngagementAudiences />} />
|
||||
<Route path="templates" element={<EngagementTemplates />} />
|
||||
<Route path="triggers" element={<EngagementTriggers />} />
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
</Route>
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
|
||||
@@ -389,6 +389,32 @@ export const api = {
|
||||
return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`)
|
||||
},
|
||||
|
||||
// Templates and the send log (engagement Phase 5b). `previewEngagementTemplate`
|
||||
// and `testSendEngagementTemplate` are POSTs that write nothing: both act on
|
||||
// the draft in the request, so the editor can show and send what is on screen
|
||||
// rather than what was last saved.
|
||||
listEngagementTemplates: () => req('/admin/engagement/templates'),
|
||||
getEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`),
|
||||
updateEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}`, { method: 'PUT', body }),
|
||||
duplicateEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/duplicate`, { method: 'POST', body }),
|
||||
deleteEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`, { method: 'DELETE' }),
|
||||
previewEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/preview`, { method: 'POST', body }),
|
||||
testSendEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/test-send`, { method: 'POST', body }),
|
||||
listEngagementSends: ({ limit, offset, triggerId, ruleId, userId, status } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (offset) qs.set('offset', String(offset))
|
||||
if (triggerId) qs.set('triggerId', triggerId)
|
||||
if (ruleId) qs.set('ruleId', String(ruleId))
|
||||
if (userId) qs.set('userId', String(userId))
|
||||
if (status) qs.set('status', status)
|
||||
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
|
||||
},
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
// setTeamDisplayName file a request and the response says `pending: true`.
|
||||
|
||||
12
client/src/emailBlocks/index.js
Normal file
12
client/src/emailBlocks/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// Client email-block registry entrypoint. Importing this module registers every
|
||||
// `email.*` authoring definition exactly once, then re-exports the registry API.
|
||||
// The template editor imports from HERE, never from ./registry, so the
|
||||
// definitions are loaded before anything reads the palette.
|
||||
//
|
||||
// Same shape as `blocks/index.js` — and the same reason for existing.
|
||||
|
||||
export * from './registry'
|
||||
export { VariablePalette } from './types.jsx'
|
||||
|
||||
// ── Definitions (self-register on import) ──────────────────────────────────
|
||||
import './types.jsx'
|
||||
100
client/src/emailBlocks/registry.js
Normal file
100
client/src/emailBlocks/registry.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── The client-side `email.*` block registry ───────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2, Phase 5b. A sibling of `blocks/registry.js` for the same
|
||||
// reason its server counterpart is a sibling of `blocks/registry.js` on that side
|
||||
// — and with ONE structural difference that is the whole argument for the shape of
|
||||
// this screen:
|
||||
//
|
||||
// **an email block definition here has no `component`.**
|
||||
//
|
||||
// A page block carries a React renderer because a page IS React. A mail body is a
|
||||
// string this deployment's server produces, and the preview shows exactly that
|
||||
// string. Giving these entries a React renderer would mean two renderers for one
|
||||
// artifact — one drawing the editor's preview, one producing what actually lands
|
||||
// in someone's inbox — and nothing would make them agree. They would agree on the
|
||||
// day they were written and drift from the first Outlook fix onward, at which
|
||||
// point the preview becomes a confident lie about mail nobody can see.
|
||||
//
|
||||
// So the division is: **this registry owns authoring, the server owns rendering.**
|
||||
// Everything here is about the editing experience — the palette entry, the prop
|
||||
// form, the starting props — and the preview arrives from
|
||||
// `POST /admin/engagement/templates/:id/preview` as HTML that goes into a
|
||||
// sandboxed iframe.
|
||||
//
|
||||
// `type` and `version` must match the server definition in
|
||||
// `server/src/emailBlocks/types/`. That pairing is the same discipline the page
|
||||
// family already runs on, and the save is the thing that enforces it: the server
|
||||
// validates against its own registry, so a client entry that has drifted produces
|
||||
// a refused save rather than a bad row.
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
// The same reserved envelope keys the server's `RESERVED_KEYS` names. Duplicated
|
||||
// rather than imported because the client cannot import from `server/`, exactly as
|
||||
// `blocks/registry.js` duplicates them — and, as there, the server is the one that
|
||||
// decides: a block this list let through is still refused at the save.
|
||||
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
|
||||
|
||||
/**
|
||||
* Register an email block definition.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.type must match the server type, e.g. 'email.heading'
|
||||
* @param {number} def.version must match the server schema version
|
||||
* @param {string} def.label palette display name
|
||||
* @param {string} def.icon palette icon glyph
|
||||
* @param {Function} def.editor ({ props, onChange, variables }) => JSX
|
||||
* @param {Function} def.defaults starting props when the block is added
|
||||
*/
|
||||
export function registerEmailBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || !def.type.startsWith('email.')) {
|
||||
throw new Error('registerEmailBlock: a definition needs a type namespaced "email."')
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
const entry = {
|
||||
type: def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
label: def.label || def.type,
|
||||
icon: def.icon || null,
|
||||
// The one-line description under the palette button. Mail blocks are less
|
||||
// self-evident than page ones — "Item list" does not say that it repeats over
|
||||
// a variable — and the palette is where that has to be said.
|
||||
hint: def.hint || '',
|
||||
editor: def.editor || null,
|
||||
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
|
||||
}
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
export function getEmailBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {object[]} every definition, in registration order — the palette. */
|
||||
export function listEmailBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh block envelope of `type`, ready to push onto the array.
|
||||
*
|
||||
* The id is random rather than sequential because block ids are unique across the
|
||||
* whole document and an operator can delete block 2 and add another; a counter
|
||||
* would hand out an id that is already taken and the save would be refused for a
|
||||
* reason nothing on screen explains.
|
||||
*/
|
||||
export function newEmailBlock(type) {
|
||||
const def = getEmailBlock(type)
|
||||
if (!def) return null
|
||||
return {
|
||||
id: `b${Math.random().toString(36).slice(2, 10)}`,
|
||||
type: def.type,
|
||||
version: def.version,
|
||||
visible: true,
|
||||
props: def.defaults(),
|
||||
}
|
||||
}
|
||||
272
client/src/emailBlocks/types.jsx
Normal file
272
client/src/emailBlocks/types.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// The six `email.*` block editors, in one file rather than one file each.
|
||||
//
|
||||
// The page family gives every block its own module because each carries a React
|
||||
// RENDERER as well as a form, and those are substantial. An email block carries
|
||||
// only a form — the rendering is the server's (see ./registry.js) — and six short
|
||||
// prop panels split across six files would be six imports of the same three
|
||||
// controls to no benefit.
|
||||
//
|
||||
// Every `type` and `version` here pairs with a definition in
|
||||
// `server/src/emailBlocks/types/`, and the field lists are the server's `onlyKeys`
|
||||
// lists. Where a server schema has a bound (`MAX_TEXT`, `MAX_LABEL`), the input
|
||||
// carries the same `maxLength` — not as the check, which is the server's, but so
|
||||
// that an operator meets the limit while typing rather than at the save.
|
||||
import { TextField, TextAreaField, SelectField, Field } from '../blocks/editorKit.jsx'
|
||||
import { registerEmailBlock } from './registry'
|
||||
|
||||
/**
|
||||
* The variable palette, rendered under whichever field is being edited.
|
||||
*
|
||||
* Clicking a variable APPENDS its token rather than inserting at the caret. That
|
||||
* is a deliberate simplification: tracking a caret across a controlled React input
|
||||
* that a parent may re-render costs a ref and a selection-restore on every change,
|
||||
* and appending is both predictable and trivially undone. §4.6.2's requirement is
|
||||
* that inserting a variable "writes a token; it is never free-text" — which this
|
||||
* satisfies — not that it lands at the cursor.
|
||||
*/
|
||||
export function VariablePalette({ variables, onInsert }) {
|
||||
if (!variables || !variables.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{variables.map((v) => (
|
||||
<button
|
||||
key={v.name}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title={`${v.type || 'string'}${v.required ? ' · required' : ''}${v.description ? ` — ${v.description}` : ''}`}
|
||||
onClick={() => onInsert(`{{${v.name}}}`)}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
|
||||
>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A text field with the palette attached — the shape four of the six blocks want. */
|
||||
function VariableTextField({ label, hint, value, onChange, variables, maxLength, area, rows }) {
|
||||
const Control = area ? TextAreaField : TextField
|
||||
return (
|
||||
<div>
|
||||
<Control
|
||||
label={label}
|
||||
hint={hint}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
maxLength={maxLength}
|
||||
rows={rows}
|
||||
/>
|
||||
<VariablePalette variables={variables} onInsert={(token) => onChange(`${value || ''}${token}`)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.heading',
|
||||
version: 1,
|
||||
label: 'Heading',
|
||||
icon: 'H',
|
||||
hint: 'A section heading, at one of three sizes.',
|
||||
defaults: () => ({ level: 'h2', text: 'Heading' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SelectField
|
||||
label="Size"
|
||||
// Named "Size" and not "Level" for the reason the server block's header
|
||||
// gives: mail clients build no outline from a message, so this is
|
||||
// typography rather than structure, and calling it a level in the UI would
|
||||
// invite someone to use it as one.
|
||||
hint="Mail clients build no document outline, so this is a size, not a rank."
|
||||
value={props.level || 'h2'}
|
||||
onChange={(level) => onChange({ ...props, level })}
|
||||
options={[
|
||||
['h1', 'Large'],
|
||||
['h2', 'Medium'],
|
||||
['h3', 'Small'],
|
||||
]}
|
||||
/>
|
||||
<VariableTextField
|
||||
label="Text"
|
||||
value={props.text}
|
||||
maxLength={200}
|
||||
variables={variables}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.text',
|
||||
version: 1,
|
||||
label: 'Paragraph',
|
||||
icon: '¶',
|
||||
hint: 'A paragraph of body text.',
|
||||
defaults: () => ({ text: 'Write your message here.', muted: false }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<VariableTextField
|
||||
label="Text"
|
||||
area
|
||||
rows={5}
|
||||
value={props.text}
|
||||
maxLength={4000}
|
||||
variables={variables}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
<Field label="Style">
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(props.muted)}
|
||||
onChange={(e) => onChange({ ...props, muted: e.target.checked })}
|
||||
/>
|
||||
<span>Quieter — for footnotes and small print</span>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.button',
|
||||
version: 1,
|
||||
label: 'Button / link',
|
||||
icon: '▭',
|
||||
hint: 'The call to action. Its plain-text form is a sentence plus the URL.',
|
||||
defaults: () => ({ label: 'Open', url: '/', textLead: 'Open it here:' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<TextField
|
||||
label="Button text"
|
||||
value={props.label}
|
||||
maxLength={80}
|
||||
onChange={(label) => onChange({ ...props, label })}
|
||||
/>
|
||||
<VariableTextField
|
||||
label="Link"
|
||||
hint="Usually a variable, so the link is built for each recipient."
|
||||
value={props.url}
|
||||
maxLength={600}
|
||||
variables={variables}
|
||||
onChange={(url) => onChange({ ...props, url })}
|
||||
/>
|
||||
<TextField
|
||||
label="Plain-text lead-in"
|
||||
// The server block's header is worth repeating here in one line, because
|
||||
// this field looks optional and is the difference between a bare URL and a
|
||||
// sentence in every text-only inbox.
|
||||
hint="A button is nothing in plain text. This sentence introduces the link there, e.g. “Choose a new password here:”."
|
||||
value={props.textLead}
|
||||
maxLength={200}
|
||||
onChange={(textLead) => onChange({ ...props, textLead })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.divider',
|
||||
version: 1,
|
||||
label: 'Divider',
|
||||
icon: '—',
|
||||
hint: 'A horizontal rule.',
|
||||
defaults: () => ({}),
|
||||
editor: () => (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
A divider has nothing to configure.
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.image',
|
||||
version: 1,
|
||||
label: 'Image',
|
||||
icon: '▣',
|
||||
hint: 'An image by URL. Many clients block images until the reader allows them.',
|
||||
defaults: () => ({ url: '/brand/logo.png', alt: 'Logo' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<VariableTextField
|
||||
label="Image URL"
|
||||
value={props.url}
|
||||
maxLength={600}
|
||||
variables={variables}
|
||||
onChange={(url) => onChange({ ...props, url })}
|
||||
/>
|
||||
<TextField
|
||||
label="Alt text"
|
||||
hint="Most mail clients block images by default, so for many readers this IS the image."
|
||||
value={props.alt}
|
||||
maxLength={200}
|
||||
onChange={(alt) => onChange({ ...props, alt })}
|
||||
/>
|
||||
<Field label="Width" hint="Pixels, 16-560. Leave blank to let the image size itself.">
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={16}
|
||||
max={560}
|
||||
value={props.width ?? ''}
|
||||
// Blank REMOVES the prop rather than setting it to 0. The server accepts
|
||||
// `width` absent or between 16 and 560, so a 0 left behind by an empty
|
||||
// field is a refused save whose message names a field the operator
|
||||
// believes they cleared.
|
||||
onChange={(e) => {
|
||||
const next = { ...props }
|
||||
const value = Number(e.target.value)
|
||||
if (!e.target.value || !Number.isFinite(value)) delete next.width
|
||||
else next.width = Math.trunc(value)
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.itemList',
|
||||
version: 1,
|
||||
label: 'Item list',
|
||||
icon: '☰',
|
||||
hint: 'Repeats over a list variable — this is how a digest lists its items.',
|
||||
defaults: () => ({ variable: '', emptyText: '' }),
|
||||
editor: ({ props, onChange, variables }) => {
|
||||
// Only LIST variables may be chosen, and the field is a select rather than a
|
||||
// text input because this prop is a bare NAME, not a token: a typo here is the
|
||||
// one variable reference a reader of the template cannot see is wrong, and it
|
||||
// renders as an empty mail rather than as a visible gap.
|
||||
const lists = (variables || []).filter((v) => v.type === 'list' || v.type === 'array')
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{lists.length ? (
|
||||
<SelectField
|
||||
label="List variable"
|
||||
hint="Each item becomes a row with its heading, excerpt and link."
|
||||
value={props.variable || ''}
|
||||
onChange={(variable) => onChange({ ...props, variable })}
|
||||
options={[['', 'Choose a list…'], ...lists.map((v) => [v.name, v.name])]}
|
||||
/>
|
||||
) : (
|
||||
<Field label="List variable">
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem', margin: 0 }}>
|
||||
This template’s trigger declares no list variable, so an item list has nothing to
|
||||
repeat over. Point the template at a trigger that declares one — a digest, typically —
|
||||
or use paragraphs instead.
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
<TextField
|
||||
label="When the list is empty"
|
||||
hint="Shown instead of the list. Leave blank to show nothing at all."
|
||||
value={props.emptyText}
|
||||
maxLength={200}
|
||||
onChange={(emptyText) => onChange({ ...props, emptyText })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -48,6 +48,9 @@ const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2
|
||||
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
|
||||
const IconMail = () => <Icon><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3.5 6.5L12 13l8.5-6.5" /></Icon>
|
||||
const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1.2" /><circle cx="4" cy="12" r="1.2" /><circle cx="4" cy="18" r="1.2" /></Icon>
|
||||
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
|
||||
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
|
||||
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||
@@ -93,15 +96,17 @@ export const NAV = [
|
||||
},
|
||||
{
|
||||
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
|
||||
// Settings. Settings is already one long page of sections, and the screens
|
||||
// that join this group in Phase 5 - Triggers, Templates and the send log -
|
||||
// are a catalog, an editor and a paged table, none of which is a settings
|
||||
// section. Email Delivery stays under Settings: configuring a transport is
|
||||
// not the same job as deciding who gets mail.
|
||||
// Settings. Settings is already one long page of sections, and these five
|
||||
// screens are two editors, a catalog and a paged table, none of which is a
|
||||
// settings section. Email Delivery stays under Settings: configuring a
|
||||
// transport is not the same job as deciding who gets mail.
|
||||
title: 'Engagement',
|
||||
items: [
|
||||
{ to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -174,6 +179,9 @@ const TITLES = {
|
||||
'/admin/account': 'Account Security',
|
||||
'/admin/engagement/rules': 'Engagement Rules',
|
||||
'/admin/engagement/audiences': 'Engagement Audiences',
|
||||
'/admin/engagement/templates': 'Message Templates',
|
||||
'/admin/engagement/triggers': 'Triggers',
|
||||
'/admin/engagement/sends': 'Send Log',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
|
||||
172
client/src/routes/admin/views/EngagementSendLog.jsx
Normal file
172
client/src/routes/admin/views/EngagementSendLog.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Send Log (ENGAGEMENT.md §4.5, gap G15, Phase 5b).
|
||||
//
|
||||
// G15 was stated as: "no per-message record — no send log, no delivery status, no
|
||||
// audit". The table has been filling since Phase 4a; this is the screen that reads
|
||||
// it, and the question it exists to answer is the operator's, not the engine's:
|
||||
// **did that person get that mail, and if not, why not?**
|
||||
//
|
||||
// Two things it deliberately does not show.
|
||||
//
|
||||
// • **The address.** The log stores a sha256 so a bounce can be correlated back
|
||||
// to a recipient (Phase 9) without becoming a second address book. The route
|
||||
// strips the column; this screen could not render it if it wanted to.
|
||||
// • **A name for the user.** The `user_id` is what the log holds, and joining
|
||||
// users in would make a delivery screen into a directory. The id is enough to
|
||||
// paste into Moderation, which is where a person's record belongs.
|
||||
//
|
||||
// `failed` rows are the point of the screen, so the reason is a column and not a
|
||||
// tooltip: a delivery log whose failures need a hover is a log nobody reads.
|
||||
|
||||
const STATUS_LABEL = {
|
||||
sent: 'Sent',
|
||||
failed: 'Failed',
|
||||
suppressed: 'Not sent',
|
||||
bounced: 'Bounced',
|
||||
complained: 'Marked as spam',
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
bounced: '#d98b84',
|
||||
complained: '#d98b84',
|
||||
}
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
export default function EngagementSendLog() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [testTrigger, setTestTrigger] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async (nextOffset, nextStatus) => {
|
||||
const result = await api.admin.listEngagementSends({
|
||||
limit: PAGE,
|
||||
offset: nextOffset,
|
||||
status: nextStatus || undefined,
|
||||
})
|
||||
setRows(result.sends || [])
|
||||
setTotal(result.total || 0)
|
||||
setTestTrigger(result.testSendTrigger || '')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(offset, status)
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [load, offset, status])
|
||||
|
||||
if (loading && rows.length === 0) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const to = Math.min(offset + PAGE, total)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 560 }}>
|
||||
Every message this deployment tried to deliver, successful or not. Addresses are not kept
|
||||
here — only a one-way hash, so a bounce can be matched back without the log becoming a
|
||||
second address book.
|
||||
</p>
|
||||
<label>
|
||||
<span className="field-label">Show</span>
|
||||
<select className="select" value={status} onChange={(e) => { setOffset(0); setStatus(e.target.value) }}>
|
||||
<option value="">Everything</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="suppressed">Not sent</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="complained">Marked as spam</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">When</th>
|
||||
<th className="adm-th">What</th>
|
||||
<th className="adm-th">To</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Result</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{/* The synthetic test-send id is rendered by name: it is not a
|
||||
registered trigger and will never appear in the catalog,
|
||||
so showing the raw id would send someone looking for it. */}
|
||||
{r.trigger_id === testTrigger
|
||||
? <span>Test send <span className="dim">from the template editor</span></span>
|
||||
: <code style={{ fontSize: '0.8rem' }}>{r.trigger_id}</code>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.user_id ? <span className="dim">user #{r.user_id}</span> : <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.channel}
|
||||
{r.transport && <span className="dim"> · {r.transport}</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
|
||||
{STATUS_LABEL[r.status] || r.status}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{r.detail || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||||
Newer
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
|
||||
Older
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
649
client/src/routes/admin/views/EngagementTemplates.jsx
Normal file
649
client/src/routes/admin/views/EngagementTemplates.jsx
Normal file
@@ -0,0 +1,649 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { getEmailBlock, listEmailBlocks, newEmailBlock } from '../../../emailBlocks/index.js'
|
||||
|
||||
// Admin → Engagement → Templates (ENGAGEMENT.md §4.6.2, Phase 5b).
|
||||
//
|
||||
// Phase 5a moved every subject and body out of `mailer.js` into rows. This is the
|
||||
// screen that lets someone change one, and its whole shape follows from a single
|
||||
// fact about email:
|
||||
//
|
||||
// **the server renders the mail, so the server renders the preview.**
|
||||
//
|
||||
// There is no React renderer for an `email.*` block anywhere in this client. The
|
||||
// preview is HTML the server produced with the same call the send path uses,
|
||||
// dropped into a sandboxed iframe. That costs a round trip per edit — debounced
|
||||
// below — and buys the only property that matters on a screen like this: what is
|
||||
// on screen is what will arrive, not a second implementation's opinion of it.
|
||||
//
|
||||
// **The sandbox is a security boundary, not a nicety.** The preview is
|
||||
// operator-authored HTML. It renders with `sandbox` and no `allow-scripts`, from
|
||||
// `srcdoc` (an opaque origin), so it can neither run script nor reach this page's
|
||||
// cookies even if someone stores markup that gets past `sanitizeHtml`. The
|
||||
// attributes are asserted in `client/test/emailTemplates.test.js` for the same
|
||||
// reason the server's checks are asserted: this is the kind of attribute someone
|
||||
// removes while debugging and does not put back.
|
||||
//
|
||||
// What the operator can do here is deliberately bounded (settled with the org
|
||||
// lead at the start of the phase):
|
||||
//
|
||||
// • **A shipped default is edited in place.** `protected` blocks deletion and
|
||||
// nothing else; saving sets `customized = 1`, which is what stops the next
|
||||
// seed bump from taking the edit back.
|
||||
// • **Duplicate is the only way to a new template**, so every template on a
|
||||
// deployment descends from one that renders.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
|
||||
// Three widths, because a mail body has to survive all of them and the failures
|
||||
// are different: 640 is a desktop client's reading pane, 360 is a phone, and the
|
||||
// plain-text part is what a text-only client and every screen reader gets.
|
||||
const WIDTHS = [
|
||||
['desktop', 'Desktop', 640],
|
||||
['mobile', 'Mobile', 360],
|
||||
]
|
||||
|
||||
/** Short, human label for a template's channel. */
|
||||
const CHANNEL_LABEL = { email: 'Email', inapp: 'On the site', push: 'Push' }
|
||||
|
||||
// ── The preview frame ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The rendered HTML, in a sandboxed frame.
|
||||
*
|
||||
* `dark` applies a CSS inversion to the FRAME, not to the mail: it approximates
|
||||
* what Apple Mail and Outlook do to a light-only message, which is the failure
|
||||
* §4.6.2 asks this control to expose ("a light-only template renders as unreadable
|
||||
* dark-on-dark in about a third of inboxes"). It is an approximation and says so
|
||||
* on screen — the alternative, rendering a second dark palette server-side, would
|
||||
* be a preview of a mail this system does not send.
|
||||
*/
|
||||
function PreviewFrame({ html, width, dark }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: dark ? '#1b1b1b' : '#f4f4f5',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
// No allow-scripts, and no allow-same-origin. Both omissions are load
|
||||
// bearing; see this file's header.
|
||||
sandbox=""
|
||||
srcDoc={html || ''}
|
||||
title="Message preview"
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
height: 520,
|
||||
border: '1px solid var(--rule)',
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
filter: dark ? 'invert(1) hue-rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TemplateEditor({ template, triggers, onDone, onCancel }) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [subject, setSubject] = useState(template.subject || '')
|
||||
const [blocks, setBlocks] = useState(template.blocks || [])
|
||||
const [textBody, setTextBody] = useState(template.text_body || '')
|
||||
const [status, setStatus] = useState(template.status)
|
||||
const [triggerId, setTriggerId] = useState(template.trigger_id || '')
|
||||
const [selected, setSelected] = useState(template.blocks?.[0]?.id || null)
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [tab, setTab] = useState('html')
|
||||
const [width, setWidth] = useState('desktop')
|
||||
const [dark, setDark] = useState(false)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [testTo, setTestTo] = useState('')
|
||||
const [testState, setTestState] = useState(null)
|
||||
|
||||
// The variable palette. It comes from the server with the row and is refreshed
|
||||
// by every preview, because re-pointing the template at another trigger changes
|
||||
// it and the server is the one that knows what that trigger declares.
|
||||
const [variables, setVariables] = useState(template.variables || [])
|
||||
|
||||
const draft = useMemo(
|
||||
() => ({ name, subject, blocks, textBody: textBody || null, status, triggerId: triggerId || null }),
|
||||
[name, subject, blocks, textBody, status, triggerId],
|
||||
)
|
||||
|
||||
// Debounced preview. The delay is not about server load — it is one small
|
||||
// render — but about the frame: re-mounting an iframe on every keystroke makes
|
||||
// the preview flicker and steals nothing back.
|
||||
const timer = useRef(null)
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const body = { subject: draft.subject, blocks: draft.blocks, textBody: draft.textBody, triggerId: draft.triggerId }
|
||||
const result = await api.admin.previewEngagementTemplate(template.id, body)
|
||||
setPreview(result)
|
||||
setPreviewError(null)
|
||||
if (Array.isArray(result.variables)) setVariables(result.variables)
|
||||
} catch (err) {
|
||||
// A preview failure is expected while a block is half-edited, so it is
|
||||
// shown where the preview would be rather than as a page-level error.
|
||||
setPreviewError(err.body?.errors?.join(' · ') || err.message)
|
||||
}
|
||||
}, 400)
|
||||
return () => timer.current && clearTimeout(timer.current)
|
||||
}, [draft, template.id])
|
||||
|
||||
const selectedBlock = blocks.find((b) => b.id === selected) || null
|
||||
const selectedDef = selectedBlock ? getEmailBlock(selectedBlock.type) : null
|
||||
|
||||
const updateBlock = (id, props) =>
|
||||
setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, props } : b)))
|
||||
|
||||
const addBlock = (type) => {
|
||||
const block = newEmailBlock(type)
|
||||
if (!block) return
|
||||
setBlocks((bs) => [...bs, block])
|
||||
setSelected(block.id)
|
||||
}
|
||||
|
||||
const move = (id, delta) =>
|
||||
setBlocks((bs) => {
|
||||
const i = bs.findIndex((b) => b.id === id)
|
||||
const j = i + delta
|
||||
if (i < 0 || j < 0 || j >= bs.length) return bs
|
||||
const next = [...bs]
|
||||
;[next[i], next[j]] = [next[j], next[i]]
|
||||
return next
|
||||
})
|
||||
|
||||
const removeBlock = (id) =>
|
||||
setBlocks((bs) => {
|
||||
const next = bs.filter((b) => b.id !== id)
|
||||
if (selected === id) setSelected(next[0]?.id || null)
|
||||
return next
|
||||
})
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setErrors([])
|
||||
setSaved(false)
|
||||
try {
|
||||
await api.admin.updateEngagementTemplate(template.id, draft)
|
||||
setSaved(true)
|
||||
onDone()
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
setTestState({ busy: true })
|
||||
try {
|
||||
const body = { ...draft, to: testTo }
|
||||
const result = await api.admin.testSendEngagementTemplate(template.id, body)
|
||||
setTestState({ ok: true, message: `Sent to ${result.to}.` })
|
||||
} catch (err) {
|
||||
setTestState({ ok: false, message: err.body?.errors?.join(' · ') || err.message })
|
||||
}
|
||||
}
|
||||
|
||||
const widthPx = WIDTHS.find(([id]) => id === width)?.[2] || 640
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: '0 0 4px', fontSize: '1.05rem' }}>{template.name}</h2>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
<code>{template.key}</code> · {CHANNEL_LABEL[template.channel] || template.channel}
|
||||
{template.protected && ' · part of the system'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Back</button>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="panel" style={{ padding: 14, marginBottom: 16, borderColor: '#5b2020' }}>
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="sans" style={{ margin: '0 0 4px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{saved && errors.length === 0 && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--muted)' }}>Saved.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.2fr)', gap: 22, alignItems: 'start' }}>
|
||||
{/* ── Authoring ── */}
|
||||
<div>
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
{template.channel === 'email' && (
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Subject</span>
|
||||
<input className="input" value={subject} maxLength={300} onChange={(e) => setSubject(e.target.value)} />
|
||||
<VariableButtons variables={variables} onInsert={(t) => setSubject((s) => s + t)} />
|
||||
</label>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
|
||||
{/* "None" is the right default and not a missing value: every
|
||||
transactional template is tied to no trigger — mailer renders
|
||||
it by key with no rule involved. */}
|
||||
<option value="">None — used by key, not by a rule</option>
|
||||
{triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label} ({t.id})</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
The trigger decides which variables this template may use.
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Status</span>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="draft">Draft — the shipped default is sent instead</option>
|
||||
<option value="published">Published — this is what goes out</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Body</div>
|
||||
{blocks.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>No blocks yet. Add one below.</p>
|
||||
)}
|
||||
{blocks.map((b, i) => {
|
||||
const def = getEmailBlock(b.type)
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', marginBottom: 4,
|
||||
borderRadius: 4, cursor: 'pointer',
|
||||
background: b.id === selected ? 'var(--panel-2, rgba(255,255,255,0.05))' : 'transparent',
|
||||
border: `1px solid ${b.id === selected ? 'var(--accent)' : 'transparent'}`,
|
||||
}}
|
||||
onClick={() => setSelected(b.id)}
|
||||
>
|
||||
<span style={{ width: 18, textAlign: 'center' }}>{def?.icon || '?'}</span>
|
||||
<span className="sans" style={{ flex: 1, fontSize: '0.86rem' }}>
|
||||
{/* An unknown type is a client/server version skew, and saying
|
||||
so beats rendering a blank row the operator cannot act on. */}
|
||||
{def ? def.label : `${b.type} (not known to this client)`}
|
||||
</span>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === 0}
|
||||
onClick={(e) => { e.stopPropagation(); move(b.id, -1) }}>↑</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === blocks.length - 1}
|
||||
onClick={(e) => { e.stopPropagation(); move(b.id, 1) }}>↓</button>
|
||||
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.7rem' }}
|
||||
onClick={(e) => { e.stopPropagation(); removeBlock(b.id) }}>×</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
|
||||
{listEmailBlocks().map((def) => (
|
||||
<button key={def.type} type="button" className="pill" title={def.hint}
|
||||
style={{ fontSize: '0.74rem' }} onClick={() => addBlock(def.type)}>
|
||||
+ {def.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBlock && selectedDef?.editor && (
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>{selectedDef.label}</div>
|
||||
<selectedDef.editor
|
||||
props={selectedBlock.props || {}}
|
||||
variables={variables}
|
||||
onChange={(props) => updateBlock(selectedBlock.id, props)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Plain-text part (optional override)</span>
|
||||
<textarea
|
||||
className="input" rows={5} value={textBody}
|
||||
placeholder="Leave blank to generate it from the blocks above."
|
||||
onChange={(e) => setTextBody(e.target.value)}
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.82rem' }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
Every message has both parts. Writing one here REPLACES the generated text entirely.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Preview ── */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'html' ? 1 : 0.6 }}
|
||||
onClick={() => setTab('html')}>HTML</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'text' ? 1 : 0.6 }}
|
||||
onClick={() => setTab('text')}>Plain text</button>
|
||||
{tab === 'html' && (
|
||||
<>
|
||||
<span style={{ width: 10 }} />
|
||||
{WIDTHS.map(([id, label]) => (
|
||||
<button key={id} type="button" className="pill"
|
||||
style={{ fontSize: '0.74rem', opacity: width === id ? 1 : 0.6 }}
|
||||
onClick={() => setWidth(id)}>{label}</button>
|
||||
))}
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: dark ? 1 : 0.6 }}
|
||||
onClick={() => setDark((d) => !d)}>Dark mode</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewError ? (
|
||||
<div className="panel" style={{ padding: 16, borderColor: '#5b2020' }}>
|
||||
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{previewError}</p>
|
||||
</div>
|
||||
) : !preview ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Rendering…</p>
|
||||
) : tab === 'html' ? (
|
||||
<>
|
||||
{template.channel === 'email' && (
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
|
||||
<span className="dim">Subject: </span>{preview.subject || <em className="dim">none</em>}
|
||||
</p>
|
||||
)}
|
||||
<PreviewFrame html={preview.html} width={widthPx} dark={dark} />
|
||||
{dark && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 6 }}>
|
||||
An approximation of how a client that inverts a light-only message will show it.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<pre className="panel" style={{ padding: 16, fontSize: '0.82rem', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
{preview.text || '(empty — a published template is refused with no text part)'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{preview?.missing?.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
No example value for: {preview.missing.join(', ')} — these render as nothing here and
|
||||
will carry real values when the message is actually sent.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginTop: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Send a test</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
Sends what is on screen, saved or not, through the configured transport.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="input" type="email" placeholder="you@example.com" value={testTo}
|
||||
onChange={(e) => setTestTo(e.target.value)} style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-sq" onClick={sendTest} disabled={testState?.busy}>
|
||||
{testState?.busy ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
{testState && !testState.busy && (
|
||||
<p className="sans" style={{ margin: '8px 0 0', fontSize: '0.82rem', color: testState.ok ? 'var(--muted)' : '#d98b84' }}>
|
||||
{testState.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The variable tokens, for the two fields that are not block props. */
|
||||
function VariableButtons({ variables, onInsert }) {
|
||||
if (!variables?.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{variables.map((v) => (
|
||||
<button key={v.name} type="button" className="btn btn-ghost btn-xs"
|
||||
title={`${v.type || 'string'}${v.description ? ` — ${v.description}` : ''}`}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
|
||||
onClick={() => onInsert(`{{${v.name}}}`)}>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Duplicate ──────────────────────────────────────────────────────────────
|
||||
|
||||
function DuplicateForm({ source, triggers, onDone, onCancel }) {
|
||||
const [key, setKey] = useState('')
|
||||
const [name, setName] = useState(`${source.name} (copy)`)
|
||||
const [triggerId, setTriggerId] = useState(source.trigger_id || '')
|
||||
const [errors, setErrors] = useState([])
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
try {
|
||||
const { template } = await api.admin.duplicateEngagementTemplate(source.id, { key, name, triggerId: triggerId || null })
|
||||
onDone(template)
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.98rem' }}>Duplicate “{source.name}”</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 16px', fontSize: '0.82rem' }}>
|
||||
The copy starts as a draft, so nothing sends it until you publish it.
|
||||
</p>
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
|
||||
))}
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Key</span>
|
||||
<input className="input" value={key} maxLength={96} placeholder="notify.my-event"
|
||||
onChange={(e) => setKey(e.target.value)} />
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
How a rule points at this template. Lowercase letters, digits, dots and dashes; it cannot be
|
||||
changed afterwards.
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
|
||||
<option value="">None — used by key, not by a rule</option>
|
||||
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} ({t.id})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq">Duplicate</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The list ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EngagementTemplates() {
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [triggers, setTriggers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [rowError, setRowError] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [duplicating, setDuplicating] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [t, tr] = await Promise.all([api.admin.listEngagementTemplates(), api.admin.engagementTriggers()])
|
||||
setTemplates(t.templates || [])
|
||||
setTriggers(tr.triggers || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [load])
|
||||
|
||||
async function open(row) {
|
||||
setRowError(null)
|
||||
try {
|
||||
const { template } = await api.admin.getEngagementTemplate(row.id)
|
||||
setEditing(template)
|
||||
} catch (err) {
|
||||
setRowError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
if (!window.confirm(`Delete “${row.name}”?`)) return
|
||||
setRowError(null)
|
||||
try {
|
||||
await api.admin.deleteEngagementTemplate(row.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.body?.errors?.join(' · ') || err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<TemplateEditor
|
||||
template={editing}
|
||||
triggers={triggers}
|
||||
onDone={load}
|
||||
onCancel={async () => { setEditing(null); await load() }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{duplicating && (
|
||||
<DuplicateForm
|
||||
source={duplicating}
|
||||
triggers={triggers}
|
||||
onCancel={() => setDuplicating(null)}
|
||||
onDone={async (template) => { setDuplicating(null); await load(); setEditing(template) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
|
||||
Every message this deployment sends. The shipped ones are editable — your edits survive
|
||||
upgrades — and cannot be deleted, because the system breaks without them. To make a new
|
||||
template, duplicate one that already works.
|
||||
</p>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Key</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="adm-td">
|
||||
{t.name}
|
||||
{t.protected && (
|
||||
<span className="pill" style={{ marginLeft: 8, fontSize: '0.68rem' }}>system</span>
|
||||
)}
|
||||
<Flags template={t} />
|
||||
</td>
|
||||
<td className="adm-td"><code style={{ fontSize: '0.8rem' }}>{t.key}</code></td>
|
||||
<td className="adm-td">{CHANNEL_LABEL[t.channel] || t.channel}</td>
|
||||
<td className="adm-td">{t.status === 'published' ? 'Published' : 'Draft'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => open(t)}>Edit</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => setDuplicating(t)}>Duplicate</button>
|
||||
<button type="button" className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem', opacity: t.protected ? 0.4 : 1 }}
|
||||
disabled={t.protected}
|
||||
title={t.protected ? 'Part of the system — edit it or duplicate it' : undefined}
|
||||
onClick={() => remove(t)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three warnings a row can carry. Each is a different fact and they are worded
|
||||
* as what an operator should DO, not as the flag name: "dormant" and "behind" mean
|
||||
* nothing to someone who has not read the design document.
|
||||
*/
|
||||
function Flags({ template }) {
|
||||
const notes = []
|
||||
if (template.dormant) {
|
||||
notes.push(`No installed module declares ${template.trigger_id} — nothing will send this.`)
|
||||
}
|
||||
if (template.triggerBehind) {
|
||||
notes.push('Its trigger has changed since this was written; check the variables still exist.')
|
||||
}
|
||||
if (template.seedBehind) {
|
||||
notes.push('A newer version of the shipped default exists. Your edits were kept, so it was not applied.')
|
||||
}
|
||||
if (!notes.length) return null
|
||||
return (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{notes.map((n) => <div key={n}>{n}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
client/src/routes/admin/views/EngagementTriggers.jsx
Normal file
129
client/src/routes/admin/views/EngagementTriggers.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Triggers (ENGAGEMENT.md §4.3, Phase 5b).
|
||||
//
|
||||
// Read-only, and structurally so: **there is no table behind this screen.** A
|
||||
// trigger is DECLARED in code by core or by an installed module, so this is
|
||||
// whatever registered on the current boot. Uninstall a module and its triggers
|
||||
// stop appearing here; nothing was deleted and nothing needs to be.
|
||||
//
|
||||
// It exists because the two things it shows are otherwise invisible and both are
|
||||
// load-bearing elsewhere:
|
||||
//
|
||||
// • **The variables** are the contract a template may reference. When a rule
|
||||
// mails nothing sensible, "which variables does this event actually carry"
|
||||
// is the first question, and the answer used to live only in a module's source.
|
||||
// • **The ceiling** is the security boundary from G24 — the widest audience a
|
||||
// rule may ever give this trigger. A rule editor that offers a narrower set
|
||||
// than an operator expects is obeying a number declared here.
|
||||
|
||||
const CEILING_NOTE = {
|
||||
owner: 'only the person the event is about',
|
||||
members: 'only members of the thing it is about',
|
||||
subscribers: 'only people who opted in',
|
||||
staff: 'only staff',
|
||||
authenticated: 'any signed-in account',
|
||||
everyone: 'anyone',
|
||||
}
|
||||
|
||||
export default function EngagementTriggers() {
|
||||
const [triggers, setTriggers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const { triggers: list } = await api.admin.engagementTriggers()
|
||||
if (alive) setTriggers(list || [])
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
|
||||
The events a rule can be built on, declared in code by core and by installed modules. This
|
||||
list is whatever is registered right now — it is not stored anywhere, so a module that is
|
||||
uninstalled simply stops appearing.
|
||||
</p>
|
||||
|
||||
{triggers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing is registered.</p>
|
||||
)}
|
||||
|
||||
{triggers.map((t) => (
|
||||
<div className="panel" key={t.id} style={{ padding: 18, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h3 className="sans" style={{ margin: '0 0 2px', fontSize: '0.98rem' }}>{t.label}</h3>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
<code>{t.id}</code> · from {t.owner} · v{t.version}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="field-label" style={{ marginBottom: 2 }}>Can reach at most</div>
|
||||
<div className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
{t.ceiling}
|
||||
<span className="dim"> — {CEILING_NOTE[t.ceiling] || 'see the design document'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{t.description && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{t.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(t.variables || []).length > 0 && (
|
||||
<table className="adm-table" style={{ marginTop: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Variable</th>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">Example</th>
|
||||
<th className="adm-th">What it is</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.variables.map((v) => (
|
||||
<tr key={v.name}>
|
||||
{/* `nowrap`: without it the "always set" pill wraps between its
|
||||
two words on a longer variable name, orphaning "set" on a
|
||||
line of its own and making the row read as two facts. */}
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.8rem' }}>{`{{${v.name}}}`}</code>
|
||||
{v.required && <span className="pill" style={{ marginLeft: 6, fontSize: '0.66rem' }}>always set</span>}
|
||||
</td>
|
||||
<td className="adm-td">{v.type}</td>
|
||||
<td className="adm-td" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
|
||||
<span className="dim" style={{ fontSize: '0.8rem' }}>
|
||||
{/* A list variable's example is an array of objects; showing
|
||||
it as JSON is honest and short, and it is the shape an
|
||||
item list repeats over. */}
|
||||
{typeof v.example === 'string' ? v.example : JSON.stringify(v.example)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{v.description || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
154
client/test/emailTemplates.test.js
Normal file
154
client/test/emailTemplates.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
RESERVED_KEYS,
|
||||
registerEmailBlock,
|
||||
getEmailBlock,
|
||||
listEmailBlocks,
|
||||
newEmailBlock,
|
||||
} from '../src/emailBlocks/registry.js'
|
||||
|
||||
// Engagement Phase 5b — the client half of the template editor.
|
||||
//
|
||||
// Two kinds of test, and the second kind is the one worth explaining.
|
||||
//
|
||||
// `registry.js` is plain `.js` and imports nothing, so it is exercised directly.
|
||||
// `types.jsx` and `EngagementTemplates.jsx` cannot be: this runner has no JSX
|
||||
// transform and no DOM, the same limit `moduleRegistry.test.js` documents. So the
|
||||
// properties that live in those files are asserted **against their source text**.
|
||||
//
|
||||
// That is a weaker test than executing them, and it is used for exactly two things
|
||||
// where a weak test still beats none:
|
||||
//
|
||||
// • **The preview sandbox.** `sandbox=""` with no `allow-scripts` is the reason
|
||||
// operator-authored HTML cannot run under this site's origin. It is one
|
||||
// attribute, on one element, and it is precisely the sort of thing someone
|
||||
// removes to debug a rendering problem and does not put back. A source
|
||||
// assertion catches that in review; nothing else here would.
|
||||
// • **Registry drift.** Every `email.*` type this client offers must exist in
|
||||
// the server registry with the same version, because the server validates
|
||||
// against its own and a drifted client produces a refused save with no
|
||||
// explanation on screen. Reading both trees is the only way to check a
|
||||
// pairing that spans a process boundary.
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const read = (rel) => fs.readFileSync(path.join(here, '..', rel), 'utf8')
|
||||
|
||||
// The registry is module state; each test starts from a known entry.
|
||||
beforeEach(() => {
|
||||
if (!getEmailBlock('email.test')) {
|
||||
registerEmailBlock({
|
||||
type: 'email.test',
|
||||
version: 2,
|
||||
label: 'Test block',
|
||||
defaults: () => ({ text: 'hi' }),
|
||||
editor: () => null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── The registry ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a definition must be namespaced "email."', () => {
|
||||
assert.throws(() => registerEmailBlock({ type: 'heading' }), /namespaced/)
|
||||
assert.throws(() => registerEmailBlock({}), /namespaced/)
|
||||
})
|
||||
|
||||
test('a duplicate type is a programmer error, caught at import', () => {
|
||||
assert.throws(() => registerEmailBlock({ type: 'email.test' }), /already registered/)
|
||||
})
|
||||
|
||||
test('a new block carries the envelope the server expects, and a unique id', () => {
|
||||
const a = newEmailBlock('email.test')
|
||||
const b = newEmailBlock('email.test')
|
||||
assert.deepEqual(Object.keys(a).sort(), [...RESERVED_KEYS].sort())
|
||||
assert.equal(a.type, 'email.test')
|
||||
assert.equal(a.version, 2)
|
||||
assert.deepEqual(a.props, { text: 'hi' })
|
||||
// Ids are unique across a whole document. A counter would re-issue an id after
|
||||
// a delete and the save would be refused for a reason nothing on screen explains.
|
||||
assert.notEqual(a.id, b.id)
|
||||
})
|
||||
|
||||
test('an unknown type yields nothing rather than a half-built block', () => {
|
||||
assert.equal(newEmailBlock('email.nope'), null)
|
||||
assert.equal(getEmailBlock('email.nope'), null)
|
||||
})
|
||||
|
||||
// ── The sandbox: §4.6.2's security posture, as an attribute ────────────────
|
||||
|
||||
test('the preview frame is sandboxed with no allow-scripts', () => {
|
||||
const source = read('src/routes/admin/views/EngagementTemplates.jsx')
|
||||
|
||||
// It renders in an iframe at all — not into the page.
|
||||
assert.match(source, /<iframe/)
|
||||
|
||||
// Read the ATTRIBUTE, not the file. The first version of this test searched the
|
||||
// whole source for "allow-scripts" and failed on the comment above the iframe
|
||||
// explaining that there is no allow-scripts — a check that a correct file fails
|
||||
// is worse than no check, because the fix is to delete the explanation.
|
||||
const sandboxes = [...source.matchAll(/sandbox=(?:"([^"]*)"|\{([^}]*)\})/g)].map((m) => m[1] ?? m[2])
|
||||
assert.equal(sandboxes.length, 1, 'expected exactly one sandboxed frame')
|
||||
// Empty: every restriction on, nothing granted back.
|
||||
assert.equal(sandboxes[0], '')
|
||||
// The two grants that would undo it, whatever else were listed.
|
||||
assert.doesNotMatch(sandboxes[0], /allow-scripts/)
|
||||
assert.doesNotMatch(sandboxes[0], /allow-same-origin/)
|
||||
|
||||
// And no iframe without one at all.
|
||||
assert.equal((source.match(/<iframe/g) || []).length, sandboxes.length)
|
||||
|
||||
// From srcDoc — an opaque origin — rather than a src pointing at this site.
|
||||
assert.match(source, /srcDoc=/)
|
||||
})
|
||||
|
||||
test('the preview HTML is never injected into this document', () => {
|
||||
const source = read('src/routes/admin/views/EngagementTemplates.jsx')
|
||||
// The one API that would undo all of the above in a single line.
|
||||
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
|
||||
})
|
||||
|
||||
// ── Drift between the two registries ───────────────────────────────────────
|
||||
|
||||
test('every client email block pairs with a server definition at the same version', () => {
|
||||
const clientSource = read('src/emailBlocks/types.jsx')
|
||||
const clientTypes = [...clientSource.matchAll(/type:\s*'(email\.[A-Za-z]+)',\s*\n\s*version:\s*(\d+)/g)].map(
|
||||
(m) => [m[1], Number(m[2])],
|
||||
)
|
||||
assert.ok(clientTypes.length >= 6, 'expected the six block definitions to be found')
|
||||
|
||||
const serverDir = path.join(here, '..', '..', 'server', 'src', 'emailBlocks', 'types')
|
||||
const serverTypes = new Map()
|
||||
for (const file of fs.readdirSync(serverDir)) {
|
||||
const src = fs.readFileSync(path.join(serverDir, file), 'utf8')
|
||||
const type = src.match(/type:\s*'(email\.[A-Za-z]+)'/)
|
||||
const version = src.match(/\n\s*version:\s*(\d+)/)
|
||||
if (type) serverTypes.set(type[1], version ? Number(version[1]) : 1)
|
||||
}
|
||||
|
||||
for (const [type, version] of clientTypes) {
|
||||
assert.ok(serverTypes.has(type), `${type} has no server definition`)
|
||||
assert.equal(serverTypes.get(type), version, `${type} version differs between client and server`)
|
||||
}
|
||||
// And the other direction: a server block with no authoring form is a block an
|
||||
// operator can be sent a template containing and cannot edit.
|
||||
for (const type of serverTypes.keys()) {
|
||||
assert.ok(
|
||||
clientTypes.some(([t]) => t === type),
|
||||
`${type} exists on the server but has no editor in this client`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('no client email block declares a React renderer', () => {
|
||||
// The structural claim in registry.js's header. A `component` here would be a
|
||||
// second renderer for a body the server produces, and the two would agree only
|
||||
// until the first Outlook fix.
|
||||
const clientSource = read('src/emailBlocks/types.jsx')
|
||||
assert.doesNotMatch(clientSource, /\n\s*component:/)
|
||||
assert.ok(listEmailBlocks().every((d) => !('component' in d)))
|
||||
})
|
||||
@@ -284,6 +284,78 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/sends",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/templates/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/templates/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/duplicate",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/preview",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/test-send",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers",
|
||||
|
||||
@@ -125,6 +125,38 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/segments/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/sends"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/duplicate"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/preview"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/test-send"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
const registry = require('./registry')
|
||||
const render = require('./render')
|
||||
const interpolate = require('./interpolate')
|
||||
const variables = require('./variables')
|
||||
|
||||
// ── Block definitions (self-register on require) ───────────────────────────
|
||||
require('./types/heading')
|
||||
@@ -23,4 +24,5 @@ module.exports = {
|
||||
...registry,
|
||||
...render,
|
||||
...interpolate,
|
||||
...variables,
|
||||
}
|
||||
|
||||
@@ -31,8 +31,19 @@
|
||||
// sanitize: (props) => props, // optional, run on save AFTER validation
|
||||
// toHtml: (props, ctx) => '<tr>…', // a table ROW; see render.js for the shell
|
||||
// toText: (props, ctx) => 'text', // '' means "contributes nothing"
|
||||
// variables: (props) => [], // optional; see below
|
||||
// }
|
||||
//
|
||||
// `variables` exists because of ONE block, and the exception is the reason it has
|
||||
// to be declared rather than inferred. Every other block references a declared
|
||||
// variable the same way a person writes it — as a `{{token}}` inside an authored
|
||||
// string — so scanning the string props finds them all. `email.itemList` does not:
|
||||
// its `variable` prop holds a BARE NAME (`items`), because the block iterates the
|
||||
// value rather than interpolating it. A save-time check that only scanned tokens
|
||||
// would pass a template pointing its one repeating block at a variable no trigger
|
||||
// declares, and the failure would surface as an empty digest in someone's inbox.
|
||||
// A block that reads a variable by any means other than a token says so here.
|
||||
//
|
||||
// `ctx` is the render context (render.js): resolved brand values, an `interp`
|
||||
// that substitutes declared variables HTML-escaped, and `interpText` that does
|
||||
// the same without escaping for the plain-text part.
|
||||
@@ -73,6 +84,9 @@ function registerEmailBlock(def) {
|
||||
if (def.sanitize != null && typeof def.sanitize !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.sanitize must be a function`)
|
||||
}
|
||||
if (def.variables != null && typeof def.variables !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.variables must be a function`)
|
||||
}
|
||||
const entry = Object.freeze({
|
||||
type: def.type,
|
||||
label: def.label || def.type,
|
||||
@@ -81,6 +95,10 @@ function registerEmailBlock(def) {
|
||||
sanitize: def.sanitize || null,
|
||||
toHtml: def.toHtml,
|
||||
toText: def.toText,
|
||||
// Null, not a default `() => []`: `variables.js` distinguishes "this block
|
||||
// declares no non-token references" from "this block was never asked", and
|
||||
// only the second is worth a comment when a new block type is added.
|
||||
variables: def.variables || null,
|
||||
// The shared walk reads these; email has no containers, and saying so here is
|
||||
// what lets `makeValidateBlocks` be the same function for both families.
|
||||
container: false,
|
||||
|
||||
@@ -56,6 +56,13 @@ registerEmailBlock({
|
||||
if (empty) errors.push(empty)
|
||||
return errors
|
||||
},
|
||||
// The one block whose variable reference is not a token (see registry.js).
|
||||
// Without this the Phase 5b save check reads a template whose digest points at
|
||||
// `itmes` as clean, and the mistake surfaces as an empty mail rather than as an
|
||||
// error naming the variable.
|
||||
variables(props) {
|
||||
return typeof props.variable === 'string' && props.variable ? [props.variable] : []
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
const items = itemsOf(ctx.values[props.variable])
|
||||
if (items.length === 0) {
|
||||
|
||||
100
server/src/emailBlocks/variables.js
Normal file
100
server/src/emailBlocks/variables.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── Which declared variables a template references ─────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2: "A template referencing an undeclared variable is refused
|
||||
// at save, naming the variable — the editor validates, it does not blindly
|
||||
// interpolate module JSON."
|
||||
//
|
||||
// This is the walk that makes that sentence enforceable. It is deliberately a
|
||||
// SEPARATE pass from rendering: a render only discovers a bad reference when a
|
||||
// value happens to be missing at that moment, which makes the failure depend on
|
||||
// the event rather than on the template. Phase 5a's `renderTemplate` already
|
||||
// reports `missing` for exactly that runtime case; this answers the static
|
||||
// question — what does this template ask for at all — and it can therefore refuse
|
||||
// a save before any mail exists.
|
||||
//
|
||||
// Two kinds of reference, and both have to be found or the check is theatre:
|
||||
//
|
||||
// - **Tokens** in every authored string: the subject, an overriding text part,
|
||||
// and every string-valued prop on every block. `scanTokens` finds these.
|
||||
// - **Named references** a block declares (`registry.js`'s `variables`), which
|
||||
// today is `email.itemList.variable` and its bare `items`. A token scan cannot
|
||||
// see these and would pass them silently.
|
||||
//
|
||||
// The block walk mirrors `makeValidateBlocks`' — top level plus container slots —
|
||||
// rather than sharing it, because that function's job is to decide validity and
|
||||
// this one's is to collect names from a structure already known to be valid. The
|
||||
// email family has no containers today; the slot arm exists so that adding one
|
||||
// does not quietly halve this function's coverage.
|
||||
|
||||
const { scanTokens } = require('./interpolate')
|
||||
const { getEmailBlock } = require('./registry')
|
||||
|
||||
/** Every distinct token name in a string, an array of strings, or a nested plain object. */
|
||||
function tokensIn(value, out) {
|
||||
if (typeof value === 'string') {
|
||||
for (const name of scanTokens(value)) out.add(name)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) tokensIn(entry, out)
|
||||
return
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const entry of Object.values(value)) tokensIn(entry, out)
|
||||
}
|
||||
}
|
||||
|
||||
function walkBlock(block, out) {
|
||||
if (!block || typeof block !== 'object') return
|
||||
tokensIn(block.props, out)
|
||||
const def = getEmailBlock(block.type)
|
||||
if (def && typeof def.variables === 'function') {
|
||||
let named = []
|
||||
try {
|
||||
named = def.variables(block.props || {}) || []
|
||||
} catch {
|
||||
// A definition that throws on malformed props must not take the save path
|
||||
// down with it: validation runs first and has already refused those props,
|
||||
// so the only way here is a definition bug, and the right answer to that is
|
||||
// to contribute no names rather than to 500 the request.
|
||||
named = []
|
||||
}
|
||||
for (const name of named) if (typeof name === 'string' && name) out.add(name)
|
||||
}
|
||||
for (const slot of def?.containerSlots || []) {
|
||||
const children = block.props?.[slot]
|
||||
if (Array.isArray(children)) for (const child of children) walkBlock(child, out)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every declared-variable name this template references, in no particular order.
|
||||
*
|
||||
* @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function referencedVariables(template) {
|
||||
const out = new Set()
|
||||
tokensIn(template?.subject, out)
|
||||
tokensIn(template?.text_body, out)
|
||||
if (Array.isArray(template?.blocks)) for (const block of template.blocks) walkBlock(block, out)
|
||||
return [...out]
|
||||
}
|
||||
|
||||
/**
|
||||
* The names `referencedVariables` found that `declared` does not contain.
|
||||
*
|
||||
* @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template
|
||||
* @param {Array<{ name: string }>} declared what §4.3 declares for this template's
|
||||
* trigger, PLUS the ambient variables every template may use — the caller
|
||||
* passes `templates.variablesFor(...)`, which already merges the two.
|
||||
* @returns {string[]} sorted, so the error message is stable across saves
|
||||
*/
|
||||
function undeclaredVariables(template, declared) {
|
||||
const known = new Set((declared || []).map((v) => v && v.name).filter(Boolean))
|
||||
return referencedVariables(template)
|
||||
.filter((name) => !known.has(name))
|
||||
.sort()
|
||||
}
|
||||
|
||||
module.exports = { referencedVariables, undeclaredVariables }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -47,8 +47,22 @@ const countSentSince = async (ruleId, since) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/** The admin send log (Phase 4b/5), newest first. */
|
||||
const list = ({ triggerId = null, userId = null, ruleId = null, limit = 100, offset = 0 } = {}) => {
|
||||
/**
|
||||
* The trigger id a template test send is logged under (Phase 5b, decision 4).
|
||||
*
|
||||
* §4.6.2 asks for a test send "recorded in `engagement_sends` like any other
|
||||
* message", and `trigger_id` is NOT NULL — but a transactional template has no
|
||||
* trigger at all, so there was nothing honest to put there. A synthetic id costs
|
||||
* no schema change and keeps the column meaning one thing: what caused this send.
|
||||
*
|
||||
* It is deliberately NOT a registered trigger. Nothing may point a rule at it, and
|
||||
* the admin list renders it by name rather than by looking it up in a catalog it
|
||||
* will never appear in.
|
||||
*/
|
||||
const TEST_SEND_TRIGGER = 'core.admin.test-send'
|
||||
|
||||
/** WHERE-clause builder shared by `list` and `count`, so the two cannot disagree. */
|
||||
const filters = ({ triggerId = null, userId = null, ruleId = null, status = null } = {}) => {
|
||||
const where = []
|
||||
const params = []
|
||||
if (triggerId) {
|
||||
@@ -63,11 +77,34 @@ const list = ({ triggerId = null, userId = null, ruleId = null, limit = 100, off
|
||||
where.push('rule_id = ?')
|
||||
params.push(ruleId)
|
||||
}
|
||||
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
|
||||
return query(
|
||||
`SELECT * FROM engagement_sends ${clause} ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
if (status) {
|
||||
where.push('status = ?')
|
||||
params.push(status)
|
||||
}
|
||||
return { clause: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list }
|
||||
/** The admin send log (Phase 5b), newest first. */
|
||||
const list = (opts = {}) => {
|
||||
const { clause, params } = filters(opts)
|
||||
return query(`SELECT * FROM engagement_sends ${clause} ORDER BY id DESC LIMIT ? OFFSET ?`, [
|
||||
...params,
|
||||
opts.limit || 50,
|
||||
opts.offset || 0,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows match the same filters — the total the paged screen needs.
|
||||
*
|
||||
* Its own query rather than `SQL_CALC_FOUND_ROWS`, which MariaDB has deprecated,
|
||||
* and rather than counting the page, which would report the page size as the total
|
||||
* on every page but the last.
|
||||
*/
|
||||
const count = async (opts = {}) => {
|
||||
const { clause, params } = filters(opts)
|
||||
const [row] = await query(`SELECT COUNT(*) AS n FROM engagement_sends ${clause}`, params)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, TEST_SEND_TRIGGER }
|
||||
|
||||
@@ -146,4 +146,87 @@ const staleCustomized = async (pairs) => {
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
module.exports = { list, getById, getByKey, existingKeys, seedOne, update, staleCustomized }
|
||||
/**
|
||||
* Insert an operator-created template. Phase 5b, and the ONLY way a row that is
|
||||
* not a seed comes into being: §4.6.2 names duplicate as the creation path, so
|
||||
* every template on a deployment descends from a shipped one that works.
|
||||
*
|
||||
* **`seed_key` is INHERITED from the source, and that is load-bearing rather than
|
||||
* bookkeeping.** `templates.variablesFor()` resolves a template's variable palette
|
||||
* from its trigger or, for the generic seeds that are tied to no trigger, from the
|
||||
* seed. Nulling `seed_key` on a copy would leave it with only the ambient
|
||||
* variables, so a duplicate of `notify.event` would fail its own save check on the
|
||||
* variables it was copied with — the one action §4.6.2 offers, refusing itself.
|
||||
*
|
||||
* It is safe to inherit because `customized = 1` is what the seeder actually reads:
|
||||
* `seedOne`'s UPDATE carries `AND customized = 0`, so it can only ever match the
|
||||
* seeded row itself, never a copy. `staleCustomized` does match a copy, and should
|
||||
* — "the default you duplicated has been improved" is worth telling someone.
|
||||
*
|
||||
* `protected` is 0 whatever the source was: protection is a statement about a row
|
||||
* the system depends on by key, and nothing depends on a copy.
|
||||
*/
|
||||
const create = async (t, userId) => {
|
||||
const res = await query(
|
||||
'INSERT INTO engagement_templates ' +
|
||||
'(`key`, name, trigger_id, trigger_version, channel, subject, blocks, text_body, status, ' +
|
||||
' protected, seed_key, seed_version, customized, updated_by) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1, ?)',
|
||||
[
|
||||
t.key,
|
||||
t.name,
|
||||
t.triggerId ?? null,
|
||||
t.triggerVersion ?? null,
|
||||
t.channel,
|
||||
t.subject ?? null,
|
||||
JSON.stringify(t.blocks),
|
||||
t.textBody ?? null,
|
||||
t.status,
|
||||
t.seedKey ?? null,
|
||||
t.seedVersion ?? null,
|
||||
userId ?? null,
|
||||
],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete by id. `protected = 0` is in the WHERE rather than only in the model:
|
||||
* the model refuses first and with a better message, but the row that must never
|
||||
* disappear is the password-reset body, and a guard that only exists in a
|
||||
* JavaScript branch is a guard one future caller skips.
|
||||
*/
|
||||
const remove = async (id) => {
|
||||
const res = await query('DELETE FROM engagement_templates WHERE id = ? AND protected = 0', [id])
|
||||
return res.affectedRows === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules that point at template key `key`, for the in-use refusal (§4.6.2's
|
||||
* delete, Phase 5b decision 5 — the answer Phase 4b already gives for a segment).
|
||||
*
|
||||
* `template_keys` is a JSON object of channel → key, so this asks MariaDB whether
|
||||
* the key appears among its VALUES. `JSON_SEARCH(..., 'one', ?)` returns a path
|
||||
* or NULL and matches the whole scalar, so `notify.event` does not also match
|
||||
* `notify.event.custom` the way a LIKE would.
|
||||
*/
|
||||
const rulesUsingKey = async (key) => {
|
||||
const rows = await query(
|
||||
"SELECT id, name FROM engagement_rules WHERE JSON_SEARCH(template_keys, 'one', ?) IS NOT NULL",
|
||||
[key],
|
||||
)
|
||||
return rows
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
getByKey,
|
||||
existingKeys,
|
||||
seedOne,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
rulesUsingKey,
|
||||
staleCustomized,
|
||||
}
|
||||
|
||||
412
server/src/model/engagement/engagementTemplates.model.js
Normal file
412
server/src/model/engagement/engagementTemplates.model.js
Normal file
@@ -0,0 +1,412 @@
|
||||
// ── Engagement templates — the save path ───────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2, Phase 5b. Phase 5a gave templates a table, a renderer and
|
||||
// nine seeded rows; nothing could change one. This file is the boundary that lets
|
||||
// an operator change one without being able to break the mail the system depends
|
||||
// on, and — like `engagementRules.model.js` — it is the boundary rather than the
|
||||
// screen. The editor re-implements some of these checks for the sake of a good
|
||||
// inline message; that second copy is expected to drift, so this one decides.
|
||||
//
|
||||
// **What the org lead settled at the start of the phase, because the plan text and
|
||||
// the schema said different things.** §4.6.2 introduces duplicate as "how an
|
||||
// operator customizes a `protected` template safely: duplicate, edit, point the
|
||||
// rule at the copy". The schema comment says the opposite and is the one that was
|
||||
// built: "Editable, NOT deletable". The org lead's call is the schema's — **a
|
||||
// default template is edited in place**, `customized = 1` stops the seeder from
|
||||
// taking that edit back, and duplicate is how a NEW template comes into being
|
||||
// rather than how an existing one is customized. So:
|
||||
//
|
||||
// - `protected` blocks DELETE and nothing else.
|
||||
// - there is no blank-page create; `duplicate` is the only way to a new row, so
|
||||
// every template on a deployment descends from a shipped one that renders.
|
||||
//
|
||||
// The four checks with teeth, in the order they can hurt:
|
||||
//
|
||||
// 1. **Undeclared variables** (§4.6.2). A token naming a variable no trigger
|
||||
// declares renders as nothing, and the failure lands in a person's inbox as
|
||||
// words gone missing. Refused at save, naming the variable.
|
||||
// 2. **An empty text part on a published template.** Also §4.6.2, and it is
|
||||
// checked by RENDERING with the declared examples rather than by inspecting
|
||||
// the blocks: whether a text part exists depends on what each block's `toText`
|
||||
// does with these props, which is a question only the renderer can answer.
|
||||
// 3. **`key` and `channel` are immutable.** `mailer` renders by key; renaming
|
||||
// `auth.password-reset` breaks password resets with no error anywhere. Changing
|
||||
// a channel would leave a row whose blocks were authored for another surface.
|
||||
// 4. **Blocks go through the same validate-then-sanitize gate a CMS page does**,
|
||||
// against the `email.*` registry. Storing operator HTML was never on the table
|
||||
// (§4.4); this is what makes that true at the write.
|
||||
//
|
||||
// **Dormancy, the same posture rules take.** A template pinned to a trigger no
|
||||
// installed module currently declares cannot have its variables checked — the
|
||||
// declaration is the only source of truth for what is legal, and it is absent.
|
||||
// Refusing the save would make a module's absence corrupt the operator's ability
|
||||
// to edit their own copy; passing it silently would call an unknowable thing
|
||||
// clean. It saves, skips check 1, and the row is reported `dormant` so the admin
|
||||
// list can say so (§7.3).
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const db = require('./engagementTemplates.db')
|
||||
const sendsDb = require('./engagementSends.db')
|
||||
const templates = require('../../engagement/templates')
|
||||
const mailer = require('../../utils/mailer')
|
||||
const emailBlocks = require('../../emailBlocks')
|
||||
const { SEEDS } = require('../../engagement/templateSeeds')
|
||||
const registries = require('../../modules/registries')
|
||||
|
||||
// Both from `engagement/templates` — see there for why one definition.
|
||||
const { KEY_RE, MAX_KEY } = templates
|
||||
const MAX_NAME = 160
|
||||
const MAX_SUBJECT = 300
|
||||
const MAX_TEXT_BODY = 20_000
|
||||
const STATUSES = ['draft', 'published']
|
||||
|
||||
/** The example values a trigger declares, as the map the renderer wants. */
|
||||
function examplesFor(template) {
|
||||
const values = {}
|
||||
for (const variable of templates.variablesFor(template)) {
|
||||
if (variable && variable.name !== undefined && variable.example !== undefined) {
|
||||
values[variable.name] = variable.example
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a candidate template with its declared examples. Used by the save check
|
||||
* and by the preview route, so that "what the preview showed" and "what the save
|
||||
* judged" are the same string produced by the same call.
|
||||
*/
|
||||
async function renderWithExamples(template, overrides = {}) {
|
||||
const resolved = await templates.ambient()
|
||||
return templates.renderTemplate(template, { ...examplesFor(template), ...overrides }, resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this template's trigger is currently declared. `null` trigger_id is not
|
||||
* dormant — it is a reusable template tied to no trigger, which is what every
|
||||
* transactional seed is.
|
||||
*/
|
||||
const isDormant = (row) => Boolean(row.trigger_id) && !registries.eventTrigger(row.trigger_id)
|
||||
|
||||
/**
|
||||
* Validate an incoming edit against `existing` (the row being changed) or, for a
|
||||
* duplicate, against the row being copied.
|
||||
*
|
||||
* @returns {Promise<{ok: true, template: object} | {ok: false, errors: string[]}>}
|
||||
*/
|
||||
async function validate(input, existing) {
|
||||
const errors = []
|
||||
const next = {
|
||||
key: existing.key,
|
||||
channel: existing.channel,
|
||||
name: typeof input.name === 'string' ? input.name.trim() : existing.name,
|
||||
subject: input.subject === undefined ? existing.subject : input.subject,
|
||||
blocks: input.blocks === undefined ? existing.blocks : input.blocks,
|
||||
textBody: input.textBody === undefined ? (existing.text_body ?? null) : input.textBody,
|
||||
status: input.status === undefined ? existing.status : input.status,
|
||||
triggerId: input.triggerId === undefined ? (existing.trigger_id ?? null) : input.triggerId,
|
||||
triggerVersion: existing.trigger_version ?? null,
|
||||
}
|
||||
|
||||
// Check 3 — stated as a refusal rather than ignored, because a caller who sends
|
||||
// a new key and gets a 200 has every reason to believe it was renamed.
|
||||
if (input.key !== undefined && input.key !== existing.key) {
|
||||
errors.push('key cannot be changed — duplicate the template instead')
|
||||
}
|
||||
if (input.channel !== undefined && input.channel !== existing.channel) {
|
||||
errors.push('channel cannot be changed — duplicate the template instead')
|
||||
}
|
||||
|
||||
if (!next.name || next.name.length > MAX_NAME) {
|
||||
errors.push(`name is required and must be at most ${MAX_NAME} characters`)
|
||||
}
|
||||
if (next.subject != null && typeof next.subject !== 'string') {
|
||||
errors.push('subject must be a string')
|
||||
} else if (typeof next.subject === 'string' && next.subject.length > MAX_SUBJECT) {
|
||||
errors.push(`subject must be at most ${MAX_SUBJECT} characters`)
|
||||
}
|
||||
if (next.textBody != null && typeof next.textBody !== 'string') {
|
||||
errors.push('textBody must be a string or null')
|
||||
} else if (typeof next.textBody === 'string' && next.textBody.length > MAX_TEXT_BODY) {
|
||||
errors.push(`textBody must be at most ${MAX_TEXT_BODY} characters`)
|
||||
}
|
||||
if (!STATUSES.includes(next.status)) {
|
||||
errors.push(`status must be one of: ${STATUSES.join(', ')}`)
|
||||
}
|
||||
// An email template's subject is not optional the way a body block is: a
|
||||
// message with no Subject header is the shape spam filters were built to catch.
|
||||
if (next.channel === 'email' && next.status === 'published' && !String(next.subject || '').trim()) {
|
||||
errors.push('a published email template needs a subject')
|
||||
}
|
||||
|
||||
if (next.triggerId != null && typeof next.triggerId !== 'string') {
|
||||
errors.push('triggerId must be a string or null')
|
||||
next.triggerId = existing.trigger_id ?? null
|
||||
}
|
||||
// Re-pointing at a trigger pins the version that was declared when it happened,
|
||||
// which is what §4.3's versioning paragraph wants: a later declaration bump is
|
||||
// then visible as a difference rather than as a silent reinterpretation.
|
||||
if (next.triggerId !== (existing.trigger_id ?? null)) {
|
||||
const declared = next.triggerId ? registries.eventTrigger(next.triggerId) : null
|
||||
if (next.triggerId && !declared) {
|
||||
errors.push(`no module declares the trigger "${next.triggerId}"`)
|
||||
}
|
||||
next.triggerVersion = declared ? (declared.version ?? 1) : null
|
||||
}
|
||||
|
||||
// Check 4 — the envelope/id/schema walk, then the registry's sanitizers.
|
||||
//
|
||||
// `validateEmailBlocks` returns `{ valid, errors }`, NOT an array. Destructured
|
||||
// here for the reason `pages.model.js` destructures it: a truthiness test on the
|
||||
// returned object passes for every input, valid or not, and the failure mode is
|
||||
// silent — unvalidated props reaching the renderer and the row.
|
||||
const { valid, errors: blockErrors } = emailBlocks.validateEmailBlocks(next.blocks)
|
||||
if (!valid) {
|
||||
errors.push(...blockErrors)
|
||||
} else {
|
||||
next.blocks = emailBlocks.sanitizeEmailBlocks(next.blocks)
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
const candidate = {
|
||||
key: next.key,
|
||||
subject: next.subject,
|
||||
blocks: next.blocks,
|
||||
text_body: next.textBody,
|
||||
trigger_id: next.triggerId,
|
||||
seed_key: existing.seed_key ?? null,
|
||||
}
|
||||
|
||||
// Check 1 — skipped, deliberately and only, when the trigger is dormant.
|
||||
if (!isDormant(candidate)) {
|
||||
const undeclared = emailBlocks.undeclaredVariables(candidate, templates.variablesFor(candidate))
|
||||
if (undeclared.length) {
|
||||
errors.push(
|
||||
`this template uses ${undeclared.length === 1 ? 'a variable' : 'variables'} ` +
|
||||
`its trigger does not declare: ${undeclared.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2 — by rendering, and only for a published template. A draft with an
|
||||
// empty text part is a work in progress, and refusing to save one is refusing
|
||||
// to let someone stop halfway.
|
||||
if (!errors.length && next.status === 'published') {
|
||||
try {
|
||||
const rendered = await renderWithExamples(candidate)
|
||||
if (!rendered.text.trim()) {
|
||||
errors.push(
|
||||
'a published template needs a plain-text part — every block rendered to nothing. ' +
|
||||
'Add text, or write the text part yourself.',
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`this template could not be rendered: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, template: next }
|
||||
}
|
||||
|
||||
/** Every template, annotated for the admin list. */
|
||||
async function listAnnotated() {
|
||||
const rows = await db.list()
|
||||
const stale = new Set()
|
||||
try {
|
||||
const behind = await db.staleCustomized(SEEDS.map((seed) => ({ key: seed.key, seedVersion: seed.seedVersion })))
|
||||
for (const row of behind) stale.add(row.id)
|
||||
} catch {
|
||||
// The annotation is a hint, not the list. A failure to compute it must not
|
||||
// cost an operator the screen.
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
dormant: isDormant(row),
|
||||
// §4.6.2: "a template pinned to an older `trigger_version` is flagged in the
|
||||
// admin list". Pinned-and-behind is a different fact from dormant — the module
|
||||
// is installed and has moved on — and it is the one that means the variable
|
||||
// palette an operator authored against is no longer the current one.
|
||||
triggerBehind: Boolean(
|
||||
row.trigger_id &&
|
||||
row.trigger_version != null &&
|
||||
registries.eventTrigger(row.trigger_id) &&
|
||||
(registries.eventTrigger(row.trigger_id).version ?? 1) > row.trigger_version,
|
||||
),
|
||||
seedBehind: stale.has(row.id),
|
||||
}))
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const row = await db.getById(id)
|
||||
if (!row) return null
|
||||
return { ...row, dormant: isDormant(row), variables: templates.variablesFor(row) }
|
||||
}
|
||||
|
||||
/** PUT — the in-place edit of any template, seeded or not. */
|
||||
async function update(id, input) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, errors: ['no such template'], status: 404 }
|
||||
const result = await validate(input, existing)
|
||||
if (!result.ok) return result
|
||||
await db.update(id, result.template, input.updatedBy ?? null)
|
||||
return { ok: true, template: await get(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /:id/duplicate — the creation path.
|
||||
*
|
||||
* The copy starts as a **draft** whatever the original was. A duplicate is made
|
||||
* to be changed, and a copy that arrives published is a second live template
|
||||
* nobody has read yet, reachable by a rule the moment its key is typed.
|
||||
*/
|
||||
async function duplicate(id, input) {
|
||||
const source = await db.getById(id)
|
||||
if (!source) return { ok: false, errors: ['no such template'], status: 404 }
|
||||
|
||||
const key = typeof input.key === 'string' ? input.key.trim() : ''
|
||||
if (!key || key.length > MAX_KEY || !KEY_RE.test(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
'key must be lowercase letters, digits, dots and dashes ' +
|
||||
`(for example "notify.my-event"), at most ${MAX_KEY} characters`,
|
||||
],
|
||||
}
|
||||
}
|
||||
if (await db.getByKey(key)) {
|
||||
return { ok: false, errors: [`a template already uses the key "${key}"`], status: 409 }
|
||||
}
|
||||
|
||||
// `seed_key` rides along — see `db.create` for why nulling it would make a
|
||||
// duplicate of a generic template fail the variable check it was copied with.
|
||||
const base = { ...source, key, protected: false }
|
||||
const result = await validate({ ...input, key: undefined, status: 'draft' }, base)
|
||||
if (!result.ok) return result
|
||||
|
||||
const created = await db.create(
|
||||
{ ...result.template, seedKey: source.seed_key ?? null, seedVersion: source.seed_version ?? null },
|
||||
input.updatedBy ?? null,
|
||||
)
|
||||
return { ok: true, template: await get(created) }
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE — refused for a protected template, and refused with a 409 for one a
|
||||
* rule points at. The second is Phase 4b's answer for a segment in use, for the
|
||||
* same reason: the alternative is a rule that silently stops producing mail.
|
||||
*/
|
||||
async function remove(id) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, errors: ['no such template'], status: 404 }
|
||||
if (existing.protected) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: ['this template is part of the system and cannot be deleted. Edit it, or duplicate it.'],
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
const used = await db.rulesUsingKey(existing.key)
|
||||
if (used.length) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
`${used.length === 1 ? 'a rule uses' : `${used.length} rules use`} this template: ` +
|
||||
`${used.map((r) => r.name).join(', ')}. Point ${used.length === 1 ? 'it' : 'them'} elsewhere first.`,
|
||||
],
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
if (!(await db.remove(id))) return { ok: false, errors: ['no such template'], status: 404 }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a candidate template for the editor's preview.
|
||||
*
|
||||
* **It renders the DRAFT, not the row**, so the preview answers "what would this
|
||||
* send" rather than "what did I last save". `id` supplies everything the draft
|
||||
* does not — channel, seed_key, and the trigger that decides the variable palette.
|
||||
*
|
||||
* The blocks are validated first and the preview refused if they fail, for a
|
||||
* reason that is not tidiness: `renderBlocks` trusts its input to have been
|
||||
* through the schema walk, so previewing unvalidated props is asking the renderer
|
||||
* to interpret whatever the client sent.
|
||||
*/
|
||||
async function preview(id, draft) {
|
||||
const source = await db.getById(id)
|
||||
if (!source) return { ok: false, errors: ['no such template'], status: 404 }
|
||||
const result = await validate({ ...draft, status: 'draft' }, source)
|
||||
if (!result.ok) return result
|
||||
const candidate = {
|
||||
key: source.key,
|
||||
subject: result.template.subject,
|
||||
blocks: result.template.blocks,
|
||||
text_body: result.template.textBody,
|
||||
trigger_id: result.template.triggerId,
|
||||
seed_key: source.seed_key ?? null,
|
||||
}
|
||||
const rendered = await renderWithExamples(candidate)
|
||||
return { ok: true, preview: { ...rendered, variables: templates.variablesFor(candidate) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the draft on screen to one address, and record it.
|
||||
*
|
||||
* Two things this deliberately does NOT do. It does not save first — a test send
|
||||
* is how someone decides whether to save. And it does not consult the recipient's
|
||||
* channel preferences or the suppression list: the address is typed by an admin
|
||||
* about their own deployment, it is not derived from a user, and running it
|
||||
* through an opt-in gate would mean an operator could not test a template until
|
||||
* they had subscribed themselves to it.
|
||||
*
|
||||
* It IS recorded (§4.6.2), under the synthetic trigger `engagementSends.db`
|
||||
* documents — including when it fails, which is the case an operator most needs
|
||||
* a record of.
|
||||
*/
|
||||
async function testSend(id, draft) {
|
||||
const to = typeof draft.to === 'string' ? draft.to.trim() : ''
|
||||
// Deliberately shallow: the relay is the authority on whether an address is
|
||||
// deliverable, and a stricter regex here would refuse addresses that work.
|
||||
if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to) || to.length > 254) {
|
||||
return { ok: false, errors: ['enter an email address to send the test to'] }
|
||||
}
|
||||
const rendered = await preview(id, draft)
|
||||
if (!rendered.ok) return rendered
|
||||
|
||||
const source = await db.getById(id)
|
||||
const addressHash = crypto.createHash('sha256').update(to.toLowerCase()).digest('hex')
|
||||
const logRow = {
|
||||
trigger_id: sendsDb.TEST_SEND_TRIGGER,
|
||||
user_id: draft.updatedBy ?? null,
|
||||
channel: source.channel,
|
||||
address_hash: addressHash,
|
||||
}
|
||||
try {
|
||||
const sent = await mailer.sendRendered(to, rendered.preview)
|
||||
await sendsDb.record({ ...logRow, transport: sent.transport, status: 'sent', detail: source.key })
|
||||
return { ok: true, sent: true, to }
|
||||
} catch (err) {
|
||||
await sendsDb
|
||||
.record({ ...logRow, status: 'failed', detail: `${source.key}: ${err.message}` })
|
||||
.catch(() => {})
|
||||
return { ok: false, errors: [err.message], status: err.code === 'NOT_CONFIGURED' ? 409 : 502 }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
preview,
|
||||
testSend,
|
||||
listAnnotated,
|
||||
get,
|
||||
update,
|
||||
duplicate,
|
||||
remove,
|
||||
renderWithExamples,
|
||||
examplesFor,
|
||||
isDormant,
|
||||
KEY_RE,
|
||||
}
|
||||
@@ -30,6 +30,8 @@ const audiences = require('../../../engagement/audiences')
|
||||
const rules = require('../../../model/engagement/engagementRules.model')
|
||||
const segments = require('../../../model/engagement/engagementSegments.model')
|
||||
const recipients = require('../../../model/engagement/engagementRecipients.db')
|
||||
const templates = require('../../../model/engagement/engagementTemplates.model')
|
||||
const sendsDb = require('../../../model/engagement/engagementSends.db')
|
||||
|
||||
// The lattice, flattened for a client: for each ceiling, the ones a rule may
|
||||
// choose under it. Served with the catalog rather than hardcoded in the admin
|
||||
@@ -300,3 +302,145 @@ exports.previewAudience = async (req, res, next) => {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Templates (Phase 5b) ───────────────────────────────────────────────────
|
||||
//
|
||||
// §4.6.2. The model owns every rule; this file reads ids out of URLs, maps a
|
||||
// refusal onto a status code and shapes responses. The one thing worth saying
|
||||
// here rather than there: **`refuse` is handed `result.status`**, because these
|
||||
// routes have three different refusals that are not all 400 — a missing template
|
||||
// is 404, a duplicate key or an in-use delete is 409, and a transport that would
|
||||
// not take the test send is 502. A single 400 for all of them would make the
|
||||
// editor's error handling guess.
|
||||
|
||||
/** GET /api/v1/admin/engagement/templates */
|
||||
exports.listTemplates = async (req, res, next) => {
|
||||
try {
|
||||
res.json({ templates: await templates.listAnnotated() })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/engagement/templates/:id */
|
||||
exports.getTemplate = async (req, res, next) => {
|
||||
try {
|
||||
const template = await templates.get(Number(req.params.id))
|
||||
if (!template) return res.status(404).json({ message: 'No such template' })
|
||||
res.json({ template })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/v1/admin/engagement/templates/:id */
|
||||
exports.updateTemplate = async (req, res, next) => {
|
||||
try {
|
||||
const result = await templates.update(Number(req.params.id), {
|
||||
...req.body,
|
||||
updatedBy: req.user?.id ?? null,
|
||||
})
|
||||
if (!result.ok) return refuse(res, result, result.status || 400)
|
||||
res.json({ template: result.template })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/engagement/templates/:id/duplicate */
|
||||
exports.duplicateTemplate = async (req, res, next) => {
|
||||
try {
|
||||
const result = await templates.duplicate(Number(req.params.id), {
|
||||
...req.body,
|
||||
updatedBy: req.user?.id ?? null,
|
||||
})
|
||||
if (!result.ok) return refuse(res, result, result.status || 400)
|
||||
res.status(201).json({ template: result.template })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/admin/engagement/templates/:id */
|
||||
exports.deleteTemplate = async (req, res, next) => {
|
||||
try {
|
||||
const result = await templates.remove(Number(req.params.id))
|
||||
if (!result.ok) return refuse(res, result, result.status || 400)
|
||||
res.status(204).end()
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/engagement/templates/:id/preview
|
||||
*
|
||||
* A POST because it renders the body in the request, not the row: the editor
|
||||
* previews unsaved edits, which is the whole reason the preview exists.
|
||||
*
|
||||
* The response is HTML the client puts into a sandboxed iframe. It is NOT served
|
||||
* as a document from this origin, and that is a security boundary rather than a
|
||||
* convenience: operator-authored HTML rendered at the site's own origin would run
|
||||
* under the site's CSP with access to its cookies. Returning it as a JSON string
|
||||
* leaves the client no way to render it except into a frame it controls the
|
||||
* sandbox attributes of.
|
||||
*/
|
||||
exports.previewTemplate = async (req, res, next) => {
|
||||
try {
|
||||
const result = await templates.preview(Number(req.params.id), req.body || {})
|
||||
if (!result.ok) return refuse(res, result, result.status || 400)
|
||||
res.json(result.preview)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/engagement/templates/:id/test-send */
|
||||
exports.testSendTemplate = async (req, res, next) => {
|
||||
try {
|
||||
const result = await templates.testSend(Number(req.params.id), {
|
||||
...req.body,
|
||||
updatedBy: req.user?.id ?? null,
|
||||
})
|
||||
if (!result.ok) return refuse(res, result, result.status || 400)
|
||||
res.json({ sent: true, to: result.to })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send log (Phase 5b) ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/engagement/sends
|
||||
*
|
||||
* G15's answer, paged. `address_hash` is a column this route never returns: the
|
||||
* log holds it so a bounce can be correlated back to a recipient (Phase 9), and
|
||||
* shipping it to a browser would turn a screen about delivery into an offline
|
||||
* dictionary attack against every address on the deployment.
|
||||
*/
|
||||
exports.listSends = async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0)
|
||||
const filters = {
|
||||
triggerId: req.query.triggerId || null,
|
||||
ruleId: req.query.ruleId ? Number(req.query.ruleId) : null,
|
||||
userId: req.query.userId ? Number(req.query.userId) : null,
|
||||
status: req.query.status || null,
|
||||
}
|
||||
const [rows, total] = await Promise.all([
|
||||
sendsDb.list({ ...filters, limit, offset }),
|
||||
sendsDb.count(filters),
|
||||
])
|
||||
res.json({
|
||||
sends: rows.map(({ address_hash: _hash, ...row }) => row),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
testSendTrigger: sendsDb.TEST_SEND_TRIGGER,
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -409,10 +409,53 @@ async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an ALREADY-RENDERED body to one address — the template editor's test send
|
||||
* (§4.6.2, Phase 5b).
|
||||
*
|
||||
* It takes the rendered parts rather than a template key because the whole point
|
||||
* of the button is to send **what is on screen**, including edits not yet saved.
|
||||
* Rendering happens in the model, from the same call the preview uses, so the mail
|
||||
* that arrives and the preview above it cannot disagree.
|
||||
*
|
||||
* Throws like `sendTest` and for the same reason: an admin is standing there
|
||||
* waiting to be told why nothing arrived.
|
||||
*/
|
||||
async function sendRendered(to, rendered) {
|
||||
const built = await buildTransport()
|
||||
if (!built) {
|
||||
const err = new Error('Email is not configured. Set a transport, its credentials and a sender address first.')
|
||||
err.code = 'NOT_CONFIGURED'
|
||||
throw err
|
||||
}
|
||||
const { transport, config } = built
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: replyToFor(config),
|
||||
subject: rendered.subject,
|
||||
text: rendered.text,
|
||||
html: rendered.html,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true, to, transport: config.transport }
|
||||
} catch (err) {
|
||||
const detail = describeSendError(err, config)
|
||||
log.error('template test send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: detail })
|
||||
const wrapped = new Error(detail)
|
||||
wrapped.code = err.code || 'SEND_FAILED'
|
||||
wrapped.transport = config.transport
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
sendContactMessage,
|
||||
sendTest,
|
||||
sendRendered,
|
||||
sendInvite,
|
||||
sendPasswordReset,
|
||||
sendEmailVerification,
|
||||
|
||||
@@ -2067,6 +2067,677 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/sends": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "The send log, newest first",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Page size, 1-200 (default 50)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"description": "Rows to skip",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "triggerId",
|
||||
"in": "query",
|
||||
"description": "Only sends caused by this trigger",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ruleId",
|
||||
"in": "query",
|
||||
"description": "Only sends made by this rule",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"description": "Only sends to this user",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"description": "sent, failed, suppressed, bounced or complained",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/templates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "List every message template, annotated",
|
||||
"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.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The templates",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"templates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/templates/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "One template, with the variables it may reference",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The template",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such template",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Edit a template, including a shipped default",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The updated template",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation failed; `errors` lists every problem",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such template",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Delete a template",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Deleted"
|
||||
},
|
||||
"409": {
|
||||
"description": "Protected, or still used by a rule",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/templates/{id}/duplicate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Copy a template under a new key",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "The new template",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The key is not a legal template key",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "That key is already taken",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"triggerId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/templates/{id}/preview": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Render the draft on screen, without saving it",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The draft is not renderable; `errors` says why",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/templates/{id}/test-send": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Send the draft on screen to one address",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Sent",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "No address, or the draft is not renderable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email is not configured on this deployment",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "The transport refused the message; the message is the relay reason",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/triggers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -397,6 +397,27 @@ test('the shipped default is used when the row is missing, and when it is unusab
|
||||
assert.equal(await templates.renderByKey('nope.not-a-key', {}), null)
|
||||
})
|
||||
|
||||
// Phase 5b, decision 3. `status` existed from 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 now means what the word means.
|
||||
test('a draft row is not what goes out; the shipped seed is', async () => {
|
||||
settings.getInstanceName = async () => SITE
|
||||
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
||||
templatesDb.getByKey = async () => ({
|
||||
subject: 'Half-written: {{siteName}}',
|
||||
blocks: [{ id: 'a', type: 'email.text', props: { text: 'Not ready.' } }],
|
||||
text_body: null,
|
||||
status: 'draft',
|
||||
})
|
||||
const r = await templates.renderByKey('admin.test', { transport: 'smtp' })
|
||||
assert.equal(r.subject, `${SITE} email test`)
|
||||
assert.doesNotMatch(r.text, /Not ready\./)
|
||||
|
||||
// ...and a seedless draft — a duplicate still being written — has nothing to
|
||||
// fall back TO, so it renders nothing rather than sending half a message.
|
||||
assert.equal(await templates.renderByKey('nope.not-a-key', {}), null)
|
||||
})
|
||||
|
||||
test('an operator edit is rendered instead of the seed, and text_body overrides the generated text', async () => {
|
||||
settings.getInstanceName = async () => SITE
|
||||
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
||||
@@ -404,6 +425,9 @@ test('an operator edit is rendered instead of the seed, and text_body overrides
|
||||
subject: 'Edited: {{siteName}}',
|
||||
blocks: [{ id: 'a', type: 'email.text', props: { text: 'Generated body.' } }],
|
||||
text_body: 'A hand-written text part for {{siteName}}.',
|
||||
// Phase 5b: only a PUBLISHED row is sent. The fixture carried no status until
|
||||
// then because nothing read the column.
|
||||
status: 'published',
|
||||
})
|
||||
const r = await templates.renderByKey('admin.test', {})
|
||||
assert.equal(r.subject, `Edited: ${SITE}`)
|
||||
|
||||
404
server/test/engagementTemplatesAdmin.test.js
Normal file
404
server/test/engagementTemplatesAdmin.test.js
Normal file
@@ -0,0 +1,404 @@
|
||||
// Engagement Phase 5b — the template save boundary.
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2's acceptance criteria, one test each, plus the two the
|
||||
// tree made necessary that the plan did not name (the itemList variable, and a
|
||||
// duplicate keeping its palette).
|
||||
//
|
||||
// The model is exercised directly with a stubbed db, the way
|
||||
// `engagementAdmin.test.js` does: what is under test is the arithmetic of what is
|
||||
// legal, and routing it through supertest would test express instead.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const templatesDb = require('../src/model/engagement/engagementTemplates.db')
|
||||
const sendsDb = require('../src/model/engagement/engagementSends.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const mailer = require('../src/utils/mailer')
|
||||
const registries = require('../src/modules/registries')
|
||||
const model = require('../src/model/engagement/engagementTemplates.model')
|
||||
const { undeclaredVariables, referencedVariables } = require('../src/emailBlocks')
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
const text = (id, body) => ({ id, type: 'email.text', version: 1, props: { text: body } })
|
||||
|
||||
/** A stored row, published, tied to no trigger — the shape every seed has. */
|
||||
const row = (over = {}) => ({
|
||||
id: 1,
|
||||
key: 'admin.test',
|
||||
name: 'Test message',
|
||||
trigger_id: null,
|
||||
trigger_version: null,
|
||||
channel: 'email',
|
||||
subject: 'Hello from {{siteName}}',
|
||||
blocks: [text('a', 'A body with {{siteName}} in it.')],
|
||||
text_body: null,
|
||||
status: 'published',
|
||||
protected: false,
|
||||
seed_key: 'admin.test',
|
||||
seed_version: 1,
|
||||
customized: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
let stored = row()
|
||||
let written = null
|
||||
let created = null
|
||||
let deleted = null
|
||||
let rulesUsing = []
|
||||
|
||||
test.beforeEach(() => {
|
||||
stored = row()
|
||||
written = null
|
||||
created = null
|
||||
deleted = null
|
||||
rulesUsing = []
|
||||
|
||||
templatesDb.getById = async (id) => (id === stored.id ? { ...stored } : null)
|
||||
templatesDb.getByKey = async (key) => (key === stored.key ? { ...stored } : null)
|
||||
templatesDb.list = async () => [{ ...stored }]
|
||||
templatesDb.staleCustomized = async () => []
|
||||
templatesDb.update = async (id, t, userId) => {
|
||||
written = { id, ...t, userId }
|
||||
stored = { ...stored, ...t, text_body: t.textBody, trigger_id: t.triggerId, status: t.status }
|
||||
return true
|
||||
}
|
||||
templatesDb.create = async (t, userId) => {
|
||||
created = { ...t, userId }
|
||||
return 2
|
||||
}
|
||||
templatesDb.remove = async (id) => {
|
||||
deleted = id
|
||||
return true
|
||||
}
|
||||
templatesDb.rulesUsingKey = async () => rulesUsing
|
||||
|
||||
settings.getInstanceName = async () => 'Runic Gateway'
|
||||
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
|
||||
})
|
||||
|
||||
// ── §4.6.2: an undeclared variable is refused, WITH THE VARIABLE NAMED ──────
|
||||
|
||||
test('a token naming a variable the trigger does not declare is refused, and the message names it', async () => {
|
||||
const result = await model.update(1, { blocks: [text('a', 'Hi {{recipientName}}, from {{siteName}}.')] })
|
||||
assert.equal(result.ok, false)
|
||||
// The name is the whole point: "validation failed" sends someone hunting
|
||||
// through a body for a token they already cannot see.
|
||||
assert.match(result.errors[0], /recipientName/)
|
||||
// ...and only the undeclared one. `siteName` is ambient and legal everywhere.
|
||||
assert.doesNotMatch(result.errors[0], /siteName/)
|
||||
assert.equal(written, null)
|
||||
})
|
||||
|
||||
test('the ambient variables are legal in every template, with no trigger at all', async () => {
|
||||
const result = await model.update(1, {
|
||||
blocks: [text('a', '{{siteName}} · {{siteUrl}} · {{year}}')],
|
||||
})
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
// The one the plan did not name. `email.itemList.variable` is a BARE NAME, not a
|
||||
// token, so a check that only scanned `{{…}}` would pass a digest pointed at a
|
||||
// variable nothing declares — and the failure would be an empty mail, not an error.
|
||||
test('an item list pointed at an undeclared variable is refused too, though it uses no token', async () => {
|
||||
const blocks = [{ id: 'a', type: 'email.itemList', version: 1, props: { variable: 'itmes', emptyText: '' } }]
|
||||
assert.deepEqual(referencedVariables({ blocks }), ['itmes'])
|
||||
|
||||
const result = await model.update(1, { blocks })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors[0], /itmes/)
|
||||
})
|
||||
|
||||
test('undeclaredVariables reads the subject and the text override, not only the blocks', () => {
|
||||
const declared = [{ name: 'known' }]
|
||||
assert.deepEqual(
|
||||
undeclaredVariables({ subject: 'Re: {{fromSubject}}', blocks: [], text_body: '{{fromText}}' }, declared),
|
||||
['fromSubject', 'fromText'],
|
||||
)
|
||||
})
|
||||
|
||||
// ── §4.6.2: a published template with an empty text part is refused ─────────
|
||||
|
||||
test('a published template whose blocks render no text at all is refused; the same body saves as a draft', async () => {
|
||||
// A divider renders to nothing in the text part by design, so a body that is
|
||||
// only dividers is the minimal case of "there is no plain-text message here".
|
||||
const blocks = [{ id: 'a', type: 'email.divider', version: 1, props: {} }]
|
||||
|
||||
const published = await model.update(1, { blocks, status: 'published' })
|
||||
assert.equal(published.ok, false)
|
||||
assert.match(published.errors[0], /plain-text/)
|
||||
|
||||
// A draft is a work in progress; refusing to save one is refusing to let
|
||||
// someone stop halfway.
|
||||
const draft = await model.update(1, { blocks, status: 'draft' })
|
||||
assert.equal(draft.ok, true)
|
||||
})
|
||||
|
||||
test('an authored text part satisfies it even when every block renders to nothing', async () => {
|
||||
const result = await model.update(1, {
|
||||
blocks: [{ id: 'a', type: 'email.divider', version: 1, props: {} }],
|
||||
textBody: 'Written by hand.',
|
||||
status: 'published',
|
||||
})
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(written.textBody, 'Written by hand.')
|
||||
})
|
||||
|
||||
// ── §4.6.2: an interpolated variable containing markup renders escaped ──────
|
||||
|
||||
test('a variable whose value contains a script tag renders escaped, in both parts', async () => {
|
||||
// `transport`, not an ambient variable: 5a's "a caller cannot override the
|
||||
// deployment brand" makes the resolved brand win over anything passed in, so
|
||||
// `siteName` is not a channel a value can arrive through at all.
|
||||
const rendered = await model.renderWithExamples(
|
||||
{ subject: 'x', blocks: [text('a', 'Hello {{transport}}')], text_body: null, seed_key: 'admin.test' },
|
||||
{ transport: '<script>alert(1)</script>' },
|
||||
)
|
||||
assert.doesNotMatch(rendered.html, /<script>/)
|
||||
assert.match(rendered.html, /<script>/)
|
||||
// The TEXT part is deliberately not escaped — there is no markup to escape into
|
||||
// and `&` in a person's inbox is a bug — so the raw string is expected here.
|
||||
assert.match(rendered.text, /<script>alert\(1\)<\/script>/)
|
||||
})
|
||||
|
||||
// ── §4.6.2: protected cannot be deleted but can be duplicated ──────────────
|
||||
|
||||
test('a protected template refuses deletion and says what to do instead', async () => {
|
||||
stored = row({ protected: true })
|
||||
const result = await model.remove(1)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 409)
|
||||
assert.match(result.errors[0], /cannot be deleted/)
|
||||
assert.equal(deleted, null)
|
||||
})
|
||||
|
||||
test('a protected template IS editable in place, and the edit is marked customized', async () => {
|
||||
// The org lead's call, and the schema's comment: "Editable, NOT deletable".
|
||||
stored = row({ protected: true })
|
||||
const result = await model.update(1, { subject: 'Edited {{siteName}}' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(written.subject, 'Edited {{siteName}}')
|
||||
// `customized` is not in the payload — the db sets it unconditionally, which is
|
||||
// what stops the next seed bump from taking the edit back.
|
||||
})
|
||||
|
||||
test('a template a rule points at cannot be deleted, and the refusal names the rule', async () => {
|
||||
rulesUsing = [{ id: 7, name: 'IDOC warning' }]
|
||||
const result = await model.remove(1)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 409)
|
||||
assert.match(result.errors[0], /IDOC warning/)
|
||||
assert.equal(deleted, null)
|
||||
})
|
||||
|
||||
test('an unprotected, unused template deletes', async () => {
|
||||
const result = await model.remove(1)
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(deleted, 1)
|
||||
})
|
||||
|
||||
// ── Duplicate ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('a duplicate starts as a draft, unprotected, and keeps the source seed reference', async () => {
|
||||
stored = row({ protected: true, status: 'published' })
|
||||
const result = await model.duplicate(1, { key: 'admin.test-copy', name: 'A copy' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(created.status, 'draft')
|
||||
assert.equal(created.key, 'admin.test-copy')
|
||||
// The seed reference rides along because `variablesFor` resolves a seedless,
|
||||
// triggerless template to the ambient variables ONLY — so a copy without it
|
||||
// would fail the undeclared check on the variables it was copied with.
|
||||
assert.equal(created.seedKey, 'admin.test')
|
||||
})
|
||||
|
||||
test('a duplicate of a generic template is not refused for the variables it was copied with', async () => {
|
||||
// The regression this protects against: `notify.event` declares `title`,
|
||||
// `intro` and friends through its seed, not through a trigger.
|
||||
stored = row({
|
||||
key: 'notify.event',
|
||||
seed_key: 'notify.event',
|
||||
blocks: [text('a', '{{intro}}')],
|
||||
subject: '{{title}}',
|
||||
})
|
||||
const result = await model.duplicate(1, { key: 'notify.mine', name: 'Mine' })
|
||||
assert.equal(result.ok, true, JSON.stringify(result.errors))
|
||||
})
|
||||
|
||||
test('a duplicate onto a taken key is a 409, and a malformed key a 400', async () => {
|
||||
const taken = await model.duplicate(1, { key: 'admin.test', name: 'x' })
|
||||
assert.equal(taken.status, 409)
|
||||
|
||||
for (const key of ['Admin.Test', 'has space', '', 'trailing.']) {
|
||||
const bad = await model.duplicate(1, { key, name: 'x' })
|
||||
assert.equal(bad.ok, false, `expected ${JSON.stringify(key)} to be refused`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The immutable pair ─────────────────────────────────────────────────────
|
||||
|
||||
test('key and channel cannot be changed, and the attempt is refused rather than ignored', async () => {
|
||||
const result = await model.update(1, { key: 'auth.something-else', channel: 'inapp' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.errors.length, 2)
|
||||
assert.match(result.errors.join(' '), /key cannot be changed/)
|
||||
assert.match(result.errors.join(' '), /channel cannot be changed/)
|
||||
assert.equal(written, null)
|
||||
})
|
||||
|
||||
test('a published email template with no subject is refused', async () => {
|
||||
const result = await model.update(1, { subject: ' ', status: 'published' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors[0], /subject/)
|
||||
})
|
||||
|
||||
// ── Dormancy ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a template pinned to a trigger no module declares still saves, and is listed dormant', async () => {
|
||||
stored = row({ trigger_id: 'gone.module.event', seed_key: null })
|
||||
// The declaration is the only source of truth for what is legal and it is
|
||||
// absent, so the variable check cannot run. Refusing would make a module's
|
||||
// absence cost the operator the ability to edit their own copy.
|
||||
const result = await model.update(1, { blocks: [text('a', 'Uses {{whateverThatWas}}.')] })
|
||||
assert.equal(result.ok, true)
|
||||
|
||||
const [listed] = await model.listAnnotated()
|
||||
assert.equal(listed.dormant, true)
|
||||
})
|
||||
|
||||
test('a template whose trigger has moved on is flagged as behind, not as dormant', async () => {
|
||||
const real = registries.eventTrigger
|
||||
try {
|
||||
registries.eventTrigger = (id) => (id === 'core.news.post' ? { id, version: 3, variables: [] } : null)
|
||||
stored = row({ trigger_id: 'core.news.post', trigger_version: 1, seed_key: null })
|
||||
const [listed] = await model.listAnnotated()
|
||||
assert.equal(listed.dormant, false)
|
||||
assert.equal(listed.triggerBehind, true)
|
||||
} finally {
|
||||
registries.eventTrigger = real
|
||||
}
|
||||
})
|
||||
|
||||
// ── Preview and test send ──────────────────────────────────────────────────
|
||||
|
||||
test('preview renders the DRAFT in the request, not the stored row', async () => {
|
||||
const result = await model.preview(1, { subject: 'Unsaved {{siteName}}', blocks: [text('a', 'Unsaved body.')] })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.preview.subject, 'Unsaved Runic Gateway')
|
||||
assert.match(result.preview.text, /Unsaved body\./)
|
||||
// Nothing was written: previewing is how someone decides whether to save.
|
||||
assert.equal(written, null)
|
||||
})
|
||||
|
||||
test('preview refuses a draft whose blocks do not validate, rather than rendering unchecked props', async () => {
|
||||
const result = await model.preview(1, { blocks: [{ id: 'a', type: 'email.text', version: 1, props: { text: 'x', bogus: 1 } }] })
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('a test send goes to the typed address and is recorded, under the synthetic trigger', async () => {
|
||||
const recorded = []
|
||||
const realRecord = sendsDb.record
|
||||
const realSend = mailer.sendRendered
|
||||
try {
|
||||
sendsDb.record = async (entry) => { recorded.push(entry); return 1 }
|
||||
mailer.sendRendered = async (to) => ({ sent: true, to, transport: 'smtp' })
|
||||
|
||||
const result = await model.testSend(1, { to: 'someone@example.com' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(recorded.length, 1)
|
||||
assert.equal(recorded[0].trigger_id, sendsDb.TEST_SEND_TRIGGER)
|
||||
assert.equal(recorded[0].status, 'sent')
|
||||
// The address is hashed, never stored: the log must not become a second
|
||||
// address book.
|
||||
assert.match(recorded[0].address_hash, /^[0-9a-f]{64}$/)
|
||||
assert.equal(JSON.stringify(recorded[0]).includes('someone@example.com'), false)
|
||||
} finally {
|
||||
sendsDb.record = realRecord
|
||||
mailer.sendRendered = realSend
|
||||
}
|
||||
})
|
||||
|
||||
test('a FAILED test send is recorded too — that is the outcome an operator needs the record of', async () => {
|
||||
const recorded = []
|
||||
const realRecord = sendsDb.record
|
||||
const realSend = mailer.sendRendered
|
||||
try {
|
||||
sendsDb.record = async (entry) => { recorded.push(entry); return 1 }
|
||||
mailer.sendRendered = async () => {
|
||||
const err = new Error('relay refused the sender')
|
||||
err.code = 'SEND_FAILED'
|
||||
throw err
|
||||
}
|
||||
|
||||
const result = await model.testSend(1, { to: 'someone@example.com' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 502)
|
||||
assert.equal(recorded[0].status, 'failed')
|
||||
assert.match(recorded[0].detail, /relay refused/)
|
||||
} finally {
|
||||
sendsDb.record = realRecord
|
||||
mailer.sendRendered = realSend
|
||||
}
|
||||
})
|
||||
|
||||
test('a test send with no address never reaches the transport', async () => {
|
||||
const realSend = mailer.sendRendered
|
||||
try {
|
||||
let called = false
|
||||
mailer.sendRendered = async () => { called = true }
|
||||
const result = await model.testSend(1, { to: ' ' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(called, false)
|
||||
} finally {
|
||||
mailer.sendRendered = realSend
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ── The Phase 4a defect this phase had to fix to be usable ─────────────────
|
||||
//
|
||||
// A rule names a template by key. Phase 4a validated that key against
|
||||
// `/^[a-z0-9][a-z0-9-]{0,63}$/` — a pattern with no dot in it, written before any
|
||||
// template existed — so it matched NO key this system actually uses and no rule
|
||||
// could point at any real template. §4.6.2's whole duplicate-and-point-a-rule-at-it
|
||||
// workflow was unreachable, and nothing failed loudly enough to say so.
|
||||
test('a rule can name a real, dotted template key — and both models agree what a key is', async () => {
|
||||
const rules = require('../src/model/engagement/engagementRules.model')
|
||||
const shared = require('../src/engagement/templates')
|
||||
|
||||
// One definition, read by both call sites, which is what stops this recurring.
|
||||
assert.equal(model.KEY_RE, shared.KEY_RE)
|
||||
|
||||
for (const key of ['notify.event', 'auth.password-reset', 'admin.contact-message', 'notify.my-copy']) {
|
||||
assert.ok(shared.KEY_RE.test(key), `${key} must be a legal template key`)
|
||||
}
|
||||
for (const key of ['Notify.Event', 'has space', 'trailing.', '.leading', '']) {
|
||||
assert.ok(!shared.KEY_RE.test(key), `${key} must not be`)
|
||||
}
|
||||
|
||||
// A trigger has to be REGISTERED for a rule to name it, and this file otherwise
|
||||
// needs no registry at all — so one is staged here and torn down again rather
|
||||
// than in a shared beforeEach that every other test would pay for.
|
||||
const api = registries.stage('probe')
|
||||
api.registerEventTriggers([
|
||||
{
|
||||
id: 'probe.thing.happened',
|
||||
label: 'A thing happened',
|
||||
ceiling: 'subscribers',
|
||||
audience: 'subscribers',
|
||||
variables: [{ name: 'what', type: 'string', required: true, example: 'a thing' }],
|
||||
},
|
||||
])
|
||||
registries.apply(api.staged)
|
||||
|
||||
const result = await rules.validate({
|
||||
triggerId: 'probe.thing.happened',
|
||||
name: 'a rule that names a real template',
|
||||
channels: ['email'],
|
||||
templateKeys: { email: 'notify.event' },
|
||||
audience: 'subscribers',
|
||||
})
|
||||
registries._reset()
|
||||
assert.equal(result.ok, true, JSON.stringify(result.errors))
|
||||
assert.equal(result.rule.template_keys.email, 'notify.event')
|
||||
})
|
||||
Reference in New Issue
Block a user