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:
431
client/src/routes/admin/views/EngagementAudiences.jsx
Normal file
431
client/src/routes/admin/views/EngagementAudiences.jsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { describeExpression, describeReach, notPlacementError } from '../../../lib/engagementRules.js'
|
||||
|
||||
// Admin → Engagement → Audiences (ENGAGEMENT.md §5.1a, Phase 4b).
|
||||
//
|
||||
// A module declares named sets of users over its own data — "members of a team",
|
||||
// "the governors" — and an operator combines them here into a saved audience a
|
||||
// rule can point at. Core learns no game vocabulary: it knows an id, a label and
|
||||
// a resolver it may call.
|
||||
//
|
||||
// **Composition narrows and never widens**, and that is the whole security
|
||||
// content of this screen:
|
||||
//
|
||||
// • the saved ceiling is DERIVED from the tightest audience in the expression,
|
||||
// not chosen — including for "any of", where the intuitive answer (the widest
|
||||
// of the two) is the wrong one. A ceiling says what an expression is allowed
|
||||
// to reach, not what it will resolve to, so the boolean operator makes no
|
||||
// difference to it.
|
||||
// • two ceilings with no ordering between them (staff and owner, say) have no
|
||||
// answer at all, and the save is refused rather than guessing a side.
|
||||
// • "none of" is only available inside an "all of" group. On its own it would
|
||||
// have to mean "everyone except…" — a broadcast built out of one narrow list.
|
||||
// The composer does not offer it anywhere else, and the server refuses it
|
||||
// anyway.
|
||||
//
|
||||
// The three-level composer here is deliberate: one top-level all-of/any-of, one
|
||||
// level of groups inside it, and audiences at the leaves. The stored grammar
|
||||
// allows more nesting; anything deeper is left to the rule that made it and shown
|
||||
// read-only, the same way the rule editor treats a nested condition.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
|
||||
/** A fresh, empty top-level group. */
|
||||
const blankExpression = () => ({ op: 'and', nodes: [] })
|
||||
|
||||
/** Is this tree one the composer can render — a single group of leaves and not-groups? */
|
||||
function isComposable(node) {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
if (!node.op) return true
|
||||
if (node.op === 'not') return (node.nodes || []).every((n) => n && !n.op)
|
||||
if (node.op !== 'and' && node.op !== 'or') return false
|
||||
return (node.nodes || []).every((n) => n && (!n.op || (n.op === 'not' && (n.nodes || []).every((c) => !c.op))))
|
||||
}
|
||||
|
||||
/** The composer edits a top-level group; a bare leaf is lifted into one. */
|
||||
const toGroup = (expression) =>
|
||||
!expression ? blankExpression() : expression.op ? expression : { op: 'and', nodes: [expression] }
|
||||
|
||||
// ── One leaf: an audience and its declared parameters ──────────────────────
|
||||
|
||||
function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate }) {
|
||||
const declared = audiences.find((a) => a.id === node.audienceId)
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Audience</span>
|
||||
<select
|
||||
className="select"
|
||||
value={node.audienceId || ''}
|
||||
onChange={(e) => onChange({ audienceId: e.target.value, params: {} })}
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
{audiences.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.label} — reaches at most “{a.ceiling}”</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{(declared?.params || []).map((p) => (
|
||||
<label key={p.id} style={{ flex: '0 1 160px' }}>
|
||||
<span className="field-label">{p.id}{p.required ? ' *' : ''}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={node.params?.[p.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...node,
|
||||
params: {
|
||||
...node.params,
|
||||
// `int` params are sent as numbers: the server type-checks each
|
||||
// declared param, and "3" against an int is a refusal.
|
||||
[p.id]: p.type === 'int' && e.target.value !== '' ? Number(e.target.value) : e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{canNegate && (
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, paddingBottom: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={negated} onChange={onToggleNegate} />
|
||||
exclude
|
||||
</label>
|
||||
)}
|
||||
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.72rem', marginBottom: 6 }} onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The composer ───────────────────────────────────────────────────────────
|
||||
|
||||
function SegmentEditor({ audiences, segment, onSaved, onCancel }) {
|
||||
const [name, setName] = useState(segment?.name || '')
|
||||
const [group, setGroup] = useState(() => toGroup(segment?.expression))
|
||||
const [errors, setErrors] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isNew = !segment
|
||||
|
||||
// `not` is only offered under "all of" (§5.1a). Under "any of" the checkbox
|
||||
// disappears rather than being offered and refused.
|
||||
const canNegate = group.op === 'and'
|
||||
|
||||
function setNodes(nodes) {
|
||||
setGroup((g) => ({ ...g, nodes }))
|
||||
}
|
||||
|
||||
function addLeaf() {
|
||||
setNodes([...group.nodes, { audienceId: '', params: {} }])
|
||||
}
|
||||
|
||||
function replaceAt(i, next) {
|
||||
setNodes(group.nodes.map((n, j) => (i === j ? next : n)))
|
||||
}
|
||||
|
||||
function toggleNegate(i) {
|
||||
const node = group.nodes[i]
|
||||
replaceAt(i, node.op === 'not' ? node.nodes[0] : { op: 'not', nodes: [node] })
|
||||
}
|
||||
|
||||
function changeOp(op) {
|
||||
// Switching to "any of" drops the exclusions rather than sending a tree the
|
||||
// server will refuse — and says so, because silently keeping them and failing
|
||||
// at save would be worse than either.
|
||||
const nodes = op === 'or' ? group.nodes.map((n) => (n.op === 'not' ? n.nodes[0] : n)) : group.nodes
|
||||
setGroup({ op, nodes })
|
||||
}
|
||||
|
||||
const expression = useMemo(() => {
|
||||
const nodes = group.nodes.filter((n) => (n.op === 'not' ? n.nodes[0]?.audienceId : n.audienceId))
|
||||
if (!nodes.length) return null
|
||||
if (nodes.length === 1 && !nodes[0].op) return nodes[0]
|
||||
return { op: group.op, nodes }
|
||||
}, [group])
|
||||
|
||||
const localError = expression ? notPlacementError(expression) : null
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
if (!expression) return setErrors(['Add at least one audience.'])
|
||||
if (localError) return setErrors([localError])
|
||||
setBusy(true)
|
||||
try {
|
||||
const body = { name: name.trim(), expression }
|
||||
if (isNew) await api.admin.createEngagementSegment(body)
|
||||
else await api.admin.updateEngagementSegment(segment.id, body)
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save that audience.'])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<div className="field-label" style={{ marginBottom: 14 }}>
|
||||
{isNew ? 'New saved audience' : `Editing “${segment.name}”`}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Governors" />
|
||||
</label>
|
||||
<label style={{ flex: '0 1 200px' }}>
|
||||
<span className="field-label">Combine with</span>
|
||||
<select className="select" value={group.op} onChange={(e) => changeOp(e.target.value)}>
|
||||
<option value="and">all of these</option>
|
||||
<option value="or">any of these</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
{group.nodes.length === 0 && (
|
||||
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
No audiences yet. A saved audience is built out of the lists installed modules declare.
|
||||
</p>
|
||||
)}
|
||||
{group.nodes.map((node, i) => {
|
||||
const negated = node.op === 'not'
|
||||
const leaf = negated ? node.nodes[0] : node
|
||||
return (
|
||||
<LeafRow
|
||||
key={i}
|
||||
audiences={audiences}
|
||||
node={leaf}
|
||||
negated={negated}
|
||||
canNegate={canNegate}
|
||||
onToggleNegate={() => toggleNegate(i)}
|
||||
onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)}
|
||||
onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<button type="button" className="btn btn-sq" onClick={addLeaf} disabled={!audiences.length}>
|
||||
Add an audience
|
||||
</button>
|
||||
{!audiences.length && (
|
||||
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
No module currently declares any. Install one, or use a plain audience on the rule itself.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canNegate ? (
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
“Exclude” removes people from what the other rows produced. It is only available under “all
|
||||
of”: on its own it would mean “everyone except…”, which is a way to reach the whole
|
||||
deployment from one narrow list.
|
||||
</p>
|
||||
) : (
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
“Any of” takes the tightest limit of the audiences in it, not the widest — combining two
|
||||
lists never reaches further than the narrower one allows.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(errors.length > 0 || localError) && (
|
||||
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
|
||||
{(errors.length ? errors : [localError]).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' : 'Save changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EngagementAudiences() {
|
||||
const [audiences, setAudiences] = useState([])
|
||||
const [segments, setSegments] = useState(null)
|
||||
const [editing, setEditing] = useState(null) // null | { segment } | { segment: null }
|
||||
const [error, setError] = useState('')
|
||||
const [rowError, setRowError] = useState('')
|
||||
const [reach, setReach] = useState({}) // segment id -> preview
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [declared, saved] = await Promise.all([
|
||||
api.admin.engagementAudiences(),
|
||||
api.admin.listEngagementSegments(),
|
||||
])
|
||||
setAudiences(declared.audiences || [])
|
||||
setSegments(saved.segments || [])
|
||||
} catch {
|
||||
setError('Could not load audiences.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const audiencesById = useMemo(
|
||||
() => Object.fromEntries(audiences.map((a) => [a.id, a])),
|
||||
[audiences],
|
||||
)
|
||||
|
||||
async function preview(segment) {
|
||||
try {
|
||||
const counted = await api.admin.previewEngagementReach({ audienceSegmentId: segment.id })
|
||||
setReach((r) => ({ ...r, [segment.id]: counted }))
|
||||
} catch (err) {
|
||||
setReach((r) => ({ ...r, [segment.id]: { count: 0, dormant: true, reason: err.message } }))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(segment) {
|
||||
if (!window.confirm(`Delete “${segment.name}”?`)) return
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.deleteEngagementSegment(segment.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
// A 409 here is the interesting case and the message carries the count:
|
||||
// deleting a segment a rule still points at would leave that rule reaching
|
||||
// a different set of people, so it is refused rather than cascaded.
|
||||
setRowError(err.message || 'Could not delete that audience.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!segments) return <Loading />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<section>
|
||||
<SegmentEditor
|
||||
audiences={audiences}
|
||||
segment={editing.segment}
|
||||
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 }}>
|
||||
Named sets of people a rule can be pointed at, built out of the lists installed modules
|
||||
declare. A saved audience can only ever narrow — combining two lists never reaches further
|
||||
than the tighter of them allows.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ segment: null })}>
|
||||
New audience
|
||||
</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">Name</th>
|
||||
<th className="adm-th">Made of</th>
|
||||
<th className="adm-th">Reaches at most</th>
|
||||
<th className="adm-th">Right now</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{segments.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No saved audiences yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{segments.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>
|
||||
{s.name}
|
||||
{s.dormant && (
|
||||
<div>
|
||||
<span
|
||||
className="badge"
|
||||
title={`Not declared right now: ${(s.missingAudiences || []).join(', ')}`}
|
||||
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
Dormant
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{describeExpression(s.expression, audiencesById)}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{s.ceiling}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{reach[s.id] ? (
|
||||
describeReach(reach[s.id])
|
||||
) : (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => preview(s)}>
|
||||
Count
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
disabled={!isComposable(s.expression)}
|
||||
title={isComposable(s.expression) ? undefined : 'Nested more deeply than this composer renders'}
|
||||
onClick={() => setEditing({ segment: s })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => remove(s)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>What modules currently declare</div>
|
||||
{audiences.length === 0 ? (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
Nothing. Audiences come from installed modules — core declares none, because core knows no
|
||||
game vocabulary.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{audiences.map((a) => (
|
||||
<li key={a.id}>
|
||||
<span style={{ color: 'var(--text)' }}>{a.label}</span> — <code>{a.id}</code>, reaches at
|
||||
most “{a.ceiling}”
|
||||
{(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user