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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user