feat(teams): the web surface — a notifications screen that did not exist

This is phase 6's first finding, and it changed the phase's shape.

TEAMS.md §6.3 says the per-Team mute list is surfaced "under the existing
notification settings screen". There was no such screen. `/auth/me/notifications/*`
was built for the Android app in M7 and had ZERO web consumers — a browser could
not see the stream catalog or its own subscriptions at all. That is tolerable
while push is the only sink, because push needs the app anyway. It is not
tolerable for email, whose entire argument is the web-only user who runs neither
the app nor Discord, so the sink and the screen to configure it had to ship
together.

`/account/notifications` carries all three: what to be told about, which Teams,
and whether any of it reaches a mailbox — in the order a user actually reasons
about them.

The mute toggle goes in a THIRD module-declared slot, above the roster, because
muting is an action ON the guild page while the feed and forum are content IN it.
It renders nothing for a viewer with no preference available, which is a privacy
property rather than a tidiness one: whether a preference EXISTS for a Team
answers "is this person in it", and the guild page is public.

`/unsubscribe/:token` is public and POSTs on mount — the link the user clicked was
a GET, and a GET that mutated would be triggered by every mail-client link scanner.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:35:08 -05:00
parent 2a56cbf22a
commit b458c1f46f
8 changed files with 563 additions and 0 deletions

View File

@@ -187,6 +187,26 @@ export const api = {
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
teamGrantRevoke: (slug, userId) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
// ----- notifications (TEAMS.md Part 6) -----
//
// Under /auth/me rather than /player: these are role-agnostic self-service, the
// same rule that put the forum under /player rather than behind a staff gate.
// The streams catalog and the per-stream subscriptions were built for the app
// and had no web consumer at all until phase 6 gave them one.
notificationStreams: () => req('/auth/me/notifications/streams'),
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
// `streams` is always sent, empty array included — the endpoint requires the
// field, so clearing the last subscription must not become an absent key.
setNotificationSubscriptions: (streams) =>
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
setTeamNotificationPrefs: (teams) =>
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
// Unauthenticated, and the one write in the public tier: the caller is reading
// their mail, not signed in. Always resolves 200 whatever the token was.
unsubscribeTeam: (token) =>
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link