diff --git a/server/db/schema.sql b/server/db/schema.sql index b28e013..a1ba538 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1266,6 +1266,45 @@ CREATE TABLE IF NOT EXISTS team_activity ( INDEX idx_team_activity_feed (team_id, occurred_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Per-Team notification preference (TEAMS.md §6.3/§6.4, phase 6). OPT-OUT, not +-- opt-in: a user in a single Team must never have to configure anything, so the +-- absence of a row is the default and every column here is a deviation from it. +-- +-- Team scoping lives HERE and in the recipient computation, never in a stream id. +-- The push catalog is a static registration validated at boot against a namespaced +-- pattern; it cannot express one stream per Team, and stream ids are stored in +-- notification_subscriptions rows that would then need garbage-collecting every +-- time a Team archived. Four fixed streams plus this table is the same feature +-- with nothing to collect. +-- +-- `last_digest_at` is the digest's ONLY state. There is no queue of pending items: +-- the worker asks what arrived after this timestamp and re-runs the access +-- resolver, so a deployment that was down for a day sends one correct digest +-- rather than replaying a backlog, and a user who lost forum access between the +-- post and the send is not emailed content they can no longer read. +CREATE TABLE IF NOT EXISTS team_notification_prefs ( + user_id INT NOT NULL, + team_id INT NOT NULL, + muted TINYINT(1) NOT NULL DEFAULT 0, + -- 'off', and NOT the design-of-record's 'digest'. Digest-by-default would mean + -- every member of every Team starts receiving daily mail the moment an operator + -- connects Gmail, which is a decision about other people's inboxes made on their + -- behalf. Email is therefore the one sink here that is opt-IN; the mute is still + -- opt-out, because a mute silences something the user already asked for. + -- + -- It also keeps this column honest as a deviation-from-default: a row written to + -- set `muted` alone leaves email exactly where it was. + email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off', + last_digest_at DATETIME NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, team_id), + CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + -- The digest worker's driving query is "rows in digest mode, oldest send first", + -- which is a scan of this index rather than of every preference ever written. + INDEX idx_tnp_digest (email_mode, last_digest_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. diff --git a/server/src/config/coreStreams.js b/server/src/config/coreStreams.js index 5c839e1..20e3bc1 100644 --- a/server/src/config/coreStreams.js +++ b/server/src/config/coreStreams.js @@ -2,13 +2,18 @@ // // What is left of config/notificationStreams.js once the shard-derived catalog // moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is -// core, the CATALOG is content). Exactly one stream is core's: `news.post` is -// produced by the website's own posts path, not by any game feed. +// core, the CATALOG is content). `news.post` is produced by the website's own +// posts path, not by any game feed, and the four `team.*` streams by core's own +// Team sync and forum. // // Registered through modules/registries.js like any module's, and read back // through it — nothing imports this file to get "the catalog", because the // catalog is core's plus every module's. // +// Phase 6 added the four Team streams below. They are core's for the same reason +// the Team tables are: a module supplies who is in a Team, but who may be told +// about it is the access resolver's answer, and that is core's (TEAMS.md Part 6). +// // The payload that ever leaves the server is a CONTENT-FREE tickle // ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content // over the authenticated API (docs/android/PLAN.md §11). @@ -21,6 +26,55 @@ const STREAMS = [ personal: false, requiresLinkedAccount: false, }, + // ── Teams (TEAMS.md §6.2, phase 6) ─────────────────────────────────────── + // + // FOUR streams, and not one per Team. The catalog is a static registration + // validated at boot; it has no way to express an unbounded runtime-created set, + // and a stream id per Team would leave rows in notification_subscriptions to + // collect every time a Team archived. Which Team an event came from lives in + // the RECIPIENT SET (utils/teamNotify.js) and in the `ref`, never in the id. + // + // `requiresLinkedAccount: false` on all four is deliberate and reads oddly. + // These are game-sourced events, so the instinct is to demand a linked game + // account — but a forum-granted user with no game identity at all is exactly + // the population §2.5 path 3 exists for, and they are a legitimate recipient of + // `team.forum.post`. The flag would refuse them a toggle they have every right + // to. What enforces who gets what is the recipient computation, which asks the + // access resolver; the stream flag is not a second, weaker copy of that rule. + // + // `personal: false` for the same reason it is false on news.post: these are not + // owner-keyed events about one account's own property. `publishToUsers` is a + // third fan-out shape alongside "everyone subscribed" and "this one owner", and + // the catalog has no flag for it because the flag would say nothing a caller + // does not already know by choosing the function. + { + id: 'team.member.joined', + label: 'Team — new member', + description: 'Someone joined a Team you belong to.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'team.leadership.changed', + label: 'Team — leadership change', + description: 'Leadership changed in a Team you belong to.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'team.forum.post', + label: 'Team — new forum post', + description: 'A new thread or reply in a Team forum you can read.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'team.announcement', + label: 'Team — announcements', + description: 'A leader posted an announcement in a Team you can read.', + personal: false, + requiresLinkedAccount: false, + }, ] module.exports = { STREAMS } diff --git a/server/src/model/pushDevices/pushDevices.db.js b/server/src/model/pushDevices/pushDevices.db.js index 04b8492..0b17e7f 100644 --- a/server/src/model/pushDevices/pushDevices.db.js +++ b/server/src/model/pushDevices/pushDevices.db.js @@ -48,4 +48,44 @@ const endpointsForUserStream = (userId, streamId) => [userId, streamId], ) -module.exports = { upsert, getByUserEndpoint, listByUser, remove, endpointsForStream, endpointsForUserStream } +// `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 + +// Endpoints of a COMPUTED SET of users' devices, each still gated on that user's +// own subscription (TEAMS.md §6.2's third fan-out shape). +// +// The set is the whole Team-scoping mechanism: the four `team.*` streams are +// global, and which Team an event belongs to is expressed by who is in `userIds` +// rather than by a stream id per Team. The caller has already resolved access and +// subtracted mutes; this function's only remaining job is to honour each +// recipient's own opt-in, which is why the JOIN is here and not left to the +// caller — a fan-out that skipped it would deliver to a user who had turned the +// stream off. +// +// Returns [] for an empty set rather than building `IN ()`, which is a syntax +// error in MariaDB. That case is common, not exceptional: most Team events have +// no subscribed recipients on a deployment with no app installed at all. +async function endpointsForUsersStream(userIds, streamId) { + const ids = [...new Set((userIds || []).map(Number).filter(isUserId))] + if (ids.length === 0) return [] + return query( + `SELECT d.endpoint, d.transport + FROM push_devices d + JOIN notification_subscriptions s ON s.user_id = d.user_id + WHERE s.stream_id = ? AND d.user_id IN (${ids.map(() => '?').join(',')})`, + [streamId, ...ids], + ) +} + +module.exports = { + upsert, + getByUserEndpoint, + listByUser, + remove, + endpointsForStream, + endpointsForUserStream, + endpointsForUsersStream, +} diff --git a/server/src/model/pushDevices/pushDevices.model.js b/server/src/model/pushDevices/pushDevices.model.js index f42125a..0e94dbe 100644 --- a/server/src/model/pushDevices/pushDevices.model.js +++ b/server/src/model/pushDevices/pushDevices.model.js @@ -28,5 +28,13 @@ const remove = async (id, userId) => (await db.remove(id, userId)) > 0 // Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped). const endpointsForStream = (streamId) => db.endpointsForStream(streamId) const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId) +const endpointsForUsersStream = (userIds, streamId) => db.endpointsForUsersStream(userIds, streamId) -module.exports = { register, listForUser, remove, endpointsForStream, endpointsForUserStream } +module.exports = { + register, + listForUser, + remove, + endpointsForStream, + endpointsForUserStream, + endpointsForUsersStream, +} diff --git a/server/src/model/teams/teamNotify.db.js b/server/src/model/teams/teamNotify.db.js new file mode 100644 index 0000000..7beb8ee --- /dev/null +++ b/server/src/model/teams/teamNotify.db.js @@ -0,0 +1,223 @@ +// 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, +} diff --git a/server/src/model/teams/teamNotify.model.js b/server/src/model/teams/teamNotify.model.js new file mode 100644 index 0000000..168dd66 --- /dev/null +++ b/server/src/model/teams/teamNotify.model.js @@ -0,0 +1,128 @@ +// 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), +} diff --git a/server/src/utils/pushDispatch.js b/server/src/utils/pushDispatch.js index 5a96ccc..20688b6 100644 --- a/server/src/utils/pushDispatch.js +++ b/server/src/utils/pushDispatch.js @@ -108,4 +108,40 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) { await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps))) } -module.exports = { publish, isAllowedEndpoint } +// `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 + +/** + * Publish one content-free tickle to a COMPUTED SET of users (TEAMS.md §6.2). + * + * The third fan-out shape. `publish` answers "everyone subscribed" and "this one + * owner"; Team notifications need "these N users", because the four `team.*` + * streams are global and which Team an event belongs to is expressed by who is in + * the set. Nothing about the tickle changes — same `{ stream, ref }`, same + * untrusted-relay assumption, same SSRF gate on every endpoint. + * + * The set arrives already resolved: the caller has asked the access resolver who + * may read this Team and subtracted the per-Team mutes. What this function still + * enforces is each recipient's own stream subscription, in the query. Never + * throws — a notification failing must not fail the write that produced it. + */ +async function publishToUsers(streamId, { ref, userIds } = {}, deps = {}) { + const devices = deps.pushDevices || pushDevicesModel + const ids = [...new Set((userIds || []).map(Number).filter(isUserId))] + if (ids.length === 0) return + let rows + try { + rows = await devices.endpointsForUsersStream(ids, streamId) + } catch (err) { + log.warn('push endpoint lookup failed', { streamId, message: err.message }) + return + } + if (!rows || rows.length === 0) return + const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null }) + await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps))) +} + +module.exports = { publish, publishToUsers, isAllowedEndpoint }