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>
339 lines
15 KiB
JavaScript
339 lines
15 KiB
JavaScript
// ── Templates: resolve, render, seed ───────────────────────────────────────
|
|
//
|
|
// The seam between a stored `engagement_templates` row and the two body parts a
|
|
// transport sends. Everything that needs a database happens here; `emailBlocks/`
|
|
// stays pure and synchronous below it.
|
|
//
|
|
// **A missing row renders the shipped default rather than nothing.** `renderByKey`
|
|
// falls back to `templateSeeds.js` whenever the row is absent or its blocks will
|
|
// not parse. This is not defensive padding — it is what makes it safe for
|
|
// `mailer` to depend on the database for a password-reset body at all. Before the
|
|
// first seed runs, after a restore that dropped the table, on a deployment whose
|
|
// operator deleted a row by hand: the mail still goes out, in the shipped wording,
|
|
// and the `protected` flag stops the last of those from being reachable through
|
|
// the API. The same posture `settingsJson` and `resolveThemeTokens` take — a
|
|
// stored value that is unusable is treated as absent, never as an error.
|
|
|
|
const templatesDb = require('../model/engagement/engagementTemplates.db')
|
|
const settings = require('../model/settings/settings.model')
|
|
const brand = require('../config/brand')
|
|
const emailBlocks = require('../emailBlocks')
|
|
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
|
|
// The trigger registry lives with the module registries, not here — a trigger is
|
|
// something a MODULE declares (see engagement/index.js's header).
|
|
const { eventTrigger } = require('../modules/registries')
|
|
const log = require('../utils/logger')('templates')
|
|
|
|
const baseUrl = () => (process.env.APP_BASE_URL || brand.url || 'http://localhost:5173').replace(/\/+$/, '')
|
|
|
|
/**
|
|
* The brand values every template may reference, resolved from the same places
|
|
* the site's own chrome resolves them (§4.6.1 property 2).
|
|
*
|
|
* **They are merged OVER the caller's values, not under.** A caller supplies the
|
|
* message; the deployment supplies its identity. Letting a caller pass its own
|
|
* `siteName` would mean a module — or a bug — could send mail that claims to be
|
|
* from somewhere else, which is precisely the thing a recipient cannot check.
|
|
*
|
|
* Never throws: a settings read that fails degrades to the BRAND_* env values, so
|
|
* mail is branded slightly less specifically rather than not sent.
|
|
*/
|
|
async function ambient() {
|
|
let name = brand.name
|
|
let logo = brand.logo
|
|
let theme = null
|
|
try {
|
|
name = await settings.getInstanceName()
|
|
const shell = await settings.getShellBrand()
|
|
logo = shell.logo || brand.logo
|
|
theme = shell.theme
|
|
} catch (err) {
|
|
log.warn('brand resolution failed; falling back to BRAND_* env', { message: err.message })
|
|
}
|
|
const base = baseUrl()
|
|
const absLogo = logo && logo.startsWith('/') ? `${base}${logo}` : logo || ''
|
|
return {
|
|
values: {
|
|
siteName: name,
|
|
siteUrl: base,
|
|
logoUrl: absLogo,
|
|
year: String(new Date().getUTCFullYear()),
|
|
},
|
|
// resolveThemeTokens speaks CSS custom properties; the renderer speaks colour
|
|
// names. One mapping, here, rather than the renderer knowing about CSS.
|
|
theme: { accent: theme ? theme['--accent'] : undefined },
|
|
baseUrl: base,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which variables a template may reference — the input to Phase 5b's palette and
|
|
* to its save-time "undeclared variable" refusal.
|
|
*
|
|
* Two sources, because a template has two possible origins. One tied to a trigger
|
|
* reads §4.3's declaration, which is the authority for anything a module emits.
|
|
* One with no trigger — every transactional seed is one; `mailer` renders them by
|
|
* key with no rule involved — has no trigger to ask, so its shipped definition
|
|
* carries the list. Ambient brand variables are appended to both.
|
|
*
|
|
* @param {{ trigger_id?: string|null, seed_key?: string|null }} template
|
|
* @returns {Array<{name: string, type: string, required: boolean, example: unknown}>}
|
|
*/
|
|
function variablesFor(template) {
|
|
const own = []
|
|
if (template && template.trigger_id) {
|
|
const declared = eventTrigger(template.trigger_id)
|
|
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
|
|
} else if (template && template.seed_key) {
|
|
const seed = seedByKey(template.seed_key)
|
|
if (seed) own.push(...seed.variables)
|
|
}
|
|
const names = new Set(own.map((v) => v.name))
|
|
return [...own, ...AMBIENT_VARIABLES.filter((v) => !names.has(v.name))]
|
|
}
|
|
|
|
/**
|
|
* Render one template into its two body parts.
|
|
*
|
|
* @param {object} template a row, or a seed definition
|
|
* @param {Record<string, unknown>} values
|
|
* @param {object} resolved the result of ambient()
|
|
* @returns {{ subject: string, html: string, text: string, missing: string[] }}
|
|
*/
|
|
function renderTemplate(template, values, resolved) {
|
|
const merged = { ...values, ...resolved.values }
|
|
const missing = new Set()
|
|
const ctx = emailBlocks.buildContext({
|
|
values: merged,
|
|
theme: resolved.theme,
|
|
baseUrl: resolved.baseUrl,
|
|
missing,
|
|
})
|
|
const rendered = emailBlocks.renderBlocks(template.blocks, ctx)
|
|
const subject = template.subject ? ctx.t(template.subject) : ''
|
|
// An authored `text_body` REPLACES the generated one (§4.4), and is interpolated
|
|
// like any other authored string. It is a per-template override, not an addition.
|
|
const text = template.text_body ? ctx.t(template.text_body) : rendered.text
|
|
return {
|
|
subject,
|
|
html: emailBlocks.renderDocument(rendered.html, ctx, subject),
|
|
text,
|
|
missing: [...missing],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The template `key` should actually render through, or null.
|
|
*
|
|
* Extracted from `renderByKey` in Phase 7 rather than duplicated into the in-app
|
|
* channel: the fallback chain below is a policy about what this deployment sends
|
|
* when its own table is in a bad state, and a second channel resolving templates
|
|
* by its own rules would be a second answer to that. `renderInappByKey` takes the
|
|
* same rows, the same seeds and the same three refusals.
|
|
*
|
|
* @returns {Promise<{subject: string|null, blocks: object[], text_body: string|null}|null>}
|
|
*/
|
|
async function resolveTemplate(key) {
|
|
let template = null
|
|
try {
|
|
template = await templatesDb.getByKey(key)
|
|
} catch (err) {
|
|
log.warn('template read failed; using the shipped default', { key, message: err.message })
|
|
}
|
|
// 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 (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 template
|
|
}
|
|
|
|
/**
|
|
* Render the template stored under `key`, falling back to its shipped default.
|
|
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
|
|
* 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()
|
|
const template = await resolveTemplate(key)
|
|
if (!template) return null
|
|
return renderTemplate(template, values, resolved)
|
|
}
|
|
|
|
// ── The in-app projection (Phase 7) ────────────────────────────────────────
|
|
//
|
|
// `user_notifications` has three columns — title, body, url — where email has a
|
|
// subject and a document, so the in-app channel needs the template rendered into
|
|
// those three rather than into a mail. **The mapping is by block ROLE**, and it
|
|
// is here rather than in the channel because it is a statement about what the
|
|
// block registry means, not about how a row gets written:
|
|
//
|
|
// - the first `email.heading` → `title` (a heading IS the item's headline)
|
|
// - the first `email.button` → `url` (a button IS the item's one action)
|
|
// - everything else, as TEXT → `body`
|
|
//
|
|
// **Text, not the email HTML, and that is the load-bearing choice.** The block
|
|
// renderer's HTML is built for mail clients: table rows, inline hex colours, a
|
|
// light-only palette declared with `color-scheme`. Dropped into a page that
|
|
// follows the viewer's theme it renders as a pale card floating in a dark one.
|
|
// `toText` is the same content with none of that, and it is the part the block
|
|
// contract already promises every block can produce.
|
|
//
|
|
// The three refusals a mail can afford and an inbox row cannot are handled here
|
|
// too: a title is NOT NULL, so an empty one falls back to the projected `title`
|
|
// and then to the trigger id; and a url that is not site-relative is dropped
|
|
// rather than stored, because the column's whole contract is that a template
|
|
// cannot aim a signed-in user's click off-site.
|
|
|
|
const HEADING = 'email.heading'
|
|
const BUTTON = 'email.button'
|
|
|
|
// user_notifications.title / .url. Truncated rather than refused: a long title is
|
|
// a cosmetic problem and a dropped notification is not.
|
|
const MAX_TITLE = 300
|
|
const MAX_URL = 500
|
|
|
|
// The same character class `pageUrlTemplate` and the engine's `url` variables
|
|
// use (registries.js, engagementEmit.js). Duplicated as a literal rather than
|
|
// imported from `engagementEmit`, which would be a cycle through the engine.
|
|
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
|
|
|
|
/**
|
|
* Site-relative form of `raw`, or null.
|
|
*
|
|
* An absolute url on this deployment's own base is accepted and reduced — a
|
|
* template that writes `{{siteUrl}}/guilds/4` is saying the same thing as
|
|
* `/guilds/4`, and refusing it would make the ambient `siteUrl` variable a trap
|
|
* in the one channel where the link never leaves the site.
|
|
*/
|
|
function relativeUrl(raw, base) {
|
|
const value = String(raw || '').trim()
|
|
if (!value) return null
|
|
const stripped = base && value.startsWith(`${base}/`) ? value.slice(base.length) : value
|
|
if (!RELATIVE_URL.test(stripped)) return null
|
|
return stripped.slice(0, MAX_URL)
|
|
}
|
|
|
|
/**
|
|
* Render one template into an inbox item.
|
|
*
|
|
* @returns {Promise<{title: string, body: string|null, url: string|null, missing: string[]}|null>}
|
|
* null when `key` names no usable row and no seed — the caller reports a
|
|
* terminal failure, exactly as the email channel does.
|
|
*/
|
|
async function renderInappByKey(key, values = {}) {
|
|
const resolved = await ambient()
|
|
const template = await resolveTemplate(key)
|
|
if (!template) return null
|
|
|
|
const merged = { ...values, ...resolved.values }
|
|
const missing = new Set()
|
|
const ctx = emailBlocks.buildContext({
|
|
values: merged,
|
|
theme: resolved.theme,
|
|
baseUrl: resolved.baseUrl,
|
|
missing,
|
|
})
|
|
|
|
const blocks = Array.isArray(template.blocks) ? template.blocks : []
|
|
const visible = blocks.filter((b) => b && b.visible !== false)
|
|
const heading = visible.find((b) => b.type === HEADING)
|
|
const button = visible.find((b) => b.type === BUTTON)
|
|
// Only the FIRST of each is consumed; a second heading or button is ordinary
|
|
// body content, which is what an operator who added one meant.
|
|
const rest = visible.filter((b) => b !== heading && b !== button)
|
|
|
|
const headingText = heading ? ctx.t((heading.props || {}).text || '').trim() : ''
|
|
const title = (headingText || String(merged.title || '').trim() || key).slice(0, MAX_TITLE)
|
|
const url = button ? relativeUrl(ctx.t((button.props || {}).url || ''), resolved.baseUrl) : null
|
|
const body = emailBlocks.renderBlocks(rest, ctx).text.trim()
|
|
|
|
return { title, body: body || null, url, missing: [...missing] }
|
|
}
|
|
|
|
/**
|
|
* Ensure every shipped template exists, and bring un-customized rows up to the
|
|
* current seed. Idempotent: a second run reports nine skips and writes nothing.
|
|
*
|
|
* Never throws — it is called from `seedDefaults()` on the boot path, and a
|
|
* template that failed to seed costs the shipped default (see the header note),
|
|
* not the deployment.
|
|
*/
|
|
async function seedTemplates() {
|
|
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
|
|
for (const seed of SEEDS) {
|
|
// Validated against the registry before it is stored, even though a seed is
|
|
// code rather than input. The alternative is a shipped block array that no
|
|
// renderer understands sitting in the table, which reads to an operator as
|
|
// their deployment being broken; refusing to write it leaves `renderByKey`'s
|
|
// fallback in charge and puts the reason in the boot log.
|
|
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
|
if (!valid) {
|
|
log.error('shipped template is invalid and was not seeded', { key: seed.key, errors })
|
|
counts.invalid += 1
|
|
continue
|
|
}
|
|
try {
|
|
counts[await templatesDb.seedOne(seed)] += 1
|
|
} catch (err) {
|
|
log.error('template seed failed', { key: seed.key, message: err.message })
|
|
}
|
|
}
|
|
// The third arm of §4.6.1 property 3: a customized row is never touched, and the
|
|
// fact that a better default now exists is surfaced instead of applied.
|
|
let stale = []
|
|
try {
|
|
stale = await templatesDb.staleCustomized(SEEDS.map((s) => ({ key: s.key, seedVersion: s.seedVersion })))
|
|
} catch {
|
|
stale = []
|
|
}
|
|
if (stale.length) {
|
|
log.info('customized templates have a newer shipped default', { keys: stale.map((t) => t.key) })
|
|
}
|
|
log.info('engagement templates ensured', counts)
|
|
return { ...counts, stale: stale.map((t) => t.key) }
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
resolveTemplate,
|
|
renderByKey,
|
|
renderInappByKey,
|
|
relativeUrl,
|
|
seedTemplates,
|
|
baseUrl,
|
|
KEY_RE,
|
|
MAX_KEY,
|
|
}
|