feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an operator can edit. A relocation, not a regression: nothing that sends mail today starts depending on an operator authoring something first. - `email.*` block family in its own registry, sharing the page family's envelope walk and validate-then-sanitize order by binding rather than by copy. - A server-side renderer producing both parts of a multipart message; the text part is byte-identical to the literals this commit deletes. - Nine seeded templates, six of them wired now; the seeder's `customized = 0` guard lives in the UPDATE's own WHERE. - `renderByKey` falls back to the shipped seed when a row is missing or unusable, so no failure of the table can stop a password reset. Also fixes `check:hosts` reading the template key `auth.email-verify` as the hostname `auth.email`. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
149
server/src/model/engagement/engagementTemplates.db.js
Normal file
149
server/src/model/engagement/engagementTemplates.db.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// Same JSON-column caveat as engagementRules.db.js — `blocks` is a MEDIUMTEXT
|
||||
// holding JSON rather than a JSON column (it can be large and is never queried
|
||||
// into), so it is always a string on the way out and always parsed here.
|
||||
function parseJson(value, fallback) {
|
||||
if (value === null || value === undefined) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// A stored `blocks` that will not parse degrades to an EMPTY array, not to an
|
||||
// error. Same posture `settingsJson` takes and the same one `resolveThemeTokens`
|
||||
// takes: a row hand-edited in the DB, or written by an older version of this code,
|
||||
// must not stop a password-reset mail from being attempted — the renderer produces
|
||||
// an empty body, the send log records it, and the operator is told in the admin
|
||||
// list rather than at 3am by a boot that will not come up.
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
blocks: parseJson(row.blocks, []),
|
||||
protected: Boolean(row.protected),
|
||||
customized: Boolean(row.customized),
|
||||
}
|
||||
|
||||
const list = async () =>
|
||||
(await query('SELECT * FROM engagement_templates ORDER BY channel, `key`')).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_templates WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
const getByKey = async (key) => {
|
||||
const [row] = await query('SELECT * FROM engagement_templates WHERE `key` = ?', [key])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Which of `keys` exist. Used to validate a rule's `template_keys` map. */
|
||||
const existingKeys = async (keys) => {
|
||||
if (!Array.isArray(keys) || keys.length === 0) return []
|
||||
const marks = keys.map(() => '?').join(',')
|
||||
const rows = await query(`SELECT \`key\` FROM engagement_templates WHERE \`key\` IN (${marks})`, keys)
|
||||
return rows.map((r) => r.key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a shipped template, or bring an un-customized one up to a newer seed.
|
||||
*
|
||||
* **The `customized = 0` guard is in the SQL, not in a read-then-write.** The
|
||||
* seeder runs on every boot and a deployment can start two app processes at once;
|
||||
* a check in JavaScript followed by an UPDATE is a window in which an operator's
|
||||
* edit can be overwritten by a concurrent boot. `WHERE customized = 0` in the
|
||||
* UPDATE closes it, and MariaDB's `ON DUPLICATE KEY UPDATE` cannot express a
|
||||
* WHERE — so this is deliberately an INSERT IGNORE plus a guarded UPDATE rather
|
||||
* than the upsert §4.6.1 sketches.
|
||||
*
|
||||
* @returns {'inserted'|'updated'|'skipped'} what happened, for the boot log
|
||||
*/
|
||||
const seedOne = async (t) => {
|
||||
const inserted = await query(
|
||||
'INSERT IGNORE INTO engagement_templates ' +
|
||||
'(`key`, name, trigger_id, trigger_version, channel, subject, blocks, text_body, status, ' +
|
||||
' protected, seed_key, seed_version, customized) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, 0)',
|
||||
[
|
||||
t.key,
|
||||
t.name,
|
||||
t.triggerId ?? null,
|
||||
t.triggerVersion ?? null,
|
||||
t.channel,
|
||||
t.subject ?? null,
|
||||
JSON.stringify(t.blocks),
|
||||
t.status || 'published',
|
||||
t.protected ? 1 : 0,
|
||||
t.key,
|
||||
t.seedVersion,
|
||||
],
|
||||
)
|
||||
if (inserted.affectedRows === 1) return 'inserted'
|
||||
|
||||
const updated = await query(
|
||||
'UPDATE engagement_templates SET name = ?, channel = ?, subject = ?, blocks = ?, ' +
|
||||
'protected = ?, seed_version = ?, status = ? ' +
|
||||
'WHERE seed_key = ? AND customized = 0 AND (seed_version IS NULL OR seed_version < ?)',
|
||||
[
|
||||
t.name,
|
||||
t.channel,
|
||||
t.subject ?? null,
|
||||
JSON.stringify(t.blocks),
|
||||
t.protected ? 1 : 0,
|
||||
t.seedVersion,
|
||||
t.status || 'published',
|
||||
t.key,
|
||||
t.seedVersion,
|
||||
],
|
||||
)
|
||||
return updated.affectedRows === 1 ? 'updated' : 'skipped'
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an operator's edit. Always sets `customized = 1` — that flag is not a
|
||||
* field the caller may choose, it is the record that a human touched this row, and
|
||||
* it is the only thing standing between their work and the next seed bump.
|
||||
*
|
||||
* **`affectedRows === 1` here means "the row exists", not "something changed",**
|
||||
* because the connector defaults to `foundRows: true` (the trap Phase 4a's
|
||||
* cooldown check fell into). That is the semantics this caller wants — re-saving a
|
||||
* template unchanged is a success, not a 404 — and it is stated rather than
|
||||
* relied on, since the same expression means the other thing under `foundRows:
|
||||
* false`. `seedOne`'s UPDATE above is safe under either reading: its WHERE only
|
||||
* matches a row whose `seed_version` is behind, so a match always implies a write.
|
||||
*/
|
||||
const update = async (id, t, userId) => {
|
||||
const res = await query(
|
||||
'UPDATE engagement_templates SET name = ?, subject = ?, blocks = ?, text_body = ?, ' +
|
||||
'status = ?, trigger_id = ?, trigger_version = ?, customized = 1, updated_by = ? WHERE id = ?',
|
||||
[
|
||||
t.name,
|
||||
t.subject ?? null,
|
||||
JSON.stringify(t.blocks),
|
||||
t.textBody ?? null,
|
||||
t.status,
|
||||
t.triggerId ?? null,
|
||||
t.triggerVersion ?? null,
|
||||
userId ?? null,
|
||||
id,
|
||||
],
|
||||
)
|
||||
return res.affectedRows === 1
|
||||
}
|
||||
|
||||
/** Templates whose shipped default has moved on since the operator edited them. */
|
||||
const staleCustomized = async (pairs) => {
|
||||
if (!Array.isArray(pairs) || pairs.length === 0) return []
|
||||
const clauses = pairs.map(() => '(seed_key = ? AND seed_version < ?)').join(' OR ')
|
||||
const params = pairs.flatMap((p) => [p.key, p.seedVersion])
|
||||
const rows = await query(
|
||||
`SELECT * FROM engagement_templates WHERE customized = 1 AND (${clauses})`,
|
||||
params,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
module.exports = { list, getById, getByKey, existingKeys, seedOne, update, staleCustomized }
|
||||
Reference in New Issue
Block a user