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:
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 }
|
||||
Reference in New Issue
Block a user