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;
|
||||
|
||||
Reference in New Issue
Block a user