Files
website/server/src/model/teams/teamNotify.model.js
wtclaude 13312d7fc3
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 8m53s
fix(teams): make "replace the whole set" actually replace it
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>
2026-08-18 18:01:59 -05:00

147 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Per-Team notification preferences, and the recipient sets built from them
// (TEAMS.md §6.2§6.4, phase 6).
//
// **The absence of a row is the default, and the two sinks default OPPOSITE ways.**
// Push is opt-out: a user in one Team must never have to configure anything to be
// tickled about it, and the per-Team mute is how they stop. Email is opt-IN
// (`email_mode` defaults to `'off'`, deviating from §6.4 on the org lead's call):
// turning on Gmail in the admin panel must not start sending daily mail to every
// member of every Team on the deployment.
//
// Both are read the same way — COALESCE to the column default, never treat a
// missing row as "unknown" — so the asymmetry lives in ONE place, the schema, and
// not in a condition anybody has to remember.
//
// **This file never decides who may READ a Team.** It asks the same two tables
// teamAccess.forumAccess() asks, in one query, because a fan-out cannot afford a
// round trip per recipient — but it asks them for the same answer. If the access
// rule ever changes, both must; the SQL in teamNotify.db.js says so at the union
// it builds on, and the test that matters is the one asserting a revoked guest
// receives nothing.
const db = require('./teamNotify.db')
// Stored as an ENUM, restated here because a value arriving from a request body
// must be checked against something in JavaScript before it reaches the column —
// a bad value would otherwise be a 500 from the driver rather than a 400 from us.
const EMAIL_MODES = ['off', 'digest', 'immediate']
const isEmailMode = (v) => EMAIL_MODES.includes(v)
function publicPref(row) {
return {
teamId: Number(row.team_id),
slug: row.slug,
// The same `display_name_override || name` rule every other Team surface
// uses (§2.8.3). A notification screen showing the raw name would show a name
// staff have deliberately replaced everywhere else.
name: row.display_name_override || row.name,
archived: row.team_status === 'archived',
muted: Boolean(Number(row.muted)),
emailMode: row.email_mode,
}
}
/** Every Team this user could be notified about, with its current preference. */
async function listPrefs(userId) {
return (await db.prefsForUser(userId)).map(publicPref)
}
/** One Team's preference for one user, defaults applied. Never null. */
async function prefFor(userId, teamId) {
const row = await db.prefFor(userId, teamId)
return {
teamId: Number(teamId),
muted: Boolean(row && Number(row.muted)),
emailMode: (row && row.email_mode) || 'off',
}
}
/**
* Replace this user's whole set of Team preferences.
*
* PUT-the-whole-set, matching the existing subscription endpoint, and the
* Android gotcha carried forward from `docs/android/PLAN.md` §11 applies to the
* ROUTE rather than to this function: the array is required even when empty.
*
* **A preference may only be written for a Team the caller is actually in.** The
* ids are checked against `listPrefs`, not trusted from the body — otherwise any
* authenticated user could write a row naming any Team, which is a (small) write
* primitive into a table keyed by someone else's private membership. Unknown ids
* are dropped rather than 400'd: a Team the user left between loading the screen
* and saving it is an ordinary race, not a client bug.
*/
async function replacePrefs(userId, entries) {
const allowed = new Map((await listPrefs(userId)).map((p) => [p.teamId, p]))
const written = []
for (const entry of entries) {
const teamId = Number(entry && entry.teamId)
if (!allowed.has(teamId)) continue
const emailMode = isEmailMode(entry.emailMode) ? entry.emailMode : 'off'
// eslint-disable-next-line no-await-in-loop
await db.setPref(userId, teamId, { muted: Boolean(entry.muted), emailMode })
written.push(teamId)
}
// A Team the caller COULD have named and did not is returned to its defaults.
//
// Without this, "replace the whole set" was a lie the endpoint told: omitting an
// entry left the old preference standing, which made `teams: []` — the body the
// route requires precisely so that clearing everything is expressible — clear
// nothing at all.
//
// Reset rather than deleted, and the difference is `last_digest_at`. That column
// is the digest worker's state, not a preference; 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 already read.
for (const teamId of allowed.keys()) {
if (written.includes(teamId)) continue
// eslint-disable-next-line no-await-in-loop
await db.setPref(userId, teamId, { muted: false, emailMode: 'off' })
}
return { written, prefs: await listPrefs(userId) }
}
/**
* Mute one Team for one user — the one-click unsubscribe's only effect.
*
* Deliberately narrow. The unsubscribe link is reached without a session, so what
* it can do is what an attacker holding a leaked link can do: silence one Team's
* notifications for one account, visibly and reversibly on the account screen.
* It writes no other column, and there is no "unsubscribe from everything".
*/
async function mute(userId, teamId) {
await db.setPref(userId, teamId, { muted: true })
}
/** Un-mute, for the toggle's other half. */
async function unmute(userId, teamId) {
await db.setPref(userId, teamId, { muted: false })
}
module.exports = {
EMAIL_MODES,
isEmailMode,
listPrefs,
prefFor,
replacePrefs,
mute,
unmute,
// Recipient sets, passed through so callers depend on the model rather than on
// the SQL. The fan-out in utils/teamNotify.js and the digest worker are the only
// callers.
//
// Wrapped rather than re-exported (`recipientIds: db.recipientIds`), which is
// the obvious shorter form and is wrong: that captures the function OBJECT at
// require time, so the layer below can never be substituted afterwards — which
// makes the db layer untestable in isolation and, more to the point, means the
// model is not really the seam it claims to be. These resolve `db.x` at call
// time, so the boundary is real.
recipientIds: (teamId, opts) => db.recipientIds(teamId, opts),
emailRecipients: (teamId, opts) => db.emailRecipients(teamId, opts),
stampDigest: (userId, teamId, at) => db.stampDigest(userId, teamId, at),
teamsWithForumActivitySince: (since) => db.teamsWithForumActivitySince(since),
digestPostsSince: (teamId, since, limit) => db.digestPostsSince(teamId, since, limit),
}