feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3) #169

Merged
whitlocktech merged 1 commits from feature/engagement-channel-prefs into edge 2026-08-29 12:12:03 +00:00
18 changed files with 1530 additions and 12 deletions

View File

@@ -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;

View File

@@ -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",

View File

@@ -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"

View File

@@ -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'),

View File

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

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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 }

View File

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

View File

@@ -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 users 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 channels 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 users 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

View File

@@ -8672,6 +8672,114 @@
]
}
},
"/api/v1/auth/me/notifications/channels": {
"get": {
"tags": [
"Auth · Me"
],
"summary": "Get the current users 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 channels 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 users 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 triggers 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 channels 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": {

View File

@@ -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 triggers 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 channels 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`).",

View File

@@ -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 "<user> <id> <channel>" -> mode
let subRows // Set "<user> <id>"
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)
})