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>
204 lines
8.0 KiB
JavaScript
204 lines
8.0 KiB
JavaScript
// ── 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])
|
|
})
|