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
|
WHERE refresh_token_enc IS NOT NULL
|
||||||
AND credential_enc IS NULL
|
AND credential_enc IS NULL
|
||||||
AND status <> 'unconfigured';
|
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"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/auth/me/notifications/streams",
|
"path": "/api/v1/auth/me/notifications/streams",
|
||||||
|
|||||||
@@ -569,6 +569,14 @@
|
|||||||
"method": "DELETE",
|
"method": "DELETE",
|
||||||
"path": "/api/v1/auth/me/devices/:id"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/auth/me/notifications/streams"
|
"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
|
// 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
|
// 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).
|
// 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()
|
registries.registerCore()
|
||||||
modules.load({
|
modules.load({
|
||||||
public: require('./router/v1/public'),
|
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 ────────────────────────────────────
|
// ── The engagement subsystem — one door ────────────────────────────────────
|
||||||
//
|
//
|
||||||
// ENGAGEMENT.md Phase 1. Today this is the mail transport registry and core's
|
// ENGAGEMENT.md Phases 1 and 3. Today this is the mail transport registry, core's
|
||||||
// own transports; the trigger registry, the rules engine and the delivery
|
// own transports, and the delivery-channel registry with core's three channels;
|
||||||
// channels arrive in later phases and hang here too.
|
// 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
|
// **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
|
// 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
|
// `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the
|
||||||
// registry never has the side effect of populating it.
|
// registry never has the side effect of populating it.
|
||||||
//
|
//
|
||||||
// Requiring this module is what makes `smtp` available. Everything that resolves
|
// Requiring this module is what makes `smtp` and the three channels available.
|
||||||
// a transport goes through here, so there is exactly one place a transport can
|
// Everything that resolves either goes through here, so there is exactly one
|
||||||
// come into existence.
|
// place a transport or a channel can come into existence.
|
||||||
|
|
||||||
require('./transports/smtp')
|
require('./transports/smtp')
|
||||||
|
require('./coreChannels')
|
||||||
|
|
||||||
const transports = require('./transports')
|
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,
|
// 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
|
// 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
|
// is the second half only; the channel half is `../channels.js`, which Phase 3
|
||||||
// consumes it — registering a channel nothing calls would be a shape frozen
|
// added when `notification_channel_prefs` needed a single place for `defaultMode`
|
||||||
// before anything had tried to use it.
|
// 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
|
// 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
|
// 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
|
// 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
|
// installed module's, so it is read back through modules/registries rather than
|
||||||
// from a config file (MODULE_SYSTEM.md §1.8).
|
// 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 db = require('./notificationSubs.db')
|
||||||
const { isValidStream } = require('../../modules/registries')
|
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
|
// 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
|
// stale client can't create rows for streams that no longer exist. Returns the
|
||||||
// stored (cleaned) set.
|
// 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) {
|
async function setForUser(userId, streams) {
|
||||||
const clean = [...new Set((Array.isArray(streams) ? streams : []).filter(isValidStream))]
|
const clean = [...new Set((Array.isArray(streams) ? streams : []).filter(isValidStream))]
|
||||||
await db.replaceForUser(userId, clean)
|
await db.replaceForUser(userId, clean)
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
await require('../notificationChannelPrefs/notificationChannelPrefs.model').mirrorPushSet(userId, clean)
|
||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ const LABELS = {
|
|||||||
owner: 'Only the user the event is about',
|
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)
|
const CEILINGS = Object.keys(PARENT)
|
||||||
|
|
||||||
/** Is this one of the six? The gate every registration and every rule save runs. */
|
/** 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])
|
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 pushDevices = require('../../../model/pushDevices/pushDevices.model')
|
||||||
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
||||||
|
const channelPrefs = require('../../../model/notificationChannelPrefs/notificationChannelPrefs.model')
|
||||||
const registries = require('../../../modules/registries')
|
const registries = require('../../../modules/registries')
|
||||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
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
|
// 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.
|
// per Team they could be notified about whether or not they have ever set one.
|
||||||
//
|
//
|
||||||
@@ -119,6 +153,8 @@ module.exports = {
|
|||||||
getStreams,
|
getStreams,
|
||||||
getSubscriptions,
|
getSubscriptions,
|
||||||
putSubscriptions,
|
putSubscriptions,
|
||||||
|
getChannelPrefs,
|
||||||
|
putChannelPrefs,
|
||||||
getTeamPrefs,
|
getTeamPrefs,
|
||||||
putTeamPrefs,
|
putTeamPrefs,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const { requireAuth } = require('../../../auth/session.middleware')
|
|||||||
const noindex = require('../../../middleware/noindex')
|
const noindex = require('../../../middleware/noindex')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
|
const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
|
||||||
|
const { MODES } = require('../../../engagement/channels')
|
||||||
|
|
||||||
const notifRouter = express.Router()
|
const notifRouter = express.Router()
|
||||||
|
|
||||||
@@ -98,6 +99,44 @@ notifRouter.put(
|
|||||||
notif.putSubscriptions,
|
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) ──────────────────────────
|
// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
|
||||||
//
|
//
|
||||||
// The granularity per-stream opt-in cannot express: "I am in five Teams and want
|
// 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": {
|
"/api/v1/auth/me/notifications/streams": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"TeamNotificationPref": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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: {
|
TeamNotificationPref: {
|
||||||
type: 'object',
|
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`).",
|
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