feat(engagement): the in-app channel, core and web (engagement Phase 7)
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

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:
2026-08-31 02:07:10 -05:00
parent 5168446c53
commit 24a3cd85b3
34 changed files with 3153 additions and 112 deletions

View File

@@ -0,0 +1,365 @@
// ── The in-app channel (ENGAGEMENT.md Phase 7) ─────────────────────────────
//
// The phase's five acceptance criteria, plus the things building it showed were
// worth pinning:
//
// • one event delivered to `inapp` produces exactly one row
// • a duplicate `dedupeKey` is a no-op
// • mark-read is idempotent
// • a user cannot read another user's row — asserted AT THE ROUTE, which is
// what the acceptance line asks for, not only in the model
// • `url` is relative-only, by the same character class `pageUrlTemplate` uses
//
// • the block→column role mapping, which is the whole of how a template with a
// subject and a document becomes a row with three fields
// • `ctx.inbox.push` honours a preference where one exists and writes where
// none does
// • `liveChannels` puts `inapp` before `push`, which is what makes the tickle's
// deep-link ref resolve on the first pass
//
// Point the DB at a closed port before requiring anything: the registries reach
// utils/discordAnnounce, which builds the pool at require time.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const channels = require('../src/engagement/channels')
const engine = require('../src/engagement/engine')
const inappChannel = require('../src/engagement/inappChannel')
const pushChannel = require('../src/engagement/pushChannel')
const templates = require('../src/engagement/templates')
const templateSeeds = require('../src/engagement/templateSeeds')
const templatesDb = require('../src/model/engagement/engagementTemplates.db')
const settings = require('../src/model/settings/settings.model')
const inbox = require('../src/model/userNotifications/userNotifications.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const pushDispatch = require('../src/utils/pushDispatch')
const notifCtrl = require('../src/router/v1/auth/notifications.controller')
const db = require('../src/utils/db')
require('../src/engagement')
registries.registerCore()
after(() => db.close())
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
const TRIGGER = 'team.forum.post'
let world
beforeEach(() => {
world = { rows: [], tickles: [], storedModes: new Map() }
// A stand-in for `user_notifications`, keyed the way the UNIQUE index is.
patch(inbox, 'insert', async (item) => {
const clash =
item.dedupeKey &&
world.rows.some((r) => r.userId === item.userId && r.dedupeKey === item.dedupeKey)
if (clash) return { inserted: false, id: null }
const row = { id: world.rows.length + 1, ...item }
world.rows.push(row)
return { inserted: true, id: row.id }
})
patch(inbox, 'findByDedupe', async (userId, key) => {
if (!key) return null
const row = world.rows.find((r) => r.userId === Number(userId) && r.dedupeKey === key)
return row ? { id: row.id, title: row.title } : null
})
patch(recipients, 'filterActive', async (ids) => ids)
patch(recipients, 'storedModes', async () => world.storedModes)
patch(pushDispatch, 'publishToUsers', async (streamId, opts) => {
world.tickles.push({ streamId, ...opts })
})
patch(rulesDb, 'getById', async () => ({
id: 1,
trigger_id: TRIGGER,
template_keys: { inapp: 'inapp.event' },
}))
// **Stubbed at the MODULE BOUNDARY, not on `templates` itself**, and the
// distinction cost four minutes a run to find: `renderInappByKey` calls its
// own file-local `ambient()` and `resolveTemplate()`, so patching
// `templates.ambient` replaces an export nothing in that path reads, every
// call reaches the dead port, and each one waits out the driver's 30-second
// connect timeout while still passing. These two are real cross-module calls,
// so replacing them is what actually keeps the render off the database.
//
// A null row is also the path a fresh deployment takes: `resolveTemplate`
// falls through to the shipped seed.
patch(templatesDb, 'getByKey', async () => null)
patch(settings, 'getInstanceName', async () => 'Test Shard')
patch(settings, 'getShellBrand', async () => ({ logo: null, theme: null }))
})
afterEach(restore)
const outboxRow = (over = {}) => ({
id: 1,
rule_id: 1,
trigger_id: TRIGGER,
user_id: 11,
channel: 'inapp',
subject_key: 'The Silver Hand',
scope_key: 'team:1',
dedupe_key: 'post:7',
payload: { teamName: 'The Silver Hand', authorName: 'Ten', threadTitle: 'Raid', postUrl: '/g/1?thread=7' },
...over,
})
// ── The registration ───────────────────────────────────────────────────────
// The Phase 7 decision `coreChannels.js` deferred in as many words. Opt-OUT for
// in-app alone: it wakes no device and leaves no building.
test('inapp is the one channel that defaults to instant', () => {
assert.equal(channels.defaultMode('inapp'), 'instant')
assert.equal(channels.defaultMode('push'), 'off')
assert.equal(channels.defaultMode('email'), 'off')
})
test('inapp and push both have a deliver now, and push still carries no content', () => {
assert.equal(typeof channels.get('inapp').deliver, 'function')
assert.equal(typeof channels.get('push').deliver, 'function')
assert.equal(channels.get('push').carriesContent, false)
})
// ── deliver ────────────────────────────────────────────────────────────────
test('one event delivered to inapp produces exactly one row', async () => {
const result = await inappChannel.deliver(outboxRow())
assert.equal(result.ok, true)
assert.equal(world.rows.length, 1)
assert.equal(world.rows[0].userId, 11)
assert.equal(world.rows[0].triggerId, TRIGGER)
})
// The acceptance line calls it a no-op; from the recipient's side it is a
// delivery, so it reports ok with the reason in the detail rather than putting a
// red row in the send log for the mechanism working.
test('a duplicate dedupeKey is a no-op that still reports success', async () => {
await inappChannel.deliver(outboxRow())
const again = await inappChannel.deliver(outboxRow({ id: 2 }))
assert.equal(again.ok, true)
assert.match(again.detail, /duplicate/i)
assert.equal(world.rows.length, 1)
})
test('a row with no dedupe key is never deduped', async () => {
await inappChannel.deliver(outboxRow({ dedupe_key: null }))
await inappChannel.deliver(outboxRow({ id: 2, dedupe_key: null }))
assert.equal(world.rows.length, 2)
})
test('a user who can no longer be reached is a terminal failure, not a retry', async () => {
patch(recipients, 'filterActive', async () => [])
const result = await inappChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.equal(result.retry, undefined)
assert.equal(world.rows.length, 0)
})
// A throw would be read by the worker as a transient failure and retried five
// times — one unrenderable template becoming five identical send-log rows.
test('deliver never throws — a render failure is classified, not propagated', async () => {
patch(templates, 'renderInappByKey', async () => { throw new Error('blocks are broken') })
const result = await inappChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.match(result.detail, /blocks are broken/)
})
test('a template that names nothing shipped is terminal and says which key', async () => {
patch(rulesDb, 'getById', async () => ({ id: 1, template_keys: { inapp: 'nope.missing' } }))
const result = await inappChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.match(result.detail, /nope\.missing/)
})
// ── The block → column role mapping ────────────────────────────────────────
test('the heading becomes the title, the button becomes the url, the rest becomes the body', async () => {
const rendered = await templates.renderInappByKey('inapp.event', {
title: 'Your house is close to collapsing',
intro: 'The Silver Anvil has entered its final decay stage.',
actionUrl: '/player/uo/houses',
})
assert.equal(rendered.title, 'Your house is close to collapsing')
assert.equal(rendered.url, '/player/uo/houses')
assert.match(rendered.body, /final decay stage/)
// The title and the action are COLUMNS; repeating them in the body would show
// the same words twice on one card.
assert.doesNotMatch(rendered.body, /close to collapsing/)
assert.doesNotMatch(rendered.body, /player\/uo\/houses/)
})
// §4.6.1 property 1, for this channel: a trigger with no bespoke template still
// renders, because `projection.project` supplies the structural names.
test('a trigger that authored nothing still gets a title, from its declaration', async () => {
const rendered = await inappChannel.renderItem(TRIGGER, { teamName: 'The Silver Hand' }, 'inapp.event')
assert.ok(rendered.title.length > 0)
assert.notEqual(rendered.title, 'inapp.event')
})
// The seed Phase 5a wrote named `body` and `url` — names nothing supplies, so
// every rendering of it would have produced a title and nothing else.
test('the shipped inapp seed names only variables the projection actually supplies', () => {
const seed = templateSeeds.seedByKey('inapp.event')
assert.equal(seed.seedVersion, 2)
const names = seed.variables.map((v) => v.name).sort()
assert.deepEqual(names, ['actionUrl', 'intro', 'title'])
})
// ── url: relative only ─────────────────────────────────────────────────────
test('url is relative-only, and a protocol-relative one is dropped rather than stored', () => {
const base = 'https://shard.test'
assert.equal(templates.relativeUrl('/guilds/4', base), '/guilds/4')
assert.equal(templates.relativeUrl('https://shard.test/guilds/4', base), '/guilds/4')
assert.equal(templates.relativeUrl('//evil.test/x', base), null)
assert.equal(templates.relativeUrl('https://evil.test/x', base), null)
assert.equal(templates.relativeUrl('javascript:alert(1)', base), null)
assert.equal(templates.relativeUrl('', base), null)
})
// ── ctx.inbox.push ─────────────────────────────────────────────────────────
test('ctx.inbox.push writes an item for a trigger nothing has registered', async () => {
const res = await inappChannel.pushDirect('uo', 11, {
triggerId: 'uo.unregistered.thing',
title: 'Something happened',
body: 'A thing occurred.',
url: '/player/uo/houses',
})
assert.equal(res.written, true)
assert.equal(world.rows[0].url, '/player/uo/houses')
})
// The decision: a toggle somebody switched off must not be walkable around by
// the module that owns the trigger behind it.
test('ctx.inbox.push honours the users preference when the trigger IS registered', async () => {
world.storedModes = new Map([[11, 'off']])
const res = await inappChannel.pushDirect('uo', 11, { triggerId: TRIGGER, title: 'Hi' })
assert.equal(res.written, false)
assert.equal(world.rows.length, 0)
})
test('ctx.inbox.push writes for a registered trigger the user has left at the default', async () => {
world.storedModes = new Map()
const res = await inappChannel.pushDirect('uo', 11, { triggerId: TRIGGER, title: 'Hi' })
assert.equal(res.written, true)
})
test('ctx.inbox.push drops an off-site url rather than storing it', async () => {
await inappChannel.pushDirect('uo', 11, {
triggerId: 'x.y',
title: 'Hi',
url: 'https://evil.test/steal',
})
assert.equal(world.rows[0].url, null)
})
test('ctx.inbox.push refuses an item with no title, and never throws', async () => {
const res = await inappChannel.pushDirect('uo', 11, { triggerId: 'x.y' })
assert.equal(res.written, false)
patch(inbox, 'insert', async () => { throw new Error('table is gone') })
const boom = await inappChannel.pushDirect('uo', 11, { triggerId: 'x.y', title: 'Hi' })
assert.equal(boom.written, false)
})
// ── push: the tickle, its ref, and what must never ride on it ──────────────
test('the push tickle carries the stream and a ref, and no content whatsoever', async () => {
await inappChannel.deliver(outboxRow())
const result = await pushChannel.deliver(outboxRow({ id: 2, channel: 'push' }))
assert.equal(result.ok, true)
const tickle = world.tickles[0]
assert.equal(tickle.streamId, TRIGGER)
assert.equal(tickle.ref, 'notification:1')
assert.deepEqual(Object.keys(tickle).sort(), ['ref', 'streamId', 'userIds'])
assert.deepEqual(tickle.userIds, [11])
})
test('a push row with no inbox row behind it still publishes, with a null ref', async () => {
const result = await pushChannel.deliver(outboxRow({ channel: 'push', dedupe_key: null }))
assert.equal(result.ok, true)
assert.equal(world.tickles[0].ref, null)
})
// The ordering is what makes the ref resolve on the first pass: the outbox is
// swept `ORDER BY due_at, id`, so the in-app row has to be enqueued first.
test('liveChannels enqueues inapp before push, whatever order the rule names them in', () => {
assert.deepEqual(engine.liveChannels({ channels: ['push', 'inapp'] }), ['inapp', 'push'])
assert.deepEqual(engine.liveChannels({ channels: ['email', 'push'] }), ['email', 'push'])
assert.deepEqual(engine.liveChannels({ channels: ['push', 'nope'] }), ['push'])
})
// ── The routes: ownership, asserted where the acceptance line asks for it ──
function res() {
const out = { code: 200, body: null }
return {
out,
status(c) { out.code = c; return this },
json(b) { out.body = b; return this },
}
}
test('a user cannot mark another users notification read — 404 at the route', async () => {
// The model is NOT stubbed to "found": it is the real ownership predicate the
// route depends on, so the stub answers the way the SQL would.
patch(inbox, 'markRead', async (userId, id) => Number(userId) === 11 && Number(id) === 5)
patch(inbox, 'unreadCount', async () => 0)
const mine = res()
await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, mine)
assert.equal(mine.out.code, 200)
const theirs = res()
await notifCtrl.markRead({ user: { id: 12 }, params: { id: 5 } }, theirs)
assert.equal(theirs.out.code, 404)
// The same answer whether the row is nobody's or somebody else's: telling them
// apart would make this a way to ask whether an id exists.
const missing = res()
await notifCtrl.markRead({ user: { id: 11 }, params: { id: 999 } }, missing)
assert.equal(missing.out.code, 404)
})
test('mark-read is idempotent', async () => {
let stamps = 0
patch(inbox, 'markRead', async () => { stamps += 1; return true })
patch(inbox, 'unreadCount', async () => 0)
await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, res())
await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, res())
assert.equal(stamps, 2) // the route is happy to be called twice…
// …and the statement behind it only stamps an unread row, which is the half
// that makes the second call a no-op. Pinned in the SQL test.
})
// There is no route parameter and no query string that names a user, so the
// listing cannot be pointed at another account even by a caller who tries.
test('the inbox list reads the caller and nothing else', async () => {
let askedFor = null
patch(inbox, 'list', async (userId, opts) => {
askedFor = { userId, opts }
return { items: [], hasMore: false }
})
patch(inbox, 'unreadCount', async () => 2)
const r = res()
await notifCtrl.getInbox(
{ user: { id: 11 }, query: { limit: '10', before: '99', unread: 'true', userId: '12' } },
r,
)
assert.equal(askedFor.userId, 11)
assert.equal(askedFor.opts.unreadOnly, true)
assert.equal(r.out.body.unread, 2)
})