feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 10m36s

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:
2026-08-29 12:10:04 -05:00
parent 4d3f574480
commit 4b45eddb5d
17 changed files with 3777 additions and 30 deletions

View 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>
)
}