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:
@@ -461,6 +461,36 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
|
||||
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
||||
replaces the whole set. Nothing is pushed unless the user subscribed.
|
||||
|
||||
Engagement phase 3 made this the **push projection** of `notification_channel_prefs` below. It keeps
|
||||
its exact shape and stays what `utils/pushDispatch` reads — the shipped Android client cannot be
|
||||
changed from this side — and the general table carries the channel dimension it lacks.
|
||||
|
||||
### notification_channel_prefs — which channel, in which mode (engagement phase 3)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||
| stream_id | VARCHAR(64) NOT NULL | a stream id **or** a trigger id — **one namespace** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.2), which is what keeps this key single-column |
|
||||
| channel | VARCHAR(32) NOT NULL | `email` / `push` / `inapp`, from the delivery-channel registry (`src/engagement/channels.js`) |
|
||||
| mode | ENUM('off','instant','digest') NOT NULL DEFAULT 'off' | `digest` only where the channel declares `supportsDigest` |
|
||||
| updated_at | DATETIME | |
|
||||
|
||||
`PRIMARY KEY(user_id, stream_id, channel)`, `INDEX(channel, mode)`.
|
||||
|
||||
**A row exists only where the user has expressed something, and absence is the *channel's* default,
|
||||
not `off`.** That default lives in the channel registry and nowhere else (§3.1, G9: push, email and
|
||||
in-app do not agree on it). All three currently declare `off`, so absence and off happen to coincide
|
||||
today — a fact about the declarations, not about this table, and code must not assume it. The column
|
||||
`DEFAULT` is the value a write with no mode takes, not the meaning of a missing row.
|
||||
|
||||
**It is a superset of `notification_subscriptions`, which becomes its push projection.** The shipped
|
||||
Android client's wire shape is frozen (`{streams:[…]}`), so the old table stays exactly what
|
||||
`utils/pushDispatch` reads and every write to either fans out to the other. The invariant both
|
||||
directions maintain: **a `push` row with `mode <> 'off'` ⟺ a `notification_subscriptions` row.** An
|
||||
explicit `off` is *stored* rather than deleted — folding "I turned this off" back into "I never said"
|
||||
is only harmless while the default is off. Existing subscriptions are carried across by an
|
||||
`INSERT IGNORE … SELECT` backfill in `schema.sql`, replay-safe on every boot like the
|
||||
`announce_jobs → announce_job_legs` one it copies.
|
||||
|
||||
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
|
||||
|
||||
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
|
||||
@@ -837,6 +867,7 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
|
||||
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
||||
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
||||
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
|
||||
| GET · PUT | `/me/notifications/channels` | cookie / bearer | `{prefs:[{id,channel,mode}]}` on PUT | get / update own **per-channel** preferences ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5, phase 3). Returns the delivery-channel registry (`email`/`push`/`inapp`, each with `defaultMode`, `supportsDigest`, `modes`) plus one item per subscribable id — the **union** of push streams and event triggers, one namespace (§7.2) — carrying the **effective** mode on each channel that applies to it. A trigger-only id has no `push` toggle; a mode with no stored row reads as that channel’s default, so a client never sees which is which. The PUT is **sparse**: only the `(id, channel)` pairs listed are written and every other pair is untouched, so setting `email` cannot disturb `push`. `off` is a mode, never an omission — which is why this endpoint has no required-empty-array case. Entries naming an unknown id, an inapplicable channel or a mode that channel does not accept are **dropped, not refused**; the full stored state is echoed back. A `push` entry is mirrored into `/me/notifications/subscriptions`, whose wire shape is unchanged |
|
||||
| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
|
||||
|
||||
**Role-agnostic self-service (`/auth/me/*`).** The **only** self-service account surface, for every
|
||||
|
||||
Reference in New Issue
Block a user