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

@@ -17,6 +17,8 @@
const { registerDeliveryChannel } = require('./channels')
const emailChannel = require('./emailChannel')
const pushChannel = require('./pushChannel')
const inappChannel = require('./inappChannel')
const CHANNELS = [
{
@@ -44,6 +46,10 @@ const CHANNELS = [
// there is nothing to roll up. Ten events are ten wakeups or one; either way
// the app pulls the same inbox.
supportsDigest: false,
// Phase 7: the oldest sink is the last to get a `deliver`, because until the
// inbox existed there was nothing for a content-free tickle to point at.
addressFor: pushChannel.addressFor,
deliver: pushChannel.deliver,
},
{
id: 'email',
@@ -66,13 +72,25 @@ const CHANNELS = [
label: 'On the site',
description: 'An item in your notification inbox on the website and in the app.',
carriesContent: true,
// Opt-IN like the other two, and for a reason particular to this channel: the
// inbox does not exist until Phase 7. A default of 'instant' would mean every
// user is opted into a surface that has no rows and no screen, and the first
// thing Phase 7 shipped would be a backlog. Whether the inbox is opt-out once
// it is real is a Phase 7 decision with a live surface to look at.
defaultMode: 'off',
// **Opt-OUT, and the only one of the three that is** — settled by the org
// lead 2026-08-31, which is the Phase 7 decision this comment used to defer.
//
// The argument against a live default was never about in-app: it was that
// push wakes a device the user is holding and email leaves the building, so
// both must be asked for. An inbox item does neither. It is a row on a page
// the user chose to open, on this deployment, costing them one glance — and
// left at 'off' the surface would ship dead, because no rule could reach
// anyone until every user found a toggle for a channel they had never seen
// deliver anything. The backlog Phase 3 worried about cannot happen either:
// the table is empty at cutover, rules default to `enabled = 0`, and every
// rule carries a per-hour ceiling.
defaultMode: 'instant',
// Instant-only, and unlike push the reason is not that batching is
// meaningless — it is that the inbox IS the batch. A digest of inbox items
// is a list of things already sitting in a list.
supportsDigest: false,
addressFor: inappChannel.addressFor,
deliver: inappChannel.deliver,
},
]

View File

@@ -42,6 +42,22 @@ const log = require('../utils/logger')('engagement')
const HOUR_MS = 60 * 60 * 1000
// Channels one of whose payloads can REFERENCE another's result, earliest first
// (Phase 7). Only one pair qualifies today: a push tickle's `ref` deep-links to
// the inbox row `inapp` writes, and the outbox is swept `ORDER BY due_at, id`,
// so enqueueing in-app first is what makes that ref resolve on the first pass
// rather than on a retry. Everything not named here keeps the operator's own
// order, which is the order the rules screen shows.
//
// It is an ordering, not a dependency: `pushChannel` treats a missing ref as
// null and the app pulls regardless, so a rule that names only push, or a row
// that gets retried out of sequence, is still correct.
const CHANNEL_ORDER = ['inapp']
const channelRank = (id) => {
const i = CHANNEL_ORDER.indexOf(id)
return i === -1 ? CHANNEL_ORDER.length : i
}
/**
* Which of a rule's channels are actually deliverable right now?
*
@@ -50,7 +66,10 @@ const HOUR_MS = 60 * 60 * 1000
* failing the rule: the other channels of that rule are still correct, and a
* dropped one is visible in the log line below.
*/
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c))
const liveChannels = (rule) =>
(rule.channels || [])
.filter((c) => channels.has(c))
.sort((a, b) => channelRank(a) - channelRank(b))
/**
* The EFFECTIVE mode each candidate holds for (id, channel), given the event's
@@ -267,4 +286,13 @@ async function dispatch(event, now = new Date()) {
return summary
}
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, effectiveModes, liveChannels, HOUR_MS }
module.exports = {
dispatch,
applyRule,
applyCancellations,
subscribedTo,
effectiveModes,
liveChannels,
CHANNEL_ORDER,
HOUR_MS,
}

View File

@@ -0,0 +1,204 @@
// ── The in-app DeliveryChannel: addressFor + deliver ───────────────────────
//
// ENGAGEMENT.md Phase 7. The third channel to get behaviour, and the one whose
// "address" is not an address at all: the destination is the user's own row in
// this deployment's own table. `addressFor` still exists and still answers null,
// because the question it asks — *can this channel reach this user right now* —
// has a real answer here, and it is the same answer email's has: not if the
// account is no longer active. An outbox row can sit through a `delay_seconds`
// grace window, so a user banned between the emit and the send is exactly the
// case this catches.
//
// **What makes it different from email is what it does NOT have to do.** There
// is no transport, no relay to classify a failure for us, no unsubscribe link to
// mint per recipient, and no address to hash — an inbox item is addressed to a
// user id, and `engagement_sends.address_hash` exists to correlate a bounce that
// this channel cannot have. So `deliver` is two steps: render the template into
// the three columns, and insert.
//
// **It never throws**, for the reason `emailChannel` states: the worker reads a
// throw as a transient failure and retries five times, so an unrenderable
// template would become five identical failures in the send log instead of one
// honest terminal row.
//
// **A duplicate `dedupe_key` reports success.** The acceptance line calls it a
// no-op; from the recipient's side it is a delivery — they have the item — and
// recording `failed` for it would put a red row in the send log for the
// mechanism working exactly as designed. The detail says which it was.
const rulesDb = require('../model/engagement/engagementRules.db')
const registries = require('../modules/registries')
const channelRegistry = require('./channels')
const inbox = require('../model/userNotifications/userNotifications.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const log = require('../utils/logger')('engagement')
// The template a rule renders through when it names none — §4.6.1 property 1's
// implementation for this channel, exactly as `notify.event` is for email.
const DEFAULT_TEMPLATE = 'inapp.event'
/**
* Can this channel reach `userId`?
*
* Returns the shape every `addressFor` returns rather than a boolean, so the
* registry's contract stays one contract. The "address" is the user id as a
* string, which is the honest answer: this channel's destination is an account,
* and there is nothing else to name.
*/
const addressFor = async (userId) => {
const active = await recipients.filterActive([userId])
return active.length ? { address: String(active[0]) } : null
}
/**
* Render one event into an inbox item. Shared with `ctx.inbox.push`'s rule-less
* path only in spirit — that one is handed its title and body by the module and
* renders nothing.
*/
async function renderItem(triggerId, payload, templateKey) {
const values = projection.project(triggerId, payload || {})
const rendered = await templates.renderInappByKey(templateKey, values)
if (!rendered) return null
if (rendered.missing.length) {
// Names only, never values — the rule every log line in this subsystem
// follows. An optional variable a trigger chose not to supply renders as
// nothing by design, so this is debug rather than a warning.
log.debug('template variables had no value', { key: templateKey, missing: rendered.missing })
}
return rendered
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, detail?: string}>}
*/
async function deliver(row) {
try {
if (!(await addressFor(row.user_id))) {
// Terminal. A five-minute backoff does not un-ban an account, and writing
// the item anyway would put content in the inbox of somebody who is no
// longer allowed to open it.
return { ok: false, detail: 'this user can no longer be reached' }
}
const rule = await rulesDb.getById(row.rule_id)
const key = (rule && rule.template_keys && rule.template_keys.inapp) || DEFAULT_TEMPLATE
const rendered = await renderItem(row.trigger_id, row.payload, key)
if (!rendered) {
// Neither a usable row nor a shipped seed: the operator deleted a template
// a rule points at, which the admin surface refuses with a 409, so reaching
// here means it happened out of band. Terminal, and it names the key.
return { ok: false, detail: `no template and no shipped default for "${key}"` }
}
const { inserted } = await inbox.insert({
userId: row.user_id,
triggerId: row.trigger_id,
title: rendered.title,
body: rendered.body,
url: rendered.url,
dedupeKey: row.dedupe_key || null,
})
// `transport` is left absent rather than invented. The column means "which
// implementation of this channel delivered it", and this channel has one
// sink by construction — a value there would be a name nothing else uses.
return inserted
? { ok: true }
: { ok: true, detail: 'already in this inbox (duplicate dedupe key)' }
} catch (err) {
log.error('in-app delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
// ── The rule-less sink: ctx.inbox.push (§5.1) ──────────────────────────────
//
// A module writing the inbox directly, with no trigger declaration to project
// from, no rule to pick a template, and no audience to resolve. It exists for
// the cases a rule cannot express — something that concerns exactly one person
// and needs no operator configuration to be worth telling them about.
//
// **It respects the user's in-app preference where there is one to respect**
// (settled by the org lead 2026-08-31). If `triggerId` names a REGISTERED
// trigger, the user's effective mode for it decides, and 'off' drops the write:
// a toggle somebody switched off on the preferences screen must not be walkable
// around by the module that owns the trigger behind it. If it names nothing
// registered there is no toggle, nothing on any screen to have switched off, and
// the item is written — refusing it would make the sink useless for the one job
// it has while protecting a preference that does not exist.
//
// Scoped preferences are deliberately not consulted: a scope is a property of an
// EVENT (`team:12`), and a caller with no trigger declaration has no scope to
// name. The engine's path, which does, still applies them.
//
// Fire-and-forget, never throws, never rejects — `ctx.teams.activity.push`'s
// posture, for its reason: this is called from inside a game-event handler and a
// storage problem of core's must not become the module's control flow.
// user_notifications.title. Truncated rather than refused: a module that built a
// long title has still said something worth showing.
const MAX_TITLE = 300
// user_notifications.body is TEXT; this is a sanity bound, not the column's.
const MAX_BODY = 4000
/**
* Write one item on a module's behalf.
*
* @param {string} moduleId bound by the loader, never taken from the arguments
* @param {number} userId
* @param {{triggerId: string, title: string, body?: string, url?: string, dedupeKey?: string}} item
* @returns {Promise<{written: boolean, reason?: string}>} for tests; the loader
* discards it, because a module has nothing correct to do with it.
*/
async function pushDirect(moduleId, userId, item = {}) {
try {
const uid = Number(userId)
if (!Number.isInteger(uid) || uid <= 0) return { written: false, reason: 'invalid user id' }
const triggerId = String(item.triggerId || '').trim()
const title = String(item.title || '').trim().slice(0, MAX_TITLE)
if (!triggerId || !title) return { written: false, reason: 'triggerId and title are required' }
// The declaration is consulted for ONE thing — whether a preference for this
// id exists — and not to validate a payload: there is no payload here, only
// the three strings the module composed itself.
if (registries.eventTrigger(triggerId)) {
const stored = await recipients.storedModes([uid], triggerId, 'inapp')
const mode = stored.get(uid) ?? channelRegistry.defaultMode('inapp')
if (mode !== 'instant') return { written: false, reason: 'the user has this switched off' }
}
if (!(await addressFor(uid))) return { written: false, reason: 'this user can no longer be reached' }
// Same relative-only rule the rendered path applies, and for the same reason:
// this string ends up in an href on a page a signed-in user is looking at.
const url = item.url ? templates.relativeUrl(item.url, templates.baseUrl()) : null
if (item.url && !url) {
log.warn('ctx.inbox.push dropped an off-site url', { module: moduleId, trigger: triggerId })
}
const body = item.body ? String(item.body).slice(0, MAX_BODY) : null
const { inserted } = await inbox.insert({
userId: uid,
triggerId,
title,
// A module supplies data, never markup (§4.6.2's security posture). The
// body is stored as the text it claims to be and every surface renders it
// as text, so there is no markup to sanitize and none to be trusted.
body,
url,
dedupeKey: item.dedupeKey ? String(item.dedupeKey).slice(0, 190) : null,
})
return { written: inserted, reason: inserted ? undefined : 'duplicate dedupe key' }
} catch (err) {
log.error('ctx.inbox.push failed', { module: moduleId, message: err.message })
return { written: false, reason: err.message }
}
}
module.exports = { addressFor, deliver, renderItem, pushDirect, DEFAULT_TEMPLATE }

View File

@@ -0,0 +1,91 @@
// ── The push DeliveryChannel: addressFor + deliver ─────────────────────────
//
// ENGAGEMENT.md Phase 7. Push is the channel that has existed longest and had a
// `deliver` last, because until this phase there was nothing for a tickle to
// point AT: `{ stream, ref }` carries no content by design, so a rule firing on
// push before the inbox existed would have woken a phone to pull a screen that
// had nothing on it.
//
// **The tickle invariant is the whole of this file's security posture.** What
// leaves the server is the stream id and an opaque ref, never a title, never a
// body, never the payload — `carriesContent: false` on the registration is the
// declaration and this is the implementation. ntfy is treated as an untrusted
// relay, so a leaked topic must reveal nothing but that *something* happened;
// the app then pulls the real item over the authenticated, ownership-checked
// inbox API. Every claim in that paragraph is one `pushDispatch` already makes,
// which is why delivery here is a call into it rather than a second publisher.
//
// **`ref` points at the inbox row when there is one, and is null otherwise.**
// A rule spanning `inapp` and `push` enqueues both, and `liveChannels` orders
// `inapp` first precisely so the row exists by the time this runs — but that is
// an optimisation, not a guarantee: the two rows are independent, either can be
// retried, and a push-only rule has no inbox row at all. So the ref is a HINT.
// The app's contract (docs/android/PLAN.md §11, Phase 8) is wake-and-pull; a
// client that renders the ref instead of pulling is a client that will show
// nothing the first time a retry reorders these two rows.
const inbox = require('../model/userNotifications/userNotifications.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const pushDispatch = require('../utils/pushDispatch')
const log = require('../utils/logger')('engagement')
/**
* Can this channel reach `userId`?
*
* Active account only, the same re-check `emailChannel` and `inappChannel` make
* for the same reason (a row can sit through a `delay_seconds` window). It does
* NOT check for a registered device: whether any endpoint is subscribed is the
* question `publishToUsers` answers in its own query, and asking it twice would
* mean two different definitions of "reachable" that could disagree.
*/
const addressFor = async (userId) => {
const active = await recipients.filterActive([userId])
return active.length ? { address: String(active[0]) } : null
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function deliver(row) {
try {
if (!(await addressFor(row.user_id))) {
return { ok: false, detail: 'this user can no longer be reached' }
}
// Best effort, and it fails to null rather than to an error: no dedupe key,
// no in-app row for it, or an inapp row this rule never enqueued all mean
// the same thing to the app — wake up and pull.
let ref = null
try {
const item = await inbox.findByDedupe(row.user_id, row.dedupe_key)
if (item) ref = `notification:${item.id}`
} catch (err) {
log.debug('could not resolve a push ref', { outbox: row.id, message: err.message })
}
// The stream id IS the trigger id — §7.2's one namespace, settled in Phase 2.
// A push stream and an event trigger share a name space, so the app's
// existing `{ stream }` switch keeps working for an engagement rule without
// learning a second vocabulary.
await pushDispatch.publishToUsers(row.trigger_id, { ref, userIds: [row.user_id] })
// **Success here means "handed to the relay", and the send log must not
// claim more than that.** `publishToUsers` resolves whether it found a
// subscribed device or none at all, and a tickle is fire-and-forget over
// HTTP to a relay that owes us no receipt. Retrying on "we are not sure"
// would mean five wakeups for one event, which is worse than one uncertain
// log line — so this is the one channel whose 'sent' is weaker than email's,
// and saying so in the detail is how an operator reading G15 finds that out.
return { ok: true, transport: 'unifiedpush', detail: 'tickle published' }
} catch (err) {
// pushDispatch never throws, so reaching here is a programming error rather
// than a relay being down. Terminal for that reason: retrying a bug is five
// identical rows in the send log.
log.error('push delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
module.exports = { addressFor, deliver }

View File

@@ -260,19 +260,33 @@ const SEEDS = [
name: 'On-site notification',
channel: 'inapp',
protected: false,
seedVersion: 1,
// **seedVersion 2, and the bump is a correction rather than an improvement.**
// Phase 5a wrote this template before the channel that renders it existed, and
// named its variables `body` and `url` — names NOTHING supplies. A trigger
// declares domain names (`teamName`, `threadTitle`), and `projection.project`
// fills the gaps with the STRUCTURAL ones the generic seeds use: `title`,
// `intro`, `actionUrl`. So every rendering of this template would have found
// `body` and `url` missing and produced a title and nothing else. Renamed to
// the vocabulary `notify.event` uses, which is the same property stated once:
// a new trigger must render with no authoring at all.
seedVersion: 2,
// No subject: an inbox row has a title, and the title is a block. The column
// is email's, and leaving it NULL is how a non-email template says so.
subject: null,
variables: [
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
{ name: 'body', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'url', type: 'string', required: false, example: 'https://example.com/houses' },
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'actionUrl', type: 'string', required: false, example: '/player/uo/houses' },
],
// The three blocks map onto the three columns of `user_notifications` by ROLE
// (templates.js `renderInappByKey`): the heading is the item's title, the
// button is its one action, and everything else is the body. There is no
// unsubscribe line — an inbox item has nowhere to send someone that the
// preferences screen it links to from does not already reach.
blocks: [
heading('h', '{{title}}', 'h3'),
text('body', '{{body}}'),
button('cta', 'Open', '{{url}}'),
text('intro', '{{intro}}'),
button('cta', 'Open', '{{actionUrl}}'),
],
},
]

View File

@@ -123,13 +123,17 @@ function renderTemplate(template, values, resolved) {
}
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null when `key` names no usable row AND no seed — which now includes a
* duplicated (seedless) template still in draft.
* The template `key` should actually render through, or null.
*
* Extracted from `renderByKey` in Phase 7 rather than duplicated into the in-app
* channel: the fallback chain below is a policy about what this deployment sends
* when its own table is in a bad state, and a second channel resolving templates
* by its own rules would be a second answer to that. `renderInappByKey` takes the
* same rows, the same seeds and the same three refusals.
*
* @returns {Promise<{subject: string|null, blocks: object[], text_body: string|null}|null>}
*/
async function renderByKey(key, values = {}) {
const resolved = await ambient()
async function resolveTemplate(key) {
let template = null
try {
template = await templatesDb.getByKey(key)
@@ -158,9 +162,113 @@ async function renderByKey(key, values = {}) {
if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key })
template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
}
return template
}
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null when `key` names no usable row AND no seed — which now includes a
* duplicated (seedless) template still in draft.
*/
async function renderByKey(key, values = {}) {
const resolved = await ambient()
const template = await resolveTemplate(key)
if (!template) return null
return renderTemplate(template, values, resolved)
}
// ── The in-app projection (Phase 7) ────────────────────────────────────────
//
// `user_notifications` has three columns — title, body, url — where email has a
// subject and a document, so the in-app channel needs the template rendered into
// those three rather than into a mail. **The mapping is by block ROLE**, and it
// is here rather than in the channel because it is a statement about what the
// block registry means, not about how a row gets written:
//
// - the first `email.heading` → `title` (a heading IS the item's headline)
// - the first `email.button` → `url` (a button IS the item's one action)
// - everything else, as TEXT → `body`
//
// **Text, not the email HTML, and that is the load-bearing choice.** The block
// renderer's HTML is built for mail clients: table rows, inline hex colours, a
// light-only palette declared with `color-scheme`. Dropped into a page that
// follows the viewer's theme it renders as a pale card floating in a dark one.
// `toText` is the same content with none of that, and it is the part the block
// contract already promises every block can produce.
//
// The three refusals a mail can afford and an inbox row cannot are handled here
// too: a title is NOT NULL, so an empty one falls back to the projected `title`
// and then to the trigger id; and a url that is not site-relative is dropped
// rather than stored, because the column's whole contract is that a template
// cannot aim a signed-in user's click off-site.
const HEADING = 'email.heading'
const BUTTON = 'email.button'
// user_notifications.title / .url. Truncated rather than refused: a long title is
// a cosmetic problem and a dropped notification is not.
const MAX_TITLE = 300
const MAX_URL = 500
// The same character class `pageUrlTemplate` and the engine's `url` variables
// use (registries.js, engagementEmit.js). Duplicated as a literal rather than
// imported from `engagementEmit`, which would be a cycle through the engine.
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
/**
* Site-relative form of `raw`, or null.
*
* An absolute url on this deployment's own base is accepted and reduced — a
* template that writes `{{siteUrl}}/guilds/4` is saying the same thing as
* `/guilds/4`, and refusing it would make the ambient `siteUrl` variable a trap
* in the one channel where the link never leaves the site.
*/
function relativeUrl(raw, base) {
const value = String(raw || '').trim()
if (!value) return null
const stripped = base && value.startsWith(`${base}/`) ? value.slice(base.length) : value
if (!RELATIVE_URL.test(stripped)) return null
return stripped.slice(0, MAX_URL)
}
/**
* Render one template into an inbox item.
*
* @returns {Promise<{title: string, body: string|null, url: string|null, missing: string[]}|null>}
* null when `key` names no usable row and no seed — the caller reports a
* terminal failure, exactly as the email channel does.
*/
async function renderInappByKey(key, values = {}) {
const resolved = await ambient()
const template = await resolveTemplate(key)
if (!template) return null
const merged = { ...values, ...resolved.values }
const missing = new Set()
const ctx = emailBlocks.buildContext({
values: merged,
theme: resolved.theme,
baseUrl: resolved.baseUrl,
missing,
})
const blocks = Array.isArray(template.blocks) ? template.blocks : []
const visible = blocks.filter((b) => b && b.visible !== false)
const heading = visible.find((b) => b.type === HEADING)
const button = visible.find((b) => b.type === BUTTON)
// Only the FIRST of each is consumed; a second heading or button is ordinary
// body content, which is what an operator who added one meant.
const rest = visible.filter((b) => b !== heading && b !== button)
const headingText = heading ? ctx.t((heading.props || {}).text || '').trim() : ''
const title = (headingText || String(merged.title || '').trim() || key).slice(0, MAX_TITLE)
const url = button ? relativeUrl(ctx.t((button.props || {}).url || ''), resolved.baseUrl) : null
const body = emailBlocks.renderBlocks(rest, ctx).text.trim()
return { title, body: body || null, url, missing: [...missing] }
}
/**
* Ensure every shipped template exists, and bring un-customized rows up to the
* current seed. Idempotent: a second run reports nine skips and writes nothing.
@@ -215,4 +323,16 @@ async function seedTemplates() {
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
const MAX_KEY = 96
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl, KEY_RE, MAX_KEY }
module.exports = {
ambient,
variablesFor,
renderTemplate,
resolveTemplate,
renderByKey,
renderInappByKey,
relativeUrl,
seedTemplates,
baseUrl,
KEY_RE,
MAX_KEY,
}

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,
}

View File

@@ -122,6 +122,7 @@ function buildCtx(id, moduleRoot) {
const teams = require('../model/teams/teamSync.model')
const teamActivity = require('../model/teams/teamActivity.model')
const engagementEmit = require('../utils/engagementEmit')
const inappChannel = require('../engagement/inappChannel')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */
@@ -229,17 +230,26 @@ function buildCtx(id, moduleRoot) {
},
},
// The in-app sink (§5.1) — a module writing the inbox directly, without a
// rule. It is PRESENT AND THROWS until Phase 7 builds the channel and the
// `user_notifications` table behind it.
// rule. Live from Phase 7; it threw until the `user_notifications` table
// behind it existed.
//
// Present-and-throwing rather than absent is the shape 1.6.0 settled on for
// exactly this situation (`ctx.teams.activity.push` before its phase landed):
// the version number states a whole surface, so a member of 1.7.0 that is
// missing would make the version a lie, and one that silently accepted data
// into a table that does not exist would be the worst of the three.
// Fire-and-forget and returns undefined, like `events.emit` above and
// `teams.activity.push` before it, and for the same reason: a module calls
// this from inside a game-event handler, and there is nothing it could
// correctly do with a storage failure of core's. The decision the sink makes
// that a module might want to know about — the user has this switched off —
// is deliberately not reported either, because a module that could see it
// would be a module that could enumerate people's preferences one write at a
// time.
//
// `id` is bound here and never taken from the arguments, exactly as `emit`
// and `teams.activity.push` bind theirs.
inbox: {
push: () => {
throw new Error('ctx.inbox.push is not available until the in-app channel lands (ENGAGEMENT.md Phase 7)')
push: (userId, item) => {
inappChannel.pushDirect(id, userId, item).then(
(result) => { void result },
(err) => { log.error('ctx.inbox.push failed', { module: id, message: err.message }) },
)
},
},
// One function, for one caller: the `admin.users.detail` slot router needs

View File

@@ -14,12 +14,15 @@
// `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and
// `ctx.inbox.push(userId, item)`. module-uo's `coreApi: "^1.3.0"` still resolves.
//
// **As in 1.6.0, the number covers the whole surface and the members arrive by
// phase.** `ctx.inbox.push` is present and THROWS until Phase 7 builds the
// in-app channel and the table behind it — the same choice, for the same reason:
// a member of 1.7.0 that were absent would make the version a lie, and one that
// silently accepted data into a table that does not exist would be worse than
// either. Everything else in 1.7.0 is live.
// **As in 1.6.0, the number covered the whole surface and the members arrived by
// phase, and all of them have now arrived.** `ctx.inbox.push` threw until Phase 7
// built the in-app channel and the table behind it — the same choice, for the
// same reason: a member of 1.7.0 that were absent would have made the version a
// lie, and one that silently accepted data into a table that did not exist would
// have been worse than either. **Filling it in is NOT a bump**: the signature is
// the one 1.7.0 declared, and a module written against it needs no change. What a
// module WILL see differently is the throw becoming a write, which is the whole
// point of the phase.
//
// One thing here is not a member and is still part of the contract: a trigger id
// and a notification-stream id share ONE namespace (ENGAGEMENT.md §7.2, settled

View File

@@ -9,6 +9,7 @@ const channelPrefs = require('../../../model/notificationChannelPrefs/notificati
const registries = require('../../../modules/registries')
const teamPrefs = require('../../../model/teams/teamNotify.model')
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
const inbox = require('../../../model/userNotifications/userNotifications.db')
const log = require('../../../utils/logger')('notifications')
@@ -146,6 +147,80 @@ async function putTeamPrefs(req, res) {
}
}
// ── The inbox (ENGAGEMENT.md §4.5 G17, Phase 7) ────────────────────────────
//
// The in-app channel's read side. Everything above this line is a PREFERENCE —
// which streams, which channels, which Teams — and everything below it is
// CONTENT addressed to the caller. They share a path prefix because a person
// calls both "notifications", and the shapes keep them apart: the preference
// endpoints are whole-set GET/PUT pairs on named sub-paths, the inbox is a
// paged GET on the bare path with POSTs that name a row.
//
// **`req.user.id` is the only user id any of these can name.** There is no route
// parameter for a user and no query string that selects one, so the ownership
// check is not something a caller can be forgetful about — it is the shape of
// the API. The model then repeats it in the WHERE clause of every statement, so
// "read someone else's notification" is a 404 twice over.
// GET /auth/me/notifications — one page of the caller's inbox, newest first.
async function getInbox(req, res) {
try {
const page = await inbox.list(req.user.id, {
limit: req.query.limit,
before: req.query.before,
unreadOnly: req.query.unread === 'true' || req.query.unread === '1',
})
// The unread count rides along on every page, so the bell and the list never
// disagree: a client that renders both from one response cannot show "3
// unread" above a list in which the third was just marked read.
return res.json({ ...page, unread: await inbox.unreadCount(req.user.id) })
} catch (err) {
log.error('getInbox', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /auth/me/notifications/unread-count — the badge, on its own.
//
// Its own route rather than a field of the list, because it is polled: a client
// asking "is there anything new" every minute should not make the server
// assemble thirty rows and their bodies to answer with one integer.
async function getUnreadCount(req, res) {
try {
return res.json({ unread: await inbox.unreadCount(req.user.id) })
} catch (err) {
log.error('getUnreadCount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/me/notifications/:id/read — mark one item read. Idempotent.
//
// 404 both when the row does not exist and when it belongs to somebody else,
// which is the same answer on purpose: distinguishing them would turn this route
// into a way to ask whether a given id is anybody's.
async function markRead(req, res) {
try {
const found = await inbox.markRead(req.user.id, req.params.id)
if (!found) return res.status(404).json({ message: 'Not Found' })
return res.json({ ok: true, unread: await inbox.unreadCount(req.user.id) })
} catch (err) {
log.error('markRead', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/me/notifications/read-all — mark the whole inbox read.
async function markAllRead(req, res) {
try {
const changed = await inbox.markAllRead(req.user.id)
return res.json({ ok: true, changed, unread: 0 })
} catch (err) {
log.error('markAllRead', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
registerDevice,
listDevices,
@@ -157,4 +232,8 @@ module.exports = {
putChannelPrefs,
getTeamPrefs,
putTeamPrefs,
getInbox,
getUnreadCount,
markRead,
markAllRead,
}

View File

@@ -7,7 +7,7 @@
// and never touches /admin.
const express = require('express')
const { body, param } = require('express-validator')
const { body, param, query } = require('express-validator')
const notif = require('./notifications.controller')
const { requireAuth } = require('../../../auth/session.middleware')
@@ -137,6 +137,72 @@ notifRouter.put(
notif.putChannelPrefs,
)
// ── The inbox (ENGAGEMENT.md §4.5 G17, phase 7) ────────────────────────────
//
// The in-app channel's read side, and the only routes in this file that carry
// CONTENT rather than a preference. They share the `/notifications` prefix
// because a person calls both by that name; the bare path is the inbox and the
// named sub-paths above are the settings for it.
//
// **Route order matters here and is not incidental.** `/notifications/streams`,
// `/notifications/subscriptions`, `/notifications/channels` and
// `/notifications/teams` are all declared ABOVE, and none of the routes below
// introduces a GET `/notifications/:something` that could shadow them. The one
// parameterised path is a POST, and its `:id` is digits-only.
notifRouter.get(
'/notifications',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'One page of the callers notification inbox'
// #swagger.description = 'The in-app channels items for the signed-in user, newest first. Paged with a keyset cursor (`before`), not an offset, because the list gains rows at the top while it is being read. `unread` counts the whole inbox, not the page. There is no way to name another user: the caller is the only account these routes can read.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, description: 'Page size (capped at 100).' }
// #swagger.parameters['before'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Return items with an id lower than this — the cursor from the previous page.' }
// #swagger.parameters['unread'] = { in: 'query', required: false, schema: { type: 'boolean' }, description: 'Only items that have not been read.' }
/* #swagger.responses[200] = { description: 'A page of the inbox', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationInbox" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
query('limit').optional().isInt({ min: 1, max: 100 }),
query('before').optional().isInt({ min: 1 }),
query('unread').optional().isIn(['true', 'false', '1', '0']),
validate,
notif.getInbox,
)
notifRouter.get(
'/notifications/unread-count',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'How many inbox items the caller has not read'
// #swagger.description = 'The badge. Its own route because it is polled — asking “is there anything new” should not make the server assemble a page of bodies to answer with one integer.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The unread count', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationUnreadCount" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getUnreadCount,
)
notifRouter.post(
'/notifications/read-all',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Mark the callers whole inbox read'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Marked read', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationReadResult" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.markAllRead,
)
notifRouter.post(
'/notifications/:id/read',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Mark one inbox item read'
// #swagger.description = 'Idempotent: a second call does not move the timestamp. 404 both when no such item exists and when it belongs to another account — the same answer on purpose, so this cannot be used to ask whether an id is anybodys.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Notification id (must belong to the caller).' }
/* #swagger.responses[200] = { description: 'Marked read', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationReadResult" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such item for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
notif.markRead,
)
// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
//
// The granularity per-stream opt-in cannot express: "I am in five Teams and want

View File

@@ -10,6 +10,7 @@ const http = require('http')
const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker')
const teamActivityPrune = require('./utils/teamActivityPrune')
const inboxPrune = require('./utils/userNotificationsPrune')
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
const teamDigestWorker = require('./utils/teamDigestWorker')
const engagementWorker = require('./utils/engagementWorker')
@@ -158,6 +159,7 @@ async function start() {
// is the obvious unbounded-growth failure, so retention starts with the feed
// rather than after someone notices. No-op on a deployment with no Teams.
teamActivityPrune.start()
inboxPrune.start()
teamForumUploadSweep.start()
teamDigestWorker.start()
@@ -183,6 +185,7 @@ function setupShutdown(server, internalServer) {
botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller
teamActivityPrune.stop() // stop the Team activity retention timer
inboxPrune.stop() // stop the in-app inbox retention timer
teamForumUploadSweep.stop() // stop the forum upload sweep
teamDigestWorker.stop() // stop the Team forum digest timer
engagementWorker.stop() // stop the engagement outbox worker

View File

@@ -0,0 +1,105 @@
// ── Inbox retention worker ─────────────────────────────────────────────────
//
// ENGAGEMENT.md Phase 7. `user_notifications` is written by a rule that can fire
// on every event of its trigger, for every member of its audience, forever —
// nothing in the engine deletes anything, and neither the outbox nor the send
// log is a bound on this table (both hold one row per DELIVERY, and an inbox
// item outlives its delivery by design). The plan specifies no retention at all,
// which is how `team_activity` grew until §4.2 gave it this same worker.
//
// **Read items only, and that is the policy rather than an implementation
// detail.** An unread item is one the user has not seen; deleting it because it
// is old is the inbox quietly answering "nothing waiting" when something is.
// A never-read backlog is bounded in practice by the per-rule hourly ceiling
// (§7.1 Q3), which is the limit an operator actually tunes.
//
// Same in-process shape as `teamActivityPrune` and `announceWorker` — setInterval
// + unref + stop(), wired into server.js start/shutdown beside them, with the
// first run delayed so a table-wide DELETE never lands in front of the first
// request on a crash-looping deployment.
const inbox = require('../model/userNotifications/userNotifications.db')
const settings = require('../model/settings/settings.model')
const log = require('./logger')('engagement')
const INTERVAL_MS = Number(process.env.INBOX_PRUNE_MS) || 24 * 60 * 60 * 1000
const FIRST_RUN_MS = Number(process.env.INBOX_PRUNE_DELAY_MS) || 5 * 60 * 1000
// In `settings`, not in env, for the reason §4.2 gives: an operator tightening a
// busy shard should not need a deploy. The key is namespaced with the table it
// governs rather than with the phase that added it.
const RETAIN_KEY = 'user_notifications_retain_days'
const DEFAULT_RETAIN_DAYS = 90
// A bound per sweep, so one run after a long outage is a series of bounded
// statements rather than a delete of a million rows holding locks. The sweep
// repeats until it clears, and stops early rather than looping forever.
const BATCH = 1000
const MAX_BATCHES = 50
/**
* How many days of read items to keep.
*
* Wrapped in a try like `teamActivity.retentionConfig`, and for its reason: this
* runs on a timer with nobody watching, so a settings table that is briefly
* unavailable must yield the default rather than an exception that kills the
* nightly job. A zero or negative value would delete the whole inbox, so it is
* rejected rather than honoured.
*/
async function retainDays() {
try {
const raw = await settings.get(RETAIN_KEY)
const days = Number(raw)
if (Number.isFinite(days) && days > 0) return Math.floor(days)
} catch (err) {
log.debug('inbox retention setting unreadable; using the default', { message: err.message })
}
return DEFAULT_RETAIN_DAYS
}
/** One prune. Never throws — it runs on a timer with nobody to catch it. */
async function tick() {
try {
const days = await retainDays()
let removed = 0
for (let i = 0; i < MAX_BATCHES; i += 1) {
const n = await inbox.pruneRead(days, BATCH)
removed += n
if (n < BATCH) break
}
if (removed) log.info('inbox pruned', { removed, retainDays: days })
return removed
} catch (err) {
log.error('inbox prune failed', { message: err.message })
return null
}
}
let timer = null
let firstRun = null
function start() {
if (timer || firstRun) return timer
firstRun = setTimeout(() => {
firstRun = null
tick()
timer = setInterval(() => { tick() }, INTERVAL_MS)
if (timer.unref) timer.unref()
}, FIRST_RUN_MS)
if (firstRun.unref) firstRun.unref()
log.info('inbox retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
return timer
}
function stop() {
if (firstRun) {
clearTimeout(firstRun)
firstRun = null
}
if (timer) {
clearInterval(timer)
timer = null
}
}
module.exports = { start, stop, tick, retainDays, RETAIN_KEY, DEFAULT_RETAIN_DAYS, INTERVAL_MS, FIRST_RUN_MS }