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>
376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
// ── Per-channel notification preferences (ENGAGEMENT.md Phase 3) ───────────
|
|
//
|
|
// The phase's acceptance criteria, one test apiece:
|
|
//
|
|
// • the shipped Android app's flat `{streams:[…]}` PUT still round-trips,
|
|
// INCLUDING the empty-array case its DTO comment warns about
|
|
// • a per-channel PUT sets `email` without touching `push`
|
|
// • a fresh user's email mode defaults `off`, and so does push
|
|
//
|
|
// …plus the two properties that make the projection safe to ship: the legacy
|
|
// wire shape is pinned BYTE-FOR-BYTE (the app cannot be changed from this side),
|
|
// and the invariant the two endpoints jointly maintain — a push pref with mode
|
|
// <> 'off' iff a `notification_subscriptions` row — is asserted from both
|
|
// directions rather than only from the one the code happens to take.
|
|
//
|
|
// 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 prefs = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.model')
|
|
const prefsDb = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.db')
|
|
const subs = require('../src/model/notificationSubs/notificationSubs.model')
|
|
const subsDb = require('../src/model/notificationSubs/notificationSubs.db')
|
|
const notifCtrl = require('../src/router/v1/auth/notifications.controller')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
const USER = 7
|
|
const PLAYER = { id: USER, role: 'player' }
|
|
const ADMIN = { id: USER, role: 'admin' }
|
|
|
|
// ── In-memory stand-ins for the two tables ─────────────────────────────────
|
|
//
|
|
// Both are stubbed at the `.db` layer, so the model's own fan-out logic — the
|
|
// part this phase actually adds — runs for real against them.
|
|
let prefRows // Map "<user> <id> <channel>" -> mode
|
|
let subRows // Set "<user> <id>"
|
|
|
|
function installStubs() {
|
|
prefRows = new Map()
|
|
subRows = new Set()
|
|
|
|
prefsDb.listByUser = async (userId) =>
|
|
[...prefRows.entries()]
|
|
.filter(([k]) => k.startsWith(`${userId} `))
|
|
.map(([k, mode]) => {
|
|
const [, streamId, channel] = k.split(' ')
|
|
return { stream_id: streamId, channel, mode }
|
|
})
|
|
.sort((a, b) => a.stream_id.localeCompare(b.stream_id) || a.channel.localeCompare(b.channel))
|
|
|
|
prefsDb.upsert = async (userId, streamId, channel, mode) => {
|
|
prefRows.set(`${userId} ${streamId} ${channel}`, mode)
|
|
}
|
|
|
|
prefsDb.offPushExcept = async (userId, keep) => {
|
|
for (const [k, mode] of prefRows.entries()) {
|
|
const [u, streamId, channel] = k.split(' ')
|
|
if (Number(u) !== userId || channel !== 'push' || mode === 'off') continue
|
|
if (!keep.includes(streamId)) prefRows.set(k, 'off')
|
|
}
|
|
}
|
|
|
|
subsDb.listByUser = async (userId) =>
|
|
[...subRows]
|
|
.filter((k) => k.startsWith(`${userId} `))
|
|
.map((k) => ({ stream_id: k.split(' ')[1] }))
|
|
.sort((a, b) => a.stream_id.localeCompare(b.stream_id))
|
|
|
|
subsDb.replaceForUser = async (userId, streams) => {
|
|
for (const k of [...subRows]) if (k.startsWith(`${userId} `)) subRows.delete(k)
|
|
for (const s of streams) subRows.add(`${userId} ${s}`)
|
|
}
|
|
subsDb.addForUser = async (userId, streamId) => subRows.add(`${userId} ${streamId}`)
|
|
subsDb.removeForUser = async (userId, streamId) => subRows.delete(`${userId} ${streamId}`)
|
|
}
|
|
|
|
// The channel registry is populated by requiring the subsystem's door, exactly
|
|
// as app.js does. Requiring `channels` alone gets the empty map — that is the
|
|
// design, and doing it the other way here would hide a boot-order regression.
|
|
function registerChannels() {
|
|
channels._reset()
|
|
delete require.cache[require.resolve('../src/engagement/coreChannels')]
|
|
// eslint-disable-next-line global-require
|
|
require('../src/engagement/coreChannels')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
registries._reset()
|
|
registries.registerCore()
|
|
registerChannels()
|
|
installStubs()
|
|
})
|
|
|
|
afterEach(() => {
|
|
registries._reset()
|
|
channels._reset()
|
|
})
|
|
|
|
const mockRes = () => ({
|
|
statusCode: 200,
|
|
body: null,
|
|
status(c) { this.statusCode = c; return this },
|
|
json(b) { this.body = b; return this },
|
|
})
|
|
|
|
const item = (surface, id) => surface.items.find((i) => i.id === id)
|
|
|
|
// ── Acceptance: the shipped app's wire shape ───────────────────────────────
|
|
|
|
test('the legacy subscriptions PUT round-trips byte-for-byte', async () => {
|
|
const put = mockRes()
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'team.forum.post'] } }, put)
|
|
|
|
// Byte-for-byte: the response is `{ streams: [...] }` and nothing else. The
|
|
// Android DTO is frozen, so an extra key is as much a break as a missing one.
|
|
assert.deepEqual(Object.keys(put.body), ['streams'])
|
|
assert.deepEqual(put.body.streams.slice().sort(), ['news.post', 'team.forum.post'])
|
|
|
|
const get = mockRes()
|
|
await notifCtrl.getSubscriptions({ user: PLAYER }, get)
|
|
assert.deepEqual(Object.keys(get.body), ['streams'])
|
|
assert.deepEqual(get.body.streams, ['news.post', 'team.forum.post'])
|
|
})
|
|
|
|
test('the empty-array case its DTO comment warns about still clears the set', async () => {
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes())
|
|
|
|
const cleared = mockRes()
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: [] } }, cleared)
|
|
assert.deepEqual(cleared.body, { streams: [] })
|
|
|
|
// And the projection cleared with it — the failure this test exists to catch
|
|
// is a channel-prefs row left at 'instant' after the app said "none", which
|
|
// would resurrect the subscription the next time anything read the new table.
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(surface, 'news.post').modes.push, 'off')
|
|
assert.equal(subRows.size, 0)
|
|
})
|
|
|
|
test('unknown stream ids are still dropped, and are not mirrored either', async () => {
|
|
const res = mockRes()
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'no.such.stream'] } }, res)
|
|
assert.deepEqual(res.body, { streams: ['news.post'] })
|
|
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(surface, 'no.such.stream'), undefined)
|
|
assert.deepEqual([...subRows], [`${USER} news.post`])
|
|
})
|
|
|
|
// ── Acceptance: defaults ───────────────────────────────────────────────────
|
|
|
|
test("a fresh user's modes are the channel defaults — push and email off, in-app on", async () => {
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
const news = item(surface, 'news.post')
|
|
|
|
// **`inapp` is 'instant' from Phase 7**, and it is the only one that is.
|
|
// Settled by the org lead 2026-08-31: the argument for opt-IN was that push
|
|
// wakes a device somebody is holding and email leaves the building, and an
|
|
// inbox item does neither — it is a row on a page the user chose to open. Left
|
|
// 'off' the surface ships dead, because no rule could reach anybody until
|
|
// every user found a toggle for a channel they had never seen deliver
|
|
// anything.
|
|
assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'instant' })
|
|
assert.equal(prefRows.size, 0, 'reading preferences must not write rows')
|
|
|
|
// The acceptance line in ENGAGEMENT.md originally said push defaults
|
|
// 'instant'. It cannot: `notification_subscriptions` is opt-IN, so that would
|
|
// have projected the whole catalog into the legacy GET for every existing
|
|
// user and switched every toggle on in the shipped app. Settled 'off' by the
|
|
// org lead; this assertion is what stops it drifting back.
|
|
const legacy = mockRes()
|
|
await notifCtrl.getSubscriptions({ user: PLAYER }, legacy)
|
|
assert.deepEqual(legacy.body, { streams: [] })
|
|
})
|
|
|
|
test('a mode with no stored row reads as the channel default, not as a hardcoded off', async () => {
|
|
// Prove the default is READ from the registry rather than assumed: re-register
|
|
// `inapp` with a different default and the same fresh user reads it back.
|
|
channels._reset()
|
|
channels.registerDeliveryChannel({
|
|
id: 'inapp', label: 'On the site', carriesContent: true, defaultMode: 'instant', supportsDigest: false,
|
|
})
|
|
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(surface, 'news.post').modes.inapp, 'instant')
|
|
})
|
|
|
|
// ── Acceptance: the sparse per-channel PUT ─────────────────────────────────
|
|
|
|
test('a per-channel PUT sets email without touching push', async () => {
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes())
|
|
|
|
const res = mockRes()
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'news.post', channel: 'email', mode: 'digest' }] } },
|
|
res,
|
|
)
|
|
|
|
const news = item(res.body, 'news.post')
|
|
assert.equal(news.modes.email, 'digest')
|
|
assert.equal(news.modes.push, 'instant', 'the push mode must survive an email-only write')
|
|
|
|
// …and the legacy endpoint agrees, which is the whole point of the projection.
|
|
const legacy = mockRes()
|
|
await notifCtrl.getSubscriptions({ user: PLAYER }, legacy)
|
|
assert.deepEqual(legacy.body, { streams: ['news.post'] })
|
|
})
|
|
|
|
test('a push write through the channels endpoint fans out to the old table', async () => {
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'instant' }] } },
|
|
mockRes(),
|
|
)
|
|
assert.deepEqual([...subRows], [`${USER} team.forum.post`])
|
|
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'off' }] } },
|
|
mockRes(),
|
|
)
|
|
assert.deepEqual([...subRows], [])
|
|
|
|
// 'off' is STORED, not deleted: it is a statement the user made, and folding it
|
|
// back into "never said" is only harmless while push's default happens to be
|
|
// off. The two are different the moment that default changes.
|
|
assert.equal(prefRows.get(`${USER} team.forum.post push`), 'off')
|
|
})
|
|
|
|
test('entries the catalog cannot accept are dropped, not refused', async () => {
|
|
const res = mockRes()
|
|
await notifCtrl.putChannelPrefs(
|
|
{
|
|
user: PLAYER,
|
|
body: {
|
|
prefs: [
|
|
{ id: 'no.such.id', channel: 'email', mode: 'instant' },
|
|
{ id: 'news.post', channel: 'carrier.pigeon', mode: 'instant' },
|
|
{ id: 'news.post', channel: 'push', mode: 'digest' }, // push has no digest
|
|
{ id: 'news.post', channel: 'email', mode: 'instant' }, // the one good row
|
|
],
|
|
},
|
|
},
|
|
res,
|
|
)
|
|
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(item(res.body, 'news.post').modes.email, 'instant')
|
|
assert.equal(item(res.body, 'news.post').modes.push, 'off')
|
|
assert.equal(prefRows.size, 1, 'only the accepted pair was written')
|
|
})
|
|
|
|
test('the last entry wins when a body names the same pair twice', async () => {
|
|
const res = mockRes()
|
|
await notifCtrl.putChannelPrefs(
|
|
{
|
|
user: PLAYER,
|
|
body: {
|
|
prefs: [
|
|
{ id: 'news.post', channel: 'email', mode: 'instant' },
|
|
{ id: 'news.post', channel: 'email', mode: 'digest' },
|
|
],
|
|
},
|
|
},
|
|
res,
|
|
)
|
|
assert.equal(item(res.body, 'news.post').modes.email, 'digest')
|
|
})
|
|
|
|
// ── The catalog: one namespace, two facets ─────────────────────────────────
|
|
|
|
test('a trigger-only id gets email and in-app, and no push toggle', async () => {
|
|
const api = registries.stage('uo')
|
|
api.registerEventTriggers([{
|
|
id: 'uo.house.idoc_warning',
|
|
label: 'House approaching collapse',
|
|
ceiling: 'owner',
|
|
variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }],
|
|
}])
|
|
registries.apply(api.staged)
|
|
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
const idoc = item(surface, 'uo.house.idoc_warning')
|
|
|
|
assert.ok(idoc, 'a trigger-only id is subscribable')
|
|
assert.deepEqual(idoc.channels, ['email', 'inapp'])
|
|
assert.equal('push' in idoc.modes, false, 'there is nothing registered to push it')
|
|
|
|
// A push entry for it is therefore inapplicable and dropped.
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'uo.house.idoc_warning', channel: 'push', mode: 'instant' }] } },
|
|
mockRes(),
|
|
)
|
|
assert.equal(subRows.size, 0)
|
|
assert.equal(prefRows.size, 0)
|
|
})
|
|
|
|
test('an id that is both a stream and a trigger appears once, with all three channels', async () => {
|
|
// Core's five trigger ids ARE its five stream ids — the same-owner upgrade the
|
|
// one-namespace rule exists for, exercised on every boot.
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
const news = surface.items.filter((i) => i.id === 'news.post')
|
|
|
|
assert.equal(news.length, 1)
|
|
assert.deepEqual(news[0].channels, ['push', 'email', 'inapp'])
|
|
assert.equal(news[0].ceiling, 'authenticated', 'the trigger facet supplies the ceiling')
|
|
})
|
|
|
|
test('a staff-ceilinged trigger is not offered to a player, and is to staff', async () => {
|
|
const api = registries.stage('uo')
|
|
api.registerEventTriggers([{
|
|
id: 'uo.cheat.detected',
|
|
label: 'Cheat detected',
|
|
ceiling: 'staff',
|
|
variables: [{ name: 'character', type: 'string', required: true, example: 'Darrow' }],
|
|
}])
|
|
registries.apply(api.staged)
|
|
|
|
const asPlayer = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(asPlayer, 'uo.cheat.detected'), undefined, 'a player is not told it exists')
|
|
|
|
const asAdmin = await prefs.getForUser(USER, ADMIN)
|
|
assert.ok(item(asAdmin, 'uo.cheat.detected'))
|
|
|
|
// And the filter is a gate, not just a display rule: a player who knows the id
|
|
// still cannot store a preference for it.
|
|
const res = mockRes()
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'uo.cheat.detected', channel: 'email', mode: 'instant' }] } },
|
|
res,
|
|
)
|
|
assert.equal(prefRows.size, 0)
|
|
})
|
|
|
|
// ── The channel registry itself ────────────────────────────────────────────
|
|
|
|
test('the registry refuses a channel that under-declares', async () => {
|
|
channels._reset()
|
|
const ok = { id: 'x', label: 'X', carriesContent: true, defaultMode: 'off', supportsDigest: false }
|
|
|
|
assert.throws(() => channels.registerDeliveryChannel({ ...ok, carriesContent: undefined }), /carriesContent/)
|
|
assert.throws(() => channels.registerDeliveryChannel({ ...ok, supportsDigest: undefined }), /supportsDigest/)
|
|
assert.throws(() => channels.registerDeliveryChannel({ ...ok, defaultMode: 'sometimes' }), /defaultMode/)
|
|
assert.throws(() => channels.registerDeliveryChannel({ ...ok, id: 'Not An Id' }), /invalid id/)
|
|
|
|
// A channel that cannot batch cannot default to batching — the failure this
|
|
// prevents is a stored 'digest' row no delivery path can ever honour.
|
|
assert.throws(
|
|
() => channels.registerDeliveryChannel({ ...ok, defaultMode: 'digest', supportsDigest: false }),
|
|
/supportsDigest/,
|
|
)
|
|
|
|
channels.registerDeliveryChannel(ok)
|
|
assert.throws(() => channels.registerDeliveryChannel(ok), /already registered/)
|
|
})
|
|
|
|
test('push is content-free and instant-only, by declaration', async () => {
|
|
assert.equal(channels.get('push').carriesContent, false)
|
|
assert.deepEqual(channels.modesFor('push'), ['off', 'instant'])
|
|
assert.deepEqual(channels.modesFor('email'), ['off', 'instant', 'digest'])
|
|
})
|
|
|
|
test('an unregistered channel reads as off and accepts nothing', async () => {
|
|
// A stored row can name a channel that is no longer registered (a downgrade).
|
|
// It must read as off, never throw and never be on by accident.
|
|
assert.equal(channels.defaultMode('discord.dm'), 'off')
|
|
assert.deepEqual(channels.modesFor('discord.dm'), [])
|
|
assert.equal(channels.acceptsMode('discord.dm', 'instant'), false)
|
|
})
|