feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3) #169

Merged
whitlocktech merged 1 commits from feature/engagement-channel-prefs into edge 2026-08-29 12:12:03 +00:00
Member

Engagement Phase 3docs/website/ENGAGEMENT.md. Companion docs PR: docs#182.

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 at all.

What lands

  • server/src/engagement/channels.jsregisterDeliveryChannel (§3.1), the declarative half only: id, label, carriesContent, defaultMode, supportsDigest. addressFor / render / deliver wait for Phases 6 and 7 — registering a function nothing calls freezes a signature before anything has tried to use it, which is why transports/index.js deferred this file in the first place. coreChannels.js declares push / email / inapp through the subsystem's one door (require('./engagement') from app.js, beside registerCore()).
  • notification_channel_prefs + the replay-safe INSERT IGNORE … SELECT backfill, copying the announce_jobs → announce_job_legs precedent.
  • 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

Question Decision
how much of §3.1's registry lands now the declarative half only
push's defaultMode off — the acceptance line said instant; it could not be
whole-set PUT or sparse sparse, on the (id, channel) pair

The phase's own acceptance criterion was wrong, and it is worth stating how. "A fresh user's email mode defaults off and push defaults instant" reads naturally beside §3.1's "email opt-IN, push opt-OUT" — and §3.1 borrowed that from team_notification_prefs, where no row genuinely does mean notified. But push stream subscriptions have never worked that way: notification_subscriptions holds a row only when a user opted in. A defaultMode of instant 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. 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 off becomes a mode rather than an omission, which means this endpoint has no empty-array case at all, so the kotlinx gotcha putTeamPrefs had to document simply cannot arise here.

The projection, as an invariant

notification_subscriptions stays exactly what utils/pushDispatch reads. Both endpoints maintain: a push pref with mode <> 'off' ⟺ a notification_subscriptions row — the legacy PUT with a whole-set sweep, the channels PUT one pair at a time. An explicit off is 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 staff can never reach a non-staff user, so offering the toggle is offering a dead control and disclosing the event exists — uo.cheat.detected would 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. members is 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 in test/notificationChannelPrefs.test.js. (One pre-existing Windows-only failure in engagementManifest.test.js is a CRLF checkout artifact under core.autocrlf=true; it fails identically on a clean edge and is green on CI's Linux.)
  • npm run swagger, npm run routes:manifest regenerated (with modules/uo moved 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.


  • AI-assisted: written with Claude Code (Opus)

🤖 Generated with Claude Code

Engagement **Phase 3** — `docs/website/ENGAGEMENT.md`. Companion docs PR: **docs#182**. `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 at all. ## What lands - **`server/src/engagement/channels.js`** — `registerDeliveryChannel` (§3.1), the *declarative half only*: `id`, `label`, `carriesContent`, `defaultMode`, `supportsDigest`. `addressFor` / `render` / `deliver` wait for Phases 6 and 7 — registering a function nothing calls freezes a signature before anything has tried to use it, which is why `transports/index.js` deferred this file in the first place. `coreChannels.js` declares push / email / inapp through the subsystem's one door (`require('./engagement')` from `app.js`, beside `registerCore()`). - **`notification_channel_prefs`** + the replay-safe `INSERT IGNORE … SELECT` backfill, copying the `announce_jobs → announce_job_legs` precedent. - **`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 | Question | Decision | |---|---| | how much of §3.1's registry lands now | the **declarative half** only | | push's `defaultMode` | **`off`** — the acceptance line said `instant`; it could not be | | whole-set PUT or sparse | **sparse**, on the `(id, channel)` pair | **The phase's own acceptance criterion was wrong, and it is worth stating how.** "A fresh user's email mode defaults `off` and push defaults `instant`" reads naturally beside §3.1's "email opt-IN, push opt-OUT" — and §3.1 borrowed that from `team_notification_prefs`, where no row genuinely *does* mean notified. But push **stream** subscriptions have never worked that way: `notification_subscriptions` holds a row only when a user opted in. A `defaultMode` of `instant` 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. 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 **`off` becomes a mode rather than an omission**, which means this endpoint has *no empty-array case at all*, so the kotlinx gotcha `putTeamPrefs` had to document simply cannot arise here. ## The projection, as an invariant `notification_subscriptions` stays exactly what `utils/pushDispatch` reads. Both endpoints maintain: **a `push` pref with `mode <> 'off'` ⟺ a `notification_subscriptions` row** — the legacy PUT with a whole-set sweep, the channels PUT one pair at a time. An explicit `off` is *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 `staff` can never reach a non-staff user, so offering the toggle is offering a dead control **and** disclosing the event exists — `uo.cheat.detected` would 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. `members` is 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** in `test/notificationChannelPrefs.test.js`. (One pre-existing Windows-only failure in `engagementManifest.test.js` is a CRLF checkout artifact under `core.autocrlf=true`; it fails identically on a clean `edge` and is green on CI's Linux.) - `npm run swagger`, `npm run routes:manifest` regenerated (with `modules/uo` moved 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. --- - [x] AI-assisted: written with Claude Code (Opus) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-29 12:09:06 +00:00
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
b13ffd584f
`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>
whitlocktech merged commit 447c9113d3 into edge 2026-08-29 12:12:03 +00:00
whitlocktech deleted branch feature/engagement-channel-prefs 2026-08-29 12:12:04 +00:00
Sign in to join this conversation.
No description provided.