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
This commit is contained in:
2026-09-01 06:35:20 +00:00
11 changed files with 849 additions and 5 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

@@ -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

@@ -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

@@ -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,206 @@ 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,
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 +979,7 @@ function stage(owner) {
slashCommands: [],
triggers: [],
audiences: [],
engagementSeeds: [],
}
return {
staged,
@@ -788,6 +1011,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 +1036,7 @@ function apply({
slashCommands: newSlashCommands = [],
triggers: newTriggers = [],
audiences: newAudiences = [],
engagementSeeds: newSeeds = [],
}) {
// ── validate ──
const seenStreams = new Set()
@@ -886,6 +1113,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 +1156,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 +1232,7 @@ function _reset() {
slashCommands.clear()
triggers.clear()
audiences.clear()
engagementSeeds.clear()
coreRegistered = false
}
@@ -1023,6 +1260,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

@@ -0,0 +1,294 @@
// ── 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('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)
})