// ── Scoped preferences: "this channel, for this one Team" ────────────────── // // ENGAGEMENT.md Phase 6, decision 4. `notification_channel_prefs` is keyed // (user, stream, channel) and has no scope column; `team_notification_prefs` is // keyed (user, Team) and is the preference people actually hold today — someone // in six Teams silences one. Migrating the second into the first would mean a // live migration of user data, a wire-shape change on two clients, and the loss // of the granularity in between. The org lead's decision was to keep the Team // table and have the engine consult it; this file is the seam that lets it, // without core's engine learning what a Team is. // // A registrant claims a scope PREFIX — the part of a scope key before the colon, // `team` in `team:12` — and answers, for a set of users and one channel, what // that scope says their mode is. // // **Where a scope answers, its answer REPLACES the stream-level preference; it // does not intersect with it.** The decision was phrased as "a suppression below // the channel preference", and building it showed that reading is the one that // cannot ship: `notification_channel_prefs` holds a row only where a user has // expressed something, absence means the channel's `defaultMode`, and email's is // `off`. Nobody has ever expressed a stream-level opinion about `team.forum.post` // — the screen that would let them is Phase 3's and the preference predates it — // so intersecting would resolve every existing Team-email subscriber to `off` and // silence the entire live pipeline on the deploy that migrated it. That is the // G22 failure mode with a different cause. Replacement keeps today's behaviour // byte-for-byte: for a Team-scoped event, `team_notification_prefs` is the // preference, exactly as it has been since Teams shipped. // // The cost, stated so nobody has to rediscover it: a user cannot turn Team email // off for every Team at once from the channels screen. That control lives on the // per-Team screen, which is where it has always lived and where the unsubscribe // link points. // // Nothing here caches. A preference read is one indexed query per (event, // channel), against a table the user can change between two events. const log = require('../utils/logger')('engagement') // prefix → provider const providers = new Map() const PREFIX_RE = /^[a-z][a-z0-9_-]*$/ /** * Parse a scope key into its prefix and id. `''` and anything malformed are * `null` — an unparseable scope must read as "no scope", never as some other * scope's. * * @returns {{ prefix: string, id: string }|null} */ function parse(scopeKey) { const raw = String(scopeKey || '') const at = raw.indexOf(':') if (at < 1 || at === raw.length - 1) return null const prefix = raw.slice(0, at) if (!PREFIX_RE.test(prefix)) return null return { prefix, id: raw.slice(at + 1) } } /** * Register a scope-preference provider. * * Validate-then-commit, the same discipline the transport and channel registries * use: every check runs before the map is touched. * * @param {object} def * @param {string} def.prefix the scope-key prefix this provider owns, e.g. 'team' * @param {string} def.label operator-facing, for the send log and admin copy * @param {(userIds: number[], channel: string, scopeId: string) => Promise>} def.modesFor * A mode per user for the users this scope has an opinion about. A user * left OUT of the map defers to the stream-level preference; a user in it * is answered by the scope. Must not throw — see `resolve`. */ function registerScopePreference(def) { if (!def || typeof def !== 'object') throw new Error('registerScopePreference: definition required') const { prefix, label, modesFor } = def if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) { throw new Error(`registerScopePreference: invalid prefix ${JSON.stringify(prefix)}`) } if (providers.has(prefix)) throw new Error(`registerScopePreference: ${prefix} is already registered`) if (typeof label !== 'string' || !label) throw new Error(`registerScopePreference(${prefix}): label required`) if (typeof modesFor !== 'function') throw new Error(`registerScopePreference(${prefix}): modesFor required`) providers.set(prefix, { prefix, label, modesFor }) return prefix } /** * What does this scope say about these users on this channel? * * @returns {Promise>} empty when the scope is absent, * unparseable, or owned by nobody — all three of which mean "this event * is not scoped as far as preferences are concerned", which is the right * answer for a module whose scope provider has been uninstalled. */ async function resolve(userIds, channel, scopeKey) { const parsed = parse(scopeKey) if (!parsed || !userIds.length) return new Map() const provider = providers.get(parsed.prefix) if (!provider) return new Map() try { const modes = await provider.modesFor(userIds, channel, parsed.id) return modes instanceof Map ? modes : new Map() } catch (err) { // **Fails OPEN, and that is the uncomfortable choice made deliberately.** A // provider that throws leaves the stream-level preference in charge, which // for every core channel is `off` — so the practical effect of a failure is // that nothing is sent, not that everybody is mailed. Failing closed by // refusing the whole event would instead drop an IDOC warning because a Team // preference query timed out. log.error('scope preference lookup failed', { scope: scopeKey, channel, message: err.message }) return new Map() } } const has = (prefix) => providers.has(prefix) // Test-only: the registry is module-level state. function _reset() { providers.clear() } module.exports = { registerScopePreference, resolve, parse, has, _reset }