8 Commits

Author SHA1 Message Date
66bb3b9a3f Merge pull request 'feat(engagement): the engagement system — cutover 3 of 7 (edgemain)' (#180) from edge into main
All checks were successful
Build container images / build (push) Successful in 47s
sync-project-tree / sync (push) Successful in -50s
Build container images / deploy (push) Successful in 49s
SonarQube / analysis (push) Successful in 9m27s
Reviewed-on: #180
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 13:56:53 +00:00
52eac24d17 Merge pull request 'fix(engagement): two defects the Phase 11b live walk found in core' (#179) from fix/engagement-live-walk-core into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m29s
Reviewed-on: #179
2026-09-01 12:32:29 +00:00
c8d45733b6 fix(engagement): two defects the Phase 11b live walk found in core
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Successful in 5m30s
Both are invisible to a fixture and loud on a real database, which is why the
walk is the phase's acceptance rather than a formality.

1. registerEngagementSeeds validated `max_sends_per_hour` and then dropped it
   from the normalized rule. The column is NOT NULL, so every one of the 25
   module-seeded rules failed to insert at boot. The registry test asserted the
   REJECTION of a bad ceiling and never that a good one survives; it now asserts
   the normalized rule against `engagementRules.db.insert`'s own column list, so
   the next field added is covered the day it is added.

2. The cooldown claim runs inside the engine's per-channel loop and its key was
   (rule, user, subject). So the first channel of a rule claimed the cooldown and
   every later one was reported as cooled -- and `inapp` is ranked first
   deliberately, so a rule naming email + in-app delivered the inbox item and
   silently never the mail. Core's own `news.post` rule has that shape. Phase
   11b's decision 8 requires the letter and the inbox item to fire together.

   `channel` joins the PRIMARY KEY (the org lead's decision 12: a cooldown is per
   delivery, not per occasion). Migrated in place behind an information_schema
   guard, because MariaDB has no conditional form of a key change and replaying
   schema.sql would otherwise fail on every boot after the first.

1551 core tests green; both new tests verified by reverting each fix in turn.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:12:09 -05:00
c3783f56f1 Merge pull request 'feat(engagement): let a module ship its own templates and rules (Phase 11b)' (#178) from feature/engagement-module-seeds into edge
Reviewed-on: #178
2026-09-01 06:35:20 +00:00
40ab1ce8d2 fix(modules): bump the CLIENT half of MODULE_API_VERSION to 1.9.0
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m0s
The two halves version ONE contract and a test asserts they agree
(client/test/moduleRegistry.test.js). I bumped server/src/modules/version.js and
not client/src/modules/version.js, so client-build went red — the job runs the
client suite before it builds.

Nothing on the client half changed: a seed is server-side data and core's seeders
write it on the boot path. It bumps for the reason its own header gives — a
module declares one `coreApi` range against both halves, and a client claiming
1.8.0 while the server answers 1.9.0 is two answers to one question.

327 client tests green; client build clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:22:00 -05:00
0a9149a04f fix(engagement): a trigger-bound template may reference the unsubscribe link
Some checks failed
PR Checks / client-build (pull_request) Failing after 23s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m6s
Found building module-uo's sixteen in-universe bodies, which are the first
trigger-bound templates in the system to carry an unsubscribe line of their own.

`emailChannel.deliver` computes an unsubscribe token per recipient and merges it
LAST over the projection, so `{{unsubscribeUrl}}` has always RENDERED correctly.
But `variablesFor` takes a trigger-bound template's variable list from the
trigger's declaration, and a trigger has no business declaring a fact about how
the mail was delivered — so the token was undeclared, and the save-time
undeclared-variable check would have refused the first operator who tried to EDIT
one of those bodies. Rendering right and then refusing the edit is the worst of
both.

Nothing had ever taken this path: core's generic `notify.event` declares
`unsubscribeUrl` in its own seed and is bound to no trigger, so `seedByKey`
supplied it there.

Adds DELIVERY_VARIABLES beside AMBIENT_VARIABLES — declared separately because
they apply to a different set of templates. Ambient facts are about the
deployment and reach every body; delivery facts are about the send and reach the
trigger-bound ones, which is exactly the set that is engagement mail.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:07:16 -05:00
cfd1cb3c3c feat(engagement): let a module ship its own templates and rules (Phase 11b)
Phase 11a declared 24 triggers and stopped where the plan said it would. Standing
11b up found that the next sentence — "24 rules, all enabled = 0; bespoke template
bodies" — described work with no mechanism to land in: templateSeeds.js and
coreRules.js are core files with core arrays in them, and there was no
registerTemplates or registerRules anywhere in registries.js.

So a module could say what an event's payload was and could never say what the
mail should read like. That is tolerable for one trigger and not for a catalogue,
and it is decisive once the bodies carry domain prose core must not contain (§5.2).

Adds api.registerEngagementSeeds({ templates, ruleGroups }) — MODULE_API 1.9.0.
The module supplies data; core keeps seedOne's customized skip, its seed_version
comparison and the block registry's validation, which is the whole argument for a
registry over the ctx.query a module already holds: a copy of any of those living
outside engagement/ would drift the first time core improved the original, and the
drift would surface as a mail somebody already received.

The two halves behave differently, deliberately:

  - Templates re-ensure on every boot, so a bumped seedVersion reaches every
    deployment except the ones where an operator edited that row.
  - Rule groups are ONE-SHOT, each under its own settings guard — re-ensuring
    would resurrect a rule an operator deleted and reset one they enabled. This is
    11a's seed-key finding stated as an API rather than as a warning: a rule
    appended to an existing group reaches fresh installs only, and one that must
    reach stamped deployments takes a new group key.

Three prohibitions, each a shipped mistake that would only surface as mail: a
seeded rule is always enabled = 0 (Q3's invariant, ignored rather than refused so
a typo cannot take a module offline at boot); a module may not mark a template
protected; and a rule may only name its own trigger ids and its own or core's
template keys, with template keys namespaced because the key column is UNIQUE.

Runs from modules/lifecycle.js boot() rather than seedDefaults(), and that is
forced rather than chosen: server.js seeds before it requires app.js, and
requiring app.js is what runs the loader — at the moment core seeds, no module has
registered anything. Placed after the installed_modules reconcile (so a disabled
or failed module is skipped) and before the onBoot dispatch (so a module warming a
cache may assume its rules exist).

16 new tests; 1549 core tests green; check:modules clean.

Refs docs#/ENGAGEMENT.md Phase 11b decision 7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 00:46:15 -05:00
81e0338a69 Merge pull request 'feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)' (#177) from feature/engagement-uo-triggers into edge
Reviewed-on: #177
2026-09-01 05:05:56 +00:00
15 changed files with 967 additions and 22 deletions

View File

@@ -11,6 +11,13 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.9.0 - a module may ship its own message bodies and rules:
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
// and core's seeders write it on the boot path - but the bodies it ships are
// edited through the template editor this half already renders, and an operator
// meets them there. This file bumps for the reason at the top: the two halves
// state ONE version, and a module declares one `coreApi` range against both.
// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
// this half changed: a ceiling is declared on the server's `api` and enforced
// there, and the admin screens that render one read the vocabulary from
@@ -58,4 +65,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.8.0'
export const MODULE_API_VERSION = '1.9.0'

View File

@@ -1774,9 +1774,18 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
-- per subject".
subject_key VARCHAR(190) NOT NULL DEFAULT '',
-- The CHANNEL the cooldown is about, added in Phase 11b after the live walk.
-- Without it a rule naming two channels delivers on exactly ONE of them: the
-- claim runs inside the engine's per-channel loop, `inapp` is ranked first on
-- purpose (so push can reference its inbox row), and every later channel is
-- then reported as cooled. Phase 11b's decision 8 requires the letter and the
-- inbox item to fire together, so the cooldown is per delivery, not per
-- occasion. VARCHAR like `engagement_outbox.channel`, and for the same reason:
-- the channel set is data a module can extend.
channel VARCHAR(32) NOT NULL DEFAULT '',
last_fired_at DATETIME NOT NULL,
fire_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (rule_id, user_id, subject_key),
PRIMARY KEY (rule_id, user_id, subject_key, channel),
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- So a prune worker can drop rows older than the longest configured cooldown.
@@ -1785,6 +1794,29 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
INDEX idx_engc_sweep (last_fired_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Widen the key on a deployment that already has the table. Two statements, and
-- the second is guarded because MariaDB has no conditional form of a PRIMARY KEY
-- change: re-running `DROP PRIMARY KEY, ADD PRIMARY KEY` on a table that already
-- carries the new one is an error, not a no-op, so replaying this file on every
-- boot would fail the whole schema after the first run. The guard reads the key
-- itself out of information_schema rather than the column's existence, because
-- `ADD COLUMN IF NOT EXISTS` above can succeed while the key change does not.
--
-- Existing rows keep `channel = ''`, which is one stale cooldown per (rule, user,
-- subject) that expires on its own interval. That is the right trade against
-- deleting them: a cooldown that outlives its rewrite costs at most one delayed
-- notification, and dropping the table would let a bounce storm through.
ALTER TABLE engagement_cooldowns ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT '';
SET @engc_key_has_channel := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'engagement_cooldowns'
AND INDEX_NAME = 'PRIMARY' AND COLUMN_NAME = 'channel'
);
SET @sql := IF(@engc_key_has_channel = 0,
'ALTER TABLE engagement_cooldowns DROP PRIMARY KEY, ADD PRIMARY KEY (rule_id, user_id, subject_key, channel)',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
-- (rule, user, channel) occurrence of an event.
CREATE TABLE IF NOT EXISTS engagement_outbox (

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.8.0",
"moduleApiVersion": "1.9.0",
"triggers": [
{
"id": "news.post",

View File

@@ -203,9 +203,18 @@ async function applyRule(rule, event, now) {
summary.capped += 1
continue
}
// One statement, guarded on the interval, so two concurrent emits cannot
// both pass a read-then-write check (§4.1).
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
// Guarded on the interval, so two concurrent emits cannot both pass a
// read-then-write check (§4.1).
//
// **Keyed on the CHANNEL as well**, which is what makes this loop correct
// rather than what makes it work. Without the channel, the first channel of
// a rule claims the cooldown and every later one is refused as cooling —
// and `inapp` is ranked first above, so a rule naming email + in-app would
// deliver the in-app item and silently never the mail. Found on Phase 11b's
// live rig; a cooldown is per delivery, not per occasion.
const allowed = await cooldownsDb.claim(
rule.id, userId, subjectKey, channel, rule.cooldown_seconds, now,
)
if (!allowed) {
summary.cooled += 1
continue

View File

@@ -0,0 +1,179 @@
// ── Seeding what a module ships (ENGAGEMENT.md Phase 11b, decision 7) ──────
//
// Core's own bodies and rules are seeded from `seedDefaults()`, and a module's
// cannot be: `server.js` calls `seedDefaults()` BEFORE it requires `app.js`, and
// requiring `app.js` is what scans the volume and runs the loader. At the moment
// core seeds, no module has registered anything at all.
//
// So this runs from `modules/lifecycle.js` `boot()` instead — after the
// `installed_modules` reconcile, so a module the operator disabled or one that
// failed to load is skipped rather than seeded, and BEFORE the `onBoot`
// dispatch, so a module that warms a cache in `onBoot` may assume its rules
// exist.
//
// **It reuses core's two seeders rather than reimplementing them**, which is the
// whole argument for the registry existing (decision 7): `seedOne` owns the
// `customized` skip and the `seed_version` comparison, `validateEmailBlocks`
// owns what a renderable body is, and a module supplies data. A copy of either
// living outside this directory would drift the first time core improved the
// original — and the drift would surface as a mail somebody already received.
//
// ── The asymmetry, once more, because it is the thing to get right ─────────
//
// **Templates are re-ensured every boot.** A row carries `seed_key`,
// `seed_version` and `customized`, so re-ensuring is how a better default
// reaches a deployment without stealing an operator's edit (§4.6.1 property 3),
// and a template added in a later module version reaches every deployment rather
// than only fresh ones.
//
// **Rule groups are one-shot, each under its own settings guard.** Re-ensuring a
// rule would resurrect one an operator deleted and reset one they enabled. This
// is 11a's seed-key finding as a mechanism: a rule appended to an existing group
// reaches fresh installs only, and a rule that must reach already-stamped
// deployments takes a new group key. The module chooses; this file honours it.
//
// **Never throws.** It is on the boot path beside every other `safe()`-wrapped
// step in `lifecycle.boot()`, and a body that would not seed costs the shipped
// default — `renderByKey`'s fallback stays in charge — not the deployment.
const templatesDb = require('../model/engagement/engagementTemplates.db')
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
const emailBlocks = require('../emailBlocks')
const log = require('../utils/logger')('engagement')
/**
* The one-shot guard for one module's rule group.
*
* Namespaced by owner AND by group so two modules may use the same group name,
* and so a module can add a second group later without touching the first. Its
* VALUE is the timestamp — purely so an operator reading the settings table can
* tell when it ran; only its presence is read.
*/
const guardKey = (owner, group) => `engagement_module_rules_seeded:${owner}:${group}`
/**
* Ensure one module's templates, and bring un-customized rows up to the current
* seed. Idempotent.
*/
async function seedModuleTemplates(owner, templates, deps = {}) {
const templates_ = deps.templatesDb || templatesDb
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
for (const seed of templates) {
// Validated against the block registry before it is stored, exactly as core's
// own seeds are and for the same reason: a shipped block array no renderer
// understands sitting in the table reads to an operator as their deployment
// being broken. Refusing to write it leaves the fallback in charge and puts
// the reason in the boot log, with the module named.
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
if (!valid) {
log.error('a module template is invalid and was not seeded', { owner, key: seed.key, errors })
counts.invalid += 1
continue
}
try {
counts[await templates_.seedOne(seed)] += 1
} catch (err) {
log.error('module template seed failed', { owner, key: seed.key, message: err.message })
}
}
// The third arm of §4.6.1 property 3: a customized row is never touched, and
// the fact that a better default now exists is surfaced instead of applied.
let stale = []
try {
stale = await templates_.staleCustomized(
templates.map((t) => ({ key: t.key, seedVersion: t.seedVersion })),
)
} catch {
stale = []
}
if (stale.length) {
log.info('customized module templates have a newer shipped default', {
owner,
keys: stale.map((t) => t.key),
})
}
return { ...counts, stale: stale.map((t) => t.key) }
}
/**
* Seed one named rule group, once, under its own guard.
*
* Mirrors `coreRules.seedGroup` deliberately, including the stamp-on-partial
* behaviour: re-running would duplicate the rules that DID insert, and a
* duplicate rule is two mails per event — worse than the one missing rule an
* operator can add from the Rules screen.
*/
async function seedRuleGroup(owner, group, deps = {}) {
const rules_ = deps.rulesDb || rulesDb
const settings_ = deps.settingsDb || settingsDb
const summary = { inserted: 0, skipped: 0 }
const key = guardKey(owner, group.key)
try {
const seen = await settings_.get(key)
if (seen) return { ...summary, skipped: group.rules.length }
for (const rule of group.rules) {
try {
await rules_.insert(rule)
summary.inserted += 1
} catch (err) {
log.error('module rule seed failed', {
owner,
group: group.key,
trigger: rule.trigger_id,
message: err.message,
})
}
}
await settings_.set(key, new Date().toISOString())
if (summary.inserted) {
log.info('seeded module engagement rules, all disabled', {
owner,
group: group.key,
rules: summary.inserted,
note: group.note || undefined,
})
}
} catch (err) {
log.error('module rule group seeding failed', { owner, group: group.key, message: err.message })
}
return summary
}
/**
* Seed every registered module's engagement content.
*
* @param {object} [deps]
* @param {Function} [deps.seeds] () => [{ owner, templates, ruleGroups }]
* @param {Set} [deps.skip] owners not to seed (disabled or failed)
* @param {object} [deps.templatesDb] / [deps.rulesDb] / [deps.settingsDb] — test seams
*/
async function seedModuleEngagement({ seeds, skip = new Set(), ...dbs } = {}) {
// eslint-disable-next-line global-require
const read = seeds || require('../modules/registries').allEngagementSeeds
const totals = { templates: 0, rules: 0 }
for (const entry of read()) {
if (skip.has(entry.owner)) {
log.info('skipping engagement seeds for a module that is not booting', { owner: entry.owner })
continue
}
const t = await seedModuleTemplates(entry.owner, entry.templates || [], dbs)
totals.templates += t.inserted + t.updated
for (const group of entry.ruleGroups || []) {
const r = await seedRuleGroup(entry.owner, group, dbs)
totals.rules += r.inserted
}
log.info('module engagement seeds ensured', { owner: entry.owner, ...t })
}
return totals
}
module.exports = {
seedModuleEngagement,
seedModuleTemplates,
seedRuleGroup,
guardKey,
}

View File

@@ -44,6 +44,27 @@ const AMBIENT_VARIABLES = Object.freeze([
{ name: 'year', type: 'string', required: true, example: '2026' },
])
// The per-DELIVERY additions, which are a different thing from the ambient set
// above and are declared separately because they apply to a different set of
// templates.
//
// `emailChannel.deliver` computes an unsubscribe token per recipient and merges
// it LAST over the projection, so a body may always reference it — but a template
// bound to a TRIGGER takes its variable list from that trigger's declaration
// (`variablesFor`), and a trigger has no business declaring a fact about how the
// mail was delivered. Without these, `{{unsubscribeUrl}}` renders correctly and
// then the save-time undeclared-variable check refuses the first operator who
// tries to EDIT the body around it.
//
// Found in Phase 11b, where module-uo's sixteen in-universe bodies are the first
// trigger-bound templates in the system to carry an unsubscribe line of their
// own: core's generic `notify.event` declares it in its own seed and is bound to
// no trigger, so nothing had ever taken this path.
const DELIVERY_VARIABLES = Object.freeze([
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
])
// A tiny helper so the block arrays below read as content rather than as JSON.
const text = (id, body, opts = {}) => ({
id,
@@ -296,4 +317,4 @@ function seedByKey(key) {
return SEEDS.find((s) => s.key === key) || null
}
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }
module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey }

View File

@@ -18,7 +18,7 @@ const templatesDb = require('../model/engagement/engagementTemplates.db')
const settings = require('../model/settings/settings.model')
const brand = require('../config/brand')
const emailBlocks = require('../emailBlocks')
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
const { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey } = require('./templateSeeds')
// The trigger registry lives with the module registries, not here — a trigger is
// something a MODULE declares (see engagement/index.js's header).
const { eventTrigger } = require('../modules/registries')
@@ -84,6 +84,11 @@ function variablesFor(template) {
if (template && template.trigger_id) {
const declared = eventTrigger(template.trigger_id)
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
// A trigger-bound body is engagement mail, and engagement mail always carries
// an unsubscribe the channel computes per recipient. A trigger declares what
// HAPPENED and has no business declaring how the mail was sent, so the
// delivery facts are added here rather than to every declaration.
own.push(...DELIVERY_VARIABLES)
} else if (template && template.seed_key) {
const seed = seedByKey(template.seed_key)
if (seed) own.push(...seed.variables)

View File

@@ -1,8 +1,14 @@
const { query } = require('../../utils/db')
/**
* Claim a fire for (rule, user, subject), or refuse it because the pair is still
* cooling. ENGAGEMENT.md §4.1.
* Claim a fire for (rule, user, subject, channel), or refuse it because that
* delivery is still cooling. ENGAGEMENT.md §4.1.
*
* **`channel` is part of the key, and Phase 11b is where that was settled.** The
* engine claims inside its per-channel loop, so a key without the channel means
* the first channel of a two-channel rule claims the cooldown and every later one
* is refused as cooling - which made every in-universe email body of Phase 11b
* unreachable behind the in-app one. A cooldown is per delivery.
*
* **Two statements, each of which is its own atomic decision** - and it is worth
* saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1
@@ -37,28 +43,29 @@ const { query } = require('../../utils/db')
* `cooldown_seconds = 0` always passes, which is the documented meaning of a rule
* with no cooldown: the guard becomes `last_fired_at <= now`, and it is.
*/
async function claim(ruleId, userId, subjectKey, cooldownSeconds, now = new Date()) {
async function claim(ruleId, userId, subjectKey, channel, cooldownSeconds, now = new Date()) {
const moved = await query(
`UPDATE engagement_cooldowns
SET last_fired_at = ?, fire_count = fire_count + 1
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?
AND last_fired_at <= ? - INTERVAL ? SECOND`,
[now, ruleId, userId, subjectKey, now, cooldownSeconds],
[now, ruleId, userId, subjectKey, channel, now, cooldownSeconds],
)
if (Number(moved?.affectedRows || 0) === 1) return true
const inserted = await query(
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
VALUES (?, ?, ?, ?, 1)`,
[ruleId, userId, subjectKey, now],
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, channel, last_fired_at, fire_count)
VALUES (?, ?, ?, ?, ?, 1)`,
[ruleId, userId, subjectKey, channel, now],
)
return Number(inserted?.affectedRows || 0) === 1
}
const get = async (ruleId, userId, subjectKey) => {
const get = async (ruleId, userId, subjectKey, channel) => {
const [row] = await query(
'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?',
[ruleId, userId, subjectKey],
`SELECT * FROM engagement_cooldowns
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?`,
[ruleId, userId, subjectKey, channel],
)
return row || null
}

View File

@@ -174,6 +174,32 @@ async function boot({ modules, model } = {}) {
}
}
// What a module SHIPS as engagement content — its message bodies and its
// seeded rules (ENGAGEMENT.md Phase 11b, decision 7).
//
// **Here rather than in `seedDefaults()`, and that is forced.** `server.js`
// seeds before it requires `app.js`, and requiring `app.js` is what scans the
// volume and runs the loader — so at the moment core seeds its own templates,
// no module has registered anything.
//
// **After the reconcile and before `onBoot`**, both deliberately: `disabled`
// is now known, so a module the operator switched off is skipped rather than
// having its rules quietly written; and a module that warms a cache in
// `onBoot` may assume its rules and bodies exist by then.
//
// Failed modules are skipped for the stronger reason. A module whose require
// or schema replay failed has registered nothing anyway — but one whose ROW
// says `startup_failed` may have registered before failing later, and seeding
// content for a module that is about to answer 503 puts rows in the operator's
// Rules screen for a thing that is not running.
const skip = new Set([
...disabled,
...scanned.filter((m) => m.state === 'startup_failed').map((m) => m.id),
])
await safe('seeding module engagement content', () =>
// eslint-disable-next-line global-require
require('../engagement/moduleSeeds').seedModuleEngagement({ skip }))
for (const { id, hook, ctx } of loader.bootable()) {
try {
// Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the

View File

@@ -356,6 +356,20 @@ function buildApi(record) {
once('registerAudiences')
record.staged.registerAudiences(audiences)
},
// What the module SHIPS behind those two — its message bodies and its
// seeded rules (API 1.9.0, ENGAGEMENT.md Phase 11b decision 7). `once` for
// the same reason again, and here it is load-bearing rather than tidy: a
// rule belongs to exactly one named group, and merging two calls would make
// "which group is this rule in" — the question the one-shot guard answers —
// unanswerable.
//
// Data only. Nothing on the object is a function and nothing on it reaches a
// recipient: seeding writes rows that are `enabled = 0`, and a module still
// cannot send mail (§1.2).
registerEngagementSeeds(seeds) {
once('registerEngagementSeeds')
record.staged.registerEngagementSeeds(seeds)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply

View File

@@ -38,6 +38,19 @@
// 5. `registerAudiences(audiences)` — §5.1a. Named sets of user ids a
// module can resolve over its own data, for an operator to point a rule at.
//
// And a sixth, in Phase 11b (decision 7):
//
// 6. `registerEngagementSeeds({ templates, ruleGroups })` — the message BODIES
// and the shipped rules behind 4 and 5. A module declaring a trigger could
// say what its payload was and never say what it should read like, so a
// module's mail was core's generic body or nothing.
//
// **6 stores data and nothing else — no function, no handle.** A template is
// blocks and a rule is columns, both validated here and both written by core's
// own seeders (`engagement/moduleSeeds.js`), which is what keeps `seed_version`,
// `customized` and the block registry in the one file that owns them. It is
// emphatically not a send path: a module still cannot mail anyone (§1.2).
//
// **Triggers and notification streams share ONE id namespace** (the org lead's
// §7.2 decision). A stream entry is a subscription toggle and a trigger is a
// payload contract, so they stay two REGISTRATIONS with two shapes — but an id
@@ -111,6 +124,15 @@ const triggers = new Map()
// trigger of the same name would be a collision between two unrelated things.
const audiences = new Map()
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
// decision 7). What a module ships as CONTENT rather than as contract: the
// bodies its triggers render through, and the rules an operator switches on.
//
// Keyed by owner and not by template key, because the seeder runs per module —
// a module the operator disabled is skipped whole, and a module that failed to
// load never gets here at all.
const engagementSeeds = new Map()
let coreRegistered = false
// Stream ids that predate the module system and may not carry their owner's
@@ -723,6 +745,210 @@ function checkAudienceShape(entry) {
}
}
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
//
// **Two mechanisms, and the asymmetry between them is the whole design.**
//
// A TEMPLATE is re-ensured on every boot. Its row carries `seed_key`,
// `seed_version` and `customized`, so re-ensuring is how a better default
// reaches a deployment without stealing an operator's edit (§4.6.1 property 3),
// and a template added in a later module version reaches every deployment rather
// than only fresh ones.
//
// A RULE is the opposite. Re-ensuring one would resurrect a rule an operator
// deleted and reset one they enabled — so rules arrive in named GROUPS, each
// with its own one-shot settings guard. That is 11a's seed-key finding stated as
// an API instead of as a warning: appending a rule to an existing group reaches
// fresh installs only, and a rule that must reach deployments already stamped
// takes a NEW group. The module names its groups, so the module makes that
// choice knowingly.
//
// Everything below is a shape check. Nothing here writes: `engagement/
// moduleSeeds.js` does, through the same `seedOne` and the same block validator
// core's own seeds go through.
// A module template key must be namespaced to its owner, for the same reason a
// trigger id must: `engagement_templates.key` is UNIQUE across the table, so an
// unprefixed `notify.event` from a module would collide with core's — and win or
// lose depending on boot order, which is the worst of both.
const TEMPLATE_KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
const MAX_TEMPLATE_KEY = 96
const SEED_GROUP_KEY = /^[a-z][a-z0-9]*(?:[-.][a-z0-9]+)*$/
// The channels a seeded template may target. Deliberately a literal rather than
// a read of the channel registry: this runs at registration time, which is
// before any channel a module might add is registered, and a seed for a channel
// nothing delivers is a row an operator can never use.
const SEEDABLE_CHANNELS = ['email', 'inapp']
// Core's own seed keys, which a module's rule MAY point at — that is §4.6.1
// property 1 in force, and the nine plain bodies of decision 9 are exactly this.
// Required lazily-safe: `templateSeeds` is pure data with no requires of its own.
// eslint-disable-next-line global-require
const coreTemplateKeys = () => new Set(require('../engagement/templateSeeds').SEEDS.map((s) => s.key))
function checkSeedTemplate(owner, entry) {
const t = entry || {}
const where = `registerEngagementSeeds: template "${t.key}"`
if (!TEMPLATE_KEY.test(t.key || '') || t.key.length > MAX_TEMPLATE_KEY) {
throw new Error(`registerEngagementSeeds: bad template key "${t.key}"`)
}
if (!t.key.startsWith(`${owner}.`)) {
throw new Error(`${where} is not namespaced "${owner}."`)
}
if (!t.name) throw new Error(`${where} has no name`)
if (!SEEDABLE_CHANNELS.includes(t.channel)) {
throw new Error(`${where} has unknown channel "${t.channel}" (one of ${SEEDABLE_CHANNELS.join(', ')})`)
}
if (!Array.isArray(t.blocks) || !t.blocks.length) throw new Error(`${where} has no blocks`)
if (!Number.isInteger(t.seedVersion) || t.seedVersion < 1) {
throw new Error(`${where} needs an integer seedVersion of 1 or more`)
}
// An email body without a subject is a mail with an empty subject line, which
// no operator meant; an in-app body WITH one is a column the inbox does not
// read (`inapp.event` leaves it NULL and says why).
if (t.channel === 'email' && !t.subject) throw new Error(`${where} is an email body with no subject`)
if (t.channel !== 'email' && t.subject) {
throw new Error(`${where} is a ${t.channel} body and cannot carry a subject`)
}
// `protected` is core's alone. It means "the system breaks without this body",
// which is true of a password reset and true of nothing a module ships; a
// module marking its own template undeletable is a module taking an operator's
// delete button away.
if (t.protected) throw new Error(`${where} may not be protected — that flag is core's`)
return {
key: t.key,
name: t.name,
channel: t.channel,
subject: t.subject || null,
blocks: t.blocks,
seedVersion: t.seedVersion,
triggerId: t.triggerId || null,
triggerVersion: Number.isInteger(t.triggerVersion) ? t.triggerVersion : null,
protected: false,
status: 'published',
}
}
function checkSeedRule(owner, entry, ownTemplateKeys, coreKeys) {
const r = entry || {}
const where = `registerEngagementSeeds: rule for "${r.trigger_id}"`
if (!EVENT_ID.test(r.trigger_id || '')) {
throw new Error(`registerEngagementSeeds: bad rule trigger_id "${r.trigger_id}"`)
}
// A module seeds rules for ITS OWN triggers. Shipping one for core's — or for
// another module's — would mean uninstalling this module leaves a rule behind
// that nobody can explain, and two modules could ship two rules for the same
// event with neither aware of the other.
if (!namespaced(owner, r.trigger_id, LEGACY_STREAM_IDS)) {
throw new Error(`${where} is not namespaced "${owner}."`)
}
if (!r.name) throw new Error(`${where} has no name`)
if (!Array.isArray(r.channels) || !r.channels.length) throw new Error(`${where} has no channels`)
if (!r.audience) throw new Error(`${where} has no audience`)
if (!Number.isInteger(r.cooldown_seconds) || r.cooldown_seconds < 0) {
throw new Error(`${where} needs a cooldown_seconds of 0 or more`)
}
// Q3's hard ceiling, and the reason a seeded rule cannot omit it: it is what
// keeps a misconfiguration from becoming a mail storm, so a module may choose
// the number and may not decline to have one.
if (!Number.isInteger(r.max_sends_per_hour) || r.max_sends_per_hour < 1) {
throw new Error(`${where} needs a max_sends_per_hour of 1 or more`)
}
const keys = r.template_keys || {}
if (!keys || typeof keys !== 'object' || Array.isArray(keys)) {
throw new Error(`${where} needs a template_keys object`)
}
for (const [channel, key] of Object.entries(keys)) {
// `digest` is a template slot rather than a channel — the digest worker's
// body for a rule whose email channel is set to digest mode — so it is
// allowed here and absent from `channels`.
if (!ownTemplateKeys.has(key) && !coreKeys.has(key)) {
throw new Error(
`${where} names template "${key}" for ${channel}, which is neither one of its own seeds nor core's`,
)
}
}
return {
trigger_id: r.trigger_id,
name: r.name,
audience: r.audience,
audience_segment_id: null,
// Checked above and carried here: the column is NOT NULL, so a normalizer
// that validates the ceiling and then drops it fails every insert in the
// group at boot — loudly, but only on a real database.
max_sends_per_hour: r.max_sends_per_hour,
channels: [...r.channels],
template_keys: { ...keys },
conditions: r.conditions === undefined ? null : r.conditions,
cooldown_seconds: r.cooldown_seconds,
delay_seconds: Number.isInteger(r.delay_seconds) ? r.delay_seconds : 0,
cancel_on: Array.isArray(r.cancel_on) ? [...r.cancel_on] : [],
// Never negotiable and never a parameter (Q3). A module that could ship an
// enabled rule could mail a deployment's whole user table on the strength of
// an upgrade nobody read the release note for.
enabled: 0,
updated_by: null,
}
}
/**
* `registerEngagementSeeds({ templates, ruleGroups })`.
*
* Validated whole, exactly as `apply()` validates: a module that got one of
* thirty-two templates wrong ships none of them, and finds out at boot with the
* offending key named rather than at send time with a half-seeded table.
*/
function checkEngagementSeeds(owner, entry) {
const e = entry || {}
if (e.templates !== undefined && !Array.isArray(e.templates)) {
throw new Error('registerEngagementSeeds: templates must be an array')
}
if (e.ruleGroups !== undefined && !Array.isArray(e.ruleGroups)) {
throw new Error('registerEngagementSeeds: ruleGroups must be an array')
}
const templates = []
const seenKeys = new Set()
for (const t of e.templates || []) {
const checked = checkSeedTemplate(owner, t)
if (seenKeys.has(checked.key)) {
throw new Error(`registerEngagementSeeds: template "${checked.key}" declared twice`)
}
seenKeys.add(checked.key)
templates.push(checked)
}
const coreKeys = coreTemplateKeys()
const ruleGroups = []
const seenGroups = new Set()
for (const g of e.ruleGroups || []) {
const group = g || {}
if (!SEED_GROUP_KEY.test(group.key || '')) {
throw new Error(`registerEngagementSeeds: bad rule group key "${group.key}"`)
}
if (seenGroups.has(group.key)) {
throw new Error(`registerEngagementSeeds: rule group "${group.key}" declared twice`)
}
seenGroups.add(group.key)
if (!Array.isArray(group.rules) || !group.rules.length) {
throw new Error(`registerEngagementSeeds: rule group "${group.key}" has no rules`)
}
ruleGroups.push({
key: group.key,
note: group.note || '',
rules: group.rules.map((r) => checkSeedRule(owner, r, seenKeys, coreKeys)),
})
}
return { templates, ruleGroups }
}
/** Every registrant's seeds, in registration order. What the seeder walks. */
const allEngagementSeeds = () =>
[...engagementSeeds.entries()].map(([owner, seeds]) => ({ owner, ...seeds }))
/** One registrant's, or null. */
const engagementSeedsFor = (owner) => engagementSeeds.get(owner) || null
// `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's
// router reaches the app through declareSlot(), which no static parse of app.js
// can follow, so swagger-autogen would silently drop every route in it — the
@@ -757,6 +983,7 @@ function stage(owner) {
slashCommands: [],
triggers: [],
audiences: [],
engagementSeeds: [],
}
return {
staged,
@@ -788,6 +1015,9 @@ function stage(owner) {
if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array')
for (const e of entries) staged.audiences.push(checkAudienceShape(e))
},
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
}
}
@@ -810,6 +1040,7 @@ function apply({
slashCommands: newSlashCommands = [],
triggers: newTriggers = [],
audiences: newAudiences = [],
engagementSeeds: newSeeds = [],
}) {
// ── validate ──
const seenStreams = new Set()
@@ -886,6 +1117,14 @@ function apply({
seenSlots.add(x.slot)
}
// One call per registrant, like the post hook and the team provider above it.
// A second call is a module that wrote its seeds in two places, and merging
// them silently would make "which group is this rule in" unanswerable.
if (newSeeds.length > 1) throw new Error(`"${owner}" registered engagement seeds more than once`)
if (newSeeds.length && engagementSeeds.has(owner)) {
throw new Error(`"${owner}" already registered engagement seeds`)
}
if (newPostHooks.length > 1) throw new Error(`"${owner}" registered more than one post hook`)
if (newPostHooks.length && postHooks.has(owner)) {
throw new Error(`"${owner}" already registered a post hook`)
@@ -921,6 +1160,7 @@ function apply({
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
}
// ── Core's own registrations ───────────────────────────────────────────────
@@ -996,6 +1236,7 @@ function _reset() {
slashCommands.clear()
triggers.clear()
audiences.clear()
engagementSeeds.clear()
coreRegistered = false
}
@@ -1023,6 +1264,9 @@ module.exports = {
allAudiences,
audience,
resolveAudience,
allEngagementSeeds,
engagementSeedsFor,
SEEDABLE_CHANNELS,
VARIABLE_TYPES,
TRIGGER_KINDS,
stage,

View File

@@ -9,6 +9,39 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.9.0 - a sixth registration call: `api.registerEngagementSeeds({ templates,
// ruleGroups })` (docs/website/ENGAGEMENT.md Phase 11b, decision 7). A module
// could declare a trigger from 1.7.0 and could never say what the mail should
// READ like: `templateSeeds.js` and `coreRules.js` are core files with core
// arrays in them, so a module's notification was core's generic body or nothing.
// Additions only, so minor: every module written against 1.8.0 keeps working and
// simply seeds nothing.
//
// **What a module has to know about it beyond the new name**, because the two
// halves behave differently on purpose:
//
// - **Templates are re-ensured on every boot**, under `seed_key` /
// `seed_version` / `customized` - so bumping a body's `seedVersion` reaches
// every deployment except the ones where an operator edited that row, and a
// template added in a later module version reaches everyone.
// - **Rules are one-shot, per named GROUP.** Re-ensuring one would resurrect a
// rule an operator deleted and reset one they enabled, so each group carries
// its own settings guard. A rule appended to an existing group therefore
// reaches FRESH INSTALLS ONLY; one that must reach deployments already
// stamped takes a new group key. That is 11a's seed-key finding as an API
// rather than as a warning, and the module makes the choice knowingly.
//
// Two things it deliberately does not permit. A seeded rule is always
// `enabled = 0` - it is not a parameter - which is Q3's invariant surviving
// contact with the largest seed set in the workstream. And a module may not mark
// a template `protected`: that flag means "the system breaks without this body",
// which is true of a password reset and of nothing a module ships, and a module
// setting it would take an operator's delete button away.
//
// It runs from `modules/lifecycle.js` `boot()` rather than `seedDefaults()`, and
// that is forced rather than chosen: core seeds before `app.js` is required, and
// requiring `app.js` is what runs the loader.
// 1.8.0 - a seventh value in the audience ceiling lattice: `admin`, a child of
// `staff` (docs/website/ENGAGEMENT.md Phase 11, decision 1). A module may now
// declare `ceiling: 'admin'` on a trigger or an audience, so the set of values
@@ -101,6 +134,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.8.0'
const MODULE_API_VERSION = '1.9.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -19,6 +19,7 @@ const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('../src/engagement/templ
const templatesDb = require('../src/model/engagement/engagementTemplates.db')
const settings = require('../src/model/settings/settings.model')
const templates = require('../src/engagement/templates')
const registries = require('../src/modules/registries')
const SITE = 'Runic Gateway'
const BASE = 'https://shard.example.com'
@@ -400,6 +401,30 @@ test('variablesFor answers from the seed for a template with no trigger, plus th
assert.deepEqual(templates.variablesFor({}).map((v) => v.name), ['siteName', 'siteUrl', 'logoUrl', 'year'])
})
test('a TRIGGER-bound template also gets the per-delivery variables', () => {
// Phase 11b. `emailChannel.deliver` computes an unsubscribe token per recipient
// and merges it last, so `{{unsubscribeUrl}}` has always RENDERED — but a
// trigger-bound template takes its variable list from the trigger, and a
// trigger has no business declaring a fact about how the mail was sent. Without
// this, a body carrying an unsubscribe line rendered correctly and then the
// save-time undeclared-variable check refused the first operator who edited it.
//
// Nothing had taken this path before: core's `notify.event` declares the
// variable in its own seed and is bound to no trigger.
registries.registerCore()
const names = templates.variablesFor({ trigger_id: 'news.post' }).map((v) => v.name)
assert.ok(names.includes('unsubscribeUrl'), 'a trigger-bound body may reference it')
assert.ok(names.includes('title'), 'and still gets the trigger\'s own')
assert.ok(names.includes('siteName'), 'and the ambient set')
// A SEEDLESS, triggerless template gets neither — there is no delivery to
// describe, and an unsubscribe link on a password reset is meaningless.
assert.equal(
templates.variablesFor({}).map((v) => v.name).includes('unsubscribeUrl'),
false,
)
})
// ── The seeder and the render entrypoint ────────────────────────────────────
test('the shipped default is used when the row is missing, and when it is unusable', async () => {

View File

@@ -98,8 +98,11 @@ function installStubs() {
// the pair is still cooling. `engagementEngineSql.test.js` is what proves the
// SQL itself - a stub can only ever agree with whoever wrote it, and in this
// case the first version of both was wrong together.
cooldownsDb.claim = async (ruleId, userId, subjectKey, cooldownSeconds, now) => {
const key = `${ruleId}|${userId}|${subjectKey}`
cooldownsDb.claim = async (ruleId, userId, subjectKey, channel, cooldownSeconds, now) => {
// `channel` is in the key, exactly as the PRIMARY KEY is: a rule naming two
// channels must deliver on both, and a stub that dropped the channel would
// agree with the engine bug Phase 11b's live walk found.
const key = `${ruleId}|${userId}|${subjectKey}|${channel}`
const row = store.cooldowns.get(key)
if (!row) {
store.cooldowns.set(key, { last_fired_at: now, fire_count: 1 })
@@ -327,6 +330,26 @@ test('a cooldown that has expired lets the same subject through again', async ()
assert.equal(outboxRows().length, 2)
})
test('a cooldown does not stop a rule delivering on its OTHER channels', async () => {
// Phase 11b's live walk. The claim runs inside the per-channel loop, so a key
// without the channel let the FIRST channel claim the cooldown and reported
// every later one as cooled — and `inapp` is ranked ahead of `email` on
// purpose, so a two-channel rule delivered the inbox item and silently never
// the mail. Decision 8 requires both, and this is the assertion that says so.
addUser(10)
optIn(10, 'uo.house.idoc_warning', 'email')
optIn(10, 'uo.house.idoc_warning', 'inapp')
addRule({ cooldown_seconds: 86_400, channels: ['email', 'inapp'] })
await engine.dispatch(event(), T0)
assert.deepEqual(outboxRows().map((r) => r.channel).sort(), ['email', 'inapp'])
// …and the cooldown still holds, on both channels, for a second event about
// the same house inside the day. Per-delivery, not per-channel-forever.
await engine.dispatch(event(), later(60_000))
assert.equal(outboxRows().length, 2)
})
test('two rules on one trigger each get their own cooldown', async () => {
addRule({ cooldown_seconds: 3600 })
addRule({ cooldown_seconds: 3600 })

View File

@@ -0,0 +1,320 @@
// ── registerEngagementSeeds + the module seeder ────────────────────────────
//
// ENGAGEMENT.md Phase 11b, decision 7. Two halves, tested apart because they
// fail differently: the REGISTRY refuses a bad declaration at boot with the key
// named, and the SEEDER decides what reaches the database and — much more
// importantly — what does not reach it a second time.
//
// The properties worth a test are the ones no hand run would catch:
//
// • a module cannot ship an ENABLED rule, or a `protected` template, or a body
// for someone else's trigger, or a rule pointing at a template that does not
// exist. Each of those is a shipped mistake that only shows up as mail.
// • templates are re-ensured and rules are NOT — the asymmetry the whole
// design rests on, and the one an implementer would most plausibly "tidy".
// • a disabled module is skipped, which is the operator's switch meaning what
// it says even for content that is only rows in a table.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const db = require('../src/utils/db')
after(() => db.close())
beforeEach(() => registries._reset())
const blocks = [{ id: 'p1', type: 'email.text', props: { text: 'Hail, {{siteName}}.' } }]
const tpl = (over = {}) => ({
key: 'demo.house.warning',
name: 'A warning',
channel: 'email',
subject: 'A warning',
seedVersion: 1,
blocks,
...over,
})
const rule = (over = {}) => ({
trigger_id: 'demo.house.warning',
name: 'House warning',
audience: 'owner',
channels: ['email'],
template_keys: { email: 'demo.house.warning' },
cooldown_seconds: 3600,
max_sends_per_hour: 200,
...over,
})
/** Register a seed batch as `owner`; returns the error message or null. */
function trySeeds(owner, seeds) {
const api = registries.stage(owner)
try {
api.registerEngagementSeeds(seeds)
registries.apply(api.staged)
return null
} catch (err) {
return err.message
}
}
// ── The registry: what a module may and may not ship ───────────────────────
test('a well-formed batch registers and reads back under its owner', () => {
assert.equal(trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', note: 'the first set', rules: [rule()] }],
}), null)
const all = registries.allEngagementSeeds()
assert.equal(all.length, 1)
assert.equal(all[0].owner, 'demo')
assert.equal(all[0].templates.length, 1)
assert.equal(all[0].ruleGroups[0].key, 'v1')
assert.deepEqual(registries.engagementSeedsFor('demo').templates[0].key, 'demo.house.warning')
assert.equal(registries.engagementSeedsFor('nobody'), null)
})
test('a seeded rule is always disabled, whatever the module said', () => {
// Q3's invariant, and the one place in the workstream where a module could
// have overridden it. `enabled: 1` is not refused — it is IGNORED — because
// refusing would let a typo take a deployment's whole module offline at boot.
assert.equal(trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ enabled: 1 })] }],
}), null)
assert.equal(registries.engagementSeedsFor('demo').ruleGroups[0].rules[0].enabled, 0)
})
test('a template key must be namespaced to its owner', () => {
// `engagement_templates.key` is UNIQUE across the table, so an unprefixed
// `notify.event` from a module would collide with core's and win or lose on
// boot order.
const err = trySeeds('demo', { templates: [tpl({ key: 'notify.event' })] })
assert.match(err, /not namespaced "demo\."/)
})
test('a module may not ship a rule for a trigger it does not own', () => {
const err = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ trigger_id: 'news.post' })] }],
})
assert.match(err, /not namespaced "demo\."/)
})
test('a module may not mark a template protected', () => {
const err = trySeeds('demo', { templates: [tpl({ protected: true })] })
assert.match(err, /may not be protected/)
})
test('a rule must name a template that exists — its own or core\'s', () => {
const missing = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ template_keys: { email: 'demo.nope' } })] }],
})
assert.match(missing, /neither one of its own seeds nor core's/)
// Core's generic bodies ARE permitted — that is §4.6.1 property 1 in force,
// and the nine plain bodies of decision 9 are exactly this case.
registries._reset()
assert.equal(trySeeds('demo', {
ruleGroups: [{
key: 'v1',
rules: [rule({ template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' } })],
}],
}), null)
})
test('an email body needs a subject and an in-app body may not have one', () => {
assert.match(trySeeds('demo', { templates: [tpl({ subject: null })] }), /no subject/)
registries._reset()
assert.match(
trySeeds('demo', { templates: [tpl({ channel: 'inapp' })] }),
/cannot carry a subject/,
)
registries._reset()
assert.equal(trySeeds('demo', { templates: [tpl({ channel: 'inapp', subject: null })] }), null)
})
test('a rule must carry a per-hour ceiling', () => {
// Q3: the module chooses the number and may not decline to have one.
const err = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ max_sends_per_hour: 0 })] }],
})
assert.match(err, /max_sends_per_hour/)
})
test('a normalized rule carries every column the insert reads', () => {
// Refusing a bad ceiling and then DROPPING a good one are different bugs, and
// the first test cannot see the second: `engagementRules.db.insert` binds a
// fixed column list, so a field validated and not carried through arrives as
// NULL and fails the whole group at boot — on a real database only. Asserted
// against the column list itself rather than one field, because the next
// field added to the declaration is the next one that can be forgotten here.
assert.equal(trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ delay_seconds: 60, cancel_on: ['demo.house.refreshed'] })] }],
}), null)
const seeded = registries.engagementSeedsFor('demo').ruleGroups[0].rules[0]
for (const column of [
'trigger_id', 'name', 'enabled', 'audience', 'audience_segment_id', 'max_sends_per_hour',
'channels', 'template_keys', 'conditions', 'cooldown_seconds', 'delay_seconds', 'cancel_on',
'updated_by',
]) {
assert.ok(column in seeded, `normalized rule is missing "${column}"`)
assert.notEqual(seeded[column], undefined, `normalized rule leaves "${column}" undefined`)
}
assert.equal(seeded.max_sends_per_hour, 200)
assert.equal(seeded.delay_seconds, 60)
assert.deepEqual(seeded.cancel_on, ['demo.house.refreshed'])
})
test('registering twice is a collision, not an addition', () => {
assert.equal(trySeeds('demo', { templates: [tpl()] }), null)
assert.match(trySeeds('demo', { templates: [tpl({ key: 'demo.other' })] }), /already registered/)
})
test('a bad template leaves nothing behind — validate-then-commit', () => {
const err = trySeeds('demo', {
templates: [tpl(), tpl({ key: 'demo.bad', channel: 'sms' })],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
assert.match(err, /unknown channel "sms"/)
assert.equal(registries.engagementSeedsFor('demo'), null)
assert.deepEqual(registries.allEngagementSeeds(), [])
})
// ── The seeder ─────────────────────────────────────────────────────────────
const moduleSeeds = require('../src/engagement/moduleSeeds')
/** A registered batch, shaped the way `allEngagementSeeds()` returns it. */
function registered(owner, seeds) {
assert.equal(trySeeds(owner, seeds), null)
return () => registries.allEngagementSeeds()
}
test('guardKey names both the owner and the group', () => {
// Two modules may use the same group name, and one module may add a second
// group later without disturbing the first.
assert.equal(moduleSeeds.guardKey('uo', 'triggers-v1'), 'engagement_module_rules_seeded:uo:triggers-v1')
assert.notEqual(moduleSeeds.guardKey('uo', 'a'), moduleSeeds.guardKey('other', 'a'))
})
test('templates are re-ensured every run and rule groups are seeded once', async () => {
// The asymmetry the design rests on. A second run must re-offer every template
// (so a bumped seedVersion reaches an existing deployment) and must offer no
// rule at all (so a rule an operator deleted stays deleted).
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
const settings = new Map()
const seededTemplates = []
const insertedRules = []
const stub = {
templatesDb: {
seedOne: async (t) => { seededTemplates.push(t.key); return 'inserted' },
staleCustomized: async () => [],
},
rulesDb: { insert: async (r) => { insertedRules.push(r.trigger_id) } },
settingsDb: {
get: async (k) => settings.get(k) || null,
set: async (k, v) => { settings.set(k, v) },
},
}
await moduleSeeds.seedModuleEngagement({ seeds, ...stub })
await moduleSeeds.seedModuleEngagement({ seeds, ...stub })
assert.deepEqual(seededTemplates, ['demo.house.warning', 'demo.house.warning'])
assert.deepEqual(insertedRules, ['demo.house.warning'])
assert.ok(settings.has(moduleSeeds.guardKey('demo', 'v1')))
})
test('a partial rule group is still stamped', async () => {
// Re-running would duplicate the rules that DID insert, and a duplicate rule
// is two mails per event — worse than the one missing rule an operator can add
// from the Rules screen. `coreRules.seedGroup` made the same call.
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule(), rule({ trigger_id: 'demo.house.gone', name: 'Gone' })] }],
})
const settings = new Map()
let inserts = 0
await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: { seedOne: async () => 'inserted', staleCustomized: async () => [] },
rulesDb: {
insert: async () => {
inserts += 1
if (inserts === 2) throw new Error('duplicate')
},
},
settingsDb: {
get: async (k) => settings.get(k) || null,
set: async (k, v) => { settings.set(k, v) },
},
})
assert.equal(inserts, 2)
assert.ok(settings.has(moduleSeeds.guardKey('demo', 'v1')))
})
test('a skipped owner is seeded not at all', async () => {
// The operator's switch means what it says even for content that is only rows.
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
let touched = 0
await moduleSeeds.seedModuleEngagement({
seeds,
skip: new Set(['demo']),
templatesDb: { seedOne: async () => { touched += 1; return 'inserted' }, staleCustomized: async () => [] },
rulesDb: { insert: async () => { touched += 1 } },
settingsDb: { get: async () => null, set: async () => {} },
})
assert.equal(touched, 0)
})
test('a database failure is logged, never thrown — this is the boot path', async () => {
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: {
seedOne: async () => { throw new Error('table is gone') },
staleCustomized: async () => { throw new Error('also gone') },
},
rulesDb: { insert: async () => { throw new Error('gone too') } },
settingsDb: { get: async () => { throw new Error('and gone') }, set: async () => {} },
})
})
test('an invalid block array is refused rather than stored', async () => {
// A shipped block array no renderer understands reads to an operator as their
// deployment being broken. Refusing leaves renderByKey's fallback in charge.
const seeds = registered('demo', {
templates: [tpl({ blocks: [{ id: 'x', type: 'email.nosuchblock', props: {} }] })],
})
let stored = 0
const totals = await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: { seedOne: async () => { stored += 1; return 'inserted' }, staleCustomized: async () => [] },
rulesDb: { insert: async () => {} },
settingsDb: { get: async () => null, set: async () => {} },
})
assert.equal(stored, 0)
assert.equal(totals.templates, 0)
})