Driving the two screens in Chrome, after the API walk had already found the two
in Phase 4a's code. None of these is visible from a test or from curl.
Two cost an operator something real:
- The Audience dropdown rendered EMPTY before a trigger was chosen. There is
genuinely nothing it may offer without a ceiling, but a select with zero
options reads as broken rather than as waiting. It now says "Choose a
trigger first..." and is disabled.
- A `members` audience with no saved audience reaches NOBODY, and only the
preview button said so. That is the design, but it is also the default the
instant a members-ceiling trigger is picked - so the rule saves, gets
switched on, and mails nobody with nothing on screen saying so. The editor
now says it inline, and stands down once a preview has answered the same
question more precisely.
One the server was already refusing, just too late:
- The composer offered "exclude" on the only row, building an `and` whose
every child is a complement. The server refuses it correctly but only after
a save, and it is one checkbox away at all times. Now refused inline, in the
operator's words.
Three wording and layout:
- the template-key input truncated its placeholder, and said "optional until
Phase 5" - a sentence about the plan document, not about the deployment
- "segment" leaked into a screen that says "saved audience" everywhere else.
The API, schema and docs keep saying segment (one word for one table);
translated at the point of display only
- the composer repeated its AUDIENCE heading above every row
Client only - no server change, so swagger and the route manifest are untouched.
Client suite 316/316; all six verified in the browser after the fix.
- [x] AI-assisted: written with Claude Code (Opus)
Co-Authored-By: Claude <noreply@anthropic.com>
434 lines
18 KiB
JavaScript
434 lines
18 KiB
JavaScript
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, first }) {
|
|
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' }}>
|
|
{/* The heading belongs to the group, not to every line in it. */}
|
|
{first && <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}
|
|
first={i === 0}
|
|
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>
|
|
)
|
|
}
|