feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)
`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:
@@ -0,0 +1,44 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query(
|
||||
'SELECT stream_id, channel, mode FROM notification_channel_prefs WHERE user_id = ? ORDER BY stream_id, channel',
|
||||
[userId],
|
||||
)
|
||||
|
||||
// One (user, stream, channel) row. Upsert rather than insert-or-update in app
|
||||
// code: the primary key is exactly the triple, so MariaDB decides, and two
|
||||
// concurrent PUTs from a phone and a browser cannot race into a duplicate-key
|
||||
// error.
|
||||
const upsert = (userId, streamId, channel, mode) =>
|
||||
query(
|
||||
`INSERT INTO notification_channel_prefs (user_id, stream_id, channel, mode)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE mode = VALUES(mode)`,
|
||||
[userId, streamId, channel, mode],
|
||||
)
|
||||
|
||||
// Every push row this user holds that is NOT in `keep`, set to 'off'. The legacy
|
||||
// whole-set PUT's other half: it says "these streams and no others", and the
|
||||
// rows it is silent about have to stop meaning 'instant'.
|
||||
//
|
||||
// It sets rather than deletes, so a user's explicit "no" survives a later change
|
||||
// to push's `defaultMode` (§3.1 / channels.js). Deleting would fold "I turned
|
||||
// this off" back into "I never said", and those are the same thing only for as
|
||||
// long as the default happens to be 'off'.
|
||||
async function offPushExcept(userId, keep) {
|
||||
if (!keep.length) {
|
||||
return query(
|
||||
"UPDATE notification_channel_prefs SET mode = 'off' WHERE user_id = ? AND channel = 'push' AND mode <> 'off'",
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
const marks = keep.map(() => '?').join(', ')
|
||||
return query(
|
||||
`UPDATE notification_channel_prefs SET mode = 'off'
|
||||
WHERE user_id = ? AND channel = 'push' AND mode <> 'off' AND stream_id NOT IN (${marks})`,
|
||||
[userId, ...keep],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { listByUser, upsert, offPushExcept }
|
||||
Binary file not shown.
@@ -13,4 +13,20 @@ async function replaceForUser(userId, streams) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listByUser, replaceForUser }
|
||||
// ── The single-row pair (ENGAGEMENT.md Phase 3) ────────────────────────────
|
||||
//
|
||||
// `notification_channel_prefs` is a SPARSE write — one (id, channel) pair at a
|
||||
// time — and this table is its push projection, so it needs the same granularity.
|
||||
// `replaceForUser` above cannot express "turn this one stream on and leave the
|
||||
// rest alone" without the caller first reading the whole set back, which is a
|
||||
// read-modify-write race between a phone and a browser saving at once.
|
||||
//
|
||||
// INSERT IGNORE / DELETE against the (user_id, stream_id) primary key, so both
|
||||
// are idempotent and neither needs to know the current state.
|
||||
const addForUser = (userId, streamId) =>
|
||||
query('INSERT IGNORE INTO notification_subscriptions (user_id, stream_id) VALUES (?, ?)', [userId, streamId])
|
||||
|
||||
const removeForUser = (userId, streamId) =>
|
||||
query('DELETE FROM notification_subscriptions WHERE user_id = ? AND stream_id = ?', [userId, streamId])
|
||||
|
||||
module.exports = { listByUser, replaceForUser, addForUser, removeForUser }
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
// applied to every device they register). The catalog is core's plus every
|
||||
// installed module's, so it is read back through modules/registries rather than
|
||||
// from a config file (MODULE_SYSTEM.md §1.8).
|
||||
//
|
||||
// **This is now the push PROJECTION of `notification_channel_prefs`**
|
||||
// (ENGAGEMENT.md Phase 3), and it keeps its exact wire shape on purpose: the
|
||||
// shipped Android client's DTO is `{ streams: [...] }` and cannot be changed from
|
||||
// this side. So the general table gained the channel dimension and this one stays
|
||||
// the answer to "which streams does this user want pushed" — the only question
|
||||
// that client knows how to ask. Every write here fans out to there; every write
|
||||
// there that touches push fans out to here. What `utils/pushDispatch` reads did
|
||||
// not change at all, which is what makes this phase touch no delivery path.
|
||||
|
||||
const db = require('./notificationSubs.db')
|
||||
const { isValidStream } = require('../../modules/registries')
|
||||
@@ -11,9 +20,16 @@ const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.
|
||||
// Replace the user's subscription set. Ignores unknown ids and de-dupes, so a
|
||||
// stale client can't create rows for streams that no longer exist. Returns the
|
||||
// stored (cleaned) set.
|
||||
//
|
||||
// The mirror is required lazily rather than at the top of the file: the channel
|
||||
// prefs model requires the registries and the channel registry, and this module
|
||||
// is required by the router at boot. A cycle here would be a silent half-loaded
|
||||
// module rather than an error, and there is nothing to gain from the eager form.
|
||||
async function setForUser(userId, streams) {
|
||||
const clean = [...new Set((Array.isArray(streams) ? streams : []).filter(isValidStream))]
|
||||
await db.replaceForUser(userId, clean)
|
||||
// eslint-disable-next-line global-require
|
||||
await require('../notificationChannelPrefs/notificationChannelPrefs.model').mirrorPushSet(userId, clean)
|
||||
return clean
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user