Files
website/server/src/engagement/templateSeeds.js
wtclaude 1667e636bd feat(events): the public calendar, event pages and participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events,
/public/events/:slug and /public/events/series/:slug, plus
GET /player/events/history, and the four screens over them.

Four org-lead decisions taken up front: split Phase 14 into 14a (website)
and 14b (the app); add a `listed` flag rather than letting `state` mean both
schedulable and announced; put the `events` capability string in the version
block rather than publishing core as a pseudo-module; and drop "venue" from
the spec rather than adding a field nothing had ever built.

`listed` is announcement, not permission. Publishing is what makes a
definition runnable, so without a separate flag a surprise event would have
to be advertised in order to be allowed to happen. It is a column, a switch
in Phase 13's editor, and three SQL predicates -- never a filter applied
after a read, which works exactly as well until the first caller that forgets.

The public shapes are a projection, and the projection is the security
boundary: nothing is spread, so a column added to event_runs next year does
not ride out through it. The spec, health, cleanup, claims, errors and
member_key are all absent by construction.

The six public event triggers gained `eventUrl` (version 1 -> 2), carrying
?run= because the page lives at the definition's slug while every trigger is
about one occurrence. notify.event-started gained the button, at seedVersion 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 06:18:38 -05:00

378 lines
19 KiB
JavaScript

