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:
@@ -634,6 +634,88 @@ const doc = {
|
||||
},
|
||||
},
|
||||
},
|
||||
DeliveryChannel: {
|
||||
type: 'object',
|
||||
description: 'One delivery channel from the registry (ENGAGEMENT.md §3.1). A channel is what kind of sink this is; a transport is how it delivers.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'email' },
|
||||
label: { type: 'string', example: 'Email' },
|
||||
description: { type: 'string', nullable: true, example: 'A message to your verified address.' },
|
||||
carriesContent: {
|
||||
type: 'boolean',
|
||||
description: 'False for push, which only ever sends a content-free tickle the client then pulls against.',
|
||||
example: true,
|
||||
},
|
||||
defaultMode: {
|
||||
type: 'string',
|
||||
enum: ['off', 'instant', 'digest'],
|
||||
description: 'The mode that applies when the user has stored no preference for an id on this channel.',
|
||||
example: 'off',
|
||||
},
|
||||
supportsDigest: { type: 'boolean', example: true },
|
||||
modes: {
|
||||
type: 'array',
|
||||
items: { type: 'string', enum: ['off', 'instant', 'digest'] },
|
||||
description: 'The modes this channel will accept. Excludes `digest` unless supportsDigest.',
|
||||
example: ['off', 'instant', 'digest'],
|
||||
},
|
||||
},
|
||||
},
|
||||
NotificationChannelPrefItem: {
|
||||
type: 'object',
|
||||
description: 'One subscribable id — a push stream, an event trigger, or both — with the effective mode on each channel that applies to it.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'news.post' },
|
||||
label: { type: 'string', example: 'News posts' },
|
||||
description: { type: 'string', example: 'New news / Five-on-Friday / newsletter posts.' },
|
||||
personal: { type: 'boolean', example: false },
|
||||
requiresLinkedAccount: { type: 'boolean', example: false },
|
||||
ceiling: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'The trigger’s audience ceiling, or null for an id with no trigger declaration.',
|
||||
example: 'authenticated',
|
||||
},
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Which channels apply. A trigger-only id has no `push` — nothing is registered to push it.',
|
||||
example: ['push', 'email', 'inapp'],
|
||||
},
|
||||
modes: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string', enum: ['off', 'instant', 'digest'] },
|
||||
description: 'Effective mode per applicable channel: the stored value, or the channel’s default where nothing is stored.',
|
||||
example: { push: 'instant', email: 'off', inapp: 'off' },
|
||||
},
|
||||
},
|
||||
},
|
||||
NotificationChannelPrefs: {
|
||||
type: 'object',
|
||||
description: 'The per-channel preferences surface: the channel registry plus one item per subscribable id. Returned by both GET and PUT.',
|
||||
properties: {
|
||||
channels: { type: 'array', items: { $ref: '#/components/schemas/DeliveryChannel' } },
|
||||
items: { type: 'array', items: { $ref: '#/components/schemas/NotificationChannelPrefItem' } },
|
||||
},
|
||||
},
|
||||
NotificationChannelPrefsUpdate: {
|
||||
type: 'object',
|
||||
description: 'A SPARSE preference update. Only the (id, channel) pairs listed are written; every other pair is left untouched. `off` is a mode, never an omission.',
|
||||
properties: {
|
||||
prefs: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'news.post' },
|
||||
channel: { type: 'string', example: 'email' },
|
||||
mode: { type: 'string', enum: ['off', 'instant', 'digest'], example: 'digest' },
|
||||
},
|
||||
},
|
||||
example: [{ id: 'news.post', channel: 'email', mode: 'digest' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
TeamNotificationPref: {
|
||||
type: 'object',
|
||||
description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).",
|
||||
|
||||
Reference in New Issue
Block a user