// SQL for Team notification recipients and per-Team preferences (TEAMS.md Part 6). // // **The recipient set is the whole of Team scoping.** The four `team.*` streams // are global and carry no Team in their id; who an event reaches is decided here. // That is §6.2's design and it is not an optimisation — the push catalog is a // static registration validated at boot, so a stream per Team is unexpressible, // and stream ids live in `notification_subscriptions` rows that a per-Team id // would leave behind every time a Team archived. // // **One recipient query serves all four streams**, because the two populations in // §6.2's table are the same set written twice: "active members with a user_id, // plus active forum grants" IS "everyone with resolved forum access", by the // definition of teamAccess.forumAccess() (membership OR grant). What differs // between the streams is only who is subtracted — the author of the post that // caused it — and that is a caller's argument, not a second query. // // **Mutes are subtracted in SQL, not in the caller.** A recipient list that came // back complete and was filtered afterwards would be one refactor away from being // used unfiltered; there is no function here that returns an unmuted set. const { query } = require('../../utils/db') // The union, as a derived table both recipient functions build on. Written once // so that "who is in a Team for notification purposes" has exactly one definition. // // `status = 'active'` on the membership half and `revoked_at IS NULL` on the // grant half are the same two conditions the access resolver uses; a departed // member and a revoked guest are both people who could still be read a private // forum by a query that forgot one. const RECIPIENT_UNION = ` SELECT user_id FROM team_members WHERE team_id = ? AND status = 'active' AND user_id IS NOT NULL UNION SELECT user_id FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL` // `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an // integer, so a null slipping into a caller's list would become user id 0 and // ride into an IN clause. No row has id 0, so it is harmless today — which is // exactly why it would never be noticed. const isUserId = (n) => Number.isInteger(n) && n > 0 /** * Every user id that may be notified about `teamId`, mutes already removed. * * `exclude` is the author of the thing that happened. Passed rather than removed * afterwards for the reason in the header, and taken as a list because a caller * with nobody to exclude should not have to invent a sentinel. */ async function recipientIds(teamId, { exclude = [] } = {}) { const skip = [...new Set(exclude.map(Number).filter(isUserId))] const notMe = skip.length ? `AND r.user_id NOT IN (${skip.map(() => '?').join(',')})` : '' const rows = await query( `SELECT DISTINCT r.user_id FROM (${RECIPIENT_UNION}) r LEFT JOIN team_notification_prefs p ON p.user_id = r.user_id AND p.team_id = ? WHERE COALESCE(p.muted, 0) = 0 ${notMe}`, [teamId, teamId, teamId, ...skip], ) return rows.map((r) => Number(r.user_id)) } /** * The same set, narrowed to those reachable by EMAIL and carrying each one's mode. * * A separate query rather than a join onto `recipientIds` because email has two * conditions push does not: an address to send to, and an account still allowed to * have one. A banned or disabled account keeps its forum grant in the ledger — * revoking it is a separate staff decision — but must not keep receiving the * Team's private discussion in its inbox. * * `email_mode` is COALESCEd to the column default rather than read as NULL — and * that default is `'off'`, so this query returns the whole set with most of it * marked as not wanting mail. Filtering to a mode is the CALLER's job, because * `immediate` and `digest` are consumed by two different senders. */ async function emailRecipients(teamId, { exclude = [] } = {}) { const skip = [...new Set(exclude.map(Number).filter(isUserId))] const notMe = skip.length ? `AND u.id NOT IN (${skip.map(() => '?').join(',')})` : '' return query( `SELECT u.id AS user_id, u.username, u.email, COALESCE(p.email_mode, 'off') AS email_mode, p.last_digest_at FROM (${RECIPIENT_UNION}) r JOIN users u ON u.id = r.user_id LEFT JOIN team_notification_prefs p ON p.user_id = u.id AND p.team_id = ? WHERE COALESCE(p.muted, 0) = 0 AND u.email IS NOT NULL AND u.email <> '' AND u.status = 'active' ${notMe} GROUP BY u.id, u.username, u.email, p.email_mode, p.last_digest_at`, [teamId, teamId, teamId, ...skip], ) } // ── Preferences ──────────────────────────────────────────────────────────── /** * One row per Team this user may be notified about, whether or not a preference * has ever been written for it — the account screen has to offer a Team the user * has never touched, and a list built from the prefs table alone would be empty * for exactly the users who have configured nothing. * * Archived Teams appear only when a preference row exists for them, so a mute the * user set does not vanish from the screen the moment a guild disbands, while a * disbanded guild nobody configured does not linger on it forever. */ async function prefsForUser(userId) { return query( `SELECT t.id AS team_id, t.slug, t.name, t.display_name_override, t.status AS team_status, COALESCE(p.muted, 0) AS muted, COALESCE(p.email_mode, 'off') AS email_mode FROM teams t LEFT JOIN team_notification_prefs p ON p.team_id = t.id AND p.user_id = ? WHERE ( EXISTS (SELECT 1 FROM team_members m WHERE m.team_id = t.id AND m.user_id = ? AND m.status = 'active') OR EXISTS (SELECT 1 FROM team_forum_grants g WHERE g.team_id = t.id AND g.user_id = ? AND g.revoked_at IS NULL) OR p.user_id IS NOT NULL ) ORDER BY t.status, t.name`, [userId, userId, userId], ) } /** One Team's preference for one user, or undefined. Read by the mute toggle. */ async function prefFor(userId, teamId) { const rows = await query( `SELECT team_id, muted, email_mode, last_digest_at FROM team_notification_prefs WHERE user_id = ? AND team_id = ?`, [userId, teamId], ) return rows[0] } /** * Write one preference. * * An upsert that touches ONLY the columns it was given: the one-click unsubscribe * writes `muted` and must not reset an `email_mode` the user chose, and the * settings screen writes both. `last_digest_at` is never written here — it is the * worker's column, and a preference change must not look like a delivery. */ async function setPref(userId, teamId, { muted, emailMode }) { const sets = ['updated_at = CURRENT_TIMESTAMP'] if (muted != null) sets.push('muted = VALUES(muted)') if (emailMode != null) sets.push('email_mode = VALUES(email_mode)') await query( `INSERT INTO team_notification_prefs (user_id, team_id, muted, email_mode) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE ${sets.join(', ')}`, [userId, teamId, muted ? 1 : 0, emailMode || 'off'], ) } /** Stamp a digest as delivered. The worker's column, and its only writer. */ async function stampDigest(userId, teamId, at) { await query( `INSERT INTO team_notification_prefs (user_id, team_id, last_digest_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`, [userId, teamId, at], ) } /** * Active Teams that have had forum activity since `since` — the digest worker's * driving query. * * Driven from ACTIVITY rather than from the prefs table, which is what makes the * worker's cost proportional to what was WRITTEN rather than to how many people * once opened a settings screen. A Team nobody posted in costs one row of this * query and no recipient computation at all. */ async function teamsWithForumActivitySince(since) { return query( `SELECT DISTINCT t.id, t.slug, t.name, t.display_name_override FROM teams t JOIN team_forum_threads th ON th.team_id = t.id JOIN team_forum_posts po ON po.thread_id = th.id WHERE t.status = 'active' AND po.created_at > ? AND po.status = 'visible' AND th.status = 'visible'`, [since], ) } /** * The posts one digest covers: visible posts in visible threads, newer than the * recipient's own `since`. * * Re-read at send time rather than accumulated at publish time. A queue of pending * items would have to be garbage-collected, would replay a backlog after an outage, * and — the reason that actually matters — could email a body a moderator hid in * between. This query cannot: a hidden post is simply not in it. */ async function digestPostsSince(teamId, since, limit = 20) { return query( `SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_username, th.title, th.type FROM team_forum_posts po JOIN team_forum_threads th ON th.id = po.thread_id WHERE th.team_id = ? AND po.created_at > ? AND po.status = 'visible' AND th.status = 'visible' ORDER BY po.created_at LIMIT ?`, [teamId, since, Number(limit)], ) } module.exports = { recipientIds, emailRecipients, prefsForUser, prefFor, setPref, stampDigest, teamsWithForumActivitySince, digestPostsSince, }