diff --git a/client/src/App.jsx b/client/src/App.jsx index b1d9789..00a21a5 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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). */} } /> - {/* 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() { } /> } /> } /> + } /> + } /> + } /> } /> {/* Installed modules' admin pages, at /admin//…, already inside diff --git a/client/src/api/client.js b/client/src/api/client.js index af9423f..6f01d54 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -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`. diff --git a/client/src/emailBlocks/index.js b/client/src/emailBlocks/index.js new file mode 100644 index 0000000..adb9920 --- /dev/null +++ b/client/src/emailBlocks/index.js @@ -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' diff --git a/client/src/emailBlocks/registry.js b/client/src/emailBlocks/registry.js new file mode 100644 index 0000000..027fa92 --- /dev/null +++ b/client/src/emailBlocks/registry.js @@ -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(), + } +} diff --git a/client/src/emailBlocks/types.jsx b/client/src/emailBlocks/types.jsx new file mode 100644 index 0000000..fde6eb2 --- /dev/null +++ b/client/src/emailBlocks/types.jsx @@ -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 ( +
+ {variables.map((v) => ( + + ))} +
+ ) +} + +/** 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 ( +
+ + onChange(`${value || ''}${token}`)} /> +
+ ) +} + +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 }) => ( +
+ onChange({ ...props, level })} + options={[ + ['h1', 'Large'], + ['h2', 'Medium'], + ['h3', 'Small'], + ]} + /> + onChange({ ...props, text })} + /> +
+ ), +}) + +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 }) => ( +
+ onChange({ ...props, text })} + /> + + + +
+ ), +}) + +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 }) => ( +
+ onChange({ ...props, label })} + /> + onChange({ ...props, url })} + /> + onChange({ ...props, textLead })} + /> +
+ ), +}) + +registerEmailBlock({ + type: 'email.divider', + version: 1, + label: 'Divider', + icon: '—', + hint: 'A horizontal rule.', + defaults: () => ({}), + editor: () => ( +

+ A divider has nothing to configure. +

+ ), +}) + +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 }) => ( +
+ onChange({ ...props, url })} + /> + onChange({ ...props, alt })} + /> + + { + 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) + }} + /> + +
+ ), +}) + +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 ( +
+ {lists.length ? ( + onChange({ ...props, variable })} + options={[['', 'Choose a list…'], ...lists.map((v) => [v.name, v.name])]} + /> + ) : ( + +

+ 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. +

+
+ )} + onChange({ ...props, emptyText })} + /> +
+ ) + }, +}) diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index ddef1ec..10a4a24 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -48,6 +48,9 @@ const IconPalette = () => const IconMail = () => const IconList = () => +const IconTemplate = () => +const IconSpark = () => +const IconLog = () => // 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 diff --git a/client/src/routes/admin/views/EngagementSendLog.jsx b/client/src/routes/admin/views/EngagementSendLog.jsx new file mode 100644 index 0000000..bba6231 --- /dev/null +++ b/client/src/routes/admin/views/EngagementSendLog.jsx @@ -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 + if (error) return + + const to = Math.min(offset + PAGE, total) + + return ( +
+
+

+ 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. +

+ +
+ + {total === 0 ? ( +

+ {status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'} +

+ ) : ( + <> +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
WhenWhatToChannelResultDetail
+ {new Date(r.created_at).toLocaleString()} + + {/* 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 + ? Test send from the template editor + : {r.trigger_id}} + + {r.user_id ? user #{r.user_id} : } + + {r.channel} + {r.transport && · {r.transport}} + + {STATUS_LABEL[r.status] || r.status} + + {r.detail || ''} +
+
+ +
+ + {offset + 1}–{to} of {total} + +
+ + +
+
+ + )} +
+ ) +} diff --git a/client/src/routes/admin/views/EngagementTemplates.jsx b/client/src/routes/admin/views/EngagementTemplates.jsx new file mode 100644 index 0000000..ecead56 --- /dev/null +++ b/client/src/routes/admin/views/EngagementTemplates.jsx @@ -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 ( +
+