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>
349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
// 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 ''
|
|
const why = operatorWords(preview.reason)
|
|
if (preview.dormant) return `Resolves to nobody right now — ${why || 'dormant'}.`
|
|
if (preview.permitted === false) {
|
|
return `Reaches ${preview.count}, but this trigger does not permit that audience — saving will be refused.`
|
|
}
|
|
if (why) return `${preview.count} right now — ${why}.`
|
|
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.`
|
|
}
|
|
|
|
/**
|
|
* The server says "segment"; these screens say "saved audience".
|
|
*
|
|
* The API, the schema and the docs all call it a segment and should keep doing
|
|
* so - it is one word for one table. But an operator meets the concept here,
|
|
* under a heading that says "Audiences", and a sentence that switches vocabulary
|
|
* mid-screen reads as a sentence about something else.
|
|
*/
|
|
export function operatorWords(text) {
|
|
if (!text) return text
|
|
// Word-wise rather than a regex, so "segmented" and the like are left alone.
|
|
const swap = { segment: 'saved audience', segments: 'saved audiences' }
|
|
return String(text)
|
|
.split(' ')
|
|
.map((word) => swap[word] || word)
|
|
.join(' ')
|
|
}
|
|
|
|
/**
|
|
* The one audience choice that silently reaches nobody, said out loud.
|
|
*
|
|
* `members` is the ceiling for "a module-declared list". Without a saved
|
|
* audience naming WHICH list there is no list, and core knows no game vocabulary
|
|
* with which to guess - so the rule resolves to the empty set every time it
|
|
* fires. It is also the DEFAULT the moment an operator picks a `members`-ceiling
|
|
* trigger, which is what makes it a trap rather than a curiosity: the rule saves,
|
|
* switches on, and mails nobody, with nothing on the screen saying so unless the
|
|
* operator happens to press Preview.
|
|
*
|
|
* Returns a sentence, or null when there is nothing to warn about.
|
|
*/
|
|
export function audienceWarning(form) {
|
|
if (!form) return null
|
|
if (form.audienceSegmentId) return null
|
|
if (form.audience === 'members') {
|
|
return 'This reaches nobody as it stands. “Members of a module-declared list” needs a saved audience naming which list.'
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ── 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 'An excluded audience can only be used alongside an included one — on its own it would mean “everyone except…”.'
|
|
}
|
|
// The same rule from the other side: a group of nothing but exclusions has
|
|
// no set to take them from. The composer offers "exclude" on every row, so
|
|
// this is one checkbox away at all times and is worth saying before the
|
|
// round trip - the server refuses it, correctly, but only after a save.
|
|
if ((node.op === 'and' || node.op === 'or') && (node.nodes || []).length) {
|
|
if ((node.nodes || []).every((c) => c && c.op === 'not')) {
|
|
return 'At least one audience has to be included — a list made only of exclusions has nothing to exclude from.'
|
|
}
|
|
}
|
|
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`
|
|
}
|