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:
@@ -5,6 +5,7 @@
|
||||
|
||||
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
|
||||
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
||||
const channelPrefs = require('../../../model/notificationChannelPrefs/notificationChannelPrefs.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
||||
@@ -79,6 +80,39 @@ async function putSubscriptions(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/channels — the per-channel preferences surface
|
||||
// (ENGAGEMENT.md Phase 3): the channel registry's declarative half, plus one item
|
||||
// per subscribable id with its EFFECTIVE mode on each channel that applies.
|
||||
//
|
||||
// The superset of `/notifications/streams` + `/notifications/subscriptions`,
|
||||
// which stay exactly as they are for the shipped app.
|
||||
async function getChannelPrefs(req, res) {
|
||||
try {
|
||||
return res.json(await channelPrefs.getForUser(req.user.id, req.user))
|
||||
} catch (err) {
|
||||
log.error('getChannelPrefs', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /auth/me/notifications/channels — apply a SPARSE set of preferences.
|
||||
//
|
||||
// Only the (id, channel) pairs in the body are written; every other pair is left
|
||||
// alone, so a screen that manages one channel need not know about the others. Off
|
||||
// is a mode, not an omission — which is also why this endpoint has no
|
||||
// empty-array case to get wrong, unlike its two neighbours. Entries naming an
|
||||
// unknown id, an inapplicable channel or a mode that channel does not accept are
|
||||
// dropped by the model; the full stored state is echoed back so the caller can
|
||||
// see what actually took.
|
||||
async function putChannelPrefs(req, res) {
|
||||
try {
|
||||
return res.json(await channelPrefs.applyForUser(req.user.id, req.body.prefs, req.user))
|
||||
} catch (err) {
|
||||
log.error('putChannelPrefs', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/teams — this user's per-Team preferences, one row
|
||||
// per Team they could be notified about whether or not they have ever set one.
|
||||
//
|
||||
@@ -119,6 +153,8 @@ module.exports = {
|
||||
getStreams,
|
||||
getSubscriptions,
|
||||
putSubscriptions,
|
||||
getChannelPrefs,
|
||||
putChannelPrefs,
|
||||
getTeamPrefs,
|
||||
putTeamPrefs,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
|
||||
const { MODES } = require('../../../engagement/channels')
|
||||
|
||||
const notifRouter = express.Router()
|
||||
|
||||
@@ -98,6 +99,44 @@ notifRouter.put(
|
||||
notif.putSubscriptions,
|
||||
)
|
||||
|
||||
// ── Per-channel preferences (ENGAGEMENT.md §4.5, phase 3) ──────────────────
|
||||
//
|
||||
// The channel dimension `notification_subscriptions` lacks. The two endpoints
|
||||
// above are unchanged and become the push projection of these — the shipped app
|
||||
// keeps its wire shape, and a newer client manages email and in-app through here.
|
||||
//
|
||||
// The PUT is SPARSE, deliberately unlike the two whole-set PUTs either side of
|
||||
// it: only the pairs named are written. `off` is a mode rather than an omission,
|
||||
// so there is no "clearing the last entry" case and no empty-array DTO gotcha.
|
||||
notifRouter.get(
|
||||
'/notifications/channels',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Get the current user’s per-channel notification preferences'
|
||||
// #swagger.description = 'The delivery channels (email, push, in-app) with their defaults, plus one item per subscribable id — every push stream and every event trigger, one namespace — carrying the effective mode on each channel that applies to it. A trigger-only id has no push toggle. Modes not stored are reported as the channel’s default, so a client never has to know which it is looking at.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Per-channel preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefs" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
notif.getChannelPrefs,
|
||||
)
|
||||
|
||||
notifRouter.put(
|
||||
'/notifications/channels',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Update the current user’s per-channel notification preferences'
|
||||
// #swagger.description = 'A SPARSE update: only the (id, channel) pairs in `prefs` are written and every other pair is left untouched, so setting `email` does not disturb `push`. Entries naming an unknown id, a channel that does not apply to that id, or a mode that channel does not accept are ignored. A `push` entry is mirrored into /notifications/subscriptions. The full stored state is echoed back.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefsUpdate" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefs" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('prefs').isArray(),
|
||||
body('prefs.*.id').isString().isLength({ min: 1, max: 64 }),
|
||||
body('prefs.*.channel').isString().isLength({ min: 1, max: 32 }),
|
||||
body('prefs.*.mode').isIn(MODES),
|
||||
validate,
|
||||
notif.putChannelPrefs,
|
||||
)
|
||||
|
||||
// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
|
||||
//
|
||||
// The granularity per-stream opt-in cannot express: "I am in five Teams and want
|
||||
|
||||
Reference in New Issue
Block a user