// ── 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 }