Files
website/server/src/model/teams/teamNotify.db.js
wtclaude 26c23bd603 feat(teams): the notification core — four streams, and a recipient set
Phase 6's foundation: the fan-out shape the existing pipeline could not express.

`pushDispatch.publish` answers "everyone subscribed to a stream" and "this one
owner". Team notifications need "these N users", because Team scoping cannot live
in a stream id: the catalog is a static registration validated at boot against a
namespaced pattern, so a stream per Team is unexpressible, and stream ids are
stored in `notification_subscriptions` rows that would need collecting every time
a Team archived. So there are FOUR fixed core streams and the Team lives entirely
in the recipient set.

`team_notification_prefs` is opt-out for push and opt-IN for email — the two sinks
default opposite ways, and the asymmetry lives in the column defaults so no
condition anywhere has to remember it.

One recipient query serves all four streams, because §6.2's two populations are
the same set written twice: "active members with a user_id plus active grants" IS
"everyone with resolved forum access". Mutes are subtracted in SQL rather than by
the caller — there is no function here that returns an unfiltered set.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:24 -05:00

224 lines
9.4 KiB
JavaScript

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