feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)
`notification_subscriptions` answers one question — which streams a user wants
PUSHED — because that is the only question the shipped Android client can ask.
This adds the general one: which subscribable ids, on which channel, in which
mode. The old table becomes the push projection of the new one and keeps its
exact wire shape, so the shipped APK needs no update and no delivery path is
touched.
What lands:
- `engagement/channels.js` — `registerDeliveryChannel` (ENGAGEMENT.md §3.1), the
declarative half only: id, label, `carriesContent`, `defaultMode`,
`supportsDigest`. `addressFor`/`render`/`deliver` wait for Phases 6 and 7, for
the reason `transports/index.js` deferred this file at all. `coreChannels.js`
declares push / email / inapp through the subsystem's one door.
- `notification_channel_prefs` + a replay-safe `INSERT IGNORE … SELECT` backfill,
copying the `announce_jobs → announce_job_legs` precedent.
- `GET · PUT /auth/me/notifications/channels`. The PUT is SPARSE — only the
`(id, channel)` pairs named are written — deliberately unlike the two whole-set
PUTs beside it. `off` is a mode rather than an omission, so this endpoint has
no empty-array case and the kotlinx DTO gotcha cannot arise here.
Three decisions the org lead settled before any code, and one corrects the
phase's own acceptance criterion: push's `defaultMode` is `off`, not `instant`.
The plan borrowed "push is opt-OUT" from `team_notification_prefs`, where no row
does mean notified — but stream subscriptions have never worked that way, so
`instant` would have projected the whole catalog into the legacy GET for every
existing user and switched every toggle on in the shipped app after an upgrade
nobody asked for. A test pins the legacy GET at `{streams:[]}` for a fresh user.
One thing not named by the phase, and it is a G24 consequence rather than scope
creep: a trigger ceilinged at `staff` can never reach a non-staff user, so
offering the toggle would be offering a dead control AND disclosing the event
exists — `uo.cheat.detected` would otherwise appear in every player's screen the
moment Phase 11 declared it. Filtered from the catalog and gated on write. That
gave the `staff` label its first consumer, now written down as
`ceilings.STAFF_CEILING_ROLES` (the admin tier's three, deliberately not
`teamGrants.STAFF_ROLES`, which answers a different question).
15 new tests; swagger, route manifest and guards regenerated. No web or app
surface — those are Phases 7 and 8, where a preference governs something visible.
Refs: docs/website/ENGAGEMENT.md Phase 3, §3.1, §4.5
Co-Authored-By: Claude <noreply@anthropic.com>
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