feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 10m29s

`notification_subscriptions` answers one question — which streams a user wants
PUSHED — because that is the only question the shipped Android client can ask.
This adds the general one: which subscribable ids, on which channel, in which
mode. The old table becomes the push projection of the new one and keeps its
exact wire shape, so the shipped APK needs no update and no delivery path is
touched.

What lands:

- `engagement/channels.js` — `registerDeliveryChannel` (ENGAGEMENT.md §3.1), the
  declarative half only: id, label, `carriesContent`, `defaultMode`,
  `supportsDigest`. `addressFor`/`render`/`deliver` wait for Phases 6 and 7, for
  the reason `transports/index.js` deferred this file at all. `coreChannels.js`
  declares push / email / inapp through the subsystem's one door.
- `notification_channel_prefs` + a replay-safe `INSERT IGNORE … SELECT` backfill,
  copying the `announce_jobs → announce_job_legs` precedent.
- `GET · PUT /auth/me/notifications/channels`. The PUT is SPARSE — only the
  `(id, channel)` pairs named are written — deliberately unlike the two whole-set
  PUTs beside it. `off` is a mode rather than an omission, so this endpoint has
  no empty-array case and the kotlinx DTO gotcha cannot arise here.

Three decisions the org lead settled before any code, and one corrects the
phase's own acceptance criterion: push's `defaultMode` is `off`, not `instant`.
The plan borrowed "push is opt-OUT" from `team_notification_prefs`, where no row
does mean notified — but stream subscriptions have never worked that way, so
`instant` would have projected the whole catalog into the legacy GET for every
existing user and switched every toggle on in the shipped app after an upgrade
nobody asked for. A test pins the legacy GET at `{streams:[]}` for a fresh user.

One thing not named by the phase, and it is a G24 consequence rather than scope
creep: a trigger ceilinged at `staff` can never reach a non-staff user, so
offering the toggle would be offering a dead control AND disclosing the event
exists — `uo.cheat.detected` would otherwise appear in every player's screen the
moment Phase 11 declared it. Filtered from the catalog and gated on write. That
gave the `staff` label its first consumer, now written down as
`ceilings.STAFF_CEILING_ROLES` (the admin tier's three, deliberately not
`teamGrants.STAFF_ROLES`, which answers a different question).

15 new tests; swagger, route manifest and guards regenerated. No web or app
surface — those are Phases 7 and 8, where a preference governs something visible.

Refs: docs/website/ENGAGEMENT.md Phase 3, §3.1, §4.5

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 07:08:17 -05:00
parent ea3499e70b
commit b13ffd584f
18 changed files with 1530 additions and 12 deletions

View File

@@ -0,0 +1,368 @@
// ── 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, and all three are off", async () => {
const surface = await prefs.getForUser(USER, PLAYER)
const news = item(surface, 'news.post')
assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'off' })
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)
})