diff --git a/client/src/modules/version.js b/client/src/modules/version.js index e23e393..bd6f97b 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -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' diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index f873359..6bc05a8 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -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", diff --git a/server/src/engagement/moduleSeeds.js b/server/src/engagement/moduleSeeds.js new file mode 100644 index 0000000..06e62b6 --- /dev/null +++ b/server/src/engagement/moduleSeeds.js @@ -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, +} diff --git a/server/src/engagement/templateSeeds.js b/server/src/engagement/templateSeeds.js index ea44d64..d77e0f0 100644 --- a/server/src/engagement/templateSeeds.js +++ b/server/src/engagement/templateSeeds.js @@ -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 } diff --git a/server/src/engagement/templates.js b/server/src/engagement/templates.js index 1b01205..4964be3 100644 --- a/server/src/engagement/templates.js +++ b/server/src/engagement/templates.js @@ -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) diff --git a/server/src/modules/lifecycle.js b/server/src/modules/lifecycle.js index 3a9eea3..ba78702 100644 --- a/server/src/modules/lifecycle.js +++ b/server/src/modules/lifecycle.js @@ -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 diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index d53b698..51bec59 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -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 diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index b35fc0b..ab9dbba 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -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, diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 157b28e..2aa6743 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -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 } diff --git a/server/test/emailTemplates.test.js b/server/test/emailTemplates.test.js index 9ef94af..1c4cfe8 100644 --- a/server/test/emailTemplates.test.js +++ b/server/test/emailTemplates.test.js @@ -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 () => { diff --git a/server/test/moduleEngagementSeeds.test.js b/server/test/moduleEngagementSeeds.test.js new file mode 100644 index 0000000..5ac2674 --- /dev/null +++ b/server/test/moduleEngagementSeeds.test.js @@ -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) +})