feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3) #169
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-channel-prefs"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Engagement Phase 3 —
docs/website/ENGAGEMENT.md. Companion docs PR: docs#182.notification_subscriptionsanswers 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 at all.What lands
server/src/engagement/channels.js—registerDeliveryChannel(§3.1), the declarative half only:id,label,carriesContent,defaultMode,supportsDigest.addressFor/render/deliverwait for Phases 6 and 7 — registering a function nothing calls freezes a signature before anything has tried to use it, which is whytransports/index.jsdeferred this file in the first place.coreChannels.jsdeclares push / email / inapp through the subsystem's one door (require('./engagement')fromapp.js, besideregisterCore()).notification_channel_prefs+ the replay-safeINSERT IGNORE … SELECTbackfill, copying theannounce_jobs → announce_job_legsprecedent.GET · PUT /auth/me/notifications/channels,model/notificationChannelPrefs/, swagger + route manifest + guards.ceilings.STAFF_CEILING_ROLES/isStaffRole.Three decisions the org lead settled before any code
defaultModeoff— the acceptance line saidinstant; it could not be(id, channel)pairThe phase's own acceptance criterion was wrong, and it is worth stating how. "A fresh user's email mode defaults
offand push defaultsinstant" reads naturally beside §3.1's "email opt-IN, push opt-OUT" — and §3.1 borrowed that fromteam_notification_prefs, where no row genuinely does mean notified. But push stream subscriptions have never worked that way:notification_subscriptionsholds a row only when a user opted in. AdefaultModeofinstantwould have projected the entire catalog intoGET /auth/me/notifications/subscriptionsfor every existing user, and the shipped Android client would have shown every toggle switched on after an upgrade nobody asked for. A live behaviour change dressed as a default. A test pins the legacy GET at{streams:[]}for a fresh user so it cannot drift back.The sparse PUT
Deliberately unlike the two whole-set PUTs beside it. A whole-set body forces a client that only manages email to send every push row back or wipe them — and
offbecomes a mode rather than an omission, which means this endpoint has no empty-array case at all, so the kotlinx gotchaputTeamPrefshad to document simply cannot arise here.The projection, as an invariant
notification_subscriptionsstays exactly whatutils/pushDispatchreads. Both endpoints maintain: apushpref withmode <> 'off'⟺ anotification_subscriptionsrow — the legacy PUT with a whole-set sweep, the channels PUT one pair at a time. An explicitoffis stored rather than deleted, because folding "I turned this off" back into "I never said" is only harmless while the default happens to be off.One thing the phase did not name
A trigger ceilinged at
staffcan never reach a non-staff user, so offering the toggle is offering a dead control and disclosing the event exists —uo.cheat.detectedwould otherwise appear by name in every player's preferences screen the moment Phase 11 declared it. Filtered from the catalog and gated on write, not merely hidden.membersis deliberately not filtered: membership is a runtime resolver's answer, and a preference set before joining a Team should already be in place when you join. This is a G24 consequence, not scope creep.Verification
npm test— green, 15 new tests intest/notificationChannelPrefs.test.js. (One pre-existing Windows-only failure inengagementManifest.test.jsis a CRLF checkout artifact undercore.autocrlf=true; it fails identically on a cleanedgeand is green on CI's Linux.)npm run swagger,npm run routes:manifestregenerated (withmodules/uomoved aside, or the manifest picks up the module's 298 routes).No web or app surface — those are Phases 7 and 8, where a preference governs something a user can see.
🤖 Generated with Claude Code
`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>