Merge pull request 'feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)' (#169) from feature/engagement-channel-prefs into edge
Reviewed-on: #169
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
|
||||
128
server/src/engagement/channels.js
Normal file
128
server/src/engagement/channels.js
Normal 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,
|
||||
}
|
||||
74
server/src/engagement/coreChannels.js
Normal file
74
server/src/engagement/coreChannels.js
Normal 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 }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
Binary file not shown.
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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`).",
|
||||
|
||||
368
server/test/notificationChannelPrefs.test.js
Normal file
368
server/test/notificationChannelPrefs.test.js
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user