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:
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
179
server/src/engagement/moduleSeeds.js
Normal file
179
server/src/engagement/moduleSeeds.js
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
294
server/test/moduleEngagementSeeds.test.js
Normal file
294
server/test/moduleEngagementSeeds.test.js
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user