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,203 @@
// ── The inbox's raw SQL, against a real MariaDB ────────────────────────────
//
// ENGAGEMENT.md Phase 7. `engagementInapp.test.js` stubs the table and exercises
// everything the channel DECIDES. It cannot prove the three statements whose
// correctness is a server contract rather than a reading of this code:
//
// • **`UNIQUE (user_id, dedupe_key)` must admit many NULLs.** The whole
// "this item does not dedupe" case rests on it, and a unique index that
// rejected a second NULL would mean the second un-keyed notification any
// user ever received was silently dropped. It is standard SQL and it is also
// exactly the kind of assumption Phase 4a's `foundRows` defect was.
// • **`INSERT IGNORE` on a duplicate reports `affectedRows = 0`** — the value
// `insert()` returns `inserted: false` from, and therefore the value that
// decides whether the send log says "delivered" or "duplicate".
// • **`read_at IS NULL` in the mark-read predicate is what makes it
// idempotent**: the timestamp must not move on a second call.
//
// Plus the prune's one policy: it deletes read rows and leaves unread ones,
// however old.
//
// **It SKIPS when there is no database**, exactly as `engagementEngineSql`
// does and for its reason: CI runs the suite with the pool pointed at a dead
// port, and a file that failed there would make every PR red for a reason
// unrelated to itself. Run it against this machine's container with:
//
// DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=... DB_PASSWORD=... \
// node --test test/userNotificationsSql.test.js
//
// It creates a throwaway database named after the process and drops it again, so
// it can never touch a real schema.
const { test, before, after } = require('node:test')
const assert = require('node:assert/strict')
const mariadb = require('mariadb')
// Verbatim from schema.sql, minus the FK to `users` — the point of this file is
// the index semantics, and a foreign key would mean seeding an accounts table
// that has nothing to do with any of them.
const SCHEMA = `
CREATE TABLE user_notifications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
trigger_id VARCHAR(96) NOT NULL,
title VARCHAR(300) NOT NULL,
body TEXT NULL,
url VARCHAR(500) NULL,
dedupe_key VARCHAR(190) NULL,
read_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_un_dedupe (user_id, dedupe_key),
INDEX idx_un_unread (user_id, read_at, created_at),
INDEX idx_un_prune (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`
// The statements under test, verbatim from `userNotifications.db.js`. Duplicated
// rather than required for `engagementEngineSql`'s reason: requiring the model
// would drag in `utils/db`'s pool, which the harness has pointed at a dead port.
const INSERT = `
INSERT IGNORE INTO user_notifications (user_id, trigger_id, title, body, url, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?)`
const MARK_READ = `
UPDATE user_notifications SET read_at = NOW() WHERE id = ? AND user_id = ? AND read_at IS NULL`
const PRUNE = `
DELETE FROM user_notifications
WHERE read_at IS NOT NULL AND created_at < (NOW() - INTERVAL ? DAY)
LIMIT ?`
const DB = `rg_inbox_test_${process.pid}`
let pool = null
let available = false
const opts = () => ({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
})
before(async () => {
const admin = mariadb.createPool({
...opts(),
connectionLimit: 1,
connectTimeout: 2000,
initializationTimeout: 2000,
})
try {
await admin.query(`CREATE DATABASE ${DB}`)
available = true
} catch {
available = false
} finally {
await admin.end().catch(() => {})
}
if (!available) return
pool = mariadb.createPool({
...opts(),
database: DB,
connectionLimit: 3,
multipleStatements: true,
bigIntAsNumber: true,
insertIdAsNumber: true,
})
await pool.query(SCHEMA)
})
after(async () => {
if (pool) {
await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {})
await pool.end().catch(() => {})
}
})
// Checked INSIDE each test, never as a `{ skip }` option — the trap
// `engagementEngineSql` documents and this file fell into anyway: the option is
// evaluated when the file is READ, which is before `before()` has had a chance
// to find out whether there is a database, so every test skips unconditionally.
// It looks exactly like a passing suite.
const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run'
const needDb = (t) => {
if (available) return false
t.skip(SKIP)
return true
}
const write = (userId, key, over = {}) =>
pool.query(INSERT, [userId, over.trigger || 't.x', over.title || 'Hi', null, null, key])
test('a duplicate (user, dedupe key) is ignored and reports affectedRows 0', async (t) => {
if (needDb(t)) return
const first = await write(901, 'evt:1')
assert.equal(first.affectedRows, 1)
const second = await write(901, 'evt:1')
assert.equal(second.affectedRows, 0)
// Scoped to the USER, not global: one event legitimately reaches fifty people,
// and a global unique key would admit the first and drop forty-nine — the
// defect Phase 4a found in §4.2a's outbox index, in a second place.
const other = await write(902, 'evt:1')
assert.equal(other.affectedRows, 1)
})
test('a NULL dedupe key never collides, however many there are', async (t) => {
if (needDb(t)) return
for (let i = 0; i < 3; i += 1) {
const res = await write(903, null)
assert.equal(res.affectedRows, 1)
}
const rows = await pool.query('SELECT COUNT(*) AS n FROM user_notifications WHERE user_id = 903')
assert.equal(Number(rows[0].n), 3)
})
test('mark-read stamps once and a second call moves nothing', async (t) => {
if (needDb(t)) return
const ins = await write(904, 'evt:read')
const id = ins.insertId
const first = await pool.query(MARK_READ, [id, 904])
assert.equal(first.affectedRows, 1)
const [after1] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
// A second later, so a re-stamp would be visible rather than equal by accident.
await pool.query('UPDATE user_notifications SET read_at = read_at - INTERVAL 1 SECOND WHERE id = ?', [id])
const [before2] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
const second = await pool.query(MARK_READ, [id, 904])
assert.equal(second.affectedRows, 0)
const [after2] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
assert.deepEqual(after2.read_at, before2.read_at)
assert.notDeepEqual(after1.read_at, before2.read_at) // the shift really happened
})
test('mark-read scoped to the owner matches nothing for anyone else', async (t) => {
if (needDb(t)) return
const ins = await write(905, 'evt:owner')
const wrong = await pool.query(MARK_READ, [ins.insertId, 906])
assert.equal(wrong.affectedRows, 0)
const [row] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [ins.insertId])
assert.equal(row.read_at, null)
})
test('the prune drops old READ rows and keeps unread ones however old', async (t) => {
if (needDb(t)) return
const old = await write(907, 'evt:old')
const oldUnread = await write(907, 'evt:old-unread')
const recent = await write(907, 'evt:recent')
await pool.query(
'UPDATE user_notifications SET created_at = NOW() - INTERVAL 200 DAY, read_at = NOW() WHERE id = ?',
[old.insertId],
)
await pool.query('UPDATE user_notifications SET created_at = NOW() - INTERVAL 200 DAY WHERE id = ?', [
oldUnread.insertId,
])
await pool.query('UPDATE user_notifications SET read_at = NOW() WHERE id = ?', [recent.insertId])
const res = await pool.query(PRUNE, [90, 1000])
assert.equal(res.affectedRows, 1)
const rows = await pool.query('SELECT id FROM user_notifications WHERE user_id = 907 ORDER BY id')
assert.deepEqual(rows.map((r) => Number(r.id)), [oldUnread.insertId, recent.insertId])
})