`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>
129 lines
5.2 KiB
JavaScript
129 lines
5.2 KiB
JavaScript
// ── 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,
|
|
}
|