feat(engagement): the in-app channel, core and web (engagement Phase 7)
ENGAGEMENT.md Phase 7. `user_notifications`, the in-app DeliveryChannel, the
four inbox routes, and the web surface — plus the two pieces earlier phases
assigned here that Phase 7's own acceptance line omits.
Four decisions settled by the org lead before any code:
1. `inapp` defaults to `instant` — the only channel that does. Push wakes a
device somebody is holding and email leaves the building, so both are asked
for; an inbox item is a row on a page the user chose to open. Left `off` the
channel ships dead.
2. The phase takes push's `deliver` (§2603) and the web per-channel preferences
screen (Phase 3's as-built), neither of which its own bullets mention.
3. The inbox takes `/auth/me/notifications` and `/account/notifications`; the
preferences screen moves to `…/settings`. The plain word belongs to the
content, which is what the bell opens.
4. `ctx.inbox.push` honours the user's in-app preference when `triggerId` names
a registered trigger, and writes when it does not.
Server
- `user_notifications` + `model/userNotifications/`. The dedupe UNIQUE is scoped
to the USER, narrower than the outbox's `(rule, user, channel)`: an inbox has
no channel dimension, so two rows for one event would be one item shown twice.
- `engagement/inappChannel.js` — renders by block ROLE (first heading → title,
first button → url, the rest → body) and inserts. `pushChannel.js` — a
content-free `{stream, ref}` tickle whose ref deep-links the inbox row.
- `engine.liveChannels` orders `inapp` first (`CHANNEL_ORDER`) so that ref
resolves on the first sweep. An ordering, not a dependency.
- `templates.renderInappByKey` + `resolveTemplate` extracted from `renderByKey`,
so both channels take the same fallback chain.
- `inapp.event` seed → seedVersion 2: it named `body`/`url`, which nothing
supplies. Renamed to the structural vocabulary the projection fills in.
- `utils/userNotificationsPrune.js` — nightly, READ items only, horizon in
`settings.user_notifications_retain_days` (default 90).
- `GET /auth/me/notifications`, `…/unread-count`, `POST …/:id/read`,
`POST …/read-all`. Swagger + route manifest + four component schemas.
Web
- `NotificationBell` in all three headers, polling its badge once a minute and
pausing while the tab is hidden. `PlayerInbox` at `/account/notifications`.
- The preferences screen becomes a channel matrix over
`/auth/me/notifications/channels` — a strict superset of the push-only stream
list it replaces. The two legacy endpoints are untouched, so the shipped
Android app keeps its wire shape.
- Staff get the same two screens at `/admin/notifications…`: `RequirePlayer`
keeps them out of `/account`, so without this the inbox was unreachable for
every non-player account. `lib/notificationPaths.js` is the one mapping.
Verified: 28 new server tests (5 of them against a real MariaDB, for the three
index/statement properties that are a server contract rather than a reading of
this code) + 3 client. Server suite green, client 327 green. A live rig walked
the whole path: two rules on one event produced three outbox rows and exactly
one inbox item, the tickle carried `ref: notification:2`, and the retention
sweep dropped an aged read row while keeping an equally aged unread one.
Docs: RunicGateway/docs#TBD, RunicGateway/runicgateway.com#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
91
server/src/engagement/pushChannel.js
Normal file
91
server/src/engagement/pushChannel.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ── The push DeliveryChannel: addressFor + deliver ─────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. Push is the channel that has existed longest and had a
|
||||
// `deliver` last, because until this phase there was nothing for a tickle to
|
||||
// point AT: `{ stream, ref }` carries no content by design, so a rule firing on
|
||||
// push before the inbox existed would have woken a phone to pull a screen that
|
||||
// had nothing on it.
|
||||
//
|
||||
// **The tickle invariant is the whole of this file's security posture.** What
|
||||
// leaves the server is the stream id and an opaque ref, never a title, never a
|
||||
// body, never the payload — `carriesContent: false` on the registration is the
|
||||
// declaration and this is the implementation. ntfy is treated as an untrusted
|
||||
// relay, so a leaked topic must reveal nothing but that *something* happened;
|
||||
// the app then pulls the real item over the authenticated, ownership-checked
|
||||
// inbox API. Every claim in that paragraph is one `pushDispatch` already makes,
|
||||
// which is why delivery here is a call into it rather than a second publisher.
|
||||
//
|
||||
// **`ref` points at the inbox row when there is one, and is null otherwise.**
|
||||
// A rule spanning `inapp` and `push` enqueues both, and `liveChannels` orders
|
||||
// `inapp` first precisely so the row exists by the time this runs — but that is
|
||||
// an optimisation, not a guarantee: the two rows are independent, either can be
|
||||
// retried, and a push-only rule has no inbox row at all. So the ref is a HINT.
|
||||
// The app's contract (docs/android/PLAN.md §11, Phase 8) is wake-and-pull; a
|
||||
// client that renders the ref instead of pulling is a client that will show
|
||||
// nothing the first time a retry reorders these two rows.
|
||||
|
||||
const inbox = require('../model/userNotifications/userNotifications.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const pushDispatch = require('../utils/pushDispatch')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* Can this channel reach `userId`?
|
||||
*
|
||||
* Active account only, the same re-check `emailChannel` and `inappChannel` make
|
||||
* for the same reason (a row can sit through a `delay_seconds` window). It does
|
||||
* NOT check for a registered device: whether any endpoint is subscribed is the
|
||||
* question `publishToUsers` answers in its own query, and asking it twice would
|
||||
* mean two different definitions of "reachable" that could disagree.
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const active = await recipients.filterActive([userId])
|
||||
return active.length ? { address: String(active[0]) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one claimed outbox row.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
try {
|
||||
if (!(await addressFor(row.user_id))) {
|
||||
return { ok: false, detail: 'this user can no longer be reached' }
|
||||
}
|
||||
|
||||
// Best effort, and it fails to null rather than to an error: no dedupe key,
|
||||
// no in-app row for it, or an inapp row this rule never enqueued all mean
|
||||
// the same thing to the app — wake up and pull.
|
||||
let ref = null
|
||||
try {
|
||||
const item = await inbox.findByDedupe(row.user_id, row.dedupe_key)
|
||||
if (item) ref = `notification:${item.id}`
|
||||
} catch (err) {
|
||||
log.debug('could not resolve a push ref', { outbox: row.id, message: err.message })
|
||||
}
|
||||
|
||||
// The stream id IS the trigger id — §7.2's one namespace, settled in Phase 2.
|
||||
// A push stream and an event trigger share a name space, so the app's
|
||||
// existing `{ stream }` switch keeps working for an engagement rule without
|
||||
// learning a second vocabulary.
|
||||
await pushDispatch.publishToUsers(row.trigger_id, { ref, userIds: [row.user_id] })
|
||||
|
||||
// **Success here means "handed to the relay", and the send log must not
|
||||
// claim more than that.** `publishToUsers` resolves whether it found a
|
||||
// subscribed device or none at all, and a tickle is fire-and-forget over
|
||||
// HTTP to a relay that owes us no receipt. Retrying on "we are not sure"
|
||||
// would mean five wakeups for one event, which is worse than one uncertain
|
||||
// log line — so this is the one channel whose 'sent' is weaker than email's,
|
||||
// and saying so in the detail is how an operator reading G15 finds that out.
|
||||
return { ok: true, transport: 'unifiedpush', detail: 'tickle published' }
|
||||
} catch (err) {
|
||||
// pushDispatch never throws, so reaching here is a programming error rather
|
||||
// than a relay being down. Terminal for that reason: retrying a bug is five
|
||||
// identical rows in the send log.
|
||||
log.error('push delivery failed', { outbox: row.id, message: err.message })
|
||||
return { ok: false, detail: `delivery error: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addressFor, deliver }
|
||||
Reference in New Issue
Block a user