feat(engagement): the in-app channel, core and web (engagement Phase 7) #175
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-inapp-channel"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
ENGAGEMENT.md Phase 7.
user_notifications, the in-appDeliveryChannel, the four inbox routes, and the web surface — plus the two pieces earlier phases assigned to this one that its own acceptance line omits.Companions: RunicGateway/docs#188 · RunicGateway/runicgateway.com#24
Four decisions, settled by the org lead before any code
inappdefaults toinstant— the only channel that does.coreChannels.jsdeferred exactly this question ("whether the inbox is opt-out once it is real is a Phase 7 decision with a live surface to look at"). 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. Leftoff, the channel ships dead — no rule could reach anybody until every user found a toggle for a channel they had never seen deliver anything.deliver(§2603) and the web per-channel preferences screen (Phase 3's as-built). Without the first, a rule naming push still finishedfailedin the send log — the oldest sink in the system, unreachable from the engine. The second endpoint had shipped with no consumer on either platform./auth/me/notificationsand/account/notifications; the preferences screen moves to…/settings. Content and settings are different kinds of thing, and the plain word belongs to the content — it is what the bell opens.ctx.inbox.pushhonours the user's in-app preference whentriggerIdnames a registered trigger, and writes when it does not. A toggle somebody switched off must not be walkable around by the module that owns the trigger behind it.Server
user_notifications+model/userNotifications/. The dedupeUNIQUEis 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.jsrenders by block role — the firstemail.headingis the title, the firstemail.buttonis the url, everything else is the body — then inserts. The body is the text render, not the email HTML: that markup is built for mail clients (table rows, inline hex, a light-only palette) and would render as a pale card in a dark page. It also means there is no operator markup on this surface to sanitize, and no way for one to appear.engagement/pushChannel.jspublishes the content-free{stream, ref}tickle,refdeep-linking the inbox row.engine.liveChannelsordersinappfirst (CHANNEL_ORDER) so that ref resolves on the first sweep — an ordering, not a dependency: the ref is a hint, null when there is no row, and the app's contract stays wake-and-pull.templates.renderInappByKey+resolveTemplateextracted fromrenderByKey, so both channels take the same fallback chain rather than each answering "what does this deployment send when its own table is in a bad state" separately.inapp.event→seedVersion2. Phase 5a wrote it before the channel existed and namedbody/url— names nothing supplies, because a trigger declares domain names andprojection.projectfills gaps with the structural ones. Every rendering would have produced a title and nothing else.utils/userNotificationsPrune.js— nightly,teamActivityPrune's shape, read items only. Age alone would delete the evidence for "I was never told". Horizon insettings.user_notifications_retain_days, default 90.GET /auth/me/notifications,…/unread-count,POST …/:id/read,POST …/read-all. Swagger + route manifest + four component schemas. Route order is commented: the four named preference sub-paths are declared above, and the one parameterised path is a POST whose:idis digits-only.Web
NotificationBellin all three headers. The badge is polled once a minute, pausing while the tab is hidden — there is nothing to push over (the site's two SSE streams are the shard's, neither is per-user, and a third stream carrying one integer would mean an open connection per signed-in tab forever).PlayerInboxat/account/notifications, keyset-paged./auth/me/notifications/channels— a strict superset of the push-only stream list it replaces. The two legacy whole-set endpoints are untouched, so the shipped Android app keeps its wire shape./admin/notifications…— see below.What the live rig found
A real MariaDB, a booted server with the real outbox worker, and a browser.
/auth/me/notificationsis role-agnostic, so the server, the tests and the API all agreed a staff member had one — butRequirePlayersends anyone who is not a player out of/account, so the bell pointed at a redirect. Fixed by mounting the same two components under/admin, adding the bell to the admin header, and putting the one mapping inlib/notificationPaths.jswith its own test. A trap inside the fix:allowedPathsForturns anend: truenav row into an exact match, which left/admin/notifications/settingsoutside the allowlist.already in this inbox (duplicate dedupe key)rather than claiming a second delivery or recording a failure.["push","inapp"]enqueued outbox 2 (inapp) before outbox 3 (push), and the tickle carriedref: "notification:2"with no content beside it.Worth knowing before Phase 11
news.postis a declared trigger that nothing emits through the engine —coreTriggers.jssays so in as many words, and Phase 6 migrated only the fourteam.*ones. So on a real deployment the only in-app items a rule can produce today come from Teams. Wiring the news emitter is deliberately not smuggled into this phase.Verification
engagementInapp.test.js, and 5 inuserNotificationsSql.test.jsagainst a throwaway MariaDB, for the three properties that are a server contract rather than a reading of this code (a UNIQUE index admitting many NULLs,INSERT IGNOREreportingaffectedRows = 0,read_at IS NULLmaking mark-read idempotent).engagementEngine's "a channel with nodeliver()" namedinapp(andemailbefore it) and was rewritten by every phase that gave a channel behaviour; it now registers a throwaway channel, because the property was never about a particular one.Co-Authored-By: Claude.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>