feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a)
Phase 4 of docs/website/ENGAGEMENT.md, split 4a/4b at the org lead's direction. This is 4a: the engine, server only, with no HTTP surface at all. A fired trigger now produces outbox rows and send-log entries; Admin - Engagement - Rules and the segment composition UI are 4b. Five tables (rules, audience segments, cooldowns, outbox, sends), the sweep worker, audience resolution, condition evaluation, the grace window and its cancellation, and the save-path validation 4b's form will call. engagementEmit's Phase 2 log line becomes the engine call. Two settled questions this phase was blocked on: Q2 (multi-instance) - neither SKIP LOCKED nor documented single-instance: the outbox claims each row with a compare-and-set into the 'sending' state the ENUM already carried. It makes the outbox safe for two instances, not the deployment. Q4 (admin surface) - its own top-level nav group, built in 4b. Two defects in the plan's own section 4, both found by building it: The global UNIQUE(dedupe_key) was data loss. A dedupe key names the EVENT, and one event is one row per (rule, user, channel) - so a fifty-person audience would have had one row admitted and forty-nine silently ignored. Scoped. Section 4.1's single INSERT ... ON DUPLICATE KEY UPDATE cooldown claim always passes against this codebase's pool: the mariadb connector defaults foundRows:true, so a no-op update reports affectedRows 1 rather than 0. It is two statements now, with the interval guard in a WHERE clause. The second defect is why there is a second test file. The stubbed suite was green against the broken claim, because a stub can only agree with whoever wrote it; engagementEngineSql.test.js runs the raw statements against a real MariaDB and skips when there is none. Verification: 43 new tests green in engagementEngine.test.js, 12 more against MariaDB 11.8, and the whole path exercised end to end against a live database - per-subject cooldowns, conditions, the CAS claim, the send log's honest failure detail, and dormancy on uninstall. The three pre-existing Windows-only CRLF failures in the generated-artifact tests are unchanged from clean edge. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
125
server/src/model/engagement/engagementRecipients.db.js
Normal file
125
server/src/model/engagement/engagementRecipients.db.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// A bound on every "resolve an audience" query. `authenticated` on a large
|
||||
// deployment is the whole user table, and the engine turns each id into an
|
||||
// outbox row - so the read that feeds it has to have a ceiling of its own. The
|
||||
// per-rule hourly cap (§7.1 Q3) is the operator-facing limit; this is the one
|
||||
// that keeps a single emit from loading a hundred thousand rows into memory.
|
||||
const MAX_AUDIENCE = 5000
|
||||
|
||||
const ids = (rows) => rows.map((r) => Number(r.id)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
|
||||
const marks = (list) => list.map(() => '?').join(', ')
|
||||
|
||||
/**
|
||||
* Every active user. The `authenticated` audience - and `everyone`, which has no
|
||||
* distinct meaning here: a signed-out visitor has no address, no device and no
|
||||
* inbox, so the widest set the engine can actually deliver to is this one. The
|
||||
* ceiling lattice still distinguishes them (a trigger ceilinged `everyone`
|
||||
* permits an `authenticated` rule and not the reverse); only the resolution
|
||||
* coincides.
|
||||
*
|
||||
* `status = 'active'` on every query in this file: a banned or disabled account
|
||||
* is refused at login, and mailing it engagement content would be the one
|
||||
* surface that did not get the message.
|
||||
*/
|
||||
const active = async (limit = MAX_AUDIENCE) =>
|
||||
ids(await query("SELECT id FROM users WHERE status = 'active' ORDER BY id LIMIT ?", [limit]))
|
||||
|
||||
/** The `staff` audience. Roles come from `ceilings.STAFF_CEILING_ROLES`. */
|
||||
const staff = async (roles, limit = MAX_AUDIENCE) => {
|
||||
if (!roles.length) return []
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT id FROM users WHERE status = 'active' AND role IN (${marks(roles)}) ORDER BY id LIMIT ?`,
|
||||
[...roles, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `subscribers` audience: active users who have opted into this id on at
|
||||
* least one channel.
|
||||
*
|
||||
* "Opted in" is the EFFECTIVE mode, not the stored one, and that is why this is
|
||||
* not simply `WHERE mode <> 'off'`. A row exists only where a user said
|
||||
* something; absence means the channel's `defaultMode` (§3.1). All three of
|
||||
* core's channels default 'off' today, so the second half of the WHERE matches
|
||||
* nobody - but writing it means the day a channel ships with a non-off default,
|
||||
* this audience is already right rather than silently excluding everyone who
|
||||
* never opened the preferences screen.
|
||||
*
|
||||
* `defaultOnChannels` is the caller's list of channels whose defaultMode is not
|
||||
* 'off'; it comes from the channel registry, so the default lives in exactly one
|
||||
* place here too.
|
||||
*/
|
||||
const subscribers = async (streamId, defaultOnChannels = [], limit = MAX_AUDIENCE) => {
|
||||
const optedIn = `EXISTS (
|
||||
SELECT 1 FROM notification_channel_prefs p
|
||||
WHERE p.user_id = u.id AND p.stream_id = ? AND p.mode <> 'off')`
|
||||
|
||||
if (!defaultOnChannels.length) {
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT u.id FROM users u WHERE u.status = 'active' AND ${optedIn} ORDER BY u.id LIMIT ?`,
|
||||
[streamId, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// "At least one default-on channel has no row for this user" - counted rather
|
||||
// than NOT EXISTS, because NOT EXISTS would mean "none of them has a row".
|
||||
const defaulted = `(
|
||||
SELECT COUNT(*) FROM notification_channel_prefs p2
|
||||
WHERE p2.user_id = u.id AND p2.stream_id = ? AND p2.channel IN (${marks(defaultOnChannels)})
|
||||
) < ?`
|
||||
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT u.id FROM users u
|
||||
WHERE u.status = 'active' AND (${optedIn} OR ${defaulted})
|
||||
ORDER BY u.id LIMIT ?`,
|
||||
[streamId, streamId, ...defaultOnChannels, defaultOnChannels.length, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a set of user ids to the active ones.
|
||||
*
|
||||
* Every audience that does NOT come from a query in this file goes through here:
|
||||
* `owner` is a single id off the event envelope, and a module-declared audience
|
||||
* (§5.1a) is a list of ids a module's own resolver produced. Neither has any
|
||||
* notion of account status, and a module must not be able to mail a banned
|
||||
* account by returning its id.
|
||||
*/
|
||||
const filterActive = async (userIds) => {
|
||||
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!wanted.length) return []
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT id FROM users WHERE status = 'active' AND id IN (${marks(capped)}) ORDER BY id`,
|
||||
capped,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored mode for one (id, channel) across a set of users, as a Map.
|
||||
*
|
||||
* The caller applies the channel's `defaultMode` to anyone missing from the map,
|
||||
* which keeps the defaulting in the one place §3.1 put it. Returning stored rows
|
||||
* rather than a decision is what makes that possible.
|
||||
*/
|
||||
const storedModes = async (userIds, streamId, channel) => {
|
||||
if (!userIds.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT user_id, mode FROM notification_channel_prefs
|
||||
WHERE stream_id = ? AND channel = ? AND user_id IN (${marks(userIds)})`,
|
||||
[streamId, channel, ...userIds],
|
||||
)
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.mode]))
|
||||
}
|
||||
|
||||
module.exports = { active, staff, subscribers, filterActive, storedModes, MAX_AUDIENCE }
|
||||
Reference in New Issue
Block a user