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,74 @@
// ── Core's own delivery channels ───────────────────────────────────────────
//
// ENGAGEMENT.md §3.1 / Phase 3. All three are core's, and none of them is a game
// concept: a mailbox, a push endpoint and an inbox row are the same three things
// on any shard running this platform.
//
// **They are declared here before two of them can deliver anything**, and that is
// deliberate rather than premature. A preference is a durable user statement; the
// three columns of the preferences screen have to exist from the moment the table
// does, or the first person to open it after Phase 6 finds an email toggle that
// has never had a value and a screen that changed shape under them. Registering
// the metadata early costs nothing — the registry holds no behaviour yet — while
// registering it late means back-filling opinions users were never asked for.
//
// The `defaultMode`s below are the whole of G9: "per-channel defaults differ and
// there is nowhere to express that generically". This is that place.
const { registerDeliveryChannel } = require('./channels')
const CHANNELS = [
{
id: 'push',
label: 'Push',
description: 'A silent tickle to your phone; the app then pulls the real content.',
// The tickle invariant (docs/android/PLAN.md §11), stated structurally rather
// than as a comment: what leaves the server on this channel is { stream, ref }
// and never a message body. Phase 7's `deliver` reads this flag; declaring it
// false here is what makes "push must not carry content" a property of the
// registration instead of a rule each caller has to remember.
carriesContent: false,
// **Opt-IN, and this is the one place the phase's own acceptance line was
// wrong.** ENGAGEMENT.md Phase 3 said a fresh user's push defaults to
// 'instant'; §3.1 called push "opt-OUT", borrowing the semantics of
// `team_notification_prefs` (where no row does mean notified). But push
// STREAM subscriptions have never worked that way: `notification_subscriptions`
// holds a row only when a user opted in, so no row means not subscribed.
// Defaulting to 'instant' here would have projected the entire catalog into
// `GET /auth/me/notifications/subscriptions` for every existing user, and the
// shipped Android client would have shown every toggle switched on after an
// upgrade nobody asked for. Settled by the org lead 2026-08-29: 'off'.
defaultMode: 'off',
// A batched tickle is a contradiction — the content is not in the message, so
// there is nothing to roll up. Ten events are ten wakeups or one; either way
// the app pulls the same inbox.
supportsDigest: false,
},
{
id: 'email',
label: 'Email',
description: 'A message to your verified address.',
carriesContent: true,
// Opt-IN, per §7.1 Q1: standard marketing-email practice, and the posture
// `team_notification_prefs.email_mode` already takes ('off' by default).
defaultMode: 'off',
supportsDigest: true,
},
{
id: 'inapp',
label: 'On the site',
description: 'An item in your notification inbox on the website and in the app.',
carriesContent: true,
// Opt-IN like the other two, and for a reason particular to this channel: the
// inbox does not exist until Phase 7. A default of 'instant' would mean every
// user is opted into a surface that has no rows and no screen, and the first
// thing Phase 7 shipped would be a backlog. Whether the inbox is opt-out once
// it is real is a Phase 7 decision with a live surface to look at.
defaultMode: 'off',
supportsDigest: false,
},
]
for (const channel of CHANNELS) registerDeliveryChannel(channel)
module.exports = { CHANNELS }