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:
127
server/src/model/engagement/engagementRules.db.js
Normal file
127
server/src/model/engagement/engagementRules.db.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// JSON columns come back from the driver already parsed on some MariaDB/driver
|
||||
// combinations and as a string on others (it depends on whether the column is a
|
||||
// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as). Every
|
||||
// read below goes through this, so no caller has to know which it got.
|
||||
function parseJson(value, fallback) {
|
||||
if (value === null || value === undefined) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
enabled: Boolean(row.enabled),
|
||||
channels: parseJson(row.channels, []),
|
||||
template_keys: parseJson(row.template_keys, {}),
|
||||
conditions: parseJson(row.conditions, null),
|
||||
cancel_on: parseJson(row.cancel_on, []),
|
||||
}
|
||||
|
||||
const list = async () =>
|
||||
(await query('SELECT * FROM engagement_rules ORDER BY trigger_id, name, id')).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_rules WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ENABLED rule for one trigger. The engine's hot path: one indexed read
|
||||
* per emit, and `idx_engr_trigger (trigger_id, enabled)` is exactly this query.
|
||||
*/
|
||||
const enabledForTrigger = async (triggerId) =>
|
||||
(await query('SELECT * FROM engagement_rules WHERE trigger_id = ? AND enabled = 1', [triggerId])).map(hydrate)
|
||||
|
||||
/**
|
||||
* Every enabled rule that names `triggerId` in its `cancel_on`.
|
||||
*
|
||||
* A JSON_CONTAINS rather than a scan: `cancel_on` is a small array on a small
|
||||
* table, but this runs on EVERY emit — including the overwhelming majority that
|
||||
* cancel nothing — so it must not be a full table read of the rule set.
|
||||
*/
|
||||
const enabledCancelledBy = async (triggerId) =>
|
||||
(
|
||||
await query(
|
||||
"SELECT * FROM engagement_rules WHERE enabled = 1 AND cancel_on IS NOT NULL AND JSON_CONTAINS(cancel_on, JSON_QUOTE(?))",
|
||||
[triggerId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
const insert = async (rule) => {
|
||||
const result = await query(
|
||||
`INSERT INTO engagement_rules
|
||||
(trigger_id, name, enabled, audience, audience_segment_id, max_sends_per_hour,
|
||||
channels, template_keys, conditions, cooldown_seconds, delay_seconds, cancel_on, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
rule.trigger_id,
|
||||
rule.name,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.audience,
|
||||
rule.audience_segment_id,
|
||||
rule.max_sends_per_hour,
|
||||
JSON.stringify(rule.channels),
|
||||
JSON.stringify(rule.template_keys),
|
||||
rule.conditions === null ? null : JSON.stringify(rule.conditions),
|
||||
rule.cooldown_seconds,
|
||||
rule.delay_seconds,
|
||||
JSON.stringify(rule.cancel_on || []),
|
||||
rule.updated_by,
|
||||
],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
const update = (id, rule) =>
|
||||
query(
|
||||
`UPDATE engagement_rules
|
||||
SET name = ?, enabled = ?, audience = ?, audience_segment_id = ?, max_sends_per_hour = ?,
|
||||
channels = ?, template_keys = ?, conditions = ?, cooldown_seconds = ?,
|
||||
delay_seconds = ?, cancel_on = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
rule.name,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.audience,
|
||||
rule.audience_segment_id,
|
||||
rule.max_sends_per_hour,
|
||||
JSON.stringify(rule.channels),
|
||||
JSON.stringify(rule.template_keys),
|
||||
rule.conditions === null ? null : JSON.stringify(rule.conditions),
|
||||
rule.cooldown_seconds,
|
||||
rule.delay_seconds,
|
||||
JSON.stringify(rule.cancel_on || []),
|
||||
rule.updated_by,
|
||||
id,
|
||||
],
|
||||
)
|
||||
|
||||
const remove = (id) => query('DELETE FROM engagement_rules WHERE id = ?', [id])
|
||||
|
||||
/** Does any rule still point at this segment? The check before a segment delete. */
|
||||
const countUsingSegment = async (segmentId) => {
|
||||
const [row] = await query(
|
||||
'SELECT COUNT(*) AS n FROM engagement_rules WHERE audience_segment_id = ?',
|
||||
[segmentId],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
enabledForTrigger,
|
||||
enabledCancelledBy,
|
||||
insert,
|
||||
update,
|
||||
remove,
|
||||
countUsingSegment,
|
||||
parseJson,
|
||||
}
|
||||
Reference in New Issue
Block a user