feat(engagement): the in-app channel, core and web (engagement Phase 7)
All checks were successful
PR Checks / client-build (pull_request) Successful in 37s
PR Checks / server-tests (pull_request) Successful in 3m27s
PR Checks / bot-tests (pull_request) Successful in 8m36s

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:
2026-08-31 02:07:10 -05:00
parent 5168446c53
commit 24a3cd85b3
34 changed files with 3153 additions and 112 deletions

View File

@@ -0,0 +1,189 @@
// ── The in-app inbox: SQL ──────────────────────────────────────────────────
//
// ENGAGEMENT.md §4.5 (G17), Phase 7. `user_notifications` is a small table with
// one unusual property worth stating up front: **every read here is scoped by
// `user_id`, and none of them takes an id alone.**
//
// That is not belt-and-braces over the route's own auth check. A notification is
// the only content core stores that is addressed to exactly one person, so
// "mark 41 read" is a request whose whole meaning is which account is asking.
// Passing the caller down to the WHERE clause makes the ownership check part of
// the statement that does the work, rather than a separate question asked
// earlier and trusted afterwards — an `UPDATE … WHERE id = ? AND user_id = ?`
// that matches nothing is a 404, and there is no ordering in which it is not.
// Phase 7's acceptance line asks for that assertion at the ROUTE; this is what
// makes the route's answer true rather than merely tested.
const { query } = require('../../utils/db')
// The page size a client gets when it asks for none, and the largest it may ask
// for. An inbox is read newest-first and nobody scrolls to row 500; the cap is
// what stops `?limit=100000` from being a way to make the server assemble the
// whole table.
const DEFAULT_LIMIT = 30
const MAX_LIMIT = 100
const num = (n) => (Number.isFinite(Number(n)) ? Number(n) : 0)
/** Shape one row for the API. `read` as a boolean beside the stamp: a client
* renders the flag and shows the stamp, and neither has to parse the other. */
const toItem = (row) => ({
id: num(row.id),
triggerId: row.trigger_id,
title: row.title,
body: row.body || null,
url: row.url || null,
read: row.read_at != null,
readAt: row.read_at || null,
createdAt: row.created_at,
})
/**
* Write one item, ignoring a duplicate `dedupe_key`.
*
* @returns {Promise<{inserted: boolean, id: number|null}>}
*
* `INSERT IGNORE` rather than a SELECT-then-INSERT, because the two callers race
* by construction: the outbox worker can be mid-retry while a module calls
* `ctx.inbox.push` for the same event. IGNORE also swallows an FK failure on a
* deleted user, which is the right outcome for the same reason — a row addressed
* to an account that no longer exists is not a failure anybody can act on.
*
* `inserted: false` is the dedupe path and the caller reports success: the user
* has the item, which is what "delivered" means. Distinguishing them at all is
* for the send log, which is entitled to say the second one was a duplicate.
*/
const insert = async ({ userId, triggerId, title, body = null, url = null, dedupeKey = null }) => {
const res = await query(
`INSERT IGNORE INTO user_notifications (user_id, trigger_id, title, body, url, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?)`,
[Number(userId), String(triggerId), String(title), body, url, dedupeKey],
)
const inserted = num(res && res.affectedRows) > 0
return { inserted, id: inserted ? num(res.insertId) : null }
}
/**
* One page of a user's inbox, newest first.
*
* @param {number} userId
* @param {{limit?: number, before?: number, unreadOnly?: boolean}} [opts]
* `before` is a keyset cursor (an id), not an offset. An inbox gains rows
* at the top while it is being paged; OFFSET under those conditions skips
* or repeats items, and the id is already the ordering key.
*/
const list = async (userId, { limit, before, unreadOnly } = {}) => {
const take = Math.min(Math.max(Number(limit) || DEFAULT_LIMIT, 1), MAX_LIMIT)
const params = [Number(userId)]
let where = 'user_id = ?'
if (unreadOnly) where += ' AND read_at IS NULL'
if (Number(before) > 0) {
where += ' AND id < ?'
params.push(Number(before))
}
// take + 1 so the caller can say whether there is another page without a
// second COUNT over the same predicate.
const rows = await query(
`SELECT id, trigger_id, title, body, url, read_at, created_at
FROM user_notifications
WHERE ${where}
ORDER BY id DESC
LIMIT ?`,
[...params, take + 1],
)
const hasMore = rows.length > take
return { items: rows.slice(0, take).map(toItem), hasMore }
}
/**
* The item written for one (user, dedupe key), or null.
*
* The push channel's `ref` lookup and nothing else. A NULL dedupe key is not a
* wildcard — it means "this item does not dedupe", and matching on it would
* return an arbitrary earlier notification.
*/
const findByDedupe = async (userId, dedupeKey) => {
if (!dedupeKey) return null
const rows = await query(
`SELECT id, trigger_id, title, body, url, read_at, created_at
FROM user_notifications WHERE user_id = ? AND dedupe_key = ? LIMIT 1`,
[Number(userId), String(dedupeKey)],
)
return rows.length ? toItem(rows[0]) : null
}
/** How many of this user's items are unread. The badge. */
const unreadCount = async (userId) => {
const rows = await query(
'SELECT COUNT(*) AS n FROM user_notifications WHERE user_id = ? AND read_at IS NULL',
[Number(userId)],
)
return num(rows[0] && rows[0].n)
}
/**
* Mark one item read. Idempotent, and scoped to its owner.
*
* `read_at IS NULL` in the predicate is what makes a second call a no-op rather
* than a re-stamp: the acceptance line says mark-read is idempotent, and a
* timestamp that moves every time somebody re-opens the page is not.
*
* @returns {Promise<boolean>} whether the row exists FOR THIS USER — false is a
* 404 whether the id belongs to nobody or to somebody else, which is
* also the only answer that does not report other people's row ids.
*/
const markRead = async (userId, id) => {
await query(
'UPDATE user_notifications SET read_at = NOW() WHERE id = ? AND user_id = ? AND read_at IS NULL',
[Number(id), Number(userId)],
)
const rows = await query('SELECT id FROM user_notifications WHERE id = ? AND user_id = ?', [
Number(id),
Number(userId),
])
return rows.length > 0
}
/** Mark everything read. @returns {Promise<number>} how many changed. */
const markAllRead = async (userId) => {
const res = await query(
'UPDATE user_notifications SET read_at = NOW() WHERE user_id = ? AND read_at IS NULL',
[Number(userId)],
)
return num(res && res.affectedRows)
}
/**
* Drop items older than `days`.
*
* **Read rows only.** An unread item is one the user has not seen, and an inbox
* that quietly deletes those is worse than one that grows: the whole point of
* the badge is that something is waiting. Age alone would also delete the
* evidence for "I was never told", which is the complaint this table answers.
* A never-read backlog is bounded in practice by the per-rule hourly ceiling.
*
* `LIMIT` per call so one sweep after a long outage is a bounded statement
* rather than a delete of a million rows holding locks; the sweep runs again.
*/
const pruneRead = async (days, limit = 1000) => {
const res = await query(
`DELETE FROM user_notifications
WHERE read_at IS NOT NULL AND created_at < (NOW() - INTERVAL ? DAY)
LIMIT ?`,
[Number(days), Number(limit)],
)
return num(res && res.affectedRows)
}
module.exports = {
insert,
list,
findByDedupe,
unreadCount,
markRead,
markAllRead,
pruneRead,
toItem,
DEFAULT_LIMIT,
MAX_LIMIT,
}