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:
298
client/src/lib/engagementRules.js
Normal file
298
client/src/lib/engagementRules.js
Normal file
@@ -0,0 +1,298 @@
|
||||
// What the Engagement screens say, and what they let an operator choose.
|
||||
//
|
||||
// ENGAGEMENT.md Phase 4b. Plain JS in its own file for the reason
|
||||
// `lib/moduleAdmin.js` is: it is the part of these two screens worth testing, and
|
||||
// the test runner cannot reach a `.jsx`.
|
||||
//
|
||||
// **None of this is a boundary.** `engagementRules.model.js` on the server
|
||||
// decides what may be saved, and the engine re-checks the audience ceiling again
|
||||
// at send time. Everything here is an affordance — not offering a choice the
|
||||
// server is going to refuse, and saying why in the form rather than in a toast.
|
||||
// The two copies are expected to drift, which is why the server's is the one
|
||||
// that decides.
|
||||
//
|
||||
// The one rule worth stating out loud, because it is the reason the audience
|
||||
// list is derived rather than hardcoded: **the ceiling vocabulary comes from the
|
||||
// server** (`GET /admin/engagement/triggers` serves `ceilings`, each with the set
|
||||
// it `permits`). A second copy of the lattice in the client would be a second
|
||||
// copy of a security rule, and a second copy is a copy that drifts.
|
||||
|
||||
/** A rule row as the API returns it → the shape the form edits. */
|
||||
export function formFromRule(rule) {
|
||||
return {
|
||||
id: rule?.id ?? null,
|
||||
triggerId: rule?.trigger_id ?? '',
|
||||
name: rule?.name ?? '',
|
||||
enabled: Boolean(rule?.enabled),
|
||||
audience: rule?.audience ?? 'owner',
|
||||
audienceSegmentId: rule?.audience_segment_id ?? null,
|
||||
channels: Array.isArray(rule?.channels) ? [...rule.channels] : [],
|
||||
templateKeys: { ...(rule?.template_keys || {}) },
|
||||
conditions: rule?.conditions ?? null,
|
||||
cooldownSeconds: Number(rule?.cooldown_seconds ?? 0),
|
||||
delaySeconds: Number(rule?.delay_seconds ?? 0),
|
||||
cancelOn: Array.isArray(rule?.cancel_on) ? [...rule.cancel_on] : [],
|
||||
maxSendsPerHour: Number(rule?.max_sends_per_hour ?? 100),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The form → a POST/PUT body.
|
||||
*
|
||||
* `templateKeys` is filtered to the rule's channels rather than sent whole,
|
||||
* because unticking a channel in the form leaves its template key behind and the
|
||||
* server refuses a key naming a channel the rule does not have. Dropping it here
|
||||
* makes unticking a channel do the obvious thing instead of producing an error
|
||||
* about a field the operator cannot see.
|
||||
*/
|
||||
export function ruleToPayload(form) {
|
||||
const channels = [...new Set(form.channels || [])]
|
||||
const templateKeys = {}
|
||||
for (const channel of channels) {
|
||||
const key = (form.templateKeys || {})[channel]
|
||||
if (key) templateKeys[channel] = key
|
||||
}
|
||||
return {
|
||||
triggerId: form.triggerId,
|
||||
name: (form.name || '').trim(),
|
||||
enabled: Boolean(form.enabled),
|
||||
audience: form.audience,
|
||||
audienceSegmentId: form.audienceSegmentId ?? null,
|
||||
channels,
|
||||
templateKeys,
|
||||
conditions: form.conditions ?? null,
|
||||
cooldownSeconds: Number(form.cooldownSeconds) || 0,
|
||||
delaySeconds: Number(form.delaySeconds) || 0,
|
||||
cancelOn: [...new Set(form.cancelOn || [])],
|
||||
maxSendsPerHour: Number(form.maxSendsPerHour) || 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which plain audiences this trigger's ceiling allows, in lattice order.
|
||||
*
|
||||
* Derived from the `permits` list the server sends with each ceiling, so a
|
||||
* trigger declared `owner` offers only `owner` and the editor never presents a
|
||||
* choice the save is going to refuse. An unknown trigger (a dormant rule whose
|
||||
* module is gone) offers nothing rather than everything — failing closed is the
|
||||
* same posture `ceilings.permits` takes on the server.
|
||||
*/
|
||||
export function audienceChoicesFor(trigger, ceilings) {
|
||||
if (!trigger || !Array.isArray(ceilings)) return []
|
||||
const declared = ceilings.find((c) => c.id === trigger.ceiling)
|
||||
if (!declared) return []
|
||||
const allowed = new Set(declared.permits || [])
|
||||
return ceilings.filter((c) => allowed.has(c.id))
|
||||
}
|
||||
|
||||
/** Segments a rule under this trigger may point at — the same test, on the stored ceiling. */
|
||||
export function segmentChoicesFor(trigger, ceilings, segments) {
|
||||
const allowed = new Set(audienceChoicesFor(trigger, ceilings).map((c) => c.id))
|
||||
return (segments || []).filter((s) => allowed.has(s.ceiling))
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence rendered beside a reach preview.
|
||||
*
|
||||
* Every branch here exists because the bare number would be a lie in that case:
|
||||
* a capped count is a floor, an `owner` audience has no advance answer, a dormant
|
||||
* segment resolves to nobody for a reason worth naming, and a count the trigger's
|
||||
* ceiling forbids is a number the save is about to refuse.
|
||||
*/
|
||||
export function describeReach(preview) {
|
||||
if (!preview) return ''
|
||||
if (preview.dormant) return `Resolves to nobody right now — ${preview.reason || 'dormant'}.`
|
||||
if (preview.permitted === false) {
|
||||
return `Reaches ${preview.count}, but this trigger does not permit that audience — saving will be refused.`
|
||||
}
|
||||
if (preview.reason) return `${preview.count} right now — ${preview.reason}.`
|
||||
if (preview.capped) return `At least ${preview.count} people (the preview stops counting there).`
|
||||
return preview.count === 1 ? '1 person right now.' : `${preview.count} people right now.`
|
||||
}
|
||||
|
||||
// ── Segment expressions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `not` is legal only as a child of `and` — the server's rule, checked here so
|
||||
* the composer can grey the button out instead of letting the operator build
|
||||
* something and then be refused.
|
||||
*
|
||||
* The reason, from §5.1a: a complement needs a universe, and the only one that
|
||||
* does not widen is the set its siblings produced. `A AND NOT B` is "A, less B".
|
||||
* A bare `NOT B`, or `A OR NOT B`, would have to mean "everyone except…", which
|
||||
* is a way to build the whole deployment out of one narrow audience.
|
||||
*/
|
||||
export function notPlacementError(expression) {
|
||||
const walk = (node, underAnd) => {
|
||||
if (!node || typeof node !== 'object') return null
|
||||
if (!node.op) return null
|
||||
if (node.op === 'not' && !underAnd) {
|
||||
return 'A "not" can only be used inside an "all of" group — on its own it would mean "everyone except…".'
|
||||
}
|
||||
for (const child of node.nodes || []) {
|
||||
const err = walk(child, node.op === 'and')
|
||||
if (err) return err
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(expression, false)
|
||||
}
|
||||
|
||||
/** A one-line summary of a segment expression, for the list. */
|
||||
export function describeExpression(node, audiencesById = {}) {
|
||||
if (!node || typeof node !== 'object') return '—'
|
||||
if (!node.op) {
|
||||
const label = audiencesById[node.audienceId]?.label || node.audienceId
|
||||
const params = Object.entries(node.params || {})
|
||||
return params.length ? `${label} (${params.map(([k, v]) => `${k}: ${v}`).join(', ')})` : label
|
||||
}
|
||||
const parts = (node.nodes || []).map((n) => describeExpression(n, audiencesById))
|
||||
if (node.op === 'not') return `not ${parts.join(', ')}`
|
||||
return parts.join(node.op === 'and' ? ' and ' : ' or ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary of a rule, for the list.
|
||||
*
|
||||
* `dormant` is deliberately not folded in here — the list renders that as its own
|
||||
* badge, because "this rule cannot fire" is a different fact from "this is what
|
||||
* the rule says" and an operator needs both.
|
||||
*/
|
||||
export function describeRule(rule, { segmentsById = {} } = {}) {
|
||||
const parts = []
|
||||
const audience = rule.audience_segment_id
|
||||
? segmentsById[rule.audience_segment_id]?.name || `segment ${rule.audience_segment_id}`
|
||||
: rule.audience
|
||||
parts.push(`to ${audience}`)
|
||||
parts.push(`via ${(rule.channels || []).join(', ') || 'no channel'}`)
|
||||
if (rule.delay_seconds) parts.push(`after ${humanSeconds(rule.delay_seconds)}`)
|
||||
if (rule.cooldown_seconds) parts.push(`at most once per ${humanSeconds(rule.cooldown_seconds)}`)
|
||||
parts.push(`≤ ${rule.max_sends_per_hour}/hour`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The stored grammar is and/or/not over comparisons; the editor offers the flat
|
||||
// half of it — one and/or over a list of comparisons — because that is what a
|
||||
// dropdown-per-operator can render honestly and it covers the rules anyone
|
||||
// writes by hand.
|
||||
//
|
||||
// **A tree the editor cannot render is shown, not silently flattened.**
|
||||
// Flattening `A AND (B OR C)` into `A AND B AND C` changes which events fire the
|
||||
// rule, and the operator would have no way to know the save had done it. Such a
|
||||
// rule opens read-only with its JSON visible and one honest choice: leave it, or
|
||||
// clear it and start again.
|
||||
|
||||
/** Which comparison operators apply to a variable of this declared type? */
|
||||
export function operatorsForType(operators, type) {
|
||||
return (operators || []).filter((o) => !type || (o.types || []).includes(type))
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored conditions tree → the flat rows the editor edits.
|
||||
*
|
||||
* `editable: false` means "this file will not pretend it can round-trip that",
|
||||
* and the screen renders the tree read-only rather than losing part of it.
|
||||
*/
|
||||
export function conditionRowsFrom(conditions) {
|
||||
if (!conditions) return { op: 'and', rows: [], editable: true }
|
||||
if (conditions.cmp) return { op: 'and', rows: [rowFrom(conditions)], editable: true }
|
||||
if (conditions.op === 'and' || conditions.op === 'or') {
|
||||
const children = conditions.nodes || []
|
||||
if (children.every((n) => n && n.cmp)) {
|
||||
return { op: conditions.op, rows: children.map(rowFrom), editable: true }
|
||||
}
|
||||
}
|
||||
return { op: 'and', rows: [], editable: false }
|
||||
}
|
||||
|
||||
const rowFrom = (node) => ({
|
||||
variable: node.variable,
|
||||
cmp: node.cmp,
|
||||
// A list operator's value arrives as an array and is edited as comma-separated
|
||||
// text; everything else is edited as the literal it is.
|
||||
value: Array.isArray(node.value) ? node.value.join(', ') : node.value === undefined ? '' : String(node.value),
|
||||
})
|
||||
|
||||
/**
|
||||
* The editor's rows → a conditions tree, with each literal coerced to the type
|
||||
* the trigger DECLARED for that variable.
|
||||
*
|
||||
* The coercion is the point. Every value in an HTML input is a string, and the
|
||||
* server refuses `{ cmp: 'gt', value: "5" }` against an `int` variable — rightly,
|
||||
* because a rule whose comparison silently compares a number to a string is a
|
||||
* rule that quietly never fires. Doing it here means the form's error is about
|
||||
* something the operator typed rather than about JSON.
|
||||
*/
|
||||
export function conditionsFromRows(op, rows, variables) {
|
||||
const byName = Object.fromEntries((variables || []).map((v) => [v.name, v]))
|
||||
const nodes = (rows || [])
|
||||
.filter((r) => r.variable && r.cmp)
|
||||
.map((r) => {
|
||||
const type = byName[r.variable]?.type || 'string'
|
||||
const node = { variable: r.variable, cmp: r.cmp }
|
||||
if (r.cmp === 'present' || r.cmp === 'absent') return node
|
||||
if (r.cmp === 'in' || r.cmp === 'nin') {
|
||||
node.value = String(r.value ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => coerceLiteral(type, s))
|
||||
} else {
|
||||
node.value = coerceLiteral(type, r.value)
|
||||
}
|
||||
return node
|
||||
})
|
||||
if (!nodes.length) return null
|
||||
if (nodes.length === 1) return nodes[0]
|
||||
return { op, nodes }
|
||||
}
|
||||
|
||||
/**
|
||||
* One typed literal out of one string.
|
||||
*
|
||||
* A value that does not parse is passed through UNCHANGED rather than turned
|
||||
* into `NaN` or `false`: the server's type check will then refuse it and name the
|
||||
* variable, which is a better error than a rule that saves cleanly and compares
|
||||
* against a number the operator never typed.
|
||||
*/
|
||||
export function coerceLiteral(type, raw) {
|
||||
if (raw === null || raw === undefined) return raw
|
||||
const text = typeof raw === 'string' ? raw.trim() : raw
|
||||
switch (type) {
|
||||
case 'int': {
|
||||
const n = Number(text)
|
||||
return Number.isInteger(n) && text !== '' ? n : text
|
||||
}
|
||||
case 'float': {
|
||||
const n = Number(text)
|
||||
return Number.isFinite(n) && text !== '' ? n : text
|
||||
}
|
||||
case 'boolean': {
|
||||
if (text === true || text === 'true') return true
|
||||
if (text === false || text === 'false') return false
|
||||
return text
|
||||
}
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/** Seconds as the coarsest exact unit — 3600 is "1 hour", 3660 is "61 minutes". */
|
||||
export function humanSeconds(seconds) {
|
||||
const n = Number(seconds) || 0
|
||||
if (n === 0) return 'none'
|
||||
const units = [
|
||||
[86_400, 'day'],
|
||||
[3_600, 'hour'],
|
||||
[60, 'minute'],
|
||||
]
|
||||
for (const [size, name] of units) {
|
||||
if (n % size === 0) {
|
||||
const count = n / size
|
||||
return `${count} ${name}${count === 1 ? '' : 's'}`
|
||||
}
|
||||
}
|
||||
return `${n} seconds`
|
||||
}
|
||||
Reference in New Issue
Block a user