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:
189
server/src/model/userNotifications/userNotifications.db.js
Normal file
189
server/src/model/userNotifications/userNotifications.db.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user