// 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` }