diff --git a/server/db/schema.sql b/server/db/schema.sql index bfe0b9f..267a7d5 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1642,3 +1642,47 @@ UPDATE email_config WHERE refresh_token_enc IS NOT NULL AND credential_enc IS NULL AND status <> 'unconfigured'; + +-- ── Per-channel notification preferences (ENGAGEMENT.md §4.5, Phase 3) ────── +-- +-- G8: `notification_subscriptions` above has no channel dimension. It answers +-- "which streams does this user want pushed", and the shipped Android client's +-- wire shape (`{ streams: [...] }`) is frozen around exactly that question. This +-- table answers the general one — which streams AND triggers, on which channel, +-- in which mode — and the old table becomes its push projection: every write to +-- one fans out to the other (`notificationChannelPrefs.model`). +-- +-- `stream_id` names a stream OR a trigger id, ONE namespace (§7.2, settled in +-- Phase 2). That decision is what keeps this primary key single-keyed: under two +-- namespaces it would have needed a `kind` discriminator, and `news.post` would +-- have meant two different rows forever. +-- +-- **A row exists only where a user has expressed something.** Absence is not +-- "off" — it is "the channel's `defaultMode`", which lives in +-- `src/engagement/channels.js` and nowhere else (§3.1, G9: per-channel defaults +-- differ). All three of core's channels default 'off' today, so absence and off +-- coincide; that is a fact about the current declarations, not about this table, +-- and code must not assume it. The column DEFAULT below is the value a write with +-- no mode takes, not the value a missing row means. +CREATE TABLE IF NOT EXISTS notification_channel_prefs ( + user_id INT NOT NULL, + stream_id VARCHAR(64) NOT NULL, + channel VARCHAR(32) NOT NULL, + mode ENUM('off','instant','digest') NOT NULL DEFAULT 'off', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, stream_id, channel), + CONSTRAINT fk_ncp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_ncp_channel (channel, mode) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Carry the existing push subscriptions across, once. Same shape as the +-- announce_jobs -> announce_job_legs backfill above: an INSERT IGNORE ... SELECT, +-- so replaying this file on every boot is a no-op after the first, and a user who +-- has since turned a stream OFF is not resurrected by the next boot (their row +-- exists with mode 'off', and INSERT IGNORE leaves it alone). +-- +-- 'instant' rather than the column default, because a row in +-- notification_subscriptions IS an opt-in: the user asked to be pushed, and push +-- has no digest mode to be asked into instead. +INSERT IGNORE INTO notification_channel_prefs (user_id, stream_id, channel, mode) + SELECT user_id, stream_id, 'push', 'instant' FROM notification_subscriptions; diff --git a/server/routes.guards.json b/server/routes.guards.json index e89f215..c52051c 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -1434,6 +1434,26 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/auth/me/notifications/channels", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/auth/me/notifications/channels", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/auth/me/notifications/streams", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 0ca2665..eeff48a 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -569,6 +569,14 @@ "method": "DELETE", "path": "/api/v1/auth/me/devices/:id" }, + { + "method": "GET", + "path": "/api/v1/auth/me/notifications/channels" + }, + { + "method": "PUT", + "path": "/api/v1/auth/me/notifications/channels" + }, { "method": "GET", "path": "/api/v1/auth/me/notifications/streams" diff --git a/server/src/app.js b/server/src/app.js index f03a30a..f7d8e1c 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -192,6 +192,15 @@ app.use('/api', apiRouter) // module's collision checks are asked against what is ALREADY registered, so // core's streams, its announce leg and its extension-slot fill have to be there // before the first module registers anything (MODULE_SYSTEM.md §1.8). +// The engagement subsystem's own door, which is what brings core's mail +// transports and its three delivery channels into existence (ENGAGEMENT.md +// §3.1). Requiring `engagement/channels` or `engagement/transports` directly gets +// the empty registry — populating it is deliberately a side effect of this one +// require, so there is exactly one place either can be registered from. It runs +// beside registerCore() and before the loader for the same reason: a preference +// read or a mail send must never find a half-populated registry. +require('./engagement') + registries.registerCore() modules.load({ public: require('./router/v1/public'), diff --git a/server/src/engagement/channels.js b/server/src/engagement/channels.js new file mode 100644 index 0000000..804f844 --- /dev/null +++ b/server/src/engagement/channels.js @@ -0,0 +1,128 @@ +// ── The delivery-channel registry ────────────────────────────────────────── +// +// ENGAGEMENT.md §3.1, Phase 3. The other half of the axis `transports/index.js` +// splits: a **channel** is what kind of sink this is (email, push, in-app), a +// **transport** is how one channel actually delivers (SMTP, ntfy, FCM). Push has +// had this shape since before anyone named it — `push_devices.transport` is a +// transport column on a channel with one implementation. +// +// **Only the declarative half registers here today**, and that is the whole of +// what Phase 3 needs. `addressFor` / `render` / `deliver` arrive with the phases +// that can exercise them: email in Phase 6, in-app in Phase 7. Declaring a +// function nothing calls freezes a signature before anything has tried to use +// it, which is the reason `transports/index.js` deferred this file at all. +// +// What forced it into Phase 3 rather than Phase 6: `notification_channel_prefs` +// stores a mode only when a user has expressed one, so reading a preference +// means knowing the channel's default — and §3.1 says `defaultMode` is expressed +// **once**. A constant list beside the prefs model would be that expression in a +// second place two phases before the registry replaced it. +// +// Nothing here touches the database, the network or a user record. + +// id → channel definition, in registration order. +const channels = new Map() + +// The three modes a preference can take. `digest` is only offered by a channel +// that declares `supportsDigest` — push and in-app are instant-only in v1, +// because a digest of content-free tickles is not a thing you can batch. +const MODES = ['off', 'instant', 'digest'] + +const isMode = (value) => MODES.includes(value) + +/** + * Register a delivery channel. + * + * Validate-then-commit, the same discipline `registerMailTransport` and + * `modules/registries.js` use: every check runs before the map is touched, so a + * rejected registration leaves nothing behind. + * + * @param {object} def + * @param {string} def.id 'email' | 'push' | 'inapp' | later 'discord.dm' + * @param {string} def.label operator/user-facing name + * @param {boolean} def.carriesContent false for push — the tickle invariant, stated structurally + * @param {string} def.defaultMode the mode that applies with no stored row + * @param {boolean} def.supportsDigest may a preference for this channel be 'digest' + * @param {string} [def.description] one line for the preferences screen + */ +function registerDeliveryChannel(def) { + if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required') + const { id, label, carriesContent, defaultMode, supportsDigest } = def + if (typeof id !== 'string' || !/^[a-z][a-z0-9_.-]*$/.test(id)) { + throw new Error(`registerDeliveryChannel: invalid id ${JSON.stringify(id)}`) + } + if (channels.has(id)) throw new Error(`registerDeliveryChannel: ${id} is already registered`) + if (typeof label !== 'string' || !label) throw new Error(`registerDeliveryChannel(${id}): label required`) + if (typeof carriesContent !== 'boolean') { + throw new Error(`registerDeliveryChannel(${id}): carriesContent must be declared explicitly`) + } + if (!isMode(defaultMode)) { + throw new Error(`registerDeliveryChannel(${id}): defaultMode must be one of ${MODES.join(', ')}`) + } + if (typeof supportsDigest !== 'boolean') { + throw new Error(`registerDeliveryChannel(${id}): supportsDigest must be declared explicitly`) + } + // A channel that cannot batch cannot default to batching. Cheap to check, and + // the failure it prevents is a stored 'digest' row no delivery path can honour. + if (defaultMode === 'digest' && !supportsDigest) { + throw new Error(`registerDeliveryChannel(${id}): defaultMode 'digest' needs supportsDigest`) + } + + channels.set(id, { + id, + label, + description: def.description || null, + carriesContent, + defaultMode, + supportsDigest, + }) + return id +} + +/** Every channel, in registration order. The preferences screen's column set. */ +const all = () => [...channels.values()].map((c) => ({ ...c })) + +/** Just the ids. */ +const ids = () => [...channels.keys()] + +/** One channel, or null. Callers must handle null: a stored pref row can name a + * channel that is no longer registered, and that must read as "off", not throw. */ +const get = (id) => { + const c = channels.get(id) + return c ? { ...c } : null +} + +const has = (id) => channels.has(id) + +/** The mode that applies when the user has expressed nothing. An unregistered + * channel is 'off' — never on by accident. */ +const defaultMode = (id) => (channels.get(id) || {}).defaultMode || 'off' + +/** Which modes this channel will accept from a client. */ +const modesFor = (id) => { + const c = channels.get(id) + if (!c) return [] + return c.supportsDigest ? MODES.slice() : MODES.filter((m) => m !== 'digest') +} + +/** Is `mode` a mode this channel accepts? The gate on every preference write. */ +const acceptsMode = (id, mode) => modesFor(id).includes(mode) + +// Test-only: the registry is module-level state. +function _reset() { + channels.clear() +} + +module.exports = { + MODES, + isMode, + registerDeliveryChannel, + all, + ids, + get, + has, + defaultMode, + modesFor, + acceptsMode, + _reset, +} diff --git a/server/src/engagement/coreChannels.js b/server/src/engagement/coreChannels.js new file mode 100644 index 0000000..158c294 --- /dev/null +++ b/server/src/engagement/coreChannels.js @@ -0,0 +1,74 @@ +// ── Core's own delivery channels ─────────────────────────────────────────── +// +// ENGAGEMENT.md §3.1 / Phase 3. All three are core's, and none of them is a game +// concept: a mailbox, a push endpoint and an inbox row are the same three things +// on any shard running this platform. +// +// **They are declared here before two of them can deliver anything**, and that is +// deliberate rather than premature. A preference is a durable user statement; the +// three columns of the preferences screen have to exist from the moment the table +// does, or the first person to open it after Phase 6 finds an email toggle that +// has never had a value and a screen that changed shape under them. Registering +// the metadata early costs nothing — the registry holds no behaviour yet — while +// registering it late means back-filling opinions users were never asked for. +// +// The `defaultMode`s below are the whole of G9: "per-channel defaults differ and +// there is nowhere to express that generically". This is that place. + +const { registerDeliveryChannel } = require('./channels') + +const CHANNELS = [ + { + id: 'push', + label: 'Push', + description: 'A silent tickle to your phone; the app then pulls the real content.', + // The tickle invariant (docs/android/PLAN.md §11), stated structurally rather + // than as a comment: what leaves the server on this channel is { stream, ref } + // and never a message body. Phase 7's `deliver` reads this flag; declaring it + // false here is what makes "push must not carry content" a property of the + // registration instead of a rule each caller has to remember. + carriesContent: false, + // **Opt-IN, and this is the one place the phase's own acceptance line was + // wrong.** ENGAGEMENT.md Phase 3 said a fresh user's push defaults to + // 'instant'; §3.1 called push "opt-OUT", borrowing the semantics of + // `team_notification_prefs` (where no row does mean notified). But push + // STREAM subscriptions have never worked that way: `notification_subscriptions` + // holds a row only when a user opted in, so no row means not subscribed. + // Defaulting to 'instant' here would have projected the entire catalog into + // `GET /auth/me/notifications/subscriptions` for every existing user, and the + // shipped Android client would have shown every toggle switched on after an + // upgrade nobody asked for. Settled by the org lead 2026-08-29: 'off'. + defaultMode: 'off', + // A batched tickle is a contradiction — the content is not in the message, so + // there is nothing to roll up. Ten events are ten wakeups or one; either way + // the app pulls the same inbox. + supportsDigest: false, + }, + { + id: 'email', + label: 'Email', + description: 'A message to your verified address.', + carriesContent: true, + // Opt-IN, per §7.1 Q1: standard marketing-email practice, and the posture + // `team_notification_prefs.email_mode` already takes ('off' by default). + defaultMode: 'off', + supportsDigest: true, + }, + { + id: 'inapp', + label: 'On the site', + description: 'An item in your notification inbox on the website and in the app.', + carriesContent: true, + // Opt-IN like the other two, and for a reason particular to this channel: the + // inbox does not exist until Phase 7. A default of 'instant' would mean every + // user is opted into a surface that has no rows and no screen, and the first + // thing Phase 7 shipped would be a backlog. Whether the inbox is opt-out once + // it is real is a Phase 7 decision with a live surface to look at. + defaultMode: 'off', + supportsDigest: false, + }, +] + +for (const channel of CHANNELS) registerDeliveryChannel(channel) + +module.exports = { CHANNELS } diff --git a/server/src/engagement/index.js b/server/src/engagement/index.js index e64adab..a815d78 100644 --- a/server/src/engagement/index.js +++ b/server/src/engagement/index.js @@ -1,8 +1,11 @@ // ── The engagement subsystem — one door ──────────────────────────────────── // -// ENGAGEMENT.md Phase 1. Today this is the mail transport registry and core's -// own transports; the trigger registry, the rules engine and the delivery -// channels arrive in later phases and hang here too. +// ENGAGEMENT.md Phases 1 and 3. Today this is the mail transport registry, core's +// own transports, and the delivery-channel registry with core's three channels; +// the rules engine and the render/deliver half of a channel arrive in later +// phases and hang here too. (The trigger registry lives in `modules/registries.js` +// instead, because a trigger is something a MODULE declares and modules only ever +// see one registration door.) // // **Core's transports register through the same door a module's would**, and // they register HERE rather than at the bottom of the registry file. That keeps @@ -11,12 +14,14 @@ // `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the // registry never has the side effect of populating it. // -// Requiring this module is what makes `smtp` available. Everything that resolves -// a transport goes through here, so there is exactly one place a transport can -// come into existence. +// Requiring this module is what makes `smtp` and the three channels available. +// Everything that resolves either goes through here, so there is exactly one +// place a transport or a channel can come into existence. require('./transports/smtp') +require('./coreChannels') const transports = require('./transports') +const channels = require('./channels') -module.exports = { transports } +module.exports = { transports, channels } diff --git a/server/src/engagement/transports/index.js b/server/src/engagement/transports/index.js index 3a7d7b3..74d5d55 100644 --- a/server/src/engagement/transports/index.js +++ b/server/src/engagement/transports/index.js @@ -2,9 +2,12 @@ // // ENGAGEMENT.md §3.1, Phase 1. A **channel** is what kind of sink this is (email, // push, in-app); a **transport** is how one channel actually delivers. This file -// is the second half only. The channel registry arrives with the engine that -// consumes it — registering a channel nothing calls would be a shape frozen -// before anything had tried to use it. +// is the second half only; the channel half is `../channels.js`, which Phase 3 +// added when `notification_channel_prefs` needed a single place for `defaultMode` +// to live. Its render/deliver functions are still deferred to the phases that can +// exercise them, for the reason this comment used to give about the whole file: +// registering a function nothing calls freezes a signature before anything has +// tried to use it. // // What this replaces: `mailer.buildTransport()` had Gmail's host, port and // OAuth2 auth type as literals, so "which provider" was a code edit. Now the diff --git a/server/src/model/notificationChannelPrefs/notificationChannelPrefs.db.js b/server/src/model/notificationChannelPrefs/notificationChannelPrefs.db.js new file mode 100644 index 0000000..9176548 --- /dev/null +++ b/server/src/model/notificationChannelPrefs/notificationChannelPrefs.db.js @@ -0,0 +1,44 @@ +const { query } = require('../../utils/db') + +const listByUser = (userId) => + query( + 'SELECT stream_id, channel, mode FROM notification_channel_prefs WHERE user_id = ? ORDER BY stream_id, channel', + [userId], + ) + +// One (user, stream, channel) row. Upsert rather than insert-or-update in app +// code: the primary key is exactly the triple, so MariaDB decides, and two +// concurrent PUTs from a phone and a browser cannot race into a duplicate-key +// error. +const upsert = (userId, streamId, channel, mode) => + query( + `INSERT INTO notification_channel_prefs (user_id, stream_id, channel, mode) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE mode = VALUES(mode)`, + [userId, streamId, channel, mode], + ) + +// Every push row this user holds that is NOT in `keep`, set to 'off'. The legacy +// whole-set PUT's other half: it says "these streams and no others", and the +// rows it is silent about have to stop meaning 'instant'. +// +// It sets rather than deletes, so a user's explicit "no" survives a later change +// to push's `defaultMode` (§3.1 / channels.js). Deleting would fold "I turned +// this off" back into "I never said", and those are the same thing only for as +// long as the default happens to be 'off'. +async function offPushExcept(userId, keep) { + if (!keep.length) { + return query( + "UPDATE notification_channel_prefs SET mode = 'off' WHERE user_id = ? AND channel = 'push' AND mode <> 'off'", + [userId], + ) + } + const marks = keep.map(() => '?').join(', ') + return query( + `UPDATE notification_channel_prefs SET mode = 'off' + WHERE user_id = ? AND channel = 'push' AND mode <> 'off' AND stream_id NOT IN (${marks})`, + [userId, ...keep], + ) +} + +module.exports = { listByUser, upsert, offPushExcept } diff --git a/server/src/model/notificationChannelPrefs/notificationChannelPrefs.model.js b/server/src/model/notificationChannelPrefs/notificationChannelPrefs.model.js new file mode 100644 index 0000000..1aeff0b Binary files /dev/null and b/server/src/model/notificationChannelPrefs/notificationChannelPrefs.model.js differ diff --git a/server/src/model/notificationSubs/notificationSubs.db.js b/server/src/model/notificationSubs/notificationSubs.db.js index 2a1e866..6d1dcf6 100644 --- a/server/src/model/notificationSubs/notificationSubs.db.js +++ b/server/src/model/notificationSubs/notificationSubs.db.js @@ -13,4 +13,20 @@ async function replaceForUser(userId, streams) { } } -module.exports = { listByUser, replaceForUser } +// ── The single-row pair (ENGAGEMENT.md Phase 3) ──────────────────────────── +// +// `notification_channel_prefs` is a SPARSE write — one (id, channel) pair at a +// time — and this table is its push projection, so it needs the same granularity. +// `replaceForUser` above cannot express "turn this one stream on and leave the +// rest alone" without the caller first reading the whole set back, which is a +// read-modify-write race between a phone and a browser saving at once. +// +// INSERT IGNORE / DELETE against the (user_id, stream_id) primary key, so both +// are idempotent and neither needs to know the current state. +const addForUser = (userId, streamId) => + query('INSERT IGNORE INTO notification_subscriptions (user_id, stream_id) VALUES (?, ?)', [userId, streamId]) + +const removeForUser = (userId, streamId) => + query('DELETE FROM notification_subscriptions WHERE user_id = ? AND stream_id = ?', [userId, streamId]) + +module.exports = { listByUser, replaceForUser, addForUser, removeForUser } diff --git a/server/src/model/notificationSubs/notificationSubs.model.js b/server/src/model/notificationSubs/notificationSubs.model.js index 9311954..af980ba 100644 --- a/server/src/model/notificationSubs/notificationSubs.model.js +++ b/server/src/model/notificationSubs/notificationSubs.model.js @@ -2,6 +2,15 @@ // applied to every device they register). The catalog is core's plus every // installed module's, so it is read back through modules/registries rather than // from a config file (MODULE_SYSTEM.md §1.8). +// +// **This is now the push PROJECTION of `notification_channel_prefs`** +// (ENGAGEMENT.md Phase 3), and it keeps its exact wire shape on purpose: the +// shipped Android client's DTO is `{ streams: [...] }` and cannot be changed from +// this side. So the general table gained the channel dimension and this one stays +// the answer to "which streams does this user want pushed" — the only question +// that client knows how to ask. Every write here fans out to there; every write +// there that touches push fans out to here. What `utils/pushDispatch` reads did +// not change at all, which is what makes this phase touch no delivery path. const db = require('./notificationSubs.db') const { isValidStream } = require('../../modules/registries') @@ -11,9 +20,16 @@ const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r. // Replace the user's subscription set. Ignores unknown ids and de-dupes, so a // stale client can't create rows for streams that no longer exist. Returns the // stored (cleaned) set. +// +// The mirror is required lazily rather than at the top of the file: the channel +// prefs model requires the registries and the channel registry, and this module +// is required by the router at boot. A cycle here would be a silent half-loaded +// module rather than an error, and there is nothing to gain from the eager form. async function setForUser(userId, streams) { const clean = [...new Set((Array.isArray(streams) ? streams : []).filter(isValidStream))] await db.replaceForUser(userId, clean) + // eslint-disable-next-line global-require + await require('../notificationChannelPrefs/notificationChannelPrefs.model').mirrorPushSet(userId, clean) return clean } diff --git a/server/src/modules/ceilings.js b/server/src/modules/ceilings.js index 5367551..ae5f809 100644 --- a/server/src/modules/ceilings.js +++ b/server/src/modules/ceilings.js @@ -54,6 +54,21 @@ const LABELS = { owner: 'Only the user the event is about', } +// Which roles the `staff` ceiling actually names. The label above has always +// claimed "admin / editor / moderator"; Phase 3 gave that claim a consumer, so it +// is written down once rather than re-derived at each call site. +// +// It matches the admin TIER gate — `requireRole('admin','editor','moderator')` in +// `router/v1/admin/index.js`, and `public.controller`'s own STAFF_ROLES — and NOT +// `teamGrants.STAFF_ROLES`, which is `['admin','moderator']`. The two are +// genuinely different questions: teamGrants asks who may act on a Team they are +// not a member of, and an editor deliberately may not. A ceiling asks who may be +// TOLD, which is the tier gate's population. +const STAFF_CEILING_ROLES = ['admin', 'editor', 'moderator'] + +/** Does this user fall inside the `staff` ceiling? */ +const isStaffRole = (role) => STAFF_CEILING_ROLES.includes(role) + const CEILINGS = Object.keys(PARENT) /** Is this one of the six? The gate every registration and every rule save runs. */ @@ -104,4 +119,4 @@ function meetAll(list) { return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0]) } -module.exports = { CEILINGS, LABELS, isCeiling, permits, meet, meetAll } +module.exports = { CEILINGS, LABELS, STAFF_CEILING_ROLES, isStaffRole, isCeiling, permits, meet, meetAll } diff --git a/server/src/router/v1/auth/notifications.controller.js b/server/src/router/v1/auth/notifications.controller.js index f2b4889..bee7dad 100644 --- a/server/src/router/v1/auth/notifications.controller.js +++ b/server/src/router/v1/auth/notifications.controller.js @@ -5,6 +5,7 @@ const pushDevices = require('../../../model/pushDevices/pushDevices.model') const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model') +const channelPrefs = require('../../../model/notificationChannelPrefs/notificationChannelPrefs.model') const registries = require('../../../modules/registries') const teamPrefs = require('../../../model/teams/teamNotify.model') const { isAllowedEndpoint } = require('../../../utils/pushDispatch') @@ -79,6 +80,39 @@ async function putSubscriptions(req, res) { } } +// GET /auth/me/notifications/channels — the per-channel preferences surface +// (ENGAGEMENT.md Phase 3): the channel registry's declarative half, plus one item +// per subscribable id with its EFFECTIVE mode on each channel that applies. +// +// The superset of `/notifications/streams` + `/notifications/subscriptions`, +// which stay exactly as they are for the shipped app. +async function getChannelPrefs(req, res) { + try { + return res.json(await channelPrefs.getForUser(req.user.id, req.user)) + } catch (err) { + log.error('getChannelPrefs', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /auth/me/notifications/channels — apply a SPARSE set of preferences. +// +// Only the (id, channel) pairs in the body are written; every other pair is left +// alone, so a screen that manages one channel need not know about the others. Off +// is a mode, not an omission — which is also why this endpoint has no +// empty-array case to get wrong, unlike its two neighbours. Entries naming an +// unknown id, an inapplicable channel or a mode that channel does not accept are +// dropped by the model; the full stored state is echoed back so the caller can +// see what actually took. +async function putChannelPrefs(req, res) { + try { + return res.json(await channelPrefs.applyForUser(req.user.id, req.body.prefs, req.user)) + } catch (err) { + log.error('putChannelPrefs', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + // GET /auth/me/notifications/teams — this user's per-Team preferences, one row // per Team they could be notified about whether or not they have ever set one. // @@ -119,6 +153,8 @@ module.exports = { getStreams, getSubscriptions, putSubscriptions, + getChannelPrefs, + putChannelPrefs, getTeamPrefs, putTeamPrefs, } diff --git a/server/src/router/v1/auth/notifications.routes.js b/server/src/router/v1/auth/notifications.routes.js index d3b6731..81abffa 100644 --- a/server/src/router/v1/auth/notifications.routes.js +++ b/server/src/router/v1/auth/notifications.routes.js @@ -14,6 +14,7 @@ const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model') +const { MODES } = require('../../../engagement/channels') const notifRouter = express.Router() @@ -98,6 +99,44 @@ notifRouter.put( notif.putSubscriptions, ) +// ── Per-channel preferences (ENGAGEMENT.md §4.5, phase 3) ────────────────── +// +// The channel dimension `notification_subscriptions` lacks. The two endpoints +// above are unchanged and become the push projection of these — the shipped app +// keeps its wire shape, and a newer client manages email and in-app through here. +// +// The PUT is SPARSE, deliberately unlike the two whole-set PUTs either side of +// it: only the pairs named are written. `off` is a mode rather than an omission, +// so there is no "clearing the last entry" case and no empty-array DTO gotcha. +notifRouter.get( + '/notifications/channels', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Get the current user’s per-channel notification preferences' + // #swagger.description = 'The delivery channels (email, push, in-app) with their defaults, plus one item per subscribable id — every push stream and every event trigger, one namespace — carrying the effective mode on each channel that applies to it. A trigger-only id has no push toggle. Modes not stored are reported as the channel’s default, so a client never has to know which it is looking at.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Per-channel preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefs" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + notif.getChannelPrefs, +) + +notifRouter.put( + '/notifications/channels', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Update the current user’s per-channel notification preferences' + // #swagger.description = 'A SPARSE update: only the (id, channel) pairs in `prefs` are written and every other pair is left untouched, so setting `email` does not disturb `push`. Entries naming an unknown id, a channel that does not apply to that id, or a mode that channel does not accept are ignored. A `push` entry is mirrored into /notifications/subscriptions. The full stored state is echoed back.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefsUpdate" } } } } */ + /* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefs" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('prefs').isArray(), + body('prefs.*.id').isString().isLength({ min: 1, max: 64 }), + body('prefs.*.channel').isString().isLength({ min: 1, max: 32 }), + body('prefs.*.mode').isIn(MODES), + validate, + notif.putChannelPrefs, +) + // ── Per-Team preferences (TEAMS.md §6.3, phase 6) ────────────────────────── // // The granularity per-stream opt-in cannot express: "I am in five Teams and want diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 4d89318..59a32ae 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -8672,6 +8672,114 @@ ] } }, + "/api/v1/auth/me/notifications/channels": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "Get the current user’s per-channel notification preferences", + "description": "The delivery channels (email, push, in-app) with their defaults, plus one item per subscribable id — every push stream and every event trigger, one namespace — carrying the effective mode on each channel that applies to it. A trigger-only id has no push toggle. Modes not stored are reported as the channel’s default, so a client never has to know which it is looking at.", + "responses": { + "200": { + "description": "Per-channel preferences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelPrefs" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Auth · Me" + ], + "summary": "Update the current user’s per-channel notification preferences", + "description": "A SPARSE update: only the (id, channel) pairs in `prefs` are written and every other pair is left untouched, so setting `email` does not disturb `push`. Entries naming an unknown id, a channel that does not apply to that id, or a mode that channel does not accept are ignored. A `push` entry is mirrored into /notifications/subscriptions. The full stored state is echoed back.", + "responses": { + "200": { + "description": "Updated preferences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelPrefs" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelPrefsUpdate" + } + } + } + } + } + }, "/api/v1/auth/me/notifications/streams": { "get": { "tags": [ @@ -16503,6 +16611,509 @@ } } }, + "DeliveryChannel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One delivery channel from the registry (ENGAGEMENT.md §3.1). A channel is what kind of sink this is; a transport is how it delivers." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "email" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Email" + } + } + }, + "description": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "A message to your verified address." + } + } + }, + "carriesContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "False for push, which only ever sends a content-free tickle the client then pulls against." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "defaultMode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "The mode that applies when the user has stored no preference for an id on this channel." + }, + "example": { + "type": "string", + "example": "off" + } + } + }, + "supportsDigest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "modes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + } + } + }, + "description": { + "type": "string", + "example": "The modes this channel will accept. Excludes `digest` unless supportsDigest." + }, + "example": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "NotificationChannelPrefItem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One subscribable id — a push stream, an event trigger, or both — with the effective mode on each channel that applies to it." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "news.post" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "News posts" + } + } + }, + "description": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "New news / Five-on-Friday / newsletter posts." + } + } + }, + "personal": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "requiresLinkedAccount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "ceiling": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The trigger’s audience ceiling, or null for an id with no trigger declaration." + }, + "example": { + "type": "string", + "example": "authenticated" + } + } + }, + "channels": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "description": { + "type": "string", + "example": "Which channels apply. A trigger-only id has no `push` — nothing is registered to push it." + }, + "example": { + "type": "array", + "example": [ + "push", + "email", + "inapp" + ], + "items": { + "type": "string" + } + } + } + }, + "modes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + } + } + }, + "description": { + "type": "string", + "example": "Effective mode per applicable channel: the stored value, or the channel’s default where nothing is stored." + }, + "example": { + "type": "object", + "properties": { + "push": { + "type": "string", + "example": "instant" + }, + "email": { + "type": "string", + "example": "off" + }, + "inapp": { + "type": "string", + "example": "off" + } + } + } + } + } + } + } + } + }, + "NotificationChannelPrefs": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The per-channel preferences surface: the channel registry plus one item per subscribable id. Returned by both GET and PUT." + }, + "properties": { + "type": "object", + "properties": { + "channels": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/DeliveryChannel" + } + } + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/NotificationChannelPrefItem" + } + } + } + } + } + } + }, + "NotificationChannelPrefsUpdate": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A SPARSE preference update. Only the (id, channel) pairs listed are written; every other pair is left untouched. `off` is a mode, never an omission." + }, + "properties": { + "type": "object", + "properties": { + "prefs": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "news.post" + } + } + }, + "channel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "email" + } + } + }, + "mode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "instant", + "digest" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "digest" + } + } + } + } + } + } + }, + "example": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "news.post" + }, + "channel": { + "type": "string", + "example": "email" + }, + "mode": { + "type": "string", + "example": "digest" + } + } + } + } + } + } + } + } + } + }, "TeamNotificationPref": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index e806dea..17ef6df 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -634,6 +634,88 @@ const doc = { }, }, }, + DeliveryChannel: { + type: 'object', + description: 'One delivery channel from the registry (ENGAGEMENT.md §3.1). A channel is what kind of sink this is; a transport is how it delivers.', + properties: { + id: { type: 'string', example: 'email' }, + label: { type: 'string', example: 'Email' }, + description: { type: 'string', nullable: true, example: 'A message to your verified address.' }, + carriesContent: { + type: 'boolean', + description: 'False for push, which only ever sends a content-free tickle the client then pulls against.', + example: true, + }, + defaultMode: { + type: 'string', + enum: ['off', 'instant', 'digest'], + description: 'The mode that applies when the user has stored no preference for an id on this channel.', + example: 'off', + }, + supportsDigest: { type: 'boolean', example: true }, + modes: { + type: 'array', + items: { type: 'string', enum: ['off', 'instant', 'digest'] }, + description: 'The modes this channel will accept. Excludes `digest` unless supportsDigest.', + example: ['off', 'instant', 'digest'], + }, + }, + }, + NotificationChannelPrefItem: { + type: 'object', + description: 'One subscribable id — a push stream, an event trigger, or both — with the effective mode on each channel that applies to it.', + properties: { + id: { type: 'string', example: 'news.post' }, + label: { type: 'string', example: 'News posts' }, + description: { type: 'string', example: 'New news / Five-on-Friday / newsletter posts.' }, + personal: { type: 'boolean', example: false }, + requiresLinkedAccount: { type: 'boolean', example: false }, + ceiling: { + type: 'string', + nullable: true, + description: 'The trigger’s audience ceiling, or null for an id with no trigger declaration.', + example: 'authenticated', + }, + channels: { + type: 'array', + items: { type: 'string' }, + description: 'Which channels apply. A trigger-only id has no `push` — nothing is registered to push it.', + example: ['push', 'email', 'inapp'], + }, + modes: { + type: 'object', + additionalProperties: { type: 'string', enum: ['off', 'instant', 'digest'] }, + description: 'Effective mode per applicable channel: the stored value, or the channel’s default where nothing is stored.', + example: { push: 'instant', email: 'off', inapp: 'off' }, + }, + }, + }, + NotificationChannelPrefs: { + type: 'object', + description: 'The per-channel preferences surface: the channel registry plus one item per subscribable id. Returned by both GET and PUT.', + properties: { + channels: { type: 'array', items: { $ref: '#/components/schemas/DeliveryChannel' } }, + items: { type: 'array', items: { $ref: '#/components/schemas/NotificationChannelPrefItem' } }, + }, + }, + NotificationChannelPrefsUpdate: { + type: 'object', + description: 'A SPARSE preference update. Only the (id, channel) pairs listed are written; every other pair is left untouched. `off` is a mode, never an omission.', + properties: { + prefs: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'news.post' }, + channel: { type: 'string', example: 'email' }, + mode: { type: 'string', enum: ['off', 'instant', 'digest'], example: 'digest' }, + }, + }, + example: [{ id: 'news.post', channel: 'email', mode: 'digest' }], + }, + }, + }, TeamNotificationPref: { type: 'object', description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).", diff --git a/server/test/notificationChannelPrefs.test.js b/server/test/notificationChannelPrefs.test.js new file mode 100644 index 0000000..384ad4d --- /dev/null +++ b/server/test/notificationChannelPrefs.test.js @@ -0,0 +1,368 @@ +// ── Per-channel notification preferences (ENGAGEMENT.md Phase 3) ─────────── +// +// The phase's acceptance criteria, one test apiece: +// +// • the shipped Android app's flat `{streams:[…]}` PUT still round-trips, +// INCLUDING the empty-array case its DTO comment warns about +// • a per-channel PUT sets `email` without touching `push` +// • a fresh user's email mode defaults `off`, and so does push +// +// …plus the two properties that make the projection safe to ship: the legacy +// wire shape is pinned BYTE-FOR-BYTE (the app cannot be changed from this side), +// and the invariant the two endpoints jointly maintain — a push pref with mode +// <> 'off' iff a `notification_subscriptions` row — is asserted from both +// directions rather than only from the one the code happens to take. +// +// Point the DB at a closed port before requiring anything: the registries reach +// utils/discordAnnounce, which builds the pool at require time. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const channels = require('../src/engagement/channels') +const prefs = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.model') +const prefsDb = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.db') +const subs = require('../src/model/notificationSubs/notificationSubs.model') +const subsDb = require('../src/model/notificationSubs/notificationSubs.db') +const notifCtrl = require('../src/router/v1/auth/notifications.controller') +const db = require('../src/utils/db') + +after(() => db.close()) + +const USER = 7 +const PLAYER = { id: USER, role: 'player' } +const ADMIN = { id: USER, role: 'admin' } + +// ── In-memory stand-ins for the two tables ───────────────────────────────── +// +// Both are stubbed at the `.db` layer, so the model's own fan-out logic — the +// part this phase actually adds — runs for real against them. +let prefRows // Map " " -> mode +let subRows // Set " " + +function installStubs() { + prefRows = new Map() + subRows = new Set() + + prefsDb.listByUser = async (userId) => + [...prefRows.entries()] + .filter(([k]) => k.startsWith(`${userId} `)) + .map(([k, mode]) => { + const [, streamId, channel] = k.split(' ') + return { stream_id: streamId, channel, mode } + }) + .sort((a, b) => a.stream_id.localeCompare(b.stream_id) || a.channel.localeCompare(b.channel)) + + prefsDb.upsert = async (userId, streamId, channel, mode) => { + prefRows.set(`${userId} ${streamId} ${channel}`, mode) + } + + prefsDb.offPushExcept = async (userId, keep) => { + for (const [k, mode] of prefRows.entries()) { + const [u, streamId, channel] = k.split(' ') + if (Number(u) !== userId || channel !== 'push' || mode === 'off') continue + if (!keep.includes(streamId)) prefRows.set(k, 'off') + } + } + + subsDb.listByUser = async (userId) => + [...subRows] + .filter((k) => k.startsWith(`${userId} `)) + .map((k) => ({ stream_id: k.split(' ')[1] })) + .sort((a, b) => a.stream_id.localeCompare(b.stream_id)) + + subsDb.replaceForUser = async (userId, streams) => { + for (const k of [...subRows]) if (k.startsWith(`${userId} `)) subRows.delete(k) + for (const s of streams) subRows.add(`${userId} ${s}`) + } + subsDb.addForUser = async (userId, streamId) => subRows.add(`${userId} ${streamId}`) + subsDb.removeForUser = async (userId, streamId) => subRows.delete(`${userId} ${streamId}`) +} + +// The channel registry is populated by requiring the subsystem's door, exactly +// as app.js does. Requiring `channels` alone gets the empty map — that is the +// design, and doing it the other way here would hide a boot-order regression. +function registerChannels() { + channels._reset() + delete require.cache[require.resolve('../src/engagement/coreChannels')] + // eslint-disable-next-line global-require + require('../src/engagement/coreChannels') +} + +beforeEach(() => { + registries._reset() + registries.registerCore() + registerChannels() + installStubs() +}) + +afterEach(() => { + registries._reset() + channels._reset() +}) + +const mockRes = () => ({ + statusCode: 200, + body: null, + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, +}) + +const item = (surface, id) => surface.items.find((i) => i.id === id) + +// ── Acceptance: the shipped app's wire shape ─────────────────────────────── + +test('the legacy subscriptions PUT round-trips byte-for-byte', async () => { + const put = mockRes() + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'team.forum.post'] } }, put) + + // Byte-for-byte: the response is `{ streams: [...] }` and nothing else. The + // Android DTO is frozen, so an extra key is as much a break as a missing one. + assert.deepEqual(Object.keys(put.body), ['streams']) + assert.deepEqual(put.body.streams.slice().sort(), ['news.post', 'team.forum.post']) + + const get = mockRes() + await notifCtrl.getSubscriptions({ user: PLAYER }, get) + assert.deepEqual(Object.keys(get.body), ['streams']) + assert.deepEqual(get.body.streams, ['news.post', 'team.forum.post']) +}) + +test('the empty-array case its DTO comment warns about still clears the set', async () => { + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes()) + + const cleared = mockRes() + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: [] } }, cleared) + assert.deepEqual(cleared.body, { streams: [] }) + + // And the projection cleared with it — the failure this test exists to catch + // is a channel-prefs row left at 'instant' after the app said "none", which + // would resurrect the subscription the next time anything read the new table. + const surface = await prefs.getForUser(USER, PLAYER) + assert.equal(item(surface, 'news.post').modes.push, 'off') + assert.equal(subRows.size, 0) +}) + +test('unknown stream ids are still dropped, and are not mirrored either', async () => { + const res = mockRes() + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'no.such.stream'] } }, res) + assert.deepEqual(res.body, { streams: ['news.post'] }) + + const surface = await prefs.getForUser(USER, PLAYER) + assert.equal(item(surface, 'no.such.stream'), undefined) + assert.deepEqual([...subRows], [`${USER} news.post`]) +}) + +// ── Acceptance: defaults ─────────────────────────────────────────────────── + +test("a fresh user's modes are the channel defaults, and all three are off", async () => { + const surface = await prefs.getForUser(USER, PLAYER) + const news = item(surface, 'news.post') + + assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'off' }) + assert.equal(prefRows.size, 0, 'reading preferences must not write rows') + + // The acceptance line in ENGAGEMENT.md originally said push defaults + // 'instant'. It cannot: `notification_subscriptions` is opt-IN, so that would + // have projected the whole catalog into the legacy GET for every existing + // user and switched every toggle on in the shipped app. Settled 'off' by the + // org lead; this assertion is what stops it drifting back. + const legacy = mockRes() + await notifCtrl.getSubscriptions({ user: PLAYER }, legacy) + assert.deepEqual(legacy.body, { streams: [] }) +}) + +test('a mode with no stored row reads as the channel default, not as a hardcoded off', async () => { + // Prove the default is READ from the registry rather than assumed: re-register + // `inapp` with a different default and the same fresh user reads it back. + channels._reset() + channels.registerDeliveryChannel({ + id: 'inapp', label: 'On the site', carriesContent: true, defaultMode: 'instant', supportsDigest: false, + }) + + const surface = await prefs.getForUser(USER, PLAYER) + assert.equal(item(surface, 'news.post').modes.inapp, 'instant') +}) + +// ── Acceptance: the sparse per-channel PUT ───────────────────────────────── + +test('a per-channel PUT sets email without touching push', async () => { + await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes()) + + const res = mockRes() + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'news.post', channel: 'email', mode: 'digest' }] } }, + res, + ) + + const news = item(res.body, 'news.post') + assert.equal(news.modes.email, 'digest') + assert.equal(news.modes.push, 'instant', 'the push mode must survive an email-only write') + + // …and the legacy endpoint agrees, which is the whole point of the projection. + const legacy = mockRes() + await notifCtrl.getSubscriptions({ user: PLAYER }, legacy) + assert.deepEqual(legacy.body, { streams: ['news.post'] }) +}) + +test('a push write through the channels endpoint fans out to the old table', async () => { + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'instant' }] } }, + mockRes(), + ) + assert.deepEqual([...subRows], [`${USER} team.forum.post`]) + + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'off' }] } }, + mockRes(), + ) + assert.deepEqual([...subRows], []) + + // 'off' is STORED, not deleted: it is a statement the user made, and folding it + // back into "never said" is only harmless while push's default happens to be + // off. The two are different the moment that default changes. + assert.equal(prefRows.get(`${USER} team.forum.post push`), 'off') +}) + +test('entries the catalog cannot accept are dropped, not refused', async () => { + const res = mockRes() + await notifCtrl.putChannelPrefs( + { + user: PLAYER, + body: { + prefs: [ + { id: 'no.such.id', channel: 'email', mode: 'instant' }, + { id: 'news.post', channel: 'carrier.pigeon', mode: 'instant' }, + { id: 'news.post', channel: 'push', mode: 'digest' }, // push has no digest + { id: 'news.post', channel: 'email', mode: 'instant' }, // the one good row + ], + }, + }, + res, + ) + + assert.equal(res.statusCode, 200) + assert.equal(item(res.body, 'news.post').modes.email, 'instant') + assert.equal(item(res.body, 'news.post').modes.push, 'off') + assert.equal(prefRows.size, 1, 'only the accepted pair was written') +}) + +test('the last entry wins when a body names the same pair twice', async () => { + const res = mockRes() + await notifCtrl.putChannelPrefs( + { + user: PLAYER, + body: { + prefs: [ + { id: 'news.post', channel: 'email', mode: 'instant' }, + { id: 'news.post', channel: 'email', mode: 'digest' }, + ], + }, + }, + res, + ) + assert.equal(item(res.body, 'news.post').modes.email, 'digest') +}) + +// ── The catalog: one namespace, two facets ───────────────────────────────── + +test('a trigger-only id gets email and in-app, and no push toggle', async () => { + const api = registries.stage('uo') + api.registerEventTriggers([{ + id: 'uo.house.idoc_warning', + label: 'House approaching collapse', + ceiling: 'owner', + variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }], + }]) + registries.apply(api.staged) + + const surface = await prefs.getForUser(USER, PLAYER) + const idoc = item(surface, 'uo.house.idoc_warning') + + assert.ok(idoc, 'a trigger-only id is subscribable') + assert.deepEqual(idoc.channels, ['email', 'inapp']) + assert.equal('push' in idoc.modes, false, 'there is nothing registered to push it') + + // A push entry for it is therefore inapplicable and dropped. + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'uo.house.idoc_warning', channel: 'push', mode: 'instant' }] } }, + mockRes(), + ) + assert.equal(subRows.size, 0) + assert.equal(prefRows.size, 0) +}) + +test('an id that is both a stream and a trigger appears once, with all three channels', async () => { + // Core's five trigger ids ARE its five stream ids — the same-owner upgrade the + // one-namespace rule exists for, exercised on every boot. + const surface = await prefs.getForUser(USER, PLAYER) + const news = surface.items.filter((i) => i.id === 'news.post') + + assert.equal(news.length, 1) + assert.deepEqual(news[0].channels, ['push', 'email', 'inapp']) + assert.equal(news[0].ceiling, 'authenticated', 'the trigger facet supplies the ceiling') +}) + +test('a staff-ceilinged trigger is not offered to a player, and is to staff', async () => { + const api = registries.stage('uo') + api.registerEventTriggers([{ + id: 'uo.cheat.detected', + label: 'Cheat detected', + ceiling: 'staff', + variables: [{ name: 'character', type: 'string', required: true, example: 'Darrow' }], + }]) + registries.apply(api.staged) + + const asPlayer = await prefs.getForUser(USER, PLAYER) + assert.equal(item(asPlayer, 'uo.cheat.detected'), undefined, 'a player is not told it exists') + + const asAdmin = await prefs.getForUser(USER, ADMIN) + assert.ok(item(asAdmin, 'uo.cheat.detected')) + + // And the filter is a gate, not just a display rule: a player who knows the id + // still cannot store a preference for it. + const res = mockRes() + await notifCtrl.putChannelPrefs( + { user: PLAYER, body: { prefs: [{ id: 'uo.cheat.detected', channel: 'email', mode: 'instant' }] } }, + res, + ) + assert.equal(prefRows.size, 0) +}) + +// ── The channel registry itself ──────────────────────────────────────────── + +test('the registry refuses a channel that under-declares', async () => { + channels._reset() + const ok = { id: 'x', label: 'X', carriesContent: true, defaultMode: 'off', supportsDigest: false } + + assert.throws(() => channels.registerDeliveryChannel({ ...ok, carriesContent: undefined }), /carriesContent/) + assert.throws(() => channels.registerDeliveryChannel({ ...ok, supportsDigest: undefined }), /supportsDigest/) + assert.throws(() => channels.registerDeliveryChannel({ ...ok, defaultMode: 'sometimes' }), /defaultMode/) + assert.throws(() => channels.registerDeliveryChannel({ ...ok, id: 'Not An Id' }), /invalid id/) + + // A channel that cannot batch cannot default to batching — the failure this + // prevents is a stored 'digest' row no delivery path can ever honour. + assert.throws( + () => channels.registerDeliveryChannel({ ...ok, defaultMode: 'digest', supportsDigest: false }), + /supportsDigest/, + ) + + channels.registerDeliveryChannel(ok) + assert.throws(() => channels.registerDeliveryChannel(ok), /already registered/) +}) + +test('push is content-free and instant-only, by declaration', async () => { + assert.equal(channels.get('push').carriesContent, false) + assert.deepEqual(channels.modesFor('push'), ['off', 'instant']) + assert.deepEqual(channels.modesFor('email'), ['off', 'instant', 'digest']) +}) + +test('an unregistered channel reads as off and accepts nothing', async () => { + // A stored row can name a channel that is no longer registered (a downgrade). + // It must read as off, never throw and never be on by accident. + assert.equal(channels.defaultMode('discord.dm'), 'off') + assert.deepEqual(channels.modesFor('discord.dm'), []) + assert.equal(channels.acceptsMode('discord.dm', 'instant'), false) +})