Files
website/server/src/engagement/templates.js
wtclaude 24a3cd85b3
All checks were successful
PR Checks / client-build (pull_request) Successful in 37s
PR Checks / server-tests (pull_request) Successful in 3m27s
PR Checks / bot-tests (pull_request) Successful in 8m36s
feat(engagement): the in-app channel, core and web (engagement Phase 7)
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>
2026-08-31 02:07:10 -05:00

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,
}