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