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 (
{(declared?.params || []).map((p) => ( ))} {canNegate && ( )}
) } // ── 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 (
{isNew ? 'New saved audience' : `Editing “${segment.name}”`}
{group.nodes.length === 0 && (

No audiences yet. A saved audience is built out of the lists installed modules declare.

)} {group.nodes.map((node, i) => { const negated = node.op === 'not' const leaf = negated ? node.nodes[0] : node return ( toggleNegate(i)} onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)} onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))} /> ) })} {!audiences.length && ( No module currently declares any. Install one, or use a plain audience on the rule itself. )}
{canNegate ? (

“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.

) : (

“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.

)} {(errors.length > 0 || localError) && ( )}
) } // ── 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 if (!segments) return if (editing) { return (
{ setEditing(null); await load() }} onCancel={() => setEditing(null)} />
) } return (

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.

{rowError && (

{rowError}

)}
{segments.length === 0 && ( )} {segments.map((s) => ( ))}
Name Made of Reaches at most Right now
No saved audiences yet.
{s.name} {s.dormant && (
Dormant
)}
{describeExpression(s.expression, audiencesById)} {s.ceiling} {reach[s.id] ? ( describeReach(reach[s.id]) ) : ( )}
What modules currently declare
{audiences.length === 0 ? (

Nothing. Audiences come from installed modules — core declares none, because core knows no game vocabulary.

) : (
    {audiences.map((a) => (
  • {a.label}{a.id}, reaches at most “{a.ceiling}” {(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''}
  • ))}
)}
) }