// 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) } 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), }