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>
This commit is contained in:
@@ -123,13 +123,17 @@ function renderTemplate(template, values, resolved) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 renderByKey(key, values = {}) {
|
||||
const resolved = await ambient()
|
||||
async function resolveTemplate(key) {
|
||||
let template = null
|
||||
try {
|
||||
template = await templatesDb.getByKey(key)
|
||||
@@ -158,9 +162,113 @@ async function renderByKey(key, values = {}) {
|
||||
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.
|
||||
@@ -215,4 +323,16 @@ async function seedTemplates() {
|
||||
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
||||
const MAX_KEY = 96
|
||||
|
||||
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl, KEY_RE, MAX_KEY }
|
||||
module.exports = {
|
||||
ambient,
|
||||
variablesFor,
|
||||
renderTemplate,
|
||||
resolveTemplate,
|
||||
renderByKey,
|
||||
renderInappByKey,
|
||||
relativeUrl,
|
||||
seedTemplates,
|
||||
baseUrl,
|
||||
KEY_RE,
|
||||
MAX_KEY,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user