// ── The shipped template set (§4.6.1) ──────────────────────────────────────
//
// "A fresh deployment mails correctly before anyone opens the editor." Every body
// that used to be a template literal inside `utils/mailer.js` is a row here, so
// Phase 5 is a RELOCATION rather than a regression: nothing that sends mail today
// starts depending on an operator authoring something first.
//
// **Nine seeds, six of them wired in this phase.** The five transactional bodies
// plus `auth.email-verify` (which §4.6.1 lists as "new — Phase 9" and which Phase
// 1b in fact already shipped) are rendered by `mailer` from this moment. The three
// notification seeds are seeded but not yet rendered by anything: `notify.digest`
// and `notify.team-post` belong to `teamNotify`/`teamDigestWorker`, which Phase 6
// rewrites onto the engine, and `inapp.event` to the channel Phase 7 builds.
// Settled with the org lead: seed all nine now so those phases open something
// rather than shipping seeds of their own — a seeder bump is the mechanism of last
// resort (property 3 below), not a per-phase routine.
//
// **`seedVersion` is the whole "improve a default without stealing an operator's
// work" mechanism.** Bump it when a body changes; the seeder updates rows where
// `customized = 0` and skips rows where it is 1. Do NOT bump it for a comment.
//
// ── Two conventions the bodies follow, both of which are visible to operators ──
//
// **1. Presentational fragments are variables, because templates have no logic.**
// `mailer` used to build ` for the account “Darrow”` with a ternary. A template
// cannot, by design (interpolate.js: no conditionals). So the ternary stays at the
// call site and its RESULT arrives as a variable — `forWhom` — whose `example`
// shows exactly what it produces, leading space and quotes included. That is the
// price of a logic-free template language, and it is paid here rather than by
// giving operator-authored data a conditional to get wrong.
//
// **2. Ambient brand variables are supplied by the renderer, not by the caller.**
// `siteName`, `siteUrl`, `logoUrl` and `year` are available to every template and
// cannot be overridden by whatever a caller passes (`engagement/templates.js`).
// §4.6.1 property 2: "no template contains a literal hex code or a logo URL", so
// one prebuilt image running as any shard mails in that shard's identity.
// The ambient set, declared once so the editor's palette (Phase 5b) can offer them
// on EVERY template rather than each seed having to list them.
const AMBIENT_VARIABLES = Object.freeze([
{ name: 'siteName', type: 'string', required: true, example: 'UOMysticmoon' },
{ name: 'siteUrl', type: 'string', required: false, example: 'https://example.com' },
{ name: 'logoUrl', type: 'string', required: false, example: 'https://example.com/brand/logo.png' },
{ name: 'year', type: 'string', required: true, example: '2026' },
])
// The per-DELIVERY additions, which are a different thing from the ambient set
// above and are declared separately because they apply to a different set of
// templates.
//
// `emailChannel.deliver` computes an unsubscribe token per recipient and merges
// it LAST over the projection, so a body may always reference it — but a template
// bound to a TRIGGER takes its variable list from that trigger's declaration
// (`variablesFor`), and a trigger has no business declaring a fact about how the
// mail was delivered. Without these, `{{unsubscribeUrl}}` renders correctly and
// then the save-time undeclared-variable check refuses the first operator who
// tries to EDIT the body around it.
//
// Found in Phase 11b, where module-uo's sixteen in-universe bodies are the first
// trigger-bound templates in the system to carry an unsubscribe line of their
// own: core's generic `notify.event` declares it in its own seed and is bound to
// no trigger, so nothing had ever taken this path.
const DELIVERY_VARIABLES = Object.freeze([
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
])
// A tiny helper so the block arrays below read as content rather than as JSON.
const text = (id, body, opts = {}) => ({
id,
type: 'email.text',
props: opts.muted ? { text: body, muted: true } : { text: body },
})
const heading = (id, body, level = 'h1') => ({
id,
type: 'email.heading',
props: { level, text: body },
})
const button = (id, label, url, textLead) => ({
id,
type: 'email.button',
props: textLead ? { label, url, textLead } : { label, url },
})
const itemList = (id, variable, emptyText) => ({
id,
type: 'email.itemList',
props: emptyText ? { variable, emptyText } : { variable },
})
const divider = (id) => ({ id, type: 'email.divider', props: {} })
const SEEDS = [
// ── Transactional: protected = 1, editable but not deletable ─────────────
{
key: 'auth.password-reset',
name: 'Password reset',
channel: 'email',
protected: true,
seedVersion: 1,
subject: 'Reset your {{siteName}} password',
variables: [
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
{ name: 'forWhom', type: 'string', required: false, example: ' for the account “Darrow”' },
{ name: 'resetUrl', type: 'string', required: true, example: 'https://example.com/reset/abc123' },
],
blocks: [
text('p1', 'We received a request to reset the password{{forWhom}} at {{siteName}}.'),
button('cta', 'Choose a new password', '{{resetUrl}}', 'Choose a new password here:'),
text(
'p2',
'This link is single-use and expires in about an hour. If you didn\'t request this, ' +
'you can safely ignore this email — your password won\'t change.',
),
],
},
{
key: 'auth.invite',
name: 'Account invite',
channel: 'email',
protected: true,
seedVersion: 1,
subject: 'Your {{siteName}} invitation',
variables: [
{ name: 'acceptUrl', type: 'string', required: true, example: 'https://example.com/invite/abc123' },
{ name: 'roleLabel', type: 'string', required: false, example: ' as moderator' },
{ name: 'invitedBy', type: 'string', required: false, example: ' by Aldric' },
],
blocks: [
text('p1', 'You have been invited{{invitedBy}} to join {{siteName}}{{roleLabel}}.'),
button('cta', 'Accept your invitation', '{{acceptUrl}}', 'Accept your invitation and set up your account here:'),
text('p2', 'This link is single-use and will expire. If you weren\'t expecting this, you can ignore it.'),
],
},
{
key: 'auth.email-verify',
name: 'Email address confirmation',
channel: 'email',
protected: true,
seedVersion: 1,
subject: 'Confirm your email address for {{siteName}}',
variables: [
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
{ name: 'forWhom', type: 'string', required: false, example: ' “Darrow”' },
{ name: 'verifyUrl', type: 'string', required: true, example: 'https://example.com/verify/abc123' },
],
blocks: [
text('p1', 'The {{siteName}} account{{forWhom}} asked to use this address for contact and account recovery.'),
button('cta', 'Confirm this address', '{{verifyUrl}}', 'Confirm it here:'),
text(
'p2',
'This link is single-use and expires in about a day. Until it is used, nothing changes — ' +
'the account keeps whatever address it had.',
),
text(
'p3',
'If you did not ask for this, you can ignore this email. Someone may have mistyped their ' +
'own address; no account of yours is affected and this link grants no access to anything.',
),
],
},
{
key: 'admin.contact-message',
name: 'Contact form message',
channel: 'email',
protected: true,
seedVersion: 1,
// `fromLabel` and `fromName` are the SAME missing name with two different
// fallbacks — 'a visitor' in the subject, 'unknown' in the body. That
// divergence is inherited from the literal this replaces, and the template is
// where it becomes visible and fixable: an operator who wants one word can now
// edit the subject line instead of a source file.
subject: '{{siteName}} contact from {{fromLabel}}',
variables: [
{ name: 'fromLabel', type: 'string', required: true, example: 'a visitor' },
{ name: 'fromName', type: 'string', required: true, example: 'unknown' },
{ name: 'fromEmail', type: 'string', required: true, example: 'ann@example.com' },
{ name: 'message', type: 'string', required: true, example: 'Is the shard open to new players?' },
],
blocks: [
text('p1', 'From: {{fromName}} <{{fromEmail}}>'),
text('p2', '{{message}}'),
],
},
{
key: 'admin.test',
name: 'Delivery test',
channel: 'email',
protected: true,
seedVersion: 1,
subject: '{{siteName}} email test',
variables: [
{ name: 'transport', type: 'string', required: true, example: 'smtp' },
{ name: 'sentAt', type: 'string', required: false, example: '2026-08-29 18:04 UTC' },
],
blocks: [
text('p1', 'This is a test message confirming {{transport}} email delivery is working.'),
],
},
// ── Notification: protected = 0, replaceable ─────────────────────────────
//
// **`notify.event` and `notify.digest` are generic on purpose** (§4.6.1 property
// 1): their variables are structural — `title`, `intro`, `items[]` — rather than
// domain-specific, so a trigger from core or from any module renders through
// them with NO authoring at all. This is what stops "add a trigger" from meaning
// "and now write a template".
{
key: 'notify.event',
name: 'Notification (single event)',
channel: 'email',
protected: false,
seedVersion: 1,
subject: '{{title}}',
variables: [
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'items', type: 'list', required: false, example: [{ heading: 'The Silver Anvil', excerpt: 'Britain, Trammel (1119, 1794)' }] },
{ name: 'actionUrl', type: 'string', required: false, example: 'https://example.com/houses' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
heading('h', '{{title}}'),
text('intro', '{{intro}}'),
itemList('items', 'items'),
button('cta', 'Open {{siteName}}', '{{actionUrl}}'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],
},
{
key: 'notify.digest',
name: 'Notification digest',
channel: 'email',
protected: false,
seedVersion: 1,
subject: '{{siteName}}: {{periodLabel}}',
variables: [
{ name: 'periodLabel', type: 'string', required: true, example: 'your daily summary' },
{ name: 'intro', type: 'string', required: false, example: 'Here is what happened while you were away.' },
{ name: 'items', type: 'list', required: false, example: [{ heading: 'New thread in Guild Hall', excerpt: 'Meeting moved to Friday', url: 'https://example.com/teams/1?thread=9' }] },
// Precomputed for the same reason `forWhom` is: "and 3 more" needs a
// conditional and a plural, and a template has neither.
{ name: 'moreNote', type: 'string', required: false, example: 'and 3 more.' },
{ name: 'scopeUrl', type: 'string', required: false, example: 'https://example.com/teams/1' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
text('intro', '{{intro}}'),
itemList('items', 'items'),
text('more', '{{moreNote}}', { muted: true }),
button('cta', 'Open {{siteName}}', '{{scopeUrl}}'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],
},
{
key: 'notify.team-post',
name: 'Team post notification',
channel: 'email',
protected: false,
seedVersion: 2,
subject: '{{teamName}}: {{threadTitle}}',
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil' },
{ name: 'authorName', type: 'string', required: true, example: 'Aldric' },
{ name: 'threadTitle', type: 'string', required: true, example: 'Meeting moved to Friday' },
{ name: 'excerpt', type: 'string', required: false, example: 'We are pushing this week back a day so more people can make it.' },
{ name: 'postUrl', type: 'string', required: false, example: '/guilds/the-silver-anvil/forum/412' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
text('p1', '{{authorName}} posted in {{teamName}}.'),
heading('h', '{{threadTitle}}', 'h2'),
text('excerpt', '{{excerpt}}', { muted: true }),
button('cta', 'Read the thread', '{{postUrl}}'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
],
},
{
key: 'inapp.event',
name: 'On-site notification',
channel: 'inapp',
protected: false,
// **seedVersion 2, and the bump is a correction rather than an improvement.**
// Phase 5a wrote this template before the channel that renders it existed, and
// named its variables `body` and `url` — names NOTHING supplies. A trigger
// declares domain names (`teamName`, `threadTitle`), and `projection.project`
// fills the gaps with the STRUCTURAL ones the generic seeds use: `title`,
// `intro`, `actionUrl`. So every rendering of this template would have found
// `body` and `url` missing and produced a title and nothing else. Renamed to
// the vocabulary `notify.event` uses, which is the same property stated once:
// a new trigger must render with no authoring at all.
seedVersion: 2,
// No subject: an inbox row has a title, and the title is a block. The column
// is email's, and leaving it NULL is how a non-email template says so.
subject: null,
variables: [
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'actionUrl', type: 'string', required: false, example: '/player/uo/houses' },
],
// The three blocks map onto the three columns of `user_notifications` by ROLE
// (templates.js `renderInappByKey`): the heading is the item's title, the
// button is its one action, and everything else is the body. There is no
// unsubscribe line — an inbox item has nowhere to send someone that the
// preferences screen it links to from does not already reach.
blocks: [
heading('h', '{{title}}', 'h3'),
text('intro', '{{intro}}'),
button('cta', 'Open', '{{actionUrl}}'),
],
},
// ── The event system (EVENTS.md §J — Phase 10) ─────────────────────────
//
// **One body, not seven.** Six of the seven `event.` triggers render through
// `notify.event` and the structural projection with no authoring at all
// (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail
// is already headed with the event's name — and seeding a bespoke body per
// trigger would be seven templates an operator has to maintain to change one
// sentence.
//
// `event.run.started` gets one because it is the flagship: the mail that
// answers §8.5's *"Come back for X — a scheduled event is starting"*, the one
// an operator will actually enable, and the one where the generic body reads
// visibly worse — `notify.event` renders the title over the TRIGGER's
// description, while this reads the payload's own names and says what is
// starting, when, and what arc it belongs to. Same argument `notify.team-post`
// makes beside the generic body, one feature along.
//
// **Every optional line is one token on its own**, which is this template
// language's whole conditional (see `email.text`: a block whose content is a
// single absent variable renders nothing, in both parts). A standalone event
// has no `seriesName` and its line disappears rather than reading "Part of .".
//
// **The button arrived with the page it points at** (Phase 14a). Until then
// there was no public event page, the six public triggers declared no url
// variable, and a button here would have rendered as an inert grey label in
// every mail — worse than no button, because it advertises a link the reader
// cannot follow. `eventUrl` is optional and `email.button` drops itself when
// its url interpolates to nothing, so an event that is not public still mails
// correctly: the block disappears rather than degrading.
{
key: 'notify.event-started',
name: 'Event starting',
channel: 'email',
protected: false,
// Bumped with the button. A deployment whose operator has not customized
// this template gets the new one; one that has is left alone and reported as
// stale, which is the whole mechanism.
seedVersion: 2,
subject: '{{title}} is starting',
variables: [
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
{ name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
heading('h', '{{title}}'),
text('summary', '{{summary}}'),
text('when', '{{startsAtLabel}}', { muted: true }),
text('series', '{{seriesName}}', { muted: true }),
button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],
},
]
/** @returns {object|null} the seed definition for `key`. */
function seedByKey(key) {
return SEEDS.find((s) => s.key === key) || null
}
module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey }