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>
38 lines
1.5 KiB
JavaScript
38 lines
1.5 KiB
JavaScript
const { query } = require('../../utils/db')
|
|
const { parseJson } = require('./engagementRules.db')
|
|
|
|
const hydrate = (row) => row && { ...row, expression: parseJson(row.expression, null) }
|
|
|
|
const list = async () =>
|
|
(await query('SELECT * FROM engagement_audience_segments ORDER BY name, id')).map(hydrate)
|
|
|
|
const getById = async (id) => {
|
|
const [row] = await query('SELECT * FROM engagement_audience_segments WHERE id = ?', [id])
|
|
return hydrate(row)
|
|
}
|
|
|
|
/**
|
|
* `ceiling` is written by the caller from `segments.deriveCeiling`, never taken
|
|
* from an operator. It is a stored column rather than a runtime computation so
|
|
* an audit can read what a rule was ALLOWED to reach without re-resolving it,
|
|
* and so a module that later widens its own audience's ceiling cannot
|
|
* retroactively widen a segment that was saved under the old one.
|
|
*/
|
|
const insert = async (segment) => {
|
|
const result = await query(
|
|
'INSERT INTO engagement_audience_segments (name, expression, ceiling, updated_by) VALUES (?, ?, ?, ?)',
|
|
[segment.name, JSON.stringify(segment.expression), segment.ceiling, segment.updated_by ?? null],
|
|
)
|
|
return result.insertId
|
|
}
|
|
|
|
const update = (id, segment) =>
|
|
query(
|
|
'UPDATE engagement_audience_segments SET name = ?, expression = ?, ceiling = ?, updated_by = ? WHERE id = ?',
|
|
[segment.name, JSON.stringify(segment.expression), segment.ceiling, segment.updated_by ?? null, id],
|
|
)
|
|
|
|
const remove = (id) => query('DELETE FROM engagement_audience_segments WHERE id = ?', [id])
|
|
|
|
module.exports = { list, getById, insert, update, remove }
|