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:
@@ -17,6 +17,8 @@
|
||||
|
||||
const { registerDeliveryChannel } = require('./channels')
|
||||
const emailChannel = require('./emailChannel')
|
||||
const pushChannel = require('./pushChannel')
|
||||
const inappChannel = require('./inappChannel')
|
||||
|
||||
const CHANNELS = [
|
||||
{
|
||||
@@ -44,6 +46,10 @@ const CHANNELS = [
|
||||
// there is nothing to roll up. Ten events are ten wakeups or one; either way
|
||||
// the app pulls the same inbox.
|
||||
supportsDigest: false,
|
||||
// Phase 7: the oldest sink is the last to get a `deliver`, because until the
|
||||
// inbox existed there was nothing for a content-free tickle to point at.
|
||||
addressFor: pushChannel.addressFor,
|
||||
deliver: pushChannel.deliver,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
@@ -66,13 +72,25 @@ const CHANNELS = [
|
||||
label: 'On the site',
|
||||
description: 'An item in your notification inbox on the website and in the app.',
|
||||
carriesContent: true,
|
||||
// Opt-IN like the other two, and for a reason particular to this channel: the
|
||||
// inbox does not exist until Phase 7. A default of 'instant' would mean every
|
||||
// user is opted into a surface that has no rows and no screen, and the first
|
||||
// thing Phase 7 shipped would be a backlog. Whether the inbox is opt-out once
|
||||
// it is real is a Phase 7 decision with a live surface to look at.
|
||||
defaultMode: 'off',
|
||||
// **Opt-OUT, and the only one of the three that is** — settled by the org
|
||||
// lead 2026-08-31, which is the Phase 7 decision this comment used to defer.
|
||||
//
|
||||
// The argument against a live default was never about in-app: it was that
|
||||
// push wakes a device the user is holding and email leaves the building, so
|
||||
// both must be asked for. An inbox item does neither. It is a row on a page
|
||||
// the user chose to open, on this deployment, costing them one glance — and
|
||||
// left at 'off' the surface would ship dead, because no rule could reach
|
||||
// anyone until every user found a toggle for a channel they had never seen
|
||||
// deliver anything. The backlog Phase 3 worried about cannot happen either:
|
||||
// the table is empty at cutover, rules default to `enabled = 0`, and every
|
||||
// rule carries a per-hour ceiling.
|
||||
defaultMode: 'instant',
|
||||
// Instant-only, and unlike push the reason is not that batching is
|
||||
// meaningless — it is that the inbox IS the batch. A digest of inbox items
|
||||
// is a list of things already sitting in a list.
|
||||
supportsDigest: false,
|
||||
addressFor: inappChannel.addressFor,
|
||||
deliver: inappChannel.deliver,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -42,6 +42,22 @@ const log = require('../utils/logger')('engagement')
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
// Channels one of whose payloads can REFERENCE another's result, earliest first
|
||||
// (Phase 7). Only one pair qualifies today: a push tickle's `ref` deep-links to
|
||||
// the inbox row `inapp` writes, and the outbox is swept `ORDER BY due_at, id`,
|
||||
// so enqueueing in-app first is what makes that ref resolve on the first pass
|
||||
// rather than on a retry. Everything not named here keeps the operator's own
|
||||
// order, which is the order the rules screen shows.
|
||||
//
|
||||
// It is an ordering, not a dependency: `pushChannel` treats a missing ref as
|
||||
// null and the app pulls regardless, so a rule that names only push, or a row
|
||||
// that gets retried out of sequence, is still correct.
|
||||
const CHANNEL_ORDER = ['inapp']
|
||||
const channelRank = (id) => {
|
||||
const i = CHANNEL_ORDER.indexOf(id)
|
||||
return i === -1 ? CHANNEL_ORDER.length : i
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of a rule's channels are actually deliverable right now?
|
||||
*
|
||||
@@ -50,7 +66,10 @@ const HOUR_MS = 60 * 60 * 1000
|
||||
* failing the rule: the other channels of that rule are still correct, and a
|
||||
* dropped one is visible in the log line below.
|
||||
*/
|
||||
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c))
|
||||
const liveChannels = (rule) =>
|
||||
(rule.channels || [])
|
||||
.filter((c) => channels.has(c))
|
||||
.sort((a, b) => channelRank(a) - channelRank(b))
|
||||
|
||||
/**
|
||||
* The EFFECTIVE mode each candidate holds for (id, channel), given the event's
|
||||
@@ -267,4 +286,13 @@ async function dispatch(event, now = new Date()) {
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, effectiveModes, liveChannels, HOUR_MS }
|
||||
module.exports = {
|
||||
dispatch,
|
||||
applyRule,
|
||||
applyCancellations,
|
||||
subscribedTo,
|
||||
effectiveModes,
|
||||
liveChannels,
|
||||
CHANNEL_ORDER,
|
||||
HOUR_MS,
|
||||
}
|
||||
|
||||
204
server/src/engagement/inappChannel.js
Normal file
204
server/src/engagement/inappChannel.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// ── The in-app DeliveryChannel: addressFor + deliver ───────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. The third channel to get behaviour, and the one whose
|
||||
// "address" is not an address at all: the destination is the user's own row in
|
||||
// this deployment's own table. `addressFor` still exists and still answers null,
|
||||
// because the question it asks — *can this channel reach this user right now* —
|
||||
// has a real answer here, and it is the same answer email's has: not if the
|
||||
// account is no longer active. An outbox row can sit through a `delay_seconds`
|
||||
// grace window, so a user banned between the emit and the send is exactly the
|
||||
// case this catches.
|
||||
//
|
||||
// **What makes it different from email is what it does NOT have to do.** There
|
||||
// is no transport, no relay to classify a failure for us, no unsubscribe link to
|
||||
// mint per recipient, and no address to hash — an inbox item is addressed to a
|
||||
// user id, and `engagement_sends.address_hash` exists to correlate a bounce that
|
||||
// this channel cannot have. So `deliver` is two steps: render the template into
|
||||
// the three columns, and insert.
|
||||
//
|
||||
// **It never throws**, for the reason `emailChannel` states: the worker reads a
|
||||
// throw as a transient failure and retries five times, so an unrenderable
|
||||
// template would become five identical failures in the send log instead of one
|
||||
// honest terminal row.
|
||||
//
|
||||
// **A duplicate `dedupe_key` reports success.** The acceptance line calls it a
|
||||
// no-op; from the recipient's side it is a delivery — they have the item — and
|
||||
// recording `failed` for it would put a red row in the send log for the
|
||||
// mechanism working exactly as designed. The detail says which it was.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const registries = require('../modules/registries')
|
||||
const channelRegistry = require('./channels')
|
||||
const inbox = require('../model/userNotifications/userNotifications.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const templates = require('./templates')
|
||||
const projection = require('./projection')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
// The template a rule renders through when it names none — §4.6.1 property 1's
|
||||
// implementation for this channel, exactly as `notify.event` is for email.
|
||||
const DEFAULT_TEMPLATE = 'inapp.event'
|
||||
|
||||
/**
|
||||
* Can this channel reach `userId`?
|
||||
*
|
||||
* Returns the shape every `addressFor` returns rather than a boolean, so the
|
||||
* registry's contract stays one contract. The "address" is the user id as a
|
||||
* string, which is the honest answer: this channel's destination is an account,
|
||||
* and there is nothing else to name.
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const active = await recipients.filterActive([userId])
|
||||
return active.length ? { address: String(active[0]) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one event into an inbox item. Shared with `ctx.inbox.push`'s rule-less
|
||||
* path only in spirit — that one is handed its title and body by the module and
|
||||
* renders nothing.
|
||||
*/
|
||||
async function renderItem(triggerId, payload, templateKey) {
|
||||
const values = projection.project(triggerId, payload || {})
|
||||
const rendered = await templates.renderInappByKey(templateKey, values)
|
||||
if (!rendered) return null
|
||||
if (rendered.missing.length) {
|
||||
// Names only, never values — the rule every log line in this subsystem
|
||||
// follows. An optional variable a trigger chose not to supply renders as
|
||||
// nothing by design, so this is debug rather than a warning.
|
||||
log.debug('template variables had no value', { key: templateKey, missing: rendered.missing })
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one claimed outbox row.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, detail?: string}>}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
try {
|
||||
if (!(await addressFor(row.user_id))) {
|
||||
// Terminal. A five-minute backoff does not un-ban an account, and writing
|
||||
// the item anyway would put content in the inbox of somebody who is no
|
||||
// longer allowed to open it.
|
||||
return { ok: false, detail: 'this user can no longer be reached' }
|
||||
}
|
||||
|
||||
const rule = await rulesDb.getById(row.rule_id)
|
||||
const key = (rule && rule.template_keys && rule.template_keys.inapp) || DEFAULT_TEMPLATE
|
||||
|
||||
const rendered = await renderItem(row.trigger_id, row.payload, key)
|
||||
if (!rendered) {
|
||||
// Neither a usable row nor a shipped seed: the operator deleted a template
|
||||
// a rule points at, which the admin surface refuses with a 409, so reaching
|
||||
// here means it happened out of band. Terminal, and it names the key.
|
||||
return { ok: false, detail: `no template and no shipped default for "${key}"` }
|
||||
}
|
||||
|
||||
const { inserted } = await inbox.insert({
|
||||
userId: row.user_id,
|
||||
triggerId: row.trigger_id,
|
||||
title: rendered.title,
|
||||
body: rendered.body,
|
||||
url: rendered.url,
|
||||
dedupeKey: row.dedupe_key || null,
|
||||
})
|
||||
|
||||
// `transport` is left absent rather than invented. The column means "which
|
||||
// implementation of this channel delivered it", and this channel has one
|
||||
// sink by construction — a value there would be a name nothing else uses.
|
||||
return inserted
|
||||
? { ok: true }
|
||||
: { ok: true, detail: 'already in this inbox (duplicate dedupe key)' }
|
||||
} catch (err) {
|
||||
log.error('in-app delivery failed', { outbox: row.id, message: err.message })
|
||||
return { ok: false, detail: `delivery error: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ── The rule-less sink: ctx.inbox.push (§5.1) ──────────────────────────────
|
||||
//
|
||||
// A module writing the inbox directly, with no trigger declaration to project
|
||||
// from, no rule to pick a template, and no audience to resolve. It exists for
|
||||
// the cases a rule cannot express — something that concerns exactly one person
|
||||
// and needs no operator configuration to be worth telling them about.
|
||||
//
|
||||
// **It respects the user's in-app preference where there is one to respect**
|
||||
// (settled by the org lead 2026-08-31). If `triggerId` names a REGISTERED
|
||||
// trigger, the user's effective mode for it decides, and 'off' drops the write:
|
||||
// a toggle somebody switched off on the preferences screen must not be walkable
|
||||
// around by the module that owns the trigger behind it. If it names nothing
|
||||
// registered there is no toggle, nothing on any screen to have switched off, and
|
||||
// the item is written — refusing it would make the sink useless for the one job
|
||||
// it has while protecting a preference that does not exist.
|
||||
//
|
||||
// Scoped preferences are deliberately not consulted: a scope is a property of an
|
||||
// EVENT (`team:12`), and a caller with no trigger declaration has no scope to
|
||||
// name. The engine's path, which does, still applies them.
|
||||
//
|
||||
// Fire-and-forget, never throws, never rejects — `ctx.teams.activity.push`'s
|
||||
// posture, for its reason: this is called from inside a game-event handler and a
|
||||
// storage problem of core's must not become the module's control flow.
|
||||
|
||||
// user_notifications.title. Truncated rather than refused: a module that built a
|
||||
// long title has still said something worth showing.
|
||||
const MAX_TITLE = 300
|
||||
// user_notifications.body is TEXT; this is a sanity bound, not the column's.
|
||||
const MAX_BODY = 4000
|
||||
|
||||
/**
|
||||
* Write one item on a module's behalf.
|
||||
*
|
||||
* @param {string} moduleId bound by the loader, never taken from the arguments
|
||||
* @param {number} userId
|
||||
* @param {{triggerId: string, title: string, body?: string, url?: string, dedupeKey?: string}} item
|
||||
* @returns {Promise<{written: boolean, reason?: string}>} for tests; the loader
|
||||
* discards it, because a module has nothing correct to do with it.
|
||||
*/
|
||||
async function pushDirect(moduleId, userId, item = {}) {
|
||||
try {
|
||||
const uid = Number(userId)
|
||||
if (!Number.isInteger(uid) || uid <= 0) return { written: false, reason: 'invalid user id' }
|
||||
|
||||
const triggerId = String(item.triggerId || '').trim()
|
||||
const title = String(item.title || '').trim().slice(0, MAX_TITLE)
|
||||
if (!triggerId || !title) return { written: false, reason: 'triggerId and title are required' }
|
||||
|
||||
// The declaration is consulted for ONE thing — whether a preference for this
|
||||
// id exists — and not to validate a payload: there is no payload here, only
|
||||
// the three strings the module composed itself.
|
||||
if (registries.eventTrigger(triggerId)) {
|
||||
const stored = await recipients.storedModes([uid], triggerId, 'inapp')
|
||||
const mode = stored.get(uid) ?? channelRegistry.defaultMode('inapp')
|
||||
if (mode !== 'instant') return { written: false, reason: 'the user has this switched off' }
|
||||
}
|
||||
|
||||
if (!(await addressFor(uid))) return { written: false, reason: 'this user can no longer be reached' }
|
||||
|
||||
// Same relative-only rule the rendered path applies, and for the same reason:
|
||||
// this string ends up in an href on a page a signed-in user is looking at.
|
||||
const url = item.url ? templates.relativeUrl(item.url, templates.baseUrl()) : null
|
||||
if (item.url && !url) {
|
||||
log.warn('ctx.inbox.push dropped an off-site url', { module: moduleId, trigger: triggerId })
|
||||
}
|
||||
|
||||
const body = item.body ? String(item.body).slice(0, MAX_BODY) : null
|
||||
const { inserted } = await inbox.insert({
|
||||
userId: uid,
|
||||
triggerId,
|
||||
title,
|
||||
// A module supplies data, never markup (§4.6.2's security posture). The
|
||||
// body is stored as the text it claims to be and every surface renders it
|
||||
// as text, so there is no markup to sanitize and none to be trusted.
|
||||
body,
|
||||
url,
|
||||
dedupeKey: item.dedupeKey ? String(item.dedupeKey).slice(0, 190) : null,
|
||||
})
|
||||
return { written: inserted, reason: inserted ? undefined : 'duplicate dedupe key' }
|
||||
} catch (err) {
|
||||
log.error('ctx.inbox.push failed', { module: moduleId, message: err.message })
|
||||
return { written: false, reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addressFor, deliver, renderItem, pushDirect, DEFAULT_TEMPLATE }
|
||||
91
server/src/engagement/pushChannel.js
Normal file
91
server/src/engagement/pushChannel.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ── The push DeliveryChannel: addressFor + deliver ─────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. Push is the channel that has existed longest and had a
|
||||
// `deliver` last, because until this phase there was nothing for a tickle to
|
||||
// point AT: `{ stream, ref }` carries no content by design, so a rule firing on
|
||||
// push before the inbox existed would have woken a phone to pull a screen that
|
||||
// had nothing on it.
|
||||
//
|
||||
// **The tickle invariant is the whole of this file's security posture.** What
|
||||
// leaves the server is the stream id and an opaque ref, never a title, never a
|
||||
// body, never the payload — `carriesContent: false` on the registration is the
|
||||
// declaration and this is the implementation. ntfy is treated as an untrusted
|
||||
// relay, so a leaked topic must reveal nothing but that *something* happened;
|
||||
// the app then pulls the real item over the authenticated, ownership-checked
|
||||
// inbox API. Every claim in that paragraph is one `pushDispatch` already makes,
|
||||
// which is why delivery here is a call into it rather than a second publisher.
|
||||
//
|
||||
// **`ref` points at the inbox row when there is one, and is null otherwise.**
|
||||
// A rule spanning `inapp` and `push` enqueues both, and `liveChannels` orders
|
||||
// `inapp` first precisely so the row exists by the time this runs — but that is
|
||||
// an optimisation, not a guarantee: the two rows are independent, either can be
|
||||
// retried, and a push-only rule has no inbox row at all. So the ref is a HINT.
|
||||
// The app's contract (docs/android/PLAN.md §11, Phase 8) is wake-and-pull; a
|
||||
// client that renders the ref instead of pulling is a client that will show
|
||||
// nothing the first time a retry reorders these two rows.
|
||||
|
||||
const inbox = require('../model/userNotifications/userNotifications.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const pushDispatch = require('../utils/pushDispatch')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* Can this channel reach `userId`?
|
||||
*
|
||||
* Active account only, the same re-check `emailChannel` and `inappChannel` make
|
||||
* for the same reason (a row can sit through a `delay_seconds` window). It does
|
||||
* NOT check for a registered device: whether any endpoint is subscribed is the
|
||||
* question `publishToUsers` answers in its own query, and asking it twice would
|
||||
* mean two different definitions of "reachable" that could disagree.
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const active = await recipients.filterActive([userId])
|
||||
return active.length ? { address: String(active[0]) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one claimed outbox row.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
try {
|
||||
if (!(await addressFor(row.user_id))) {
|
||||
return { ok: false, detail: 'this user can no longer be reached' }
|
||||
}
|
||||
|
||||
// Best effort, and it fails to null rather than to an error: no dedupe key,
|
||||
// no in-app row for it, or an inapp row this rule never enqueued all mean
|
||||
// the same thing to the app — wake up and pull.
|
||||
let ref = null
|
||||
try {
|
||||
const item = await inbox.findByDedupe(row.user_id, row.dedupe_key)
|
||||
if (item) ref = `notification:${item.id}`
|
||||
} catch (err) {
|
||||
log.debug('could not resolve a push ref', { outbox: row.id, message: err.message })
|
||||
}
|
||||
|
||||
// The stream id IS the trigger id — §7.2's one namespace, settled in Phase 2.
|
||||
// A push stream and an event trigger share a name space, so the app's
|
||||
// existing `{ stream }` switch keeps working for an engagement rule without
|
||||
// learning a second vocabulary.
|
||||
await pushDispatch.publishToUsers(row.trigger_id, { ref, userIds: [row.user_id] })
|
||||
|
||||
// **Success here means "handed to the relay", and the send log must not
|
||||
// claim more than that.** `publishToUsers` resolves whether it found a
|
||||
// subscribed device or none at all, and a tickle is fire-and-forget over
|
||||
// HTTP to a relay that owes us no receipt. Retrying on "we are not sure"
|
||||
// would mean five wakeups for one event, which is worse than one uncertain
|
||||
// log line — so this is the one channel whose 'sent' is weaker than email's,
|
||||
// and saying so in the detail is how an operator reading G15 finds that out.
|
||||
return { ok: true, transport: 'unifiedpush', detail: 'tickle published' }
|
||||
} catch (err) {
|
||||
// pushDispatch never throws, so reaching here is a programming error rather
|
||||
// than a relay being down. Terminal for that reason: retrying a bug is five
|
||||
// identical rows in the send log.
|
||||
log.error('push delivery failed', { outbox: row.id, message: err.message })
|
||||
return { ok: false, detail: `delivery error: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addressFor, deliver }
|
||||
@@ -260,19 +260,33 @@ const SEEDS = [
|
||||
name: 'On-site notification',
|
||||
channel: 'inapp',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
// **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: 'body', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
|
||||
{ name: 'url', type: 'string', required: false, example: 'https://example.com/houses' },
|
||||
{ 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('body', '{{body}}'),
|
||||
button('cta', 'Open', '{{url}}'),
|
||||
text('intro', '{{intro}}'),
|
||||
button('cta', 'Open', '{{actionUrl}}'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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