Compare commits
12 Commits
feature/en
...
6331b36c45
| Author | SHA1 | Date | |
|---|---|---|---|
| 6331b36c45 | |||
| 5779d15150 | |||
| e59a68c152 | |||
| eec7dbf785 | |||
| 66bb3b9a3f | |||
| 52eac24d17 | |||
| c8d45733b6 | |||
| c3783f56f1 | |||
| 40ab1ce8d2 | |||
| 0a9149a04f | |||
| cfd1cb3c3c | |||
| 81e0338a69 |
@@ -48,6 +48,7 @@ import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
||||
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
||||
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
||||
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
|
||||
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
|
||||
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
@@ -210,6 +211,7 @@ export default function App() {
|
||||
<Route path="triggers" element={<EngagementTriggers />} />
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
<Route path="suppressions" element={<EngagementSuppressions />} />
|
||||
<Route path="retention" element={<EngagementRetention />} />
|
||||
</Route>
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Staff have an inbox and channel preferences like anyone else —
|
||||
|
||||
@@ -437,8 +437,14 @@ export const api = {
|
||||
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
|
||||
// of a DELETE rather than in the path, and that is not style: a path
|
||||
// parameter lands in the access log, the browser history and every proxy in
|
||||
// front of the deployment, and this one is a real person's address. The list
|
||||
// never returns a hash to use instead.
|
||||
// front of the deployment, and this one is a real person's address.
|
||||
//
|
||||
// **Phase 14 added the second form, and it is the one the row uses.** The
|
||||
// list now returns each row's `address_hash`, so the Lift button on a row
|
||||
// needs no address at all — the operator is looking at a mask and has never
|
||||
// been told the address. `unsuppressAddress` stays for the address the
|
||||
// operator types, which is the only way to reach a row that is not on the
|
||||
// page in front of them.
|
||||
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
@@ -452,6 +458,19 @@ export const api = {
|
||||
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
|
||||
unsuppressAddress: (address, channel) =>
|
||||
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
|
||||
unsuppressByHash: (hash, channel) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (channel) qs.set('channel', channel)
|
||||
return req(`/admin/engagement/suppressions/by-hash/${hash}${withQs(qs.toString())}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
// Retention (Phase 14). Three horizons, one screen; `engagement_suppressions`
|
||||
// is not among them because a suppression does not expire.
|
||||
getEngagementRetention: () => req('/admin/engagement/retention'),
|
||||
setEngagementRetention: (body) =>
|
||||
req('/admin/engagement/retention', { method: 'PUT', body }),
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -113,6 +113,11 @@ export const NAV = [
|
||||
// "did that message go out", and this answers "why is this person not
|
||||
// getting any" - and it is the only screen that can lift a suppression.
|
||||
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
|
||||
// Last in the group because it is the one screen nobody visits weekly, and
|
||||
// beside Suppressions on purpose: it is where the reader is told that the
|
||||
// fourth engagement table does NOT expire, which is otherwise a silence
|
||||
// that reads as an oversight.
|
||||
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -196,6 +201,7 @@ const TITLES = {
|
||||
'/admin/engagement/triggers': 'Triggers',
|
||||
'/admin/engagement/suppressions': 'Suppressions',
|
||||
'/admin/engagement/sends': 'Send Log',
|
||||
'/admin/engagement/retention': 'Retention',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
|
||||
230
client/src/routes/admin/views/EngagementRetention.jsx
Normal file
230
client/src/routes/admin/views/EngagementRetention.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Retention (ENGAGEMENT.md Phase 14).
|
||||
//
|
||||
// Three of the four engagement tables grew on every fire and nothing had ever
|
||||
// deleted from any of them. This screen is the policy: how long the deployment
|
||||
// keeps a cooldown row, a finished outbox row and a send-log entry.
|
||||
//
|
||||
// **Why it is a screen, when the other two retention workers in this codebase
|
||||
// (`team_activity`, `user_notifications`) are invisible settings rows.** The
|
||||
// send-log horizon changes what an operator-facing page is *able to show* — the
|
||||
// Send Log is the only answer to "was this person told" — so an operator has to
|
||||
// be able to see it and set it, not discover it by finding rows missing. Having
|
||||
// made one visible, hiding the other two would be the worse split: "what does
|
||||
// this deployment keep" is one question and deserves one answer.
|
||||
//
|
||||
// **The fourth table is on this page as prose, not as a control.** Suppressions
|
||||
// do not expire (org lead, 2026-09-01), and saying so here is the point: an
|
||||
// operator reading a retention screen that lists three tables would reasonably
|
||||
// assume the fourth was an oversight.
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
name: 'sends',
|
||||
label: 'Send log',
|
||||
table: 'engagement_sends',
|
||||
// The one horizon the org lead asked to be pickable rather than typed —
|
||||
// and `custom` stays, because a deployment with a compliance answer to
|
||||
// give should not be limited to three numbers somebody chose.
|
||||
presets: [90, 180, 365],
|
||||
help:
|
||||
'One row per delivery attempt. This is what Admin → Engagement → Send Log reads, so the '
|
||||
+ 'horizon is also how far back "was this person told" can be answered. The per-rule hourly '
|
||||
+ 'ceiling counts this table too, which is why it can never go below a week.',
|
||||
},
|
||||
{
|
||||
name: 'cooldowns',
|
||||
label: 'Cooldowns',
|
||||
table: 'engagement_cooldowns',
|
||||
presets: [7, 30, 90],
|
||||
help:
|
||||
'One row per rule, user, subject and channel, written on every fire. Deleting a row that '
|
||||
+ 'is still in force makes the next fire count as a first fire — that is a duplicate '
|
||||
+ 'message — so this must stay longer than the longest cooldown on any enabled rule.',
|
||||
},
|
||||
{
|
||||
name: 'outbox',
|
||||
label: 'Outbox',
|
||||
table: 'engagement_outbox',
|
||||
presets: [7, 30, 90],
|
||||
help:
|
||||
'Only finished rows are ever removed: sent, failed, cancelled and not-sent. A scheduled '
|
||||
+ 'row is a message this deployment still intends to send and is never swept, however old '
|
||||
+ 'the horizon.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function EngagementRetention() {
|
||||
const [policy, setPolicy] = useState(null)
|
||||
const [limits, setLimits] = useState({})
|
||||
const [warnings, setWarnings] = useState([])
|
||||
const [longestCooldown, setLongestCooldown] = useState(0)
|
||||
const [draft, setDraft] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [note, setNote] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const apply = useCallback((result) => {
|
||||
setPolicy(result.retention)
|
||||
setDraft(result.retention)
|
||||
setLimits(result.limits || {})
|
||||
setWarnings(result.warnings || [])
|
||||
setLongestCooldown(result.longestCooldownSeconds || 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const result = await api.admin.getEngagementRetention()
|
||||
if (alive) apply(result)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [apply])
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setNote(null)
|
||||
try {
|
||||
// The whole draft, not the changed field: this screen is the one place the
|
||||
// three are set together, and a partial save would leave the warning line
|
||||
// (which is computed from the cooldown horizon) describing a policy that is
|
||||
// half saved. The route itself is sparse, so sending three is legal.
|
||||
const result = await api.admin.setEngagementRetention(draft)
|
||||
apply(result)
|
||||
setNote('Saved.')
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name]))
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem', maxWidth: 720, marginTop: 0 }}>
|
||||
How long this deployment keeps the engagement system’s own records. A nightly sweep
|
||||
removes anything older, in batches, and skips a table it cannot read rather than failing
|
||||
the run.
|
||||
</p>
|
||||
|
||||
{warnings.map((w) => (
|
||||
<p
|
||||
key={w}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
maxWidth: 720,
|
||||
padding: '10px 12px',
|
||||
borderLeft: '3px solid #d98b84',
|
||||
background: 'rgba(217, 139, 132, 0.08)',
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'grid', gap: 22, maxWidth: 720, marginTop: 20 }}>
|
||||
{FIELDS.map((f) => {
|
||||
const spec = limits[f.name] || {}
|
||||
const value = draft[f.name] ?? ''
|
||||
const isPreset = f.presets.includes(Number(value))
|
||||
return (
|
||||
<div key={f.name}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<span className="field-label" style={{ fontWeight: 600 }}>{f.label}</span>
|
||||
<code className="dim" style={{ fontSize: '0.74rem' }}>{f.table}</code>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '4px 0 8px' }}>
|
||||
{f.help}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label>
|
||||
<span className="field-label">Keep for</span>
|
||||
<select
|
||||
className="select"
|
||||
value={isPreset ? String(value) : 'custom'}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
// Choosing "custom" must not blank the field — the number
|
||||
// box below is what the operator is about to edit, and an
|
||||
// empty one would post NaN.
|
||||
if (next === 'custom') return
|
||||
setDraft({ ...draft, [f.name]: Number(next) })
|
||||
}}
|
||||
>
|
||||
{f.presets.map((d) => (
|
||||
<option key={d} value={String(d)}>{d} days</option>
|
||||
))}
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Days</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={spec.min ?? 2}
|
||||
max={spec.max ?? 3650}
|
||||
style={{ width: 110 }}
|
||||
value={value}
|
||||
onChange={(e) => setDraft({ ...draft, [f.name]: e.target.value === '' ? '' : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
{spec.min !== undefined && (
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem', paddingBottom: 8 }}>
|
||||
{spec.min}–{spec.max} days
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 24 }}>
|
||||
<button type="button" className="pill" disabled={!dirty || saving} onClick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{dirty && (
|
||||
<button type="button" className="pill" disabled={saving} onClick={() => setDraft(policy)}>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
{note && <span className="sans" style={{ fontSize: '0.82rem' }}>{note}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 720, marginTop: 32 }}>
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', marginBottom: 6 }}>
|
||||
Suppressed addresses do not expire
|
||||
</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>
|
||||
A suppression is a standing decision, not a record of something that happened. Ageing one
|
||||
out would re-mail an address that already hard-bounced or asked to be left alone, which is
|
||||
how a sender loses a domain’s reputation. The way out of that list stays a
|
||||
deliberate act:{' '}
|
||||
<strong>Lift</strong> on the row, in Admin → Engagement → Suppressions.
|
||||
</p>
|
||||
{longestCooldown > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', marginBottom: 0 }}>
|
||||
The longest cooldown on an enabled rule right now is {longestCooldown} seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,11 @@ export default function EngagementSendLog() {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [testTrigger, setTestTrigger] = useState('')
|
||||
// Phase 14. `total` is now a truncated number, and a screen that shows a total
|
||||
// without saying so is quietly wrong about the deployment's own history — this
|
||||
// is the fix for that, and the reason the horizon got an operator-facing
|
||||
// control rather than the invisible settings row the other two sweeps use.
|
||||
const [retainDays, setRetainDays] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
@@ -55,6 +60,15 @@ export default function EngagementSendLog() {
|
||||
setRows(result.sends || [])
|
||||
setTotal(result.total || 0)
|
||||
setTestTrigger(result.testSendTrigger || '')
|
||||
// Best-effort and non-blocking: the log is worth showing even if the policy
|
||||
// cannot be read, so a failure here leaves the note off rather than the
|
||||
// screen empty.
|
||||
try {
|
||||
const policy = await api.admin.getEngagementRetention()
|
||||
setRetainDays(policy?.retention?.sends ?? null)
|
||||
} catch {
|
||||
setRetainDays(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -153,6 +167,7 @@ export default function EngagementSendLog() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
{retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
|
||||
@@ -126,6 +126,26 @@ export default function EngagementSuppressions() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row Lift (Phase 14). No address is asked for and none is needed: the
|
||||
* row carries its own `address_hash`, which is the only handle this screen has
|
||||
* ever been able to have — the address itself is stored one-way.
|
||||
*
|
||||
* No confirm dialog, deliberately. Lifting is reversible in one click (the
|
||||
* Suppress field above is right there), and a browser modal blocks the whole
|
||||
* tab, which is the failure mode the automation notes in this repo warn about.
|
||||
*/
|
||||
async function liftRow(row) {
|
||||
setNote(null)
|
||||
try {
|
||||
await api.admin.unsuppressByHash(row.address_hash, row.channel)
|
||||
setNote(`${row.address_masked || 'That address'} can be mailed again.`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
@@ -211,6 +231,7 @@ export default function EngagementSuppressions() {
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Since</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -231,6 +252,20 @@ export default function EngagementSuppressions() {
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
disabled={!r.address_hash}
|
||||
title={r.address_hash
|
||||
? 'Let this address be mailed again'
|
||||
: 'This row has no handle to act on'}
|
||||
onClick={() => liftRow(r)}
|
||||
>
|
||||
Lift
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1774,9 +1774,18 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
|
||||
-- per subject".
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
-- The CHANNEL the cooldown is about, added in Phase 11b after the live walk.
|
||||
-- Without it a rule naming two channels delivers on exactly ONE of them: the
|
||||
-- claim runs inside the engine's per-channel loop, `inapp` is ranked first on
|
||||
-- purpose (so push can reference its inbox row), and every later channel is
|
||||
-- then reported as cooled. Phase 11b's decision 8 requires the letter and the
|
||||
-- inbox item to fire together, so the cooldown is per delivery, not per
|
||||
-- occasion. VARCHAR like `engagement_outbox.channel`, and for the same reason:
|
||||
-- the channel set is data a module can extend.
|
||||
channel VARCHAR(32) NOT NULL DEFAULT '',
|
||||
last_fired_at DATETIME NOT NULL,
|
||||
fire_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (rule_id, user_id, subject_key),
|
||||
PRIMARY KEY (rule_id, user_id, subject_key, channel),
|
||||
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- So a prune worker can drop rows older than the longest configured cooldown.
|
||||
@@ -1785,6 +1794,29 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
INDEX idx_engc_sweep (last_fired_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Widen the key on a deployment that already has the table. Two statements, and
|
||||
-- the second is guarded because MariaDB has no conditional form of a PRIMARY KEY
|
||||
-- change: re-running `DROP PRIMARY KEY, ADD PRIMARY KEY` on a table that already
|
||||
-- carries the new one is an error, not a no-op, so replaying this file on every
|
||||
-- boot would fail the whole schema after the first run. The guard reads the key
|
||||
-- itself out of information_schema rather than the column's existence, because
|
||||
-- `ADD COLUMN IF NOT EXISTS` above can succeed while the key change does not.
|
||||
--
|
||||
-- Existing rows keep `channel = ''`, which is one stale cooldown per (rule, user,
|
||||
-- subject) that expires on its own interval. That is the right trade against
|
||||
-- deleting them: a cooldown that outlives its rewrite costs at most one delayed
|
||||
-- notification, and dropping the table would let a bounce storm through.
|
||||
ALTER TABLE engagement_cooldowns ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT '';
|
||||
SET @engc_key_has_channel := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'engagement_cooldowns'
|
||||
AND INDEX_NAME = 'PRIMARY' AND COLUMN_NAME = 'channel'
|
||||
);
|
||||
SET @sql := IF(@engc_key_has_channel = 0,
|
||||
'ALTER TABLE engagement_cooldowns DROP PRIMARY KEY, ADD PRIMARY KEY (rule_id, user_id, subject_key, channel)',
|
||||
'DO 0');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
|
||||
-- (rule, user, channel) occurrence of an event.
|
||||
CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
@@ -1818,7 +1850,10 @@ CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
INDEX idx_engo_due (status, due_at),
|
||||
-- What a RESOLVING event queries: a house repaired back to LikeNew cancels
|
||||
-- every scheduled row for that (rule, user, house).
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status),
|
||||
-- Phase 14. The sweep is `status IN (terminal) AND created_at < ?`, and
|
||||
-- `idx_engo_due` cannot serve it: its second column is `due_at`.
|
||||
INDEX idx_engo_sweep (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
|
||||
@@ -1845,7 +1880,11 @@ CREATE TABLE IF NOT EXISTS engagement_sends (
|
||||
INDEX idx_engs_user (user_id, created_at),
|
||||
-- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be
|
||||
-- an index range scan rather than a table scan: it runs once per rule per event.
|
||||
INDEX idx_engs_rule_window (rule_id, created_at)
|
||||
INDEX idx_engs_rule_window (rule_id, created_at),
|
||||
-- Phase 14's retention sweep deletes by age alone, so it needs `created_at`
|
||||
-- LEADING. Every index above has it in second position, which serves a
|
||||
-- per-rule or per-user window and is useless to a whole-table horizon.
|
||||
INDEX idx_engs_sweep (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
|
||||
@@ -1905,6 +1944,14 @@ CREATE TABLE IF NOT EXISTS engagement_templates (
|
||||
-- link in it. Same vocabulary as engagement_digest_state.scope_key below.
|
||||
ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL;
|
||||
|
||||
-- Phase 14 (retention). The two sweep indexes, for deployments whose tables
|
||||
-- predate them. `IF NOT EXISTS` on an index is MariaDB-only and already used
|
||||
-- above (`idx_wiki_search`), so this needs no INFORMATION_SCHEMA guard like the
|
||||
-- cooldown primary-key change did -- that one needed one because MariaDB has no
|
||||
-- conditional form of a PRIMARY KEY change, not because indexes lack one.
|
||||
ALTER TABLE engagement_outbox ADD INDEX IF NOT EXISTS idx_engo_sweep (status, created_at);
|
||||
ALTER TABLE engagement_sends ADD INDEX IF NOT EXISTS idx_engs_sweep (created_at);
|
||||
|
||||
-- §4.2b: digest state, and DELIBERATELY not a digest queue.
|
||||
--
|
||||
-- The generic engine enqueues an outbox row per (rule, user, channel) at emit
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -194,6 +194,24 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/retention",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/retention",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules",
|
||||
@@ -320,6 +338,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates",
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/channels"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/retention"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/retention"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules"
|
||||
@@ -141,6 +149,10 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates"
|
||||
|
||||
@@ -159,8 +159,15 @@ const NEWS_RULES = [
|
||||
async function seedGroup(key, rules, note) {
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
try {
|
||||
const seen = await settingsDb.get(key)
|
||||
if (seen) return { ...summary, skipped: rules.length }
|
||||
// **Claimed BEFORE the loop, atomically**, and the stamp is the claim. A
|
||||
// `get()` here with a `set()` after the inserts is not a guard when two
|
||||
// instances boot together — both read "absent", both seed — and a duplicate
|
||||
// rule is two mails per event. `claim()` is an `INSERT IGNORE` reporting its
|
||||
// own `affectedRows`, so exactly one caller proceeds. See the note below on
|
||||
// what a partial run costs: that trade is unchanged, only its ordering.
|
||||
if (!(await settingsDb.claim(key, new Date().toISOString()))) {
|
||||
return { ...summary, skipped: rules.length }
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
@@ -181,10 +188,10 @@ async function seedGroup(key, rules, note) {
|
||||
log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
|
||||
}
|
||||
}
|
||||
// Stamped even on a partial run. Re-running would duplicate the rules that
|
||||
// did insert, and a duplicate rule is two mails per event — a worse outcome
|
||||
// than the one missing rule an operator can add from the screen.
|
||||
await settingsDb.set(key, new Date().toISOString())
|
||||
// Stamped even on a partial run — the claim above is the stamp. Re-running
|
||||
// would duplicate the rules that did insert, and a duplicate rule is two
|
||||
// mails per event, a worse outcome than the one missing rule an operator can
|
||||
// add from the screen.
|
||||
if (summary.inserted) {
|
||||
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
|
||||
}
|
||||
|
||||
@@ -203,9 +203,18 @@ async function applyRule(rule, event, now) {
|
||||
summary.capped += 1
|
||||
continue
|
||||
}
|
||||
// One statement, guarded on the interval, so two concurrent emits cannot
|
||||
// both pass a read-then-write check (§4.1).
|
||||
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
|
||||
// Guarded on the interval, so two concurrent emits cannot both pass a
|
||||
// read-then-write check (§4.1).
|
||||
//
|
||||
// **Keyed on the CHANNEL as well**, which is what makes this loop correct
|
||||
// rather than what makes it work. Without the channel, the first channel of
|
||||
// a rule claims the cooldown and every later one is refused as cooling —
|
||||
// and `inapp` is ranked first above, so a rule naming email + in-app would
|
||||
// deliver the in-app item and silently never the mail. Found on Phase 11b's
|
||||
// live rig; a cooldown is per delivery, not per occasion.
|
||||
const allowed = await cooldownsDb.claim(
|
||||
rule.id, userId, subjectKey, channel, rule.cooldown_seconds, now,
|
||||
)
|
||||
if (!allowed) {
|
||||
summary.cooled += 1
|
||||
continue
|
||||
|
||||
194
server/src/engagement/moduleSeeds.js
Normal file
194
server/src/engagement/moduleSeeds.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// ── 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 claim-before-insert
|
||||
* ordering and what it costs: 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. The guard is taken
|
||||
* atomically for the same reason; see the comment at the claim.
|
||||
*/
|
||||
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 {
|
||||
// **Claim BEFORE inserting, not after.** The guard used to be a `get()` here
|
||||
// and a `set()` after the loop, which is not a guard under concurrency: two
|
||||
// processes starting in the same moment both read "absent" and both insert
|
||||
// the whole group. That is not hypothetical — `docker compose up
|
||||
// --scale app=2` and a rolling restart both boot two instances deliberately,
|
||||
// and Phase 13's acceptance walk hit it with two, ending up with 52 module
|
||||
// rules where the module ships 26. `claim()` is an `INSERT IGNORE` reporting
|
||||
// its own `affectedRows`, so exactly one caller wins.
|
||||
//
|
||||
// The cost is the one this function already accepted below: a process that
|
||||
// dies mid-loop leaves the group stamped and partly seeded, and the missing
|
||||
// rules are an operator's visit to the "new rule" form. Duplicates are the
|
||||
// worse failure — two mails per event, for every rule in the group — which
|
||||
// is why the order is this way round rather than the other.
|
||||
if (!(await settings_.claim(key, new Date().toISOString()))) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Claim a fire for (rule, user, subject), or refuse it because the pair is still
|
||||
* cooling. ENGAGEMENT.md §4.1.
|
||||
* Claim a fire for (rule, user, subject, channel), or refuse it because that
|
||||
* delivery is still cooling. ENGAGEMENT.md §4.1.
|
||||
*
|
||||
* **`channel` is part of the key, and Phase 11b is where that was settled.** The
|
||||
* engine claims inside its per-channel loop, so a key without the channel means
|
||||
* the first channel of a two-channel rule claims the cooldown and every later one
|
||||
* is refused as cooling - which made every in-universe email body of Phase 11b
|
||||
* unreachable behind the in-app one. A cooldown is per delivery.
|
||||
*
|
||||
* **Two statements, each of which is its own atomic decision** - and it is worth
|
||||
* saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1
|
||||
@@ -37,28 +43,29 @@ const { query } = require('../../utils/db')
|
||||
* `cooldown_seconds = 0` always passes, which is the documented meaning of a rule
|
||||
* with no cooldown: the guard becomes `last_fired_at <= now`, and it is.
|
||||
*/
|
||||
async function claim(ruleId, userId, subjectKey, cooldownSeconds, now = new Date()) {
|
||||
async function claim(ruleId, userId, subjectKey, channel, cooldownSeconds, now = new Date()) {
|
||||
const moved = await query(
|
||||
`UPDATE engagement_cooldowns
|
||||
SET last_fired_at = ?, fire_count = fire_count + 1
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?
|
||||
AND last_fired_at <= ? - INTERVAL ? SECOND`,
|
||||
[now, ruleId, userId, subjectKey, now, cooldownSeconds],
|
||||
[now, ruleId, userId, subjectKey, channel, now, cooldownSeconds],
|
||||
)
|
||||
if (Number(moved?.affectedRows || 0) === 1) return true
|
||||
|
||||
const inserted = await query(
|
||||
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, 1)`,
|
||||
[ruleId, userId, subjectKey, now],
|
||||
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, channel, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)`,
|
||||
[ruleId, userId, subjectKey, channel, now],
|
||||
)
|
||||
return Number(inserted?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
const get = async (ruleId, userId, subjectKey) => {
|
||||
const get = async (ruleId, userId, subjectKey, channel) => {
|
||||
const [row] = await query(
|
||||
'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?',
|
||||
[ruleId, userId, subjectKey],
|
||||
`SELECT * FROM engagement_cooldowns
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?`,
|
||||
[ruleId, userId, subjectKey, channel],
|
||||
)
|
||||
return row || null
|
||||
}
|
||||
@@ -71,8 +78,23 @@ const get = async (ruleId, userId, subjectKey) => {
|
||||
* failure `teamActivityPrune` was written for. A dropped row means the next fire
|
||||
* is treated as a first fire, which is correct as long as the retention window is
|
||||
* longer than the longest configured cooldown - the caller's job, not this one's.
|
||||
* Phase 14 gave it that caller (`engagementRetentionPrune`), which also enforces
|
||||
* the horizon-versus-longest-cooldown rule this comment names.
|
||||
*
|
||||
* `limit` bounds one statement, for `userNotificationsPrune`'s reason: a first
|
||||
* sweep after a long outage must be a series of bounded DELETEs rather than one
|
||||
* that holds locks over a million rows. Omitting it keeps the original
|
||||
* unbounded behaviour, so existing callers and tests are unaffected.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const prune = (olderThan) =>
|
||||
query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
|
||||
const prune = async (olderThan, limit = 0) => {
|
||||
const bounded = Number(limit) > 0
|
||||
const result = await query(
|
||||
`DELETE FROM engagement_cooldowns WHERE last_fired_at < ?${bounded ? ' LIMIT ?' : ''}`,
|
||||
bounded ? [olderThan, Math.floor(limit)] : [olderThan],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { claim, get, prune }
|
||||
|
||||
@@ -132,11 +132,61 @@ async function cancel(ruleId, subjectKey, userId = null) {
|
||||
* stamped it), and the window has to be comfortably longer than the slowest
|
||||
* legitimate send or this reclaims rows that are merely slow.
|
||||
*/
|
||||
const reclaimStale = (before) =>
|
||||
query(
|
||||
const reclaimStale = async (before, maxAttempts = 0) => {
|
||||
// Give up first, reclaim second, and in that order: a row that has already
|
||||
// burned its attempts must leave 'sending' as `failed`, or the reclaim below
|
||||
// hands it straight back to `findDue` and it is retried forever.
|
||||
//
|
||||
// **This is what makes `pruneTerminal` a bound at all** (Phase 14). Attempts
|
||||
// are incremented by `claim`, but `MAX_ATTEMPTS` is only consulted on a
|
||||
// graceful `retry` outcome — a send that kills the process mid-flight never
|
||||
// reaches that branch, so before this the row cycled sending → scheduled →
|
||||
// sending forever, never reached a terminal status, and was therefore never
|
||||
// eligible for any retention sweep. One poisoned payload was an outbox row
|
||||
// that outlived every horizon.
|
||||
let failed = 0
|
||||
if (Number(maxAttempts) > 0) {
|
||||
const gaveUp = await query(
|
||||
`UPDATE engagement_outbox
|
||||
SET status = 'failed', last_error = 'gave up after repeated interruptions'
|
||||
WHERE status = 'sending' AND updated_at < ? AND attempts >= ?`,
|
||||
[before, Math.floor(maxAttempts)],
|
||||
)
|
||||
failed = Number(gaveUp?.affectedRows || 0)
|
||||
}
|
||||
const reclaimed = await query(
|
||||
"UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?",
|
||||
[before],
|
||||
)
|
||||
return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete terminal rows older than `before` (Phase 14).
|
||||
*
|
||||
* **Terminal only, and the status list is the whole policy.** A `scheduled` row
|
||||
* is a promise the engine has not kept yet — `delay_seconds` can legitimately
|
||||
* put one up to a day out (`MAX_DELAY_SECONDS`) — and a `sending` row may be a
|
||||
* worker mid-flight. Deleting either is not retention, it is cancelling a send
|
||||
* nobody asked to cancel. Only `sent`, `failed`, `cancelled` and `suppressed`
|
||||
* are outcomes that have already happened.
|
||||
*
|
||||
* `created_at` rather than `updated_at` is the clock deliberately: the horizon
|
||||
* an operator sets means "how long we keep the record of a delivery", which is
|
||||
* measured from when it was enqueued, not from whenever it was last touched.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const pruneTerminal = async (before, limit = 1000) => {
|
||||
const result = await query(
|
||||
`DELETE FROM engagement_outbox
|
||||
WHERE status IN ('sent', 'failed', 'cancelled', 'suppressed')
|
||||
AND created_at < ?
|
||||
LIMIT ?`,
|
||||
[before, Math.floor(limit)],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id])
|
||||
@@ -157,6 +207,7 @@ module.exports = {
|
||||
finish,
|
||||
cancel,
|
||||
reclaimStale,
|
||||
pruneTerminal,
|
||||
getById,
|
||||
listForRule,
|
||||
}
|
||||
|
||||
175
server/src/model/engagement/engagementRetention.model.js
Normal file
175
server/src/model/engagement/engagementRetention.model.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// ── Engagement retention policy ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 14. Three horizons, one place to read and write them, so
|
||||
// the nightly worker and Admin → Engagement → Retention cannot disagree about
|
||||
// what this deployment keeps — and so `/privacy` has one thing to describe.
|
||||
//
|
||||
// **The fourth engagement table is deliberately absent from this file.**
|
||||
// `engagement_suppressions` does not expire (org lead, 2026-09-01): a
|
||||
// suppression is a standing decision, and ageing out a hard bounce re-mails an
|
||||
// address that already bounced, which is how a sender loses a domain's
|
||||
// reputation. The one way out of that table stays what Phase 9 built — a
|
||||
// deliberate act by an admin, which Phase 14 only made reachable per row.
|
||||
//
|
||||
// Settings rows rather than env, for `teamActivityPrune`'s reason: an operator
|
||||
// tightening a busy shard should not need a deploy. Unlike the two workers this
|
||||
// copies, these three get a screen — the send log's horizon changes what an
|
||||
// operator-facing page can show, so it cannot be an invisible key.
|
||||
|
||||
const settings = require('../settings/settings.model')
|
||||
const rulesDb = require('./engagementRules.db')
|
||||
const log = require('../../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* One entry per horizon. `min` is not a UI nicety — each is the point below
|
||||
* which the sweep breaks something that is not retention:
|
||||
*
|
||||
* - **cooldowns**: a pruned row makes the next fire a FIRST fire, i.e. a
|
||||
* duplicate send. `MAX_COOLDOWN_SECONDS` is a validated 86 400 (one day), so
|
||||
* 2 days is the smallest provably-safe value against any rule that can be
|
||||
* saved. `checkCooldownHorizon` re-checks it against the rules that exist.
|
||||
* - **outbox**: `MAX_DELAY_SECONDS` is also 86 400, so no `scheduled` row is
|
||||
* ever more than a day out; terminal rows younger than that are still the
|
||||
* most recent thing an operator would look at.
|
||||
* - **sends**: the per-rule hourly ceiling (§7.1 Q3) counts this table, so a
|
||||
* horizon under an hour would silently disable it. The floor is set far
|
||||
* above that, at the point the Send Log stops being worth opening.
|
||||
*/
|
||||
const HORIZONS = {
|
||||
sends: {
|
||||
key: 'engagement_sends_retain_days',
|
||||
default: 180,
|
||||
min: 7,
|
||||
max: 3650,
|
||||
label: 'Send log',
|
||||
},
|
||||
cooldowns: {
|
||||
key: 'engagement_cooldowns_retain_days',
|
||||
default: 30,
|
||||
min: 2,
|
||||
max: 3650,
|
||||
label: 'Cooldowns',
|
||||
},
|
||||
outbox: {
|
||||
key: 'engagement_outbox_retain_days',
|
||||
default: 30,
|
||||
min: 2,
|
||||
max: 3650,
|
||||
label: 'Outbox',
|
||||
},
|
||||
}
|
||||
|
||||
const NAMES = Object.keys(HORIZONS)
|
||||
|
||||
/**
|
||||
* The current policy, as `{ sends, cooldowns, outbox }` in days.
|
||||
*
|
||||
* Wrapped in a try like `teamActivity.retentionConfig` and for its reason: the
|
||||
* nightly worker calls this with nobody watching, so a settings table that is
|
||||
* briefly unavailable must yield defaults rather than an exception that kills
|
||||
* the job. An unreadable, absent, non-numeric or out-of-range value all mean
|
||||
* the same thing — use the default — because none of them is a horizon.
|
||||
*/
|
||||
async function get() {
|
||||
const out = {}
|
||||
for (const name of NAMES) {
|
||||
const spec = HORIZONS[name]
|
||||
let days = spec.default
|
||||
try {
|
||||
const raw = await settings.get(spec.key)
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n >= spec.min && n <= spec.max) days = Math.floor(n)
|
||||
} catch (err) {
|
||||
log.debug('retention setting unreadable; using the default', {
|
||||
key: spec.key,
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
out[name] = days
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one or more horizons. Unknown names are ignored rather than rejected, so
|
||||
* a client sending the whole object back is not coupled to this list; an
|
||||
* out-of-range value IS rejected, because silently clamping a number an operator
|
||||
* typed would leave the screen showing something the deployment is not doing.
|
||||
*
|
||||
* @returns {Promise<object>} the policy as it now stands
|
||||
*/
|
||||
async function set(patch = {}, updatedBy = null) {
|
||||
for (const name of NAMES) {
|
||||
if (!(name in patch) || patch[name] === undefined || patch[name] === null) continue
|
||||
const spec = HORIZONS[name]
|
||||
const n = Number(patch[name])
|
||||
if (!Number.isFinite(n) || Math.floor(n) !== n) {
|
||||
throw Object.assign(new Error(`${spec.label} retention must be a whole number of days`), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
if (n < spec.min || n > spec.max) {
|
||||
throw Object.assign(
|
||||
new Error(`${spec.label} retention must be between ${spec.min} and ${spec.max} days`),
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
await settings.set(spec.key, String(n), updatedBy)
|
||||
}
|
||||
return get()
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest cooldown any ENABLED rule configures, in seconds — see
|
||||
* `engagementRules.db.maxEnabledCooldownSeconds` for why enabled only.
|
||||
*
|
||||
* Swallows its error rather than propagating: this is consulted by a worker
|
||||
* running on a timer, and a database hiccup must degrade the WARNING, never the
|
||||
* sweep. Zero reads as "no enabled rule has a cooldown", which produces no
|
||||
* warning — the same answer as an unreadable table, and the safe one, because
|
||||
* the alternative is a nightly alarm nobody can act on.
|
||||
*/
|
||||
async function longestEnabledCooldownSeconds() {
|
||||
try {
|
||||
return await rulesDb.maxEnabledCooldownSeconds()
|
||||
} catch (err) {
|
||||
log.debug('could not read the longest enabled cooldown', { message: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 14's acceptance line: the cooldown horizon is CHECKED against the
|
||||
* longest enabled rule's cooldown rather than picked.
|
||||
*
|
||||
* It returns a warning rather than throwing, and the worker sweeps anyway. The
|
||||
* alternative — refusing to prune — trades a bounded, describable fault (one
|
||||
* rule may re-fire early once) for the unbounded one this phase exists to end.
|
||||
* The screen surfaces the same warning, which is where an operator can act on it.
|
||||
*
|
||||
* @returns {Promise<{ ok: boolean, longestCooldownSeconds: number, message: string|null }>}
|
||||
*/
|
||||
async function checkCooldownHorizon(days) {
|
||||
const longest = await longestEnabledCooldownSeconds()
|
||||
const horizonSeconds = days * 24 * 60 * 60
|
||||
if (longest > 0 && horizonSeconds <= longest) {
|
||||
return {
|
||||
ok: false,
|
||||
longestCooldownSeconds: longest,
|
||||
message:
|
||||
`Cooldown retention is ${days} day(s), but an enabled rule has a cooldown of `
|
||||
+ `${longest} second(s). Pruning a cooldown row that is still in force makes the next `
|
||||
+ 'fire count as a first fire, so that rule can send twice. Raise the horizon.',
|
||||
}
|
||||
}
|
||||
return { ok: true, longestCooldownSeconds: longest, message: null }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HORIZONS,
|
||||
NAMES,
|
||||
get,
|
||||
set,
|
||||
longestEnabledCooldownSeconds,
|
||||
checkCooldownHorizon,
|
||||
}
|
||||
@@ -135,9 +135,25 @@ const countUsingSegment = async (segmentId) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest cooldown any ENABLED rule configures, in seconds (Phase 14).
|
||||
*
|
||||
* Enabled only, deliberately: a disabled rule fires nothing, so it writes no
|
||||
* cooldown row a retention sweep could destroy, and letting a forgotten disabled
|
||||
* rule with a 24-hour cooldown veto a tighter horizon would make the warning
|
||||
* advice nobody can act on.
|
||||
*/
|
||||
const maxEnabledCooldownSeconds = async () => {
|
||||
const [row] = await query(
|
||||
'SELECT MAX(cooldown_seconds) AS n FROM engagement_rules WHERE enabled = 1',
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
maxEnabledCooldownSeconds,
|
||||
enabledForTrigger,
|
||||
enabledCancelledBy,
|
||||
insert,
|
||||
|
||||
@@ -40,6 +40,12 @@ const MAX_DELAY_SECONDS = 86_400
|
||||
// what makes rules-as-data safe (§7.1 Q3).
|
||||
const MAX_SENDS_PER_HOUR = 10_000
|
||||
|
||||
// The one key in `template_keys` that is not a delivery channel. `teamDigestWorker`
|
||||
// renders it for a rule whose email channel an individual has set to digest mode,
|
||||
// so it belongs to a MODE rather than to the rule's channel list and can never
|
||||
// appear there.
|
||||
const DIGEST_SLOT = 'digest'
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
@@ -91,7 +97,17 @@ async function validate(input, { existing = null } = {}) {
|
||||
errors.push('templateKeys must be an object of { channel: templateKey }')
|
||||
} else {
|
||||
for (const [channel, key] of Object.entries(raw.templateKeys || {})) {
|
||||
if (!wanted.includes(channel)) {
|
||||
// **`digest` is a template SLOT, not a channel**, and it is legal here for
|
||||
// exactly the reason `registries.js` `checkSeedRule` says it is: it names
|
||||
// the body `teamDigestWorker` renders for a rule whose email channel an
|
||||
// individual has set to digest mode, so it never appears in `channels` and
|
||||
// never could. Rejecting it made every rule that ships one unsaveable from
|
||||
// the Rules screen — core's own team and news rules included, and sixteen
|
||||
// of module-uo's — with a 400 naming a key the operator never typed, whose
|
||||
// only remedy was deleting the digest body and silently dropping digest
|
||||
// support. Found by Phase 13's acceptance walk; the two validators now
|
||||
// agree about what `digest` is.
|
||||
if (channel !== DIGEST_SLOT && !wanted.includes(channel)) {
|
||||
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -107,4 +107,26 @@ const count = async (opts = {}) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, TEST_SEND_TRIGGER }
|
||||
/**
|
||||
* Delete send-log rows older than `before` (Phase 14).
|
||||
*
|
||||
* **Every row here is terminal**, which is why this has no status filter and the
|
||||
* outbox's sweep does: `engagement_sends` records an attempt that has already
|
||||
* resolved. The care is entirely in the horizon, because this table has two live
|
||||
* readers and they pull in opposite directions — `countSentSince` implements the
|
||||
* per-rule hourly ceiling (§7.1 Q3), so any horizon under an hour silently
|
||||
* disables that ceiling, and Admin -> Engagement -> Send Log is the operator's
|
||||
* only answer to "was this person told", so a short one blinds it. Both are the
|
||||
* caller's problem, and `engagementRetention` is where that judgement lives.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const prune = async (before, limit = 1000) => {
|
||||
const result = await query('DELETE FROM engagement_sends WHERE created_at < ? LIMIT ?', [
|
||||
before,
|
||||
Math.floor(limit),
|
||||
])
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, prune, TEST_SEND_TRIGGER }
|
||||
|
||||
@@ -36,6 +36,24 @@ async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a one-shot guard, atomically. `true` means THIS caller wrote the row.
|
||||
*
|
||||
* The same `INSERT IGNORE` as `seedDefault`, and the difference is the whole
|
||||
* point: this one reports whether it won. A guard read with `get()` and written
|
||||
* later with `set()` is not a guard at all under concurrency — two processes
|
||||
* both read "absent" and both proceed — and this is used where proceeding twice
|
||||
* means seeding a rule group twice, i.e. two mails per event.
|
||||
*
|
||||
* The atomicity is the PRIMARY KEY's: exactly one INSERT can create a given
|
||||
* `key`, so exactly one caller sees `affectedRows === 1`. No transaction and no
|
||||
* lock, the same bargain `engagementWorker`'s claim makes.
|
||||
*/
|
||||
async function claim(key, value) {
|
||||
const res = await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
return Number(res && res.affectedRows) === 1
|
||||
}
|
||||
|
||||
// Delete a settings row. "Reset to defaults" for the theming/nav keys is the
|
||||
// *absence* of a row, not a stored copy of the defaults — see
|
||||
// docs/website/THEMING_AND_NAV.md §2. Deleting a key that was never set is a
|
||||
@@ -44,4 +62,4 @@ async function remove(key) {
|
||||
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, getRow, set, seedDefault, remove }
|
||||
module.exports = { getAll, get, getRow, set, seedDefault, claim, remove }
|
||||
|
||||
@@ -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,210 @@ 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,
|
||||
// Checked above and carried here: the column is NOT NULL, so a normalizer
|
||||
// that validates the ceiling and then drops it fails every insert in the
|
||||
// group at boot — loudly, but only on a real database.
|
||||
max_sends_per_hour: r.max_sends_per_hour,
|
||||
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 +983,7 @@ function stage(owner) {
|
||||
slashCommands: [],
|
||||
triggers: [],
|
||||
audiences: [],
|
||||
engagementSeeds: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
@@ -788,6 +1015,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 +1040,7 @@ function apply({
|
||||
slashCommands: newSlashCommands = [],
|
||||
triggers: newTriggers = [],
|
||||
audiences: newAudiences = [],
|
||||
engagementSeeds: newSeeds = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
@@ -886,6 +1117,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 +1160,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 +1236,7 @@ function _reset() {
|
||||
slashCommands.clear()
|
||||
triggers.clear()
|
||||
audiences.clear()
|
||||
engagementSeeds.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -1023,6 +1264,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 }
|
||||
|
||||
@@ -34,6 +34,7 @@ const templates = require('../../../model/engagement/engagementTemplates.model')
|
||||
const sendsDb = require('../../../model/engagement/engagementSends.db')
|
||||
const suppressionsDb = require('../../../model/engagement/engagementSuppressions.db')
|
||||
const suppressions = require('../../../engagement/suppressions')
|
||||
const retention = require('../../../model/engagement/engagementRetention.model')
|
||||
|
||||
// The lattice, flattened for a client: for each ceiling, the ones a rule may
|
||||
// choose under it. Served with the catalog rather than hardcoded in the admin
|
||||
@@ -496,12 +497,18 @@ exports.listSends = async (req, res, next) => {
|
||||
// human in the loop, and without a way back a mistyped-then-corrected mailbox is
|
||||
// silenced permanently.
|
||||
//
|
||||
// **The list returns `address_masked`, never `address_hash`.** The send log route
|
||||
// above strips the hash for a stated reason — shipping a sha256 of every address
|
||||
// on the deployment to a browser is an offline dictionary attack waiting to be
|
||||
// run — and the same reasoning applies twice over here, where the rows are
|
||||
// exactly the addresses somebody would most want to confirm. The mask is what an
|
||||
// operator can act on and is not reversible.
|
||||
// **The list DOES return `address_hash`, and Phase 14 reversed a Phase 9
|
||||
// decision to get there** (org lead, 2026-09-01). Phase 9 stripped it on the
|
||||
// grounds that a sha256 of every address on the deployment is an offline
|
||||
// dictionary attack waiting to be run, and left the only way out of the table a
|
||||
// `window.prompt` asking the operator to retype the full address — which they do
|
||||
// not have, because the screen shows a mask. The trade taken: this route is
|
||||
// admin-only and an admin can already suppress and unsuppress any address they
|
||||
// can name, so the hash grants them no capability they lack; what it buys is a
|
||||
// Lift button on the row the operator is actually looking at. The send log route
|
||||
// above still strips its hash, because nothing there needs to act on a row.
|
||||
//
|
||||
// The mask remains what is DISPLAYED. The hash is a handle, never rendered.
|
||||
|
||||
/** GET /api/v1/admin/engagement/suppressions */
|
||||
exports.listSuppressions = async (req, res, next) => {
|
||||
@@ -526,7 +533,7 @@ exports.listSuppressions = async (req, res, next) => {
|
||||
suppressionsDb.countsByReason(),
|
||||
])
|
||||
res.json({
|
||||
suppressions: rows.map(({ address_hash: _hash, ...row }) => row),
|
||||
suppressions: rows,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
@@ -595,3 +602,90 @@ exports.deleteSuppression = async (req, res, next) => {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/engagement/suppressions/by-hash/:hash
|
||||
*
|
||||
* The per-row Lift button (Phase 14). Same effect as the route above and a
|
||||
* different input: the operator is looking at a masked row and does not know the
|
||||
* address, so the only thing they can act on is the row's own handle.
|
||||
*
|
||||
* **The hash still goes in the path and that is safe where an address is not.**
|
||||
* The objection to a path parameter above is that an access log, a browser
|
||||
* history and every proxy in front of the deployment would capture a real
|
||||
* person's address; a sha256 that is already only ever served to an admin
|
||||
* session leaks nothing further by being logged.
|
||||
*
|
||||
* A 404 rather than a 200 when nothing matched, so a stale screen (two admins,
|
||||
* one list, one already lifted) tells the operator rather than claiming success.
|
||||
*/
|
||||
exports.deleteSuppressionByHash = async (req, res, next) => {
|
||||
try {
|
||||
const hash = typeof req.params.hash === 'string' ? req.params.hash.trim().toLowerCase() : ''
|
||||
// Validated in shape rather than trusted: this value reaches a WHERE clause,
|
||||
// and a 64-character hex string is the only thing this column ever holds.
|
||||
if (!/^[0-9a-f]{64}$/.test(hash)) {
|
||||
return res.status(400).json({ message: 'Not a suppression handle' })
|
||||
}
|
||||
const channel = typeof req.query.channel === 'string' && req.query.channel
|
||||
? req.query.channel
|
||||
: 'email'
|
||||
const removed = await suppressionsDb.remove(hash, channel)
|
||||
if (!removed) return res.status(404).json({ message: 'That address is not suppressed' })
|
||||
res.json({ removed: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retention (Phase 14) ───────────────────────────────────────────────────
|
||||
//
|
||||
// Three horizons on one screen, because "what does this deployment keep" is one
|
||||
// question. The send-log horizon is the reason this is a screen at all rather
|
||||
// than the invisible settings row `team_activity` and `user_notifications` each
|
||||
// use: it changes what an operator-facing page is able to show, so an operator
|
||||
// has to be able to see and set it.
|
||||
|
||||
/** GET /api/v1/admin/engagement/retention */
|
||||
exports.getRetention = async (req, res, next) => {
|
||||
try {
|
||||
const policy = await retention.get()
|
||||
// The guard travels with the policy rather than only being logged at 3am by
|
||||
// the worker: the screen that can fix a too-short cooldown horizon is the one
|
||||
// that has to say it is too short.
|
||||
const cooldownCheck = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
res.json({
|
||||
retention: policy,
|
||||
limits: retention.HORIZONS,
|
||||
longestCooldownSeconds: cooldownCheck.longestCooldownSeconds,
|
||||
warnings: cooldownCheck.ok ? [] : [cooldownCheck.message],
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/engagement/retention
|
||||
*
|
||||
* A sparse PUT: only the horizons present in the body are written, so a screen
|
||||
* saving one select does not have to round-trip the other two and cannot
|
||||
* clobber a value another admin changed between load and save. Out-of-range is
|
||||
* a 400 rather than a clamp — silently storing something other than what was
|
||||
* typed would leave the screen describing a policy the deployment is not running.
|
||||
*/
|
||||
exports.putRetention = async (req, res, next) => {
|
||||
try {
|
||||
const policy = await retention.set(req.body || {}, req.user?.id ?? null)
|
||||
const cooldownCheck = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
res.json({
|
||||
retention: policy,
|
||||
limits: retention.HORIZONS,
|
||||
longestCooldownSeconds: cooldownCheck.longestCooldownSeconds,
|
||||
warnings: cooldownCheck.ok ? [] : [cooldownCheck.message],
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.status === 400) return res.status(400).json({ message: err.message })
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,4 +381,48 @@ engagementRouter.delete(
|
||||
controller.deleteSuppression,
|
||||
)
|
||||
|
||||
engagementRouter.delete(
|
||||
'/suppressions/by-hash/:hash',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Lift a suppression by its row handle'
|
||||
// #swagger.description = 'The per-row Lift button (Phase 14). Same effect as the route above, different input: the screen shows a mask, so the operator does not know the address and can only act on the row handle the list gives them. The handle IS safe in the path where an address is not - it is a sha256 already served only to an admin session, so an access log or proxy that captures it learns nothing new. 404 rather than 200 when nothing matched, so a stale screen (two admins, one list) says so instead of claiming success.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['hash'] = { in: 'path', description: 'The address_hash the list returns for that row, 64 hex characters', required: true, schema: { type: 'string' } }
|
||||
// #swagger.parameters['channel'] = { in: 'query', description: 'Defaults to email', required: false, schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'Lifted', content: { "application/json": { schema: { type: "object", properties: { removed: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Not a suppression handle', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'That address is not suppressed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.deleteSuppressionByHash,
|
||||
)
|
||||
|
||||
// ── Retention (Phase 14) ───────────────────────────────────────────────────
|
||||
|
||||
engagementRouter.get(
|
||||
'/retention',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Read the engagement retention policy'
|
||||
// #swagger.description = 'The three horizons the nightly sweep uses, in days, with the bounds each is validated against. `engagement_suppressions` is deliberately absent: a suppression is a standing decision and does not expire, because ageing out a hard bounce re-mails an address that already bounced. `warnings` carries the one check that cannot be a static bound - a cooldown horizon shorter than the longest cooldown on an ENABLED rule, which would let that rule send twice.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The current policy', content: { "application/json": { schema: { type: "object", properties: { retention: { type: "object", properties: { sends: { type: "integer" }, cooldowns: { type: "integer" }, outbox: { type: "integer" } } }, limits: { type: "object", additionalProperties: true }, longestCooldownSeconds: { type: "integer" }, warnings: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.getRetention,
|
||||
)
|
||||
|
||||
engagementRouter.put(
|
||||
'/retention',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Set the engagement retention policy'
|
||||
// #swagger.description = 'Sparse: only the horizons named in the body are written, so saving one select cannot clobber a value another admin changed between load and save. Out of range is a 400 rather than a clamp - storing something other than what was typed would leave the screen describing a policy the deployment is not running. The floors are not UI niceties: below 2 days a pruned cooldown row makes the next fire a FIRST fire (a duplicate send), and the send log is counted by the per-rule hourly ceiling.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { sends: { type: "integer", nullable: true }, cooldowns: { type: "integer", nullable: true }, outbox: { type: "integer", nullable: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The policy as it now stands', content: { "application/json": { schema: { type: "object", properties: { retention: { type: "object", additionalProperties: { type: "integer" } }, limits: { type: "object", additionalProperties: true }, longestCooldownSeconds: { type: "integer" }, warnings: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A horizon was not a whole number of days, or was out of range', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.putRetention,
|
||||
)
|
||||
|
||||
module.exports = engagementRouter
|
||||
|
||||
@@ -11,6 +11,7 @@ const botScore = require('./middleware/botScore')
|
||||
const announceWorker = require('./utils/announceWorker')
|
||||
const teamActivityPrune = require('./utils/teamActivityPrune')
|
||||
const inboxPrune = require('./utils/userNotificationsPrune')
|
||||
const engagementRetentionPrune = require('./utils/engagementRetentionPrune')
|
||||
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
||||
const teamDigestWorker = require('./utils/teamDigestWorker')
|
||||
const engagementWorker = require('./utils/engagementWorker')
|
||||
@@ -160,6 +161,7 @@ async function start() {
|
||||
// rather than after someone notices. No-op on a deployment with no Teams.
|
||||
teamActivityPrune.start()
|
||||
inboxPrune.start()
|
||||
engagementRetentionPrune.start()
|
||||
teamForumUploadSweep.start()
|
||||
teamDigestWorker.start()
|
||||
|
||||
@@ -186,6 +188,7 @@ function setupShutdown(server, internalServer) {
|
||||
announceWorker.stop() // stop the news-announcement dispatcher poller
|
||||
teamActivityPrune.stop() // stop the Team activity retention timer
|
||||
inboxPrune.stop() // stop the in-app inbox retention timer
|
||||
engagementRetentionPrune.stop() // stop the engagement retention timer
|
||||
teamForumUploadSweep.stop() // stop the forum upload sweep
|
||||
teamDigestWorker.stop() // stop the Team forum digest timer
|
||||
engagementWorker.stop() // stop the engagement outbox worker
|
||||
|
||||
134
server/src/utils/engagementRetentionPrune.js
Normal file
134
server/src/utils/engagementRetentionPrune.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── Engagement retention worker ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 14. Three of the four engagement tables grow on every fire
|
||||
// and nothing has ever deleted from any of them: `engagement_cooldowns` (one row
|
||||
// per rule × user × subject × channel per fire), `engagement_outbox` (one row per
|
||||
// enqueued delivery, terminal rows included) and `engagement_sends` (one row per
|
||||
// attempt). `engagement_suppressions` is the fourth and does not expire — see
|
||||
// `engagementRetention.model.js` for why that is a decision rather than an
|
||||
// omission.
|
||||
//
|
||||
// **One worker, three sweeps, not three workers.** They share a timer, a batch
|
||||
// discipline and one settings-backed policy object; splitting them would give an
|
||||
// operator three independent nightly table-wide DELETEs to reason about and
|
||||
// three places for a horizon to be read differently.
|
||||
//
|
||||
// Same in-process shape as `teamActivityPrune` and `userNotificationsPrune` —
|
||||
// setInterval + unref + stop(), wired into server.js start/shutdown beside them,
|
||||
// with the first run delayed so a table-wide DELETE never lands in front of the
|
||||
// first request on a crash-looping deployment.
|
||||
|
||||
const cooldownsDb = require('../model/engagement/engagementCooldowns.db')
|
||||
const outboxDb = require('../model/engagement/engagementOutbox.db')
|
||||
const sendsDb = require('../model/engagement/engagementSends.db')
|
||||
const retention = require('../model/engagement/engagementRetention.model')
|
||||
const log = require('./logger')('engagement')
|
||||
|
||||
const INTERVAL_MS = Number(process.env.ENGAGEMENT_PRUNE_MS) || 24 * 60 * 60 * 1000
|
||||
const FIRST_RUN_MS = Number(process.env.ENGAGEMENT_PRUNE_DELAY_MS) || 10 * 60 * 1000
|
||||
|
||||
// A bound per statement, so one run after a long outage is a series of bounded
|
||||
// DELETEs rather than one holding locks over a million rows. Each sweep repeats
|
||||
// until it clears and stops early rather than looping forever; a run that hits
|
||||
// the ceiling simply resumes tomorrow, which is what a horizon means anyway.
|
||||
const BATCH = 1000
|
||||
const MAX_BATCHES = 50
|
||||
|
||||
const daysAgo = (days, now) => new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
|
||||
/** Repeat a bounded delete until it stops filling its batch. Never throws. */
|
||||
async function sweep(name, del) {
|
||||
let removed = 0
|
||||
for (let i = 0; i < MAX_BATCHES; i += 1) {
|
||||
const n = await del()
|
||||
removed += n
|
||||
if (n < BATCH) break
|
||||
}
|
||||
if (removed) log.info('engagement retention swept', { table: name, removed })
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass over all three tables.
|
||||
*
|
||||
* Each sweep is caught on its own: a failure in one (a lock timeout on a huge
|
||||
* outbox, say) must not stop the other two from being bounded. Never throws — it
|
||||
* runs on a timer with nobody to catch it.
|
||||
*/
|
||||
async function tick(now = new Date()) {
|
||||
const result = { cooldowns: 0, outbox: 0, sends: 0, warnings: [] }
|
||||
let policy
|
||||
try {
|
||||
policy = await retention.get()
|
||||
} catch (err) {
|
||||
log.error('engagement retention policy unreadable; skipping this run', { message: err.message })
|
||||
return result
|
||||
}
|
||||
|
||||
// Phase 14's acceptance line: checked, not picked. A horizon shorter than a
|
||||
// live cooldown is logged and swept anyway — see the model for that trade.
|
||||
try {
|
||||
const check = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
if (!check.ok) {
|
||||
result.warnings.push(check.message)
|
||||
log.warn('cooldown retention is shorter than a live cooldown', {
|
||||
retainDays: policy.cooldowns,
|
||||
longestCooldownSeconds: check.longestCooldownSeconds,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('cooldown horizon check failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.cooldowns = await sweep('engagement_cooldowns', () =>
|
||||
cooldownsDb.prune(daysAgo(policy.cooldowns, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('cooldown prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.outbox = await sweep('engagement_outbox', () =>
|
||||
outboxDb.pruneTerminal(daysAgo(policy.outbox, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('outbox prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.sends = await sweep('engagement_sends', () =>
|
||||
sendsDb.prune(daysAgo(policy.sends, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('send-log prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let timer = null
|
||||
let firstRun = null
|
||||
|
||||
function start() {
|
||||
if (timer || firstRun) return timer
|
||||
firstRun = setTimeout(() => {
|
||||
firstRun = null
|
||||
tick()
|
||||
timer = setInterval(() => { tick() }, INTERVAL_MS)
|
||||
if (timer.unref) timer.unref()
|
||||
}, FIRST_RUN_MS)
|
||||
if (firstRun.unref) firstRun.unref()
|
||||
log.info('engagement retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
|
||||
return timer
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (firstRun) {
|
||||
clearTimeout(firstRun)
|
||||
firstRun = null
|
||||
}
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, BATCH, MAX_BATCHES, INTERVAL_MS, FIRST_RUN_MS }
|
||||
@@ -153,7 +153,7 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
|
||||
|
||||
async function tick(now = new Date()) {
|
||||
try {
|
||||
await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS))
|
||||
await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS), MAX_ATTEMPTS)
|
||||
} catch (err) {
|
||||
log.error('failed to reclaim stale rows', { message: err.message })
|
||||
}
|
||||
|
||||
@@ -1351,6 +1351,166 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/retention": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Read the engagement retention policy",
|
||||
"description": "The three horizons the nightly sweep uses, in days, with the bounds each is validated against. `engagement_suppressions` is deliberately absent: a suppression is a standing decision and does not expire, because ageing out a hard bounce re-mails an address that already bounced. `warnings` carries the one check that cannot be a static bound - a cooldown horizon shorter than the longest cooldown on an ENABLED rule, which would let that rule send twice.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The current policy",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"retention": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sends": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cooldowns": {
|
||||
"type": "integer"
|
||||
},
|
||||
"outbox": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"longestCooldownSeconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Set the engagement retention policy",
|
||||
"description": "Sparse: only the horizons named in the body are written, so saving one select cannot clobber a value another admin changed between load and save. Out of range is a 400 rather than a clamp - storing something other than what was typed would leave the screen describing a policy the deployment is not running. The floors are not UI niceties: below 2 days a pruned cooldown row makes the next fire a FIRST fire (a duplicate send), and the send log is counted by the per-rule hourly ceiling.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The policy as it now stands",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"retention": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"longestCooldownSeconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "A horizon was not a whole number of days, or was out of range",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sends": {
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
},
|
||||
"cooldowns": {
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
},
|
||||
"outbox": {
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/rules": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2490,6 +2650,90 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/suppressions/by-hash/{hash}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Lift a suppression by its row handle",
|
||||
"description": "The per-row Lift button (Phase 14). Same effect as the route above, different input: the screen shows a mask, so the operator does not know the address and can only act on the row handle the list gives them. The handle IS safe in the path where an address is not - it is a sha256 already served only to an admin session, so an access log or proxy that captures it learns nothing new. 404 rather than 200 when nothing matched, so a stale screen (two admins, one list) says so instead of claiming success.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "hash",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The address_hash the list returns for that row, 64 hex characters"
|
||||
},
|
||||
{
|
||||
"name": "channel",
|
||||
"in": "query",
|
||||
"description": "Defaults to email",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Lifted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Not a suppression handle",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "That address is not suppressed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/templates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -371,8 +371,7 @@ test('a GET on the unsubscribe endpoint mutates nothing and lands on the page',
|
||||
// ── The seeded rules (decision 3) ──────────────────────────────────────────
|
||||
|
||||
test('core seeds a rule for each Team trigger, and every one of them is OFF', async () => {
|
||||
patch(settingsDb, 'get', async () => null)
|
||||
patch(settingsDb, 'set', async (k, v) => world.settings.set(k, v))
|
||||
patch(settingsDb, 'claim', async (k, v) => { world.settings.set(k, v); return true })
|
||||
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
|
||||
|
||||
const summary = await coreRules.seedTeamRules()
|
||||
@@ -400,9 +399,32 @@ test('every seeded rule names a template that actually exists', () => {
|
||||
// Seeded once, not ensured: an operator who deletes a rule must not find it back
|
||||
// after a restart, and one they enabled must not be reset to off.
|
||||
test('a second boot seeds nothing', async () => {
|
||||
patch(settingsDb, 'get', async () => '2026-08-29T00:00:00.000Z')
|
||||
// The guard is a CLAIM, so "already seeded" is the claim losing rather than a
|
||||
// read finding a row. It is the same one-shot promise, made atomically: two
|
||||
// instances booting together used to both read "absent" and both seed, and a
|
||||
// duplicate rule is two mails per event (Phase 13's acceptance walk).
|
||||
patch(settingsDb, 'claim', async () => false)
|
||||
patch(rulesDb, 'insert', async () => { throw new Error('must not insert') })
|
||||
const summary = await coreRules.seedTeamRules()
|
||||
assert.equal(summary.inserted, 0)
|
||||
assert.equal(summary.skipped, 4)
|
||||
})
|
||||
|
||||
test('two instances booting together seed the Team rules once, not twice', async () => {
|
||||
// Not awaited in turn on purpose: interleaving the two calls is the test, and
|
||||
// awaiting the first would pass against the read-then-write guard this
|
||||
// replaced. `claim` decides and writes without yielding, exactly as the
|
||||
// settings table's PRIMARY KEY does.
|
||||
const claimed = new Set()
|
||||
patch(settingsDb, 'claim', async (k) => {
|
||||
if (claimed.has(k)) return false
|
||||
claimed.add(k)
|
||||
return true
|
||||
})
|
||||
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
|
||||
|
||||
const [a, b] = await Promise.all([coreRules.seedTeamRules(), coreRules.seedTeamRules()])
|
||||
|
||||
assert.equal(a.inserted + b.inserted, 4)
|
||||
assert.equal(world.inserted.length, 4)
|
||||
})
|
||||
|
||||
@@ -98,8 +98,11 @@ function installStubs() {
|
||||
// the pair is still cooling. `engagementEngineSql.test.js` is what proves the
|
||||
// SQL itself - a stub can only ever agree with whoever wrote it, and in this
|
||||
// case the first version of both was wrong together.
|
||||
cooldownsDb.claim = async (ruleId, userId, subjectKey, cooldownSeconds, now) => {
|
||||
const key = `${ruleId}|${userId}|${subjectKey}`
|
||||
cooldownsDb.claim = async (ruleId, userId, subjectKey, channel, cooldownSeconds, now) => {
|
||||
// `channel` is in the key, exactly as the PRIMARY KEY is: a rule naming two
|
||||
// channels must deliver on both, and a stub that dropped the channel would
|
||||
// agree with the engine bug Phase 11b's live walk found.
|
||||
const key = `${ruleId}|${userId}|${subjectKey}|${channel}`
|
||||
const row = store.cooldowns.get(key)
|
||||
if (!row) {
|
||||
store.cooldowns.set(key, { last_fired_at: now, fire_count: 1 })
|
||||
@@ -327,6 +330,26 @@ test('a cooldown that has expired lets the same subject through again', async ()
|
||||
assert.equal(outboxRows().length, 2)
|
||||
})
|
||||
|
||||
test('a cooldown does not stop a rule delivering on its OTHER channels', async () => {
|
||||
// Phase 11b's live walk. The claim runs inside the per-channel loop, so a key
|
||||
// without the channel let the FIRST channel claim the cooldown and reported
|
||||
// every later one as cooled — and `inapp` is ranked ahead of `email` on
|
||||
// purpose, so a two-channel rule delivered the inbox item and silently never
|
||||
// the mail. Decision 8 requires both, and this is the assertion that says so.
|
||||
addUser(10)
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
optIn(10, 'uo.house.idoc_warning', 'inapp')
|
||||
addRule({ cooldown_seconds: 86_400, channels: ['email', 'inapp'] })
|
||||
|
||||
await engine.dispatch(event(), T0)
|
||||
assert.deepEqual(outboxRows().map((r) => r.channel).sort(), ['email', 'inapp'])
|
||||
|
||||
// …and the cooldown still holds, on both channels, for a second event about
|
||||
// the same house inside the day. Per-delivery, not per-channel-forever.
|
||||
await engine.dispatch(event(), later(60_000))
|
||||
assert.equal(outboxRows().length, 2)
|
||||
})
|
||||
|
||||
test('two rules on one trigger each get their own cooldown', async () => {
|
||||
addRule({ cooldown_seconds: 3600 })
|
||||
addRule({ cooldown_seconds: 3600 })
|
||||
@@ -604,6 +627,44 @@ test('the hourly ceiling counts sends, not attempts', async () => {
|
||||
assert.equal(result.enqueued, 1)
|
||||
})
|
||||
|
||||
// ── templateKeys: what a channel is, and what `digest` is ──────────────────
|
||||
|
||||
test('a rule may name a `digest` body, which is a template slot rather than a channel', async () => {
|
||||
// The defect Phase 13's acceptance walk found. `registries.js` `checkSeedRule`
|
||||
// permits `digest` in as many words — it is the body `teamDigestWorker`
|
||||
// renders for a rule whose email channel an individual set to digest mode, so
|
||||
// it never appears in `channels` and never could — and core's own Team and
|
||||
// news rules ship one, as do sixteen of module-uo's. This validator rejected
|
||||
// it, so every one of those rules answered an operator who opened it and
|
||||
// pressed Save with a 400 naming a key they had never typed, and the only way
|
||||
// to save was to delete the digest body.
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
channels: ['email'],
|
||||
audience: 'owner',
|
||||
templateKeys: { email: 'notify.event', digest: 'notify.digest' },
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, true, checked.errors && checked.errors.join(' '))
|
||||
assert.equal(checked.rule.template_keys.digest, 'notify.digest')
|
||||
})
|
||||
|
||||
test('a templateKeys entry that is neither a channel nor `digest` is still refused', async () => {
|
||||
// The rule that was right all along, kept: `digest` is one named exception
|
||||
// with a renderer behind it, not a hole that admits any word.
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
channels: ['email'],
|
||||
audience: 'owner',
|
||||
templateKeys: { email: 'notify.event', carrierpigeon: 'notify.event' },
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /carrierpigeon/)
|
||||
})
|
||||
|
||||
// ── Ceilings: the security boundary, both halves ───────────────────────────
|
||||
|
||||
test('a rule may not be SAVED with an audience wider than its trigger permits', async () => {
|
||||
|
||||
234
server/test/engagementRetention.test.js
Normal file
234
server/test/engagementRetention.test.js
Normal file
@@ -0,0 +1,234 @@
|
||||
// ── Retention: the engagement schema's sweep (ENGAGEMENT.md Phase 14) ──────
|
||||
//
|
||||
// The phase's acceptance line is that every one of the four tables has a stated
|
||||
// policy — a sweep with a horizon, or a recorded decision that it does not
|
||||
// expire — and that the cooldown horizon is CHECKED against the longest enabled
|
||||
// rule rather than picked. Both are pinned here, plus the three things building
|
||||
// it showed are silent when wrong:
|
||||
//
|
||||
// • **the outbox sweep is terminal-only.** A `scheduled` row is a message this
|
||||
// deployment still intends to send; `delay_seconds` can legitimately put one
|
||||
// a day out. Sweeping by age alone would cancel sends nobody cancelled, and
|
||||
// the operator would see only that the mail never arrived.
|
||||
// • **`reclaimStale` has to give up.** `MAX_ATTEMPTS` is consulted only on a
|
||||
// graceful `retry` outcome, so before Phase 14 a send that killed the
|
||||
// process mid-flight cycled sending → scheduled → sending forever, never
|
||||
// reached a terminal status, and was therefore never eligible for ANY
|
||||
// retention sweep. The bound depends on this.
|
||||
// • **an unreadable setting means the default, not an exception.** The sweep
|
||||
// runs on a timer with nobody watching.
|
||||
//
|
||||
// Point the DB at a closed port before requiring anything: the registries reach
|
||||
// utils/discordAnnounce, which builds the pool at require time.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const retention = require('../src/model/engagement/engagementRetention.model')
|
||||
const prune = require('../src/utils/engagementRetentionPrune')
|
||||
const cooldownsDb = require('../src/model/engagement/engagementCooldowns.db')
|
||||
const outboxDb = require('../src/model/engagement/engagementOutbox.db')
|
||||
const sendsDb = require('../src/model/engagement/engagementSends.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const rulesDb = require('../src/model/engagement/engagementRules.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const saved = new Map()
|
||||
function patch(mod, name, fn) {
|
||||
if (!saved.has(mod)) saved.set(mod, new Map())
|
||||
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
|
||||
mod[name] = fn
|
||||
}
|
||||
function restore() {
|
||||
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
|
||||
saved.clear()
|
||||
}
|
||||
|
||||
let world
|
||||
|
||||
beforeEach(() => {
|
||||
world = {
|
||||
settings: new Map(),
|
||||
longestCooldown: 0,
|
||||
// Every prune call, so a test can assert on the horizon it was given rather
|
||||
// than only on the fact that something was deleted.
|
||||
calls: { cooldowns: [], outbox: [], sends: [] },
|
||||
deleted: { cooldowns: 0, outbox: 0, sends: 0 },
|
||||
}
|
||||
patch(settings, 'get', async (key) => {
|
||||
if (!world.settings.has(key)) return null
|
||||
return world.settings.get(key)
|
||||
})
|
||||
patch(settings, 'set', async (key, value) => { world.settings.set(key, value) })
|
||||
patch(rulesDb, 'maxEnabledCooldownSeconds', async () => world.longestCooldown)
|
||||
patch(cooldownsDb, 'prune', async (before, limit) => {
|
||||
world.calls.cooldowns.push({ before, limit })
|
||||
return world.deleted.cooldowns
|
||||
})
|
||||
patch(outboxDb, 'pruneTerminal', async (before, limit) => {
|
||||
world.calls.outbox.push({ before, limit })
|
||||
return world.deleted.outbox
|
||||
})
|
||||
patch(sendsDb, 'prune', async (before, limit) => {
|
||||
world.calls.sends.push({ before, limit })
|
||||
return world.deleted.sends
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => restore())
|
||||
|
||||
const daysBetween = (now, before) => Math.round((now.getTime() - before.getTime()) / 86400000)
|
||||
|
||||
// ── The policy ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('defaults are the shipped policy when nothing is stored', async () => {
|
||||
const policy = await retention.get()
|
||||
assert.deepEqual(policy, { sends: 180, cooldowns: 30, outbox: 30 })
|
||||
})
|
||||
|
||||
test('a stored value is used, and written back as a string', async () => {
|
||||
await retention.set({ sends: 365 }, 7)
|
||||
assert.equal(world.settings.get('engagement_sends_retain_days'), '365')
|
||||
assert.equal((await retention.get()).sends, 365)
|
||||
})
|
||||
|
||||
test('the PUT is sparse — an absent horizon is left alone', async () => {
|
||||
await retention.set({ sends: 90 })
|
||||
await retention.set({ cooldowns: 45 })
|
||||
const policy = await retention.get()
|
||||
assert.equal(policy.sends, 90, 'the earlier write survives the second call')
|
||||
assert.equal(policy.cooldowns, 45)
|
||||
assert.equal(policy.outbox, 30, 'and the untouched one is still the default')
|
||||
})
|
||||
|
||||
test('out of range is refused rather than clamped', async () => {
|
||||
// Clamping would leave the screen describing a policy the deployment is not
|
||||
// running, which is worse than a visible error.
|
||||
await assert.rejects(() => retention.set({ cooldowns: 1 }), /between 2 and 3650/)
|
||||
await assert.rejects(() => retention.set({ sends: 5 }), /between 7 and 3650/)
|
||||
await assert.rejects(() => retention.set({ outbox: 99999 }), /between 2 and 3650/)
|
||||
await assert.rejects(() => retention.set({ sends: 12.5 }), /whole number/)
|
||||
assert.equal(world.settings.size, 0, 'nothing was written')
|
||||
})
|
||||
|
||||
test('an unknown key in the body is ignored, not rejected', async () => {
|
||||
// So a client that posts the whole object back is not coupled to the list.
|
||||
await retention.set({ sends: 200, suppressions: 30, nonsense: 1 })
|
||||
assert.equal((await retention.get()).sends, 200)
|
||||
assert.equal(world.settings.has('engagement_suppressions_retain_days'), false)
|
||||
})
|
||||
|
||||
test('a stored value outside the bounds falls back to the default', async () => {
|
||||
// A row written before the bounds existed, or by hand.
|
||||
world.settings.set('engagement_cooldowns_retain_days', '0')
|
||||
assert.equal((await retention.get()).cooldowns, 30)
|
||||
world.settings.set('engagement_cooldowns_retain_days', 'soon')
|
||||
assert.equal((await retention.get()).cooldowns, 30)
|
||||
})
|
||||
|
||||
test('an unreadable settings table yields defaults rather than throwing', async () => {
|
||||
patch(settings, 'get', async () => { throw new Error('pool is dead') })
|
||||
assert.deepEqual(await retention.get(), { sends: 180, cooldowns: 30, outbox: 30 })
|
||||
})
|
||||
|
||||
// ── The cooldown guard ─────────────────────────────────────────────────────
|
||||
|
||||
test('the cooldown horizon is checked against the longest ENABLED rule', async () => {
|
||||
world.longestCooldown = 86400 // the validated maximum, one day
|
||||
const ok = await retention.checkCooldownHorizon(30)
|
||||
assert.equal(ok.ok, true)
|
||||
assert.equal(ok.message, null)
|
||||
|
||||
// A horizon shorter than a live cooldown means a pruned row makes the next
|
||||
// fire a FIRST fire — the rule sends twice.
|
||||
const bad = await retention.checkCooldownHorizon(0.5)
|
||||
assert.equal(bad.ok, false)
|
||||
assert.match(bad.message, /can send twice/)
|
||||
assert.equal(bad.longestCooldownSeconds, 86400)
|
||||
})
|
||||
|
||||
test('the horizon equal to the longest cooldown is refused, not accepted', async () => {
|
||||
// Equality is the boundary where a row is pruned exactly as it stops being in
|
||||
// force; the check has to be <=, not <.
|
||||
world.longestCooldown = 2 * 86400
|
||||
assert.equal((await retention.checkCooldownHorizon(2)).ok, false)
|
||||
assert.equal((await retention.checkCooldownHorizon(3)).ok, true)
|
||||
})
|
||||
|
||||
test('no enabled rule means no warning at any horizon', async () => {
|
||||
world.longestCooldown = 0
|
||||
assert.equal((await retention.checkCooldownHorizon(2)).ok, true)
|
||||
})
|
||||
|
||||
// ── The sweep ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('one tick sweeps all three tables at their own horizons', async () => {
|
||||
const now = new Date('2026-09-01T03:00:00Z')
|
||||
const result = await prune.tick(now)
|
||||
|
||||
assert.equal(daysBetween(now, world.calls.sends[0].before), 180)
|
||||
assert.equal(daysBetween(now, world.calls.cooldowns[0].before), 30)
|
||||
assert.equal(daysBetween(now, world.calls.outbox[0].before), 30)
|
||||
assert.deepEqual(result.warnings, [])
|
||||
})
|
||||
|
||||
test('each statement is bounded', async () => {
|
||||
await prune.tick(new Date())
|
||||
for (const table of ['cooldowns', 'outbox', 'sends']) {
|
||||
assert.equal(world.calls[table][0].limit, prune.BATCH, `${table} is batched`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a sweep repeats while its batches come back full, and stops', async () => {
|
||||
world.deleted.sends = prune.BATCH
|
||||
const result = await prune.tick(new Date())
|
||||
assert.equal(world.calls.sends.length, prune.MAX_BATCHES, 'it stops rather than looping forever')
|
||||
assert.equal(result.sends, prune.BATCH * prune.MAX_BATCHES)
|
||||
})
|
||||
|
||||
test('one failing table does not stop the other two', async () => {
|
||||
patch(outboxDb, 'pruneTerminal', async () => { throw new Error('lock wait timeout') })
|
||||
const result = await prune.tick(new Date())
|
||||
assert.equal(result.outbox, 0)
|
||||
assert.equal(world.calls.cooldowns.length, 1, 'cooldowns still swept')
|
||||
assert.equal(world.calls.sends.length, 1, 'the send log still swept')
|
||||
})
|
||||
|
||||
test('the sweep runs even when the cooldown horizon is too short, and warns', async () => {
|
||||
// Deliberate: refusing to prune would trade a bounded, describable fault (one
|
||||
// rule may re-fire early) for the unbounded one this phase exists to end.
|
||||
world.longestCooldown = 86400
|
||||
world.settings.set('engagement_cooldowns_retain_days', '2')
|
||||
const result = await prune.tick(new Date())
|
||||
assert.equal(result.warnings.length, 0, 'two days clears a one-day cooldown')
|
||||
|
||||
world.longestCooldown = 30 * 86400 // longer than any rule can actually save
|
||||
const second = await prune.tick(new Date())
|
||||
assert.equal(second.warnings.length, 1)
|
||||
assert.equal(world.calls.cooldowns.length, 2, 'it swept anyway')
|
||||
})
|
||||
|
||||
test('an unreadable policy skips the run rather than sweeping on a guess', async () => {
|
||||
patch(retention, 'get', async () => { throw new Error('pool is dead') })
|
||||
const result = await prune.tick(new Date())
|
||||
assert.deepEqual(result, { cooldowns: 0, outbox: 0, sends: 0, warnings: [] })
|
||||
assert.equal(world.calls.sends.length, 0, 'nothing was deleted')
|
||||
})
|
||||
|
||||
test('start/stop is idempotent and leaves no live timer', () => {
|
||||
prune.start()
|
||||
prune.start()
|
||||
prune.stop()
|
||||
prune.stop()
|
||||
})
|
||||
|
||||
// The SQL these depend on — terminal-only, the give-up-before-reclaim order and
|
||||
// the batch LIMIT — is proved against a real server in engagementRetentionSql.test.js.
|
||||
// It cannot be proved here: the db modules destructure `query` at require time,
|
||||
// so there is nothing left to stub, and a hand-rolled stand-in would prove only
|
||||
// that two readings of the manual agree (which is exactly the trap Phase 4a's
|
||||
// `foundRows` defect sprang).
|
||||
318
server/test/engagementRetentionSql.test.js
Normal file
318
server/test/engagementRetentionSql.test.js
Normal file
@@ -0,0 +1,318 @@
|
||||
// ── The retention sweep, against a real MariaDB ────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 14. `engagementRetention.test.js` proves the policy and
|
||||
// the worker's control flow against stubs, which is right for everything the
|
||||
// worker DECIDES. It cannot prove the three statements whose whole correctness
|
||||
// is what the server does with them, and this is the file for those — same shape
|
||||
// and same reasoning as `engagementEngineSql.test.js`, which exists because a
|
||||
// stub written from the same reading of the manual proves the reading, not the
|
||||
// server (that is how the `foundRows` cooldown defect got as far as it did).
|
||||
//
|
||||
// It is also Phase 14's acceptance rig, stated in the phase: *"a rig run shows
|
||||
// the sweep deleting terminal rows while leaving a `scheduled` outbox row and an
|
||||
// in-window send-log row alone."*
|
||||
//
|
||||
// **It SKIPS when there is no database**, deliberately: CI runs the suite with
|
||||
// the pool pointed at a dead port, and a file that failed there would make every
|
||||
// PR red for a reason unrelated to itself. Run it against this machine's
|
||||
// container with:
|
||||
//
|
||||
// DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=... DB_PASSWORD=... \
|
||||
// node --test test/engagementRetentionSql.test.js
|
||||
//
|
||||
// It creates its tables in a throwaway database named after the process and
|
||||
// drops it again, so it can never touch a real schema.
|
||||
|
||||
const { test, before, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const mariadb = require('mariadb')
|
||||
|
||||
// The three tables under sweep, with the two indexes Phase 14 added. Copied
|
||||
// rather than required, for the reason the engine's SQL file gives: requiring the
|
||||
// modules would drag in `utils/db`'s pool.
|
||||
const SCHEMA = `
|
||||
CREATE TABLE engagement_cooldowns (
|
||||
rule_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
channel VARCHAR(32) NOT NULL DEFAULT '',
|
||||
last_fired_at DATETIME NOT NULL,
|
||||
fire_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (rule_id, user_id, subject_key, channel),
|
||||
INDEX idx_engc_sweep (last_fired_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE engagement_outbox (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_id INT NOT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
payload JSON NOT NULL,
|
||||
dedupe_key VARCHAR(190) NULL,
|
||||
status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled',
|
||||
due_at DATETIME NOT NULL,
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
sent_at DATETIME NULL,
|
||||
INDEX idx_engo_due (status, due_at),
|
||||
INDEX idx_engo_sweep (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE engagement_sends (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_id INT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
user_id INT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
status ENUM('sent','failed','suppressed','bounced','complained') NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_engs_rule_window (rule_id, created_at),
|
||||
INDEX idx_engs_sweep (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`
|
||||
|
||||
// Verbatim from the three `.db` files. If one of these drifts from its source the
|
||||
// test still passes and proves nothing, which is the standing cost of the copy —
|
||||
// the alternative (requiring the modules) costs a live pool on a dead port.
|
||||
const PRUNE_COOLDOWNS = 'DELETE FROM engagement_cooldowns WHERE last_fired_at < ? LIMIT ?'
|
||||
const PRUNE_OUTBOX = `
|
||||
DELETE FROM engagement_outbox
|
||||
WHERE status IN ('sent', 'failed', 'cancelled', 'suppressed')
|
||||
AND created_at < ?
|
||||
LIMIT ?`
|
||||
const PRUNE_SENDS = 'DELETE FROM engagement_sends WHERE created_at < ? LIMIT ?'
|
||||
const GIVE_UP = `
|
||||
UPDATE engagement_outbox
|
||||
SET status = 'failed', last_error = 'gave up after repeated interruptions'
|
||||
WHERE status = 'sending' AND updated_at < ? AND attempts >= ?`
|
||||
const RECLAIM = "UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?"
|
||||
|
||||
const DB = `rg_retain_test_${process.pid}`
|
||||
let pool = null
|
||||
let available = false
|
||||
|
||||
before(async () => {
|
||||
const admin = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
connectionLimit: 1,
|
||||
connectTimeout: 2000,
|
||||
initializationTimeout: 2000,
|
||||
multipleStatements: true,
|
||||
})
|
||||
try {
|
||||
await admin.query(`CREATE DATABASE ${DB}`)
|
||||
available = true
|
||||
} catch {
|
||||
available = false
|
||||
} finally {
|
||||
await admin.end().catch(() => {})
|
||||
}
|
||||
if (!available) return
|
||||
|
||||
pool = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: DB,
|
||||
connectionLimit: 3,
|
||||
multipleStatements: true,
|
||||
bigIntAsNumber: true,
|
||||
insertIdAsNumber: true,
|
||||
})
|
||||
await pool.query(SCHEMA)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
if (pool) {
|
||||
await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {})
|
||||
await pool.end().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
// Checked INSIDE each test, never as a `{ skip }` option — the option is
|
||||
// evaluated when the file is read, before `before()` has found out whether there
|
||||
// is a database, and every test skipped unconditionally looks like a pass.
|
||||
const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run'
|
||||
const needDb = (t) => {
|
||||
if (available) return false
|
||||
t.skip(SKIP)
|
||||
return true
|
||||
}
|
||||
|
||||
const NOW = new Date('2026-09-01T12:00:00Z')
|
||||
const daysAgo = (d) => new Date(NOW.getTime() - d * 86400000)
|
||||
|
||||
const clear = async () => {
|
||||
await pool.query('DELETE FROM engagement_cooldowns')
|
||||
await pool.query('DELETE FROM engagement_outbox')
|
||||
await pool.query('DELETE FROM engagement_sends')
|
||||
}
|
||||
|
||||
const addOutbox = (status, createdAt, extra = {}) =>
|
||||
pool.query(
|
||||
`INSERT INTO engagement_outbox
|
||||
(rule_id, trigger_id, user_id, channel, payload, due_at, status, created_at, updated_at, attempts)
|
||||
VALUES (1, 't', 1, 'email', '{}', ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
extra.dueAt || createdAt,
|
||||
status,
|
||||
createdAt,
|
||||
extra.updatedAt || createdAt,
|
||||
extra.attempts ?? 0,
|
||||
],
|
||||
)
|
||||
|
||||
// ── The acceptance rig ─────────────────────────────────────────────────────
|
||||
|
||||
test('the outbox sweep deletes terminal rows and leaves a scheduled one alone', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await clear()
|
||||
|
||||
// Four terminal rows, well past the horizon.
|
||||
for (const status of ['sent', 'failed', 'cancelled', 'suppressed']) {
|
||||
await addOutbox(status, daysAgo(90))
|
||||
}
|
||||
// The row the phase names: old, and still a promise this deployment has made.
|
||||
await addOutbox('scheduled', daysAgo(90))
|
||||
// And one in flight, which is a worker's row and not the sweep's business.
|
||||
await addOutbox('sending', daysAgo(90))
|
||||
|
||||
const result = await pool.query(PRUNE_OUTBOX, [daysAgo(30), 1000])
|
||||
assert.equal(Number(result.affectedRows), 4, 'exactly the four terminal rows')
|
||||
|
||||
const left = await pool.query('SELECT status FROM engagement_outbox ORDER BY status')
|
||||
assert.deepEqual(left.map((r) => r.status), ['scheduled', 'sending'])
|
||||
})
|
||||
|
||||
test('an in-window row is left alone whatever its status', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await clear()
|
||||
await addOutbox('sent', daysAgo(29))
|
||||
await addOutbox('sent', daysAgo(31))
|
||||
|
||||
const result = await pool.query(PRUNE_OUTBOX, [daysAgo(30), 1000])
|
||||
assert.equal(Number(result.affectedRows), 1)
|
||||
const [row] = await pool.query('SELECT created_at FROM engagement_outbox')
|
||||
assert.ok(row, 'the row inside the horizon survived')
|
||||
})
|
||||
|
||||
test('the send-log sweep leaves an in-window row alone', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await clear()
|
||||
const add = (createdAt) => pool.query(
|
||||
`INSERT INTO engagement_sends (rule_id, trigger_id, user_id, channel, status, created_at)
|
||||
VALUES (1, 't', 1, 'email', 'sent', ?)`,
|
||||
[createdAt],
|
||||
)
|
||||
await add(daysAgo(200))
|
||||
await add(daysAgo(179))
|
||||
// The row the per-rule hourly ceiling counts. If a horizon could reach this,
|
||||
// the ceiling would silently stop capping anything.
|
||||
await add(new Date(NOW.getTime() - 60 * 1000))
|
||||
|
||||
const result = await pool.query(PRUNE_SENDS, [daysAgo(180), 1000])
|
||||
assert.equal(Number(result.affectedRows), 1)
|
||||
const [{ n }] = await pool.query('SELECT COUNT(*) AS n FROM engagement_sends')
|
||||
assert.equal(Number(n), 2)
|
||||
})
|
||||
|
||||
test('the cooldown sweep respects its batch limit and repeats', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await clear()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await pool.query(
|
||||
`INSERT INTO engagement_cooldowns (rule_id, user_id, subject_key, channel, last_fired_at)
|
||||
VALUES (1, ?, '', 'email', ?)`,
|
||||
[i, daysAgo(90)],
|
||||
)
|
||||
}
|
||||
const first = await pool.query(PRUNE_COOLDOWNS, [daysAgo(30), 2])
|
||||
assert.equal(Number(first.affectedRows), 2, 'LIMIT bounds one statement')
|
||||
const second = await pool.query(PRUNE_COOLDOWNS, [daysAgo(30), 10])
|
||||
assert.equal(Number(second.affectedRows), 3, 'and the rest go on the next pass')
|
||||
})
|
||||
|
||||
// ── The bound the sweep depends on ─────────────────────────────────────────
|
||||
|
||||
test('a stale row that has burned its attempts is failed, not handed back', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await clear()
|
||||
// Two rows stranded in 'sending' by a crash between the claim and the outcome.
|
||||
// One has attempts left; the other has spent them, and before Phase 14 nothing
|
||||
// could ever move it — MAX_ATTEMPTS is consulted only on a graceful `retry`,
|
||||
// so it cycled sending → scheduled → sending forever, never became terminal,
|
||||
// and was therefore never eligible for any sweep.
|
||||
await addOutbox('sending', daysAgo(1), { updatedAt: daysAgo(1), attempts: 1 })
|
||||
await addOutbox('sending', daysAgo(1), { updatedAt: daysAgo(1), attempts: 5 })
|
||||
|
||||
const gaveUp = await pool.query(GIVE_UP, [new Date(NOW.getTime() - 15 * 60 * 1000), 5])
|
||||
assert.equal(Number(gaveUp.affectedRows), 1, 'only the exhausted row')
|
||||
const reclaimed = await pool.query(RECLAIM, [new Date(NOW.getTime() - 15 * 60 * 1000)])
|
||||
assert.equal(Number(reclaimed.affectedRows), 1, 'and only the other one comes back')
|
||||
|
||||
const rows = await pool.query('SELECT status, attempts FROM engagement_outbox ORDER BY attempts')
|
||||
assert.deepEqual(rows.map((r) => r.status), ['scheduled', 'failed'])
|
||||
|
||||
// And now it is terminal, so the sweep can bound it.
|
||||
await pool.query("UPDATE engagement_outbox SET created_at = ? WHERE status = 'failed'", [daysAgo(90)])
|
||||
const swept = await pool.query(PRUNE_OUTBOX, [daysAgo(30), 1000])
|
||||
assert.equal(Number(swept.affectedRows), 1)
|
||||
})
|
||||
|
||||
test('reclaiming before giving up would loop forever — the order is the fix', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await clear()
|
||||
await addOutbox('sending', daysAgo(1), { updatedAt: daysAgo(1), attempts: 5 })
|
||||
|
||||
// The wrong order, run deliberately: reclaim first, and the exhausted row is
|
||||
// back in 'scheduled' where findDue will pick it up again.
|
||||
await pool.query(RECLAIM, [new Date(NOW.getTime() - 15 * 60 * 1000)])
|
||||
const gaveUp = await pool.query(GIVE_UP, [new Date(NOW.getTime() - 15 * 60 * 1000), 5])
|
||||
assert.equal(Number(gaveUp.affectedRows), 0, 'nothing left in sending to fail')
|
||||
const [row] = await pool.query('SELECT status FROM engagement_outbox')
|
||||
assert.equal(row.status, 'scheduled', 'which is the forever-retry this ordering avoids')
|
||||
})
|
||||
|
||||
// ── The indexes ────────────────────────────────────────────────────────────
|
||||
|
||||
test('each sweep is an index range scan, not a table scan', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await clear()
|
||||
// A plan on an empty table is not worth reading, so give the optimizer rows.
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
await addOutbox(i % 2 ? 'sent' : 'scheduled', daysAgo(i))
|
||||
await pool.query(
|
||||
`INSERT INTO engagement_sends (rule_id, trigger_id, user_id, channel, status, created_at)
|
||||
VALUES (1, 't', 1, 'email', 'sent', ?)`,
|
||||
[daysAgo(i)],
|
||||
)
|
||||
}
|
||||
await pool.query('ANALYZE TABLE engagement_outbox')
|
||||
await pool.query('ANALYZE TABLE engagement_sends')
|
||||
|
||||
const outboxPlan = await pool.query(
|
||||
`EXPLAIN SELECT id FROM engagement_outbox
|
||||
WHERE status IN ('sent','failed','cancelled','suppressed') AND created_at < ?`,
|
||||
[daysAgo(30)],
|
||||
)
|
||||
assert.ok(
|
||||
String(outboxPlan[0].key || '').includes('idx_engo'),
|
||||
`expected an index, got ${JSON.stringify(outboxPlan[0])}`,
|
||||
)
|
||||
|
||||
const sendsPlan = await pool.query(
|
||||
'EXPLAIN SELECT id FROM engagement_sends WHERE created_at < ?',
|
||||
[daysAgo(180)],
|
||||
)
|
||||
assert.equal(
|
||||
sendsPlan[0].key,
|
||||
'idx_engs_sweep',
|
||||
`every other index on this table has created_at in SECOND position: ${JSON.stringify(sendsPlan[0])}`,
|
||||
)
|
||||
})
|
||||
367
server/test/moduleEngagementSeeds.test.js
Normal file
367
server/test/moduleEngagementSeeds.test.js
Normal file
@@ -0,0 +1,367 @@
|
||||
// ── 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 ───────────────────────
|
||||
|
||||
/**
|
||||
* The settings table, as far as these tests need it — and specifically its
|
||||
* ATOMICITY. `claim` is the real `INSERT IGNORE`: it decides and writes without
|
||||
* yielding, so exactly one caller can win a key, which is the property the guard
|
||||
* depends on. A fake that read with `get` and wrote later with `set` could never
|
||||
* express that, and agreed with the bug Phase 13's walk found.
|
||||
*/
|
||||
function fakeSettings(store = new Map()) {
|
||||
return {
|
||||
store,
|
||||
get: async (k) => store.get(k) || null,
|
||||
set: async (k, v) => { store.set(k, v) },
|
||||
claim: async (k, v) => {
|
||||
if (store.has(k)) return false
|
||||
store.set(k, v)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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('a normalized rule carries every column the insert reads', () => {
|
||||
// Refusing a bad ceiling and then DROPPING a good one are different bugs, and
|
||||
// the first test cannot see the second: `engagementRules.db.insert` binds a
|
||||
// fixed column list, so a field validated and not carried through arrives as
|
||||
// NULL and fails the whole group at boot — on a real database only. Asserted
|
||||
// against the column list itself rather than one field, because the next
|
||||
// field added to the declaration is the next one that can be forgotten here.
|
||||
assert.equal(trySeeds('demo', {
|
||||
templates: [tpl()],
|
||||
ruleGroups: [{ key: 'v1', rules: [rule({ delay_seconds: 60, cancel_on: ['demo.house.refreshed'] })] }],
|
||||
}), null)
|
||||
|
||||
const seeded = registries.engagementSeedsFor('demo').ruleGroups[0].rules[0]
|
||||
for (const column of [
|
||||
'trigger_id', 'name', 'enabled', 'audience', 'audience_segment_id', 'max_sends_per_hour',
|
||||
'channels', 'template_keys', 'conditions', 'cooldown_seconds', 'delay_seconds', 'cancel_on',
|
||||
'updated_by',
|
||||
]) {
|
||||
assert.ok(column in seeded, `normalized rule is missing "${column}"`)
|
||||
assert.notEqual(seeded[column], undefined, `normalized rule leaves "${column}" undefined`)
|
||||
}
|
||||
assert.equal(seeded.max_sends_per_hour, 200)
|
||||
assert.equal(seeded.delay_seconds, 60)
|
||||
assert.deepEqual(seeded.cancel_on, ['demo.house.refreshed'])
|
||||
})
|
||||
|
||||
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: fakeSettings(settings),
|
||||
}
|
||||
|
||||
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: fakeSettings(settings),
|
||||
})
|
||||
|
||||
assert.equal(inserts, 2)
|
||||
assert.ok(settings.has(moduleSeeds.guardKey('demo', 'v1')))
|
||||
})
|
||||
|
||||
test('two instances booting together seed the group once, not twice', async () => {
|
||||
// The defect Phase 13's acceptance walk found, and the reason the guard is a
|
||||
// CLAIM rather than a read followed by a write. `docker compose up
|
||||
// --scale app=2` and a rolling restart both start two instances on purpose;
|
||||
// under the old ordering both read "not seeded" before either stamped, and
|
||||
// both inserted the whole group. The walk ended up with 52 module rules where
|
||||
// the module ships 26 — and a duplicate rule is two mails per event.
|
||||
//
|
||||
// The two calls are deliberately NOT awaited in turn: interleaving them is the
|
||||
// whole test, and awaiting the first would pass against the bug.
|
||||
const seeds = registered('demo', {
|
||||
templates: [tpl()],
|
||||
ruleGroups: [{ key: 'v1', rules: [rule(), rule({ trigger_id: 'demo.house.gone', name: 'Gone' })] }],
|
||||
})
|
||||
|
||||
const settings = fakeSettings()
|
||||
const inserted = []
|
||||
const stub = {
|
||||
seeds,
|
||||
templatesDb: { seedOne: async () => 'inserted', staleCustomized: async () => [] },
|
||||
rulesDb: { insert: async (r) => { inserted.push(r.trigger_id) } },
|
||||
settingsDb: settings,
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
moduleSeeds.seedModuleEngagement({ ...stub }),
|
||||
moduleSeeds.seedModuleEngagement({ ...stub }),
|
||||
])
|
||||
|
||||
assert.deepEqual(inserted, ['demo.house.warning', 'demo.house.gone'])
|
||||
assert.ok(settings.store.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: fakeSettings(),
|
||||
})
|
||||
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: { claim: 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: fakeSettings(),
|
||||
})
|
||||
assert.equal(stored, 0)
|
||||
assert.equal(totals.templates, 0)
|
||||
})
|
||||
Reference in New Issue
Block a user