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>
147 lines
6.4 KiB
JavaScript
147 lines
6.4 KiB
JavaScript
// 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),
|
||
}
|