docs(teams): phase 6 as built — four deviations and a ninth contract member

Part 6 gains an as-built header rather than a rewrite, so the reasoning that
produced the original design stays legible beside what the build learned.

Four deviations. There was no web notification settings screen to add the Team
list to — `/auth/me/notifications/*` was built for the app in M7 and had zero web
consumers, which is survivable for push and not for a sink whose whole argument is
the web-only user. Email defaults to `off` rather than `digest`, on the org lead's
call: digest-by-default would start mailing every member of every Team the moment
an operator connects Gmail. Roster events tickle but do not email. And a ninth
member joined MODULE_API 1.6.0.

`pageUrlTemplate` is the member, and it exists because phase 3 left core with no
Team page and therefore no way to link to one. It joins 1.6.0 in place under the
rule set in phase 2 — a contract owes a bump only once it has landed on `main`,
and 1.6.0 has only ever been on `edge`.

Two further build decisions are recorded where they belong: the digest computes at
send time and keeps no queue (§6.4), and one-click unsubscribe is a stateless HMAC
whose whole capability is muting one (user, Team) pair (§6.4).

BACKEND_DESIGN gains the table, the two `/auth/me` routes and the unsubscribe
endpoint — the only write in the public tier and the only route with no `siteMode`,
because the mail went out before the site went into maintenance.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:35:49 -05:00
parent 04eb859a48
commit 5e284d5468
3 changed files with 132 additions and 16 deletions

View File

@@ -1487,6 +1487,35 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads (
## Part 6 — Notifications
> **Built 2026-08-18 (phase 6).** As-built, and it deviates from what is written below in four
> places. Each is recorded here rather than by rewriting the section, so the reasoning that produced
> the original design stays legible next to what the build learned:
>
> 1. **There was no web notification settings screen to add the Team list to.** §6.3 says the per-Team
> mute list is surfaced "under the existing notification settings screen". No such screen existed:
> `/auth/me/notifications/*` had been built for the Android app in M7 and had **zero** web
> consumers. Tolerable while push was the only sink — push needs the app anyway. Not tolerable for
> email, whose entire argument (§6.4) is the web-only user, so the sink and the screen to configure
> it shipped together as `/account/notifications`.
> 2. **Email defaults to `off`, not to `digest`.** §6.4 specifies digest-by-default; on the org lead's
> decision it is opt-IN, because digest-by-default means every member of every Team starts
> receiving daily mail the moment an operator connects Gmail — a decision about other people's
> inboxes, made on their behalf. **Push stays opt-out.** The two sinks now default opposite ways;
> the asymmetry lives in the schema's column defaults and nowhere else.
> 3. **Roster events do not email.** All four streams exist and all four tickle. Only the two forum
> streams reach the email sink: §6.4's argument is the reply nobody hears about, and "someone
> joined the guild" arrives from a fifteen-minute sweep, is already on the activity feed, and is
> how a notification feature earns a spam complaint.
> 4. **A ninth member joined `MODULE_API_VERSION` 1.6.0** — `pageUrlTemplate` on the team provider.
> Phase 3 left core with no Team page and therefore no way to *link* to one, so an email could name
> a Team and not take you to it. The module that owns the page now says where it is. See
> [`MODULE_API.md`](MODULE_API.md) `registerTeamProvider`.
>
> Two further build decisions, neither contradicting anything above: the digest **computes at send
> time** and keeps no queue (§6.4 as-built, below), and one-click unsubscribe is a **stateless
> HMAC** rather than a token table.
### 6.1 What the existing pipeline gives us, and the one thing it does not
Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the
@@ -1556,9 +1585,23 @@ CREATE TABLE IF NOT EXISTS team_notification_prefs (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
Applied as a subtraction from the computed recipient set. Surfaced as a mute toggle on the Team page
and as a list under the existing notification settings screen (`GET|PUT
/auth/me/notifications/teams`), which the Android app can adopt without a new screen concept.
Applied as a subtraction from the computed recipient set**in SQL, not in the caller**: there is no
function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient list, because one
would be a refactor away from being used.
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
places:
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
array is required on PUT even when empty, per the Android gotcha below.
- **A mute toggle on the Team page**, filled into a THIRD module-declared slot, `uo.guild.header`.
Above the roster rather than below it, because muting is an action *on* the page and the feed and
forum are content *in* it — which is exactly the placement decision a module cannot make if core
stacks everything into one fill. It renders nothing for a viewer with no preference row available,
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
"is this person in it", and the guild page is public.
### 6.4 Email — the third sink, already built and unused
@@ -1574,13 +1617,43 @@ same event**, not a fourth pipeline.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
- **Digest, not per-event, by default.** A busy Team forum sending one email per reply is how a
notification feature gets marked as spam. Default to a daily digest per Team with an immediate
option, stored in `team_notification_prefs` as a `email_mode ENUM('off','digest','immediate')`
column.
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
`team_notification_prefs`.
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
> has.
- **The digest computes at send time and keeps no queue** (as built). The only state is
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
hid after it was written is simply not in the query; and **a user who lost forum access between the
post and the send is no longer in the recipient set**, so they are not emailed content they can no
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
eat a day of somebody's notifications every time the mail provider had a bad minute.
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
writes the preference the site shows.
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
> once — and it exists before it is needed rather than after.
>
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
> session behind it.
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
is already being built there.
@@ -2390,12 +2463,17 @@ window later.
Team's own leader, to the reporting member and to every other participant; handling it changes the
report's status and **nothing at all** about the content.
### Phase 6 — Team notifications (`website`)
### Phase 6 — Team notifications (`website` + `module-uo`) — **DONE 2026-08-18**
Four core streams, `publishToUsers` + `endpointsForUsersStream`, the recipient computation,
`team_notification_prefs`, its settings screen, and **email as the third sink** (§6.4) with digest
mode and one-click unsubscribe.
**TWO repos, not the plan's one.** `module-uo` joined for two lines it alone can supply: a third
declared slot (`uo.guild.header`, for the mute toggle) and `pageUrlTemplate` on its team provider,
without which core cannot write a link to a Team page at all — see the four amendments at the head of
[Part 6](#part-6--notifications).
**Android is deliberately not in this phase** — see the deferred note in
[`../android/PLAN.md`](../android/PLAN.md). The streams exist in the catalog and the app will show
them as toggles automatically, but nothing here builds a Team screen or a deep-link target for the