docs(website): per-channel notification preferences (engagement Phase 3)
The documentation §6.0b assigns Phase 3 — `BACKEND_DESIGN.md`'s route table and
`android/PLAN.md` §11 — plus the phase's as-built section and one correction the
build forced.
`ENGAGEMENT.md`:
- Phase 3's as-built: the three decisions settled before any code, why the
channel registry could not wait for Phase 6, what the sparse PUT buys, the
projection stated as an invariant, and the staff-ceiling filter.
- **The phase's own acceptance line was wrong and is struck through.** "A fresh
user's … push defaults `instant`" reads naturally beside §3.1's "email opt-IN,
push opt-OUT", but §3.1 borrowed that from `team_notification_prefs`, where no
row genuinely does mean notified. Push STREAM subscriptions have never worked
that way — `notification_subscriptions` holds a row only on opt-in — so
`instant` would have projected the entire catalog into the legacy GET for
every existing user. §3.1's comment is corrected in the same pass.
`BACKEND_DESIGN.md`: the `/me/notifications/channels` row, the
`notification_channel_prefs` table entry (including that absence means the
channel's default rather than `off`, and that all three agreeing on `off` today
is a fact about the declarations and not about the table), and a note on
`notification_subscriptions` that it is now the push projection.
`android/PLAN.md` §11: nothing above it changed — the shipped APK keeps working
and the `{"streams":[]}` gotcha still applies to that endpoint. The new section
documents the superset endpoint for whenever the app adopts it: render toggles
from each item's `channels` rather than a hardcoded three, `modes` is the
effective mode and the client must not re-implement the defaulting, the PUT is
sparse so the empty-array gotcha does NOT apply here, and one id may be missing
that the app expects (a `staff`-ceilinged trigger is not offered to a non-staff
caller).
Companion to website#169.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
# The Engagement System — findings and plan
|
||||
|
||||
**Status:** design of record. **Phases 1, 1a, 1b and 2 are built** (Phase 1: website#165 + docs#178,
|
||||
with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b: website#167 +
|
||||
docs#180); everything from Phase 3 on is still design. The scope decisions below are settled; **six of
|
||||
the eight questions in §7.1 are answered** — Q1, Q3, Q5 and Q7 on 2026-08-28, and Q6 on 2026-08-29 at
|
||||
the start of Phase 2, which also settled §7.2's namespace question. Q1's answer added a whole phase
|
||||
(**Phase 1b**, unique email addresses). **Q2, Q4 and Q8** remain open and block Phases 4, 5b and 8
|
||||
respectively. Per CLAUDE.md § Conventions, no implementation starts without the org lead's approval of
|
||||
the phase it belongs to.
|
||||
**Status:** design of record. **Phases 1, 1a, 1b, 2 and 3 are built** (Phase 1: website#165 +
|
||||
docs#178, with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b:
|
||||
website#167 + docs#180; Phase 2: website#168 + docs#181); everything from Phase 4 on is still design.
|
||||
The scope decisions below are settled; **six of the eight questions in §7.1 are answered** — Q1, Q3,
|
||||
Q5 and Q7 on 2026-08-28, and Q6 on 2026-08-29 at the start of Phase 2, which also settled §7.2's
|
||||
namespace question. Q1's answer added a whole phase (**Phase 1b**, unique email addresses). **Q2, Q4
|
||||
and Q8** remain open and block Phases 4, 5b and 8 respectively. Per CLAUDE.md § Conventions, no
|
||||
implementation starts without the org lead's approval of the phase it belongs to.
|
||||
|
||||
**Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover
|
||||
(Phase 13). §6.0a records the blocking precondition — six `edge` branches are stale and two repos have
|
||||
@@ -551,7 +551,8 @@ registerDeliveryChannel({
|
||||
id: 'email', // 'email' | 'push' | 'inapp' | later 'discord.dm'
|
||||
label: 'Email',
|
||||
carriesContent: true, // false for push — enforces the tickle invariant structurally
|
||||
defaultMode: 'off', // email opt-IN, push opt-OUT — G9, expressed here once
|
||||
defaultMode: 'off', // G9, expressed here once. All three are opt-IN as built —
|
||||
// 'push opt-OUT' was wrong; see Phase 3's as-built
|
||||
supportsDigest: true, // in-app and push are instant-only in v1
|
||||
addressFor(userId), // → [{ address, meta }] ; email reads users.email, push reads push_devices
|
||||
render(template, vars, ctx), // → the channel's own payload shape
|
||||
@@ -1650,7 +1651,7 @@ engine, and this is what it migrates onto.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Channel preferences
|
||||
### Phase 3 — Channel preferences ✅
|
||||
|
||||
`notification_channel_prefs` + the idempotent backfill from `notification_subscriptions`. New
|
||||
`GET·PUT /auth/me/notifications/channels`. **`/auth/me/notifications/subscriptions` keeps its exact wire
|
||||
@@ -1658,11 +1659,87 @@ shape** and becomes the push projection — writes fan out to both.
|
||||
|
||||
**Acceptance:** the shipped Android app's flat `{streams:[…]}` PUT still round-trips, including the
|
||||
empty-array case the app's DTO comment warns about; a per-channel PUT sets `email` without touching
|
||||
`push`; a fresh user's email mode defaults `off` and push defaults `instant` (§4.5's `defaultMode`).
|
||||
`push`; a fresh user's email mode defaults `off` and ~~push defaults `instant`~~ **push defaults
|
||||
`off` too** (§4.5's `defaultMode` — the struck text was wrong; see the as-built below).
|
||||
**Guardrails:** swagger + route manifest; a test pinning the legacy wire shape byte-for-byte.
|
||||
|
||||
---
|
||||
|
||||
#### As built (2026-08-29)
|
||||
|
||||
**Three decisions were settled by the org lead before any code, and one of them corrects this
|
||||
phase's own acceptance criterion.**
|
||||
|
||||
| | Question | Decision |
|
||||
|---|---|---|
|
||||
| — | how much of §3.1's `registerDeliveryChannel` lands now | **the declarative half only** — id, label, `carriesContent`, `defaultMode`, `supportsDigest`. `addressFor` / `render` / `deliver` wait for the phases that can exercise them |
|
||||
| — | push's `defaultMode` | **`off`.** The acceptance line below said `instant`; it could not be |
|
||||
| — | whole-set PUT or sparse | **sparse**, on the `(id, channel)` pair — deliberately unlike the two whole-set PUTs either side of it |
|
||||
|
||||
**The acceptance line was wrong, and it is worth saying exactly 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 got 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, so no row means not subscribed. 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. It is a live behaviour change dressed as a default. All three channels declare
|
||||
`off`, and a test asserts the legacy GET returns `{streams:[]}` for a fresh user so it cannot drift
|
||||
back.
|
||||
|
||||
**Why the channel registry could not wait for Phase 6.** §3.1 says `defaultMode` is expressed once,
|
||||
and reading a preference means knowing it — a row exists only where a user has said something. The
|
||||
alternative was a constant list beside the prefs model, i.e. that expression in a second place, two
|
||||
phases before the registry replaced it. What did *not* land is the behavioural half: registering a
|
||||
`deliver` nothing calls freezes a signature before anything has tried to use it, which is the reason
|
||||
`transports/index.js` deferred the whole file in Phase 1. Core's three channels are declared, and
|
||||
`inapp` is declared `off` for a reason particular to it — the inbox does not exist until Phase 7, and
|
||||
a default of `instant` would mean every user is opted into a surface with no rows, so the first thing
|
||||
Phase 7 shipped would be a backlog.
|
||||
|
||||
**The sparse PUT is the one place this phase leaves the router's idiom, and it buys two things.** 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 (a defaulted array field is dropped from
|
||||
the body, and "clear the last one" arrives as no array) simply cannot arise here. `prefs` is still
|
||||
required, so a request DTO with no default is still the right shape on the app side.
|
||||
|
||||
**The projection, stated as an invariant.** `notification_subscriptions` stays exactly what
|
||||
`utils/pushDispatch` reads, so this phase touches no delivery path at all. 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 landed that the phase did not name, and it is a G24 consequence rather than scope creep.**
|
||||
A trigger whose ceiling is `staff` can never reach a non-staff user, so offering them a toggle is
|
||||
offering a control that does nothing *and* disclosing that the event exists — `uo.cheat.detected`
|
||||
would otherwise appear by name in every player's preferences screen the moment Phase 11 declared it.
|
||||
It is 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 gave `ceilings.js` its first consumer for the `staff` label's
|
||||
long-standing claim of "admin / editor / moderator", now written down as `STAFF_CEILING_ROLES` — and
|
||||
deliberately **not** `teamGrants.STAFF_ROLES` (`['admin','moderator']`), which answers the different
|
||||
question of who may act on a Team they are not in.
|
||||
|
||||
**What landed:**
|
||||
|
||||
- `server/src/engagement/channels.js` — `registerDeliveryChannel`, `MODES`, `defaultMode`, `modesFor`,
|
||||
`acceptsMode`; `coreChannels.js` declares push / email / inapp, registered 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
|
||||
- `model/notificationChannelPrefs/` — the catalog union, effective-mode resolution, the sparse apply,
|
||||
and `mirrorPushSet` for the legacy path; two single-row helpers on `notificationSubs.db`
|
||||
- `GET · PUT /auth/me/notifications/channels`, swagger schemas, route manifest
|
||||
- `ceilings.STAFF_CEILING_ROLES` / `isStaffRole`
|
||||
|
||||
**Left for later, deliberately:** no web or app surface. The endpoint exists and is documented
|
||||
(`../android/PLAN.md` §11); the screens are Phase 7 (web) and Phase 8 (app), which is where a user can
|
||||
see something a preference actually governs.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — The engine: rules, cooldowns, outbox
|
||||
|
||||
`engagement_rules`, `engagement_cooldowns`, `engagement_outbox`, `engagement_sends`, the sweep worker
|
||||
|
||||
Reference in New Issue
Block a user