From 7b7a26f0ac348fa37815f9e94812663515158ce5 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 07:09:16 -0500 Subject: [PATCH] docs(website): per-channel notification preferences (engagement Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/PLAN.md | 61 ++++++++++++++++++++++++ website/BACKEND_DESIGN.md | 31 ++++++++++++ website/ENGAGEMENT.md | 99 ++++++++++++++++++++++++++++++++++----- 3 files changed, 180 insertions(+), 11 deletions(-) diff --git a/android/PLAN.md b/android/PLAN.md index bbae2c2..2e091ea 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -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 diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 6781145..2c57745 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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 diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 5fd7eab..9739c3d 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -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