feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b)
The admin surface over the Phase 4a engine: two screens, twelve routes and the
reach preview. Nothing in the engine changed; what changed is that an operator
can now reach it.
Four decisions settled by the org lead before any code:
- segments get their OWN nav entry, "Audiences", not a tab of the rules screen
- the on/off switch is its own PATCH route, not a full PUT
- the reach preview is a count only, on demand
- a rule can be hard-deleted; the send log survives it
The switch is the one with real content in it. A PUT re-validates against the
registries as they are NOW, so the rules a re-validating toggle cannot switch
off are exactly the three an operator most wants stopped: a rule whose module
was uninstalled, one naming a channel that is gone, and one whose trigger has
since narrowed its ceiling under a saved audience. PATCH .../enabled writes one
column and always works. Switching ON unvalidated is safe because the engine
re-checks the ceiling at send time.
The preview calls the engine's own resolver rather than a second query that
agrees with it today, and answers a count and nothing else - the resolver's
output for a module-declared segment is a set of players derived from game data.
It reports `capped` at the 5000-row bound (the count is a floor, not a total),
`reason` for an `owner` audience (which resolves per event and has no advance
answer), and `permitted` so the editor cannot show a healthy number beside a
save the server will refuse.
Two defects found by walking it against a live server, both in Phase 4a's code:
1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked
only whether the segment ROW existed. The other shape of the same failure
is a segment sitting exactly where it was whose every audience belongs to
an uninstalled module: same outcome, nothing deleted. Uninstalling a module
under an enabled rule produced a rule the screen showed as on and firing.
The expression walk now lives in engagement/segments.js as
`missingAudiences` and both lists ask it.
2. "1 rule still use this segment" - the delete refusal pluralised the noun
and not the verb, in the sentence an operator reads when told no.
Also: a rule's trigger is now a stated rule rather than an omission in the
UPDATE statement (its cooldowns, queued sends and history are all about one
trigger id); a condition tree the editor cannot render is shown read-only rather
than flattened, because flattening changes which events fire the rule; and
literals are coerced client-side to the type the trigger declared, with anything
that does not parse passed through unchanged so the server's refusal names the
variable.
Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones
(client/test/engagementRules.test.js), all green. The single failure in the
server suite (`the committed manifest matches the declarations in the tree`) is
the known Windows CRLF artifact and fails identically on clean edge.
Companion docs PR: docs#184.
- [x] AI-assisted: written with Claude Code (Opus)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
628
client/src/routes/admin/views/EngagementRules.jsx
Normal file
628
client/src/routes/admin/views/EngagementRules.jsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
formFromRule,
|
||||
ruleToPayload,
|
||||
audienceChoicesFor,
|
||||
segmentChoicesFor,
|
||||
describeReach,
|
||||
describeRule,
|
||||
conditionRowsFrom,
|
||||
conditionsFromRows,
|
||||
operatorsForType,
|
||||
} from '../../../lib/engagementRules.js'
|
||||
|
||||
// Admin → Engagement → Rules (ENGAGEMENT.md Phase 4b).
|
||||
//
|
||||
// A rule is trigger → audience → channels → timing, and this is the screen that
|
||||
// writes one. Everything it decides lives in lib/engagementRules.js so it can be
|
||||
// tested; this file renders it and talks to the API.
|
||||
//
|
||||
// Four things about this screen are deliberate and would be wrong the obvious
|
||||
// way round:
|
||||
//
|
||||
// 1. **The on/off switch is not the form.** It is its own request against its
|
||||
// own route, and it does not re-validate the rule. A rule whose module has
|
||||
// been uninstalled is dormant, is the rule an operator most wants stopped,
|
||||
// and is exactly the rule the form would refuse to save.
|
||||
// 2. **A rule's trigger is fixed once it exists.** Its cooldowns, its pending
|
||||
// outbox rows and its send-log history are all about one trigger id.
|
||||
// 3. **Every rule arrives off.** §7.1 Q3 makes rules operator-editable data on
|
||||
// the condition that nothing starts mailing by itself — so a new rule is
|
||||
// created disabled and switched on afterwards, as a separate act.
|
||||
// 4. **The reach preview is a number.** Never a list of people: a
|
||||
// module-declared segment resolves over game data, and this screen is about
|
||||
// mail scheduling.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
const BLANK = {
|
||||
id: null,
|
||||
triggerId: '',
|
||||
name: '',
|
||||
enabled: false,
|
||||
audience: 'owner',
|
||||
audienceSegmentId: null,
|
||||
channels: [],
|
||||
templateKeys: {},
|
||||
conditions: null,
|
||||
cooldownSeconds: 0,
|
||||
delaySeconds: 0,
|
||||
cancelOn: [],
|
||||
maxSendsPerHour: 100,
|
||||
}
|
||||
|
||||
function Dormant({ reasons }) {
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
title={reasons.join('\n')}
|
||||
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
Dormant
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
|
||||
const [form, setForm] = useState(() => (rule ? formFromRule(rule) : { ...BLANK }))
|
||||
const [conditionState, setConditionState] = useState(() => conditionRowsFrom(rule?.conditions))
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isNew = !form.id
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const trigger = useMemo(
|
||||
() => catalog.triggers.find((t) => t.id === form.triggerId) || null,
|
||||
[catalog.triggers, form.triggerId],
|
||||
)
|
||||
const audienceChoices = audienceChoicesFor(trigger, catalog.ceilings)
|
||||
const segmentChoices = segmentChoicesFor(trigger, catalog.ceilings, segments)
|
||||
const variables = trigger?.variables || []
|
||||
|
||||
// Changing the trigger invalidates the audience and every condition, because
|
||||
// both are stated in the old trigger's vocabulary. Clearing them is the honest
|
||||
// move: keeping a condition on a variable the new trigger never carries would
|
||||
// make the rule fire on nothing, silently (an absent variable fails every
|
||||
// comparison, by design).
|
||||
function pickTrigger(id) {
|
||||
const next = catalog.triggers.find((t) => t.id === id)
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
triggerId: id,
|
||||
audience: next?.audience || 'owner',
|
||||
audienceSegmentId: null,
|
||||
}))
|
||||
setConditionState({ op: 'and', rows: [], editable: true })
|
||||
setPreview(null)
|
||||
}
|
||||
|
||||
function toggleChannel(id) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
channels: f.channels.includes(id) ? f.channels.filter((c) => c !== id) : [...f.channels, id],
|
||||
}))
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
setPreviewing(true)
|
||||
try {
|
||||
setPreview(
|
||||
await api.admin.previewEngagementReach({
|
||||
audience: form.audience,
|
||||
audienceSegmentId: form.audienceSegmentId,
|
||||
triggerId: form.triggerId,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
setPreview({ count: 0, dormant: true, reason: err.message || 'could not be resolved' })
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
setBusy(true)
|
||||
const payload = ruleToPayload({
|
||||
...form,
|
||||
conditions: conditionState.editable
|
||||
? conditionsFromRows(conditionState.op, conditionState.rows, variables)
|
||||
: form.conditions,
|
||||
})
|
||||
try {
|
||||
if (isNew) await api.admin.createEngagementRule(payload)
|
||||
else await api.admin.updateEngagementRule(form.id, payload)
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
// The server sends every problem, not just the first. A form that shows one
|
||||
// makes an operator fix four things in four round trips.
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save the rule.'])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<div className="field-label" style={{ marginBottom: 14 }}>
|
||||
{isNew ? 'New rule' : `Editing “${rule.name}”`}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
{isNew ? (
|
||||
<select className="select" value={form.triggerId} onChange={(e) => pickTrigger(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{catalog.triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label} ({t.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" value={form.triggerId} readOnly disabled />
|
||||
)}
|
||||
{!isNew && (
|
||||
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||||
A rule keeps its trigger — its cooldowns, queued sends and history are all about this one.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
placeholder="IDOC warning to the owner"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{trigger?.description && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--muted)' }}>
|
||||
{trigger.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Audience ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Who it reaches</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">Audience</span>
|
||||
<select
|
||||
className="select"
|
||||
value={form.audienceSegmentId ? '' : form.audience}
|
||||
disabled={Boolean(form.audienceSegmentId)}
|
||||
onChange={(e) => { set({ audience: e.target.value, audienceSegmentId: null }); setPreview(null) }}
|
||||
>
|
||||
{audienceChoices.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">…or a saved audience</span>
|
||||
<select
|
||||
className="select"
|
||||
value={form.audienceSegmentId || ''}
|
||||
onChange={(e) => {
|
||||
set({ audienceSegmentId: e.target.value ? Number(e.target.value) : null })
|
||||
setPreview(null)
|
||||
}}
|
||||
>
|
||||
<option value="">None — use the audience on the left</option>
|
||||
{segmentChoices.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-sq" disabled={previewing || !form.triggerId} onClick={runPreview}>
|
||||
{previewing ? 'Counting…' : 'Preview reach'}
|
||||
</button>
|
||||
</div>
|
||||
{preview && (
|
||||
<p
|
||||
className="sans"
|
||||
style={{
|
||||
margin: '10px 0 0',
|
||||
fontSize: '0.84rem',
|
||||
color: preview.permitted === false || preview.dormant ? '#d98b84' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{describeReach(preview)}
|
||||
</p>
|
||||
)}
|
||||
{trigger && audienceChoices.length <= 1 && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
This event only permits “{trigger.ceiling}”. The audience a rule may use is capped by the
|
||||
event itself, not by the rule.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Channels ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>How it is delivered</div>
|
||||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||||
{catalog.channels.map((c) => (
|
||||
<div key={c.id} style={{ flex: '0 1 260px' }}>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.channels.includes(c.id)} onChange={() => toggleChannel(c.id)} />
|
||||
{c.label}
|
||||
</label>
|
||||
{form.channels.includes(c.id) && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ marginTop: 6 }}
|
||||
placeholder="template key (optional until Phase 5)"
|
||||
value={form.templateKeys[c.id] || ''}
|
||||
onChange={(e) => set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
Every channel is opt-in: a rule reaches only the people who turned that channel on for this
|
||||
event in their own notification settings.
|
||||
</p>
|
||||
|
||||
{/* ── Conditions ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Only when…</div>
|
||||
{!conditionState.editable ? (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||||
This rule has a nested condition this editor does not render. It is left exactly as it is
|
||||
unless you clear it — flattening it here would change which events fire the rule.
|
||||
</p>
|
||||
<pre
|
||||
style={{ background: 'var(--panel-flat)', border: '1px solid var(--line)', borderRadius: 6, padding: 10, fontSize: '0.76rem', overflowX: 'auto' }}
|
||||
>
|
||||
{JSON.stringify(form.conditions, null, 2)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => { set({ conditions: null }); setConditionState({ op: 'and', rows: [], editable: true }) }}
|
||||
>
|
||||
Clear and start again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{conditionState.rows.length > 1 && (
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<span className="field-label">Match</span>
|
||||
<select
|
||||
className="select"
|
||||
style={{ maxWidth: 220 }}
|
||||
value={conditionState.op}
|
||||
onChange={(e) => setConditionState((s) => ({ ...s, op: e.target.value }))}
|
||||
>
|
||||
<option value="and">all of these</option>
|
||||
<option value="or">any of these</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{conditionState.rows.map((row, i) => {
|
||||
const type = variables.find((v) => v.name === row.variable)?.type
|
||||
const ops = operatorsForType(catalog.operators, type)
|
||||
const takesValue = row.cmp !== 'present' && row.cmp !== 'absent'
|
||||
const patch = (p) =>
|
||||
setConditionState((s) => ({
|
||||
...s,
|
||||
rows: s.rows.map((r, j) => (i === j ? { ...r, ...p } : r)),
|
||||
}))
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<select
|
||||
className="select"
|
||||
style={{ flex: '1 1 160px' }}
|
||||
value={row.variable}
|
||||
onChange={(e) => patch({ variable: e.target.value })}
|
||||
>
|
||||
<option value="">Variable…</option>
|
||||
{variables.map((v) => (
|
||||
<option key={v.name} value={v.name}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="select"
|
||||
style={{ flex: '1 1 160px' }}
|
||||
value={row.cmp}
|
||||
onChange={(e) => patch({ cmp: e.target.value })}
|
||||
>
|
||||
<option value="">Is…</option>
|
||||
{ops.map((o) => (
|
||||
<option key={o.cmp} value={o.cmp}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{takesValue && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: '2 1 200px' }}
|
||||
value={row.value}
|
||||
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'comma, separated, values' : 'value'}
|
||||
onChange={(e) => patch({ value: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => setConditionState((s) => ({ ...s, rows: s.rows.filter((_, j) => j !== i) }))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={!variables.length}
|
||||
onClick={() =>
|
||||
setConditionState((s) => ({ ...s, rows: [...s.rows, { variable: '', cmp: '', value: '' }] }))
|
||||
}
|
||||
>
|
||||
Add a condition
|
||||
</button>
|
||||
{!variables.length && (
|
||||
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
Choose a trigger first — its declared variables are what a condition can talk about.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Timing and the ceiling ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Timing</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">Wait before sending (seconds)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.delaySeconds}
|
||||
onChange={(e) => set({ delaySeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">At most once per (seconds)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.cooldownSeconds}
|
||||
onChange={(e) => set({ cooldownSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">Hard cap (sends per hour)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.maxSendsPerHour}
|
||||
onChange={(e) => set({ maxSendsPerHour: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
The cooldown is per recipient and per subject
|
||||
{trigger?.subjectKey ? ` (“${trigger.subjectKey}”)` : ''} — a player whose four houses are all
|
||||
decaying hears about all four, once each. The hourly cap is per rule and is the hard stop that
|
||||
keeps a misconfiguration to a bad hour.
|
||||
</p>
|
||||
|
||||
{form.delaySeconds > 0 && (
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<span className="field-label">Cancel the wait if any of these happen</span>
|
||||
<select
|
||||
className="select"
|
||||
multiple
|
||||
size={Math.min(5, Math.max(2, catalog.triggers.length))}
|
||||
value={form.cancelOn}
|
||||
onChange={(e) => set({ cancelOn: [...e.target.selectedOptions].map((o) => o.value) })}
|
||||
>
|
||||
{catalog.triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||||
Only meaningful with a wait — there is no window to cancel otherwise, and the save says so.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
|
||||
{errors.map((e) => <li key={e}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{busy ? 'Saving…' : isNew ? 'Create rule (off)' : 'Save changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
{isNew && (
|
||||
<span className="sans" style={{ alignSelf: 'center', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
A new rule is created switched off. Turn it on from the list when you are happy with it.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EngagementRules() {
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [segments, setSegments] = useState([])
|
||||
const [rules, setRules] = useState(null)
|
||||
const [editing, setEditing] = useState(null) // null | { rule } | { rule: null } for new
|
||||
const [error, setError] = useState('')
|
||||
const [rowError, setRowError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [triggers, channels, segs, list] = await Promise.all([
|
||||
api.admin.engagementTriggers(),
|
||||
api.admin.engagementChannels(),
|
||||
api.admin.listEngagementSegments(),
|
||||
api.admin.listEngagementRules(),
|
||||
])
|
||||
setCatalog({
|
||||
triggers: triggers.triggers || [],
|
||||
ceilings: triggers.ceilings || [],
|
||||
operators: triggers.operators || [],
|
||||
channels: channels.channels || [],
|
||||
})
|
||||
setSegments(segs.segments || [])
|
||||
setRules(list.rules || [])
|
||||
} catch {
|
||||
setError('Could not load the engagement rules.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const segmentsById = useMemo(
|
||||
() => Object.fromEntries(segments.map((s) => [s.id, s])),
|
||||
[segments],
|
||||
)
|
||||
|
||||
async function toggle(rule) {
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.setEngagementRuleEnabled(rule.id, !rule.enabled)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.message || 'Could not change that rule.')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(rule) {
|
||||
if (!window.confirm(`Delete “${rule.name}”? Its queued sends go with it; the send log does not.`)) return
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.deleteEngagementRule(rule.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.message || 'Could not delete that rule.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!catalog || !rules) return <Loading />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<section>
|
||||
<RuleEditor
|
||||
catalog={catalog}
|
||||
segments={segments}
|
||||
rule={editing.rule}
|
||||
onSaved={async () => { setEditing(null); await load() }}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
|
||||
A rule turns an event into mail: which event, who hears about it, on which channels, and how
|
||||
often at most. Nothing sends until a rule is switched on.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ rule: null })}>
|
||||
New rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Rule</th>
|
||||
<th className="adm-th">Trigger</th>
|
||||
<th className="adm-th">What it does</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No rules yet. Nothing is being sent.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>{rule.name}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{rule.trigger_id}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{describeRule(rule, { segmentsById })}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={Boolean(rule.enabled)} onChange={() => toggle(rule)} />
|
||||
{rule.enabled ? 'On' : 'Off'}
|
||||
</label>
|
||||
{rule.dormant && (
|
||||
<div style={{ marginTop: 4 }}><Dormant reasons={rule.dormantReasons || []} /></div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => setEditing({ rule })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => remove(rule)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rules.some((r) => r.dormant) && (
|
||||
<p className="sans" style={{ marginTop: 12, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
A dormant rule names something that is not registered right now — usually a module that has
|
||||
been uninstalled. It is kept exactly as it is, it never fires, and it starts working again
|
||||
when the module comes back. It can still be switched off.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user