feat(teams): phase 6 — notifications, and the email sink the web never had #156
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/teams-phase6-notifications"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Teams phase 6, per
docs/website/TEAMS.mdPart 6. Intoedge.Companion PRs: Module-uo#13 (the third slot +
pageUrlTemplate) and docs#159.What lands
Four core streams,
publishToUsers+endpointsForUsersStream, the recipient computation,team_notification_prefs, a settings screen, and email as the third sink with digest mode and one-click unsubscribe.Team scoping lives in the recipient set, never in a stream id. The push catalog is a static registration validated at boot against a namespaced pattern, so a stream per Team is unexpressible, and stream ids live in
notification_subscriptionsrows that a per-Team id would leave behind every time a Team archived. One recipient query serves all four streams, because §6.2's two populations are the same set written twice — "active members with auser_idplus active grants" is "everyone with resolved forum access". Mutes are subtracted in SQL; there is no function that returns an unfiltered set.Four deviations from the design of record
Each is in the as-built header on Part 6 rather than a rewrite, so the original reasoning stays legible.
/auth/me/notifications/*was built for the app in M7 and had zero web consumers. Survivable while push was the only sink; not survivable for email, whose whole argument is the web-only user. So the sink and/account/notificationsshipped together.off, notdigest(org lead): digest-by-default would start mailing every member of every Team the moment an operator connects Gmail. Push stays opt-out. The two sinks default opposite ways and the asymmetry lives only in the schema.MODULE_API_VERSION1.6.0 —pageUrlTemplate. Phase 3 left core with no Team page and so no way to link to one; an email could name a Team and not take you to it. In place rather than 1.7.0, under the phase-2 rule (a contract owes a bump only once it has landed onmain).Two build decisions worth reading
The digest computes at send time and keeps no queue. Only
last_digest_at. A deployment down for two days sends one correct digest, not a replay; a post a moderator hid is 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 — that one would have been a security bug.sinceis clamped to seven days, and the stamp is written only on a successful send.Unsubscribe is a stateless HMAC, not a token table. Every property that makes a reset token a row is absent. Its whole capability is setting
mutedfor one (user, Team) pair. Two URLs come out of one token — the body's page and theList-Unsubscribeheader's endpoint — because RFC 8058 one-click POSTs without rendering anything. GET on the API path redirects and does not act, or a mail-client link scanner would mute Teams nobody asked to leave. It answers200whatever the token was: distinguishing a forgery would be an oracle for which (user, Team) pairs exist.Defects found while building
Number(null)is0, and0is an integer — a null in an id list survivedfilter(Number.isInteger)and rode into anINclause as user id 0. Harmless today, which is why it would never have been noticed. Fixed in all three filters, caught by a test.recipientIds: db.recipientIdscaptured the function object at require time, so the layer below could never be substituted — the model was not the seam it claimed to be. Wrapped to resolve at call time.PUTdid not actually replace the set — it wrote the entries given and left the rest standing, so{"teams": []}cleared nothing, which is the one thing the mandatory-empty-array rule exists for. Found on the live rig; invisible to unit tests and to the screen, both of which send every row. Now resets omitted Teams to defaults without touchinglast_digest_at.Verification
npm test— 1066 server, 248 client, both green.npm run swaggerandnpm run routes:manifest -- --checkcommitted and clean.GET/PUTprefs; an entry naming a Team the caller is not in dropped; the empty set clearing; unsubscribe POST muting, GET redirecting without acting, and a forged token indistinguishable; and the real recipient SQL returning the right sets — a mute honoured, a departed member and a revoked guest excluded, an unlinked member contributing no row, and a grant-only guest with no game account correctly included./account/notificationsand the guild-page mute toggle were confirmed only by their presence in the built bundles and by their endpoints answering. The core-content-inside-a-module's-page seam is exactly what unit tests cannot see — worth a look before merge, as phase 4's panel was.AI-assisted: written with Claude Code.
🤖 Generated with Claude Code
Found walking the live rig, which is the only place it could be found: every unit test and the settings screen itself send every row, so the bug was invisible to both. `PUT /auth/me/notifications/teams` documents itself as replacing the whole set. It did not — it wrote the entries it was given and left every other preference standing. So `{"teams": []}` cleared nothing, which is precisely the body the route requires the array for: the field is mandatory even when empty so that clearing everything is expressible, and it was the one thing that did not work. A Team the caller could have named and did not now returns to its defaults. RESET rather than deleted, and the difference is `last_digest_at`: that column is the digest worker's state and not a preference, so dropping the row with it would make every visit to the settings screen re-open a day-wide digest window and mail somebody a summary they had already read. Walked again after the fix on the real database: the empty set clears, an entry naming a Team the caller is not in is still dropped, and the digest stamp survives. Co-Authored-By: Claude <noreply@anthropic.com>