ENGAGEMENT.md Phase 7. `user_notifications`, the in-app DeliveryChannel, the
four inbox routes, and the web surface — plus the two pieces earlier phases
assigned here that Phase 7's own acceptance line omits.
Four decisions settled by the org lead before any code:
1. `inapp` defaults to `instant` — the only channel that does. Push wakes a
device somebody is holding and email leaves the building, so both are asked
for; an inbox item is a row on a page the user chose to open. Left `off` the
channel ships dead.
2. The phase takes push's `deliver` (§2603) and the web per-channel preferences
screen (Phase 3's as-built), neither of which its own bullets mention.
3. The inbox takes `/auth/me/notifications` and `/account/notifications`; the
preferences screen moves to `…/settings`. The plain word belongs to the
content, which is what the bell opens.
4. `ctx.inbox.push` honours the user's in-app preference when `triggerId` names
a registered trigger, and writes when it does not.
Server
- `user_notifications` + `model/userNotifications/`. The dedupe UNIQUE is scoped
to the USER, narrower than the outbox's `(rule, user, channel)`: an inbox has
no channel dimension, so two rows for one event would be one item shown twice.
- `engagement/inappChannel.js` — renders by block ROLE (first heading → title,
first button → url, the rest → body) and inserts. `pushChannel.js` — a
content-free `{stream, ref}` tickle whose ref deep-links the inbox row.
- `engine.liveChannels` orders `inapp` first (`CHANNEL_ORDER`) so that ref
resolves on the first sweep. An ordering, not a dependency.
- `templates.renderInappByKey` + `resolveTemplate` extracted from `renderByKey`,
so both channels take the same fallback chain.
- `inapp.event` seed → seedVersion 2: it named `body`/`url`, which nothing
supplies. Renamed to the structural vocabulary the projection fills in.
- `utils/userNotificationsPrune.js` — nightly, READ items only, horizon in
`settings.user_notifications_retain_days` (default 90).
- `GET /auth/me/notifications`, `…/unread-count`, `POST …/:id/read`,
`POST …/read-all`. Swagger + route manifest + four component schemas.
Web
- `NotificationBell` in all three headers, polling its badge once a minute and
pausing while the tab is hidden. `PlayerInbox` at `/account/notifications`.
- The preferences screen becomes a channel matrix over
`/auth/me/notifications/channels` — a strict superset of the push-only stream
list it replaces. The two legacy endpoints are untouched, so the shipped
Android app keeps its wire shape.
- Staff get the same two screens at `/admin/notifications…`: `RequirePlayer`
keeps them out of `/account`, so without this the inbox was unreachable for
every non-player account. `lib/notificationPaths.js` is the one mapping.
Verified: 28 new server tests (5 of them against a real MariaDB, for the three
index/statement properties that are a server contract rather than a reading of
this code) + 3 client. Server suite green, client 327 green. A live rig walked
the whole path: two rules on one event produced three outbox rows and exactly
one inbox item, the tickle carried `ref: notification:2`, and the retention
sweep dropped an aged read row while keeping an equally aged unread one.
Docs: RunicGateway/docs#TBD, RunicGateway/runicgateway.com#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
300 lines
14 KiB
JavaScript
300 lines
14 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' },
|
|
])
|
|
|
|
// 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}}'),
|
|
],
|
|
},
|
|
]
|
|
|
|
/** @returns {object|null} the seed definition for `key`. */
|
|
function seedByKey(key) {
|
|
return SEEDS.find((s) => s.key === key) || null
|
|
}
|
|
|
|
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }
|