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:
2026-08-29 07:09:16 -05:00
parent 6c19a0608a
commit 7b7a26f0ac
3 changed files with 180 additions and 11 deletions

View File

@@ -1250,6 +1250,67 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.
### Per-channel preferences — the superset endpoint (engagement phase 3, 2026-08-29)
Push is no longer the only channel a preference can name. `docs/website/ENGAGEMENT.md` phase 3 added
`notification_channel_prefs` and, with it, `GET · PUT /auth/me/notifications/channels`.
**Nothing above changed.** `/notifications/streams` and `/notifications/subscriptions` keep their
exact wire shapes, including the `{"streams":[]}` gotcha, and the shipped APK needs no update to keep
working — `notification_subscriptions` is now the **push projection** of the new table, and every
write to either fans out to the other. That was the acceptance criterion the phase was built against,
with the empty-array case tested explicitly.
**What the new endpoint adds, for whenever the app adopts it:**
```jsonc
// GET /auth/me/notifications/channels
{
"channels": [ // the delivery-channel registry
{ "id": "push", "label": "Push", "carriesContent": false,
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] },
{ "id": "email", "label": "Email", "carriesContent": true,
"defaultMode": "off", "supportsDigest": true, "modes": ["off", "instant", "digest"] },
{ "id": "inapp", "label": "On the site", "carriesContent": true,
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] }
],
"items": [ // every subscribable id, streams AND triggers
{ "id": "news.post", "label": "News posts", "description": "…",
"personal": false, "requiresLinkedAccount": false, "ceiling": "authenticated",
"channels": ["push", "email", "inapp"],
"modes": { "push": "instant", "email": "off", "inapp": "off" } }
]
}
```
Four properties the UI should be built on rather than around:
- **`items` is the union of streams and triggers**, one namespace. An id can be a push stream, an
event trigger with a payload contract, or both. A trigger-only id (`uo.house.idoc_warning`) carries
no `push` in its `channels` and no `push` key in `modes` — there is nothing registered to push it —
so **render the toggles from `channels`, never from a hardcoded three**.
- **`modes` is the *effective* mode, not the stored one.** Where the user has expressed nothing, the
server substitutes that channel's `defaultMode`. The client never has to know which it is looking
at, and must not re-implement the defaulting.
- **The PUT is sparse, and this is the one place it diverges from every other `/auth/me` PUT.** Send
only the pairs you changed: `{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}`.
Everything not named is left alone, so the notifications screen can save one toggle without holding
the whole table. `off` is a mode, never an omission — **so the empty-array gotcha above does not
apply here at all**: there is no "clearing the last one" case, because turning something off is a
row like any other. `prefs` is still required, so a DTO field with no default is still the right
shape.
- **Entries the server cannot accept are dropped, not refused** — an unknown id, a channel that does
not apply to that id, a `digest` on a channel that cannot batch. The response is the full stored
state, so re-render from it rather than assuming the request took.
**One id may be missing from `items` that the app expects.** A trigger whose declared audience
`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it
would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged.
**Phase 8** (`ENGAGEMENT.md`) is where the app grows the in-app inbox and this screen gains the
per-channel toggles. Until then the existing per-stream screen keeps working against
`/notifications/subscriptions` unmodified.
## 12. Build & CI (Gitea Actions)
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other