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>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user