import { useCallback, useEffect, useMemo, useState } from 'react' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { api } from '../../../api/client.js' import { formFromRule, ruleToPayload, audienceChoicesFor, segmentChoicesFor, describeReach, describeRule, audienceWarning, conditionRowsFrom, conditionsFromRows, operatorsForType, } from '../../../lib/engagementRules.js' // Admin → Engagement → Rules (ENGAGEMENT.md Phase 4b). // // A rule is trigger → audience → channels → timing, and this is the screen that // writes one. Everything it decides lives in lib/engagementRules.js so it can be // tested; this file renders it and talks to the API. // // Four things about this screen are deliberate and would be wrong the obvious // way round: // // 1. **The on/off switch is not the form.** It is its own request against its // own route, and it does not re-validate the rule. A rule whose module has // been uninstalled is dormant, is the rule an operator most wants stopped, // and is exactly the rule the form would refuse to save. // 2. **A rule's trigger is fixed once it exists.** Its cooldowns, its pending // outbox rows and its send-log history are all about one trigger id. // 3. **Every rule arrives off.** §7.1 Q3 makes rules operator-editable data on // the condition that nothing starts mailing by itself — so a new rule is // created disabled and switched on afterwards, as a separate act. // 4. **The reach preview is a number.** Never a list of people: a // module-declared segment resolves over game data, and this screen is about // mail scheduling. const DANGER = { color: '#d98b84', borderColor: '#5b2020' } const BLANK = { id: null, triggerId: '', name: '', enabled: false, audience: 'owner', audienceSegmentId: null, channels: [], templateKeys: {}, conditions: null, cooldownSeconds: 0, delaySeconds: 0, cancelOn: [], maxSendsPerHour: 100, } function Dormant({ reasons }) { return ( Dormant ) } // ── The editor ───────────────────────────────────────────────────────────── function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) { const [form, setForm] = useState(() => (rule ? formFromRule(rule) : { ...BLANK })) const [conditionState, setConditionState] = useState(() => conditionRowsFrom(rule?.conditions)) const [preview, setPreview] = useState(null) const [previewing, setPreviewing] = useState(false) const [errors, setErrors] = useState([]) const [busy, setBusy] = useState(false) const isNew = !form.id const set = (patch) => setForm((f) => ({ ...f, ...patch })) const trigger = useMemo( () => catalog.triggers.find((t) => t.id === form.triggerId) || null, [catalog.triggers, form.triggerId], ) const audienceChoices = audienceChoicesFor(trigger, catalog.ceilings) const segmentChoices = segmentChoicesFor(trigger, catalog.ceilings, segments) const variables = trigger?.variables || [] // Changing the trigger invalidates the audience and every condition, because // both are stated in the old trigger's vocabulary. Clearing them is the honest // move: keeping a condition on a variable the new trigger never carries would // make the rule fire on nothing, silently (an absent variable fails every // comparison, by design). function pickTrigger(id) { const next = catalog.triggers.find((t) => t.id === id) setForm((f) => ({ ...f, triggerId: id, audience: next?.audience || 'owner', audienceSegmentId: null, })) setConditionState({ op: 'and', rows: [], editable: true }) setPreview(null) } function toggleChannel(id) { setForm((f) => ({ ...f, channels: f.channels.includes(id) ? f.channels.filter((c) => c !== id) : [...f.channels, id], })) } async function runPreview() { setPreviewing(true) try { setPreview( await api.admin.previewEngagementReach({ audience: form.audience, audienceSegmentId: form.audienceSegmentId, triggerId: form.triggerId, }), ) } catch (err) { setPreview({ count: 0, dormant: true, reason: err.message || 'could not be resolved' }) } finally { setPreviewing(false) } } async function submit(e) { e.preventDefault() setErrors([]) setBusy(true) const payload = ruleToPayload({ ...form, conditions: conditionState.editable ? conditionsFromRows(conditionState.op, conditionState.rows, variables) : form.conditions, }) try { if (isNew) await api.admin.createEngagementRule(payload) else await api.admin.updateEngagementRule(form.id, payload) await onSaved() } catch (err) { // The server sends every problem, not just the first. A form that shows one // makes an operator fix four things in four round trips. setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save the rule.']) } finally { setBusy(false) } } return (
{isNew ? 'New rule' : `Editing “${rule.name}”`}
{trigger?.description && (

{trigger.description}

)} {/* ── Audience ── */}
Who it reaches
{preview && (

{describeReach(preview)}

)} {/* The `members`-with-no-saved-audience trap, said before the save rather than discovered after it. It is the DEFAULT the moment a members-ceiling trigger is chosen, and the rule it produces saves, switches on and mails nobody. */} {!preview && audienceWarning(form) && (

{audienceWarning(form)}

)} {trigger && audienceChoices.length <= 1 && (

This event only permits “{trigger.ceiling}”. The audience a rule may use is capped by the event itself, not by the rule.

)} {/* ── Channels ── */}
How it is delivered
{catalog.channels.map((c) => (
{form.channels.includes(c.id) && ( set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })} /> )}
))}

Every channel is opt-in: a rule reaches only the people who turned that channel on for this event in their own notification settings.

{/* ── Conditions ── */}
Only when…
{!conditionState.editable ? (

This rule has a nested condition this editor does not render. It is left exactly as it is unless you clear it — flattening it here would change which events fire the rule.

            {JSON.stringify(form.conditions, null, 2)}
          
) : ( <> {conditionState.rows.length > 1 && ( )} {conditionState.rows.map((row, i) => { const type = variables.find((v) => v.name === row.variable)?.type const ops = operatorsForType(catalog.operators, type) const takesValue = row.cmp !== 'present' && row.cmp !== 'absent' const patch = (p) => setConditionState((s) => ({ ...s, rows: s.rows.map((r, j) => (i === j ? { ...r, ...p } : r)), })) return (
{takesValue && ( patch({ value: e.target.value })} /> )}
) })} {!variables.length && ( Choose a trigger first — its declared variables are what a condition can talk about. )} )} {/* ── Timing and the ceiling ── */}
Timing

The cooldown is per recipient and per subject {trigger?.subjectKey ? ` (“${trigger.subjectKey}”)` : ''} — a player whose four houses are all decaying hears about all four, once each. The hourly cap is per rule and is the hard stop that keeps a misconfiguration to a bad hour.

{form.delaySeconds > 0 && ( )} {errors.length > 0 && ( )}
{isNew && ( A new rule is created switched off. Turn it on from the list when you are happy with it. )}
) } // ── The screen ───────────────────────────────────────────────────────────── // ── The Phase 6 migration notice ─────────────────────────────────────────── // // Team notifications used to be sent with no operator configuration at all; // ENGAGEMENT.md Phase 6 moved them onto rules, and the org lead's decision was to // seed those rules DISABLED rather than carve an exception into "nothing is on by // default". The consequence is a deployment whose Team email has stopped and // nobody has been told — which is G22's failure mode with a different cause — so // the screen that can fix it says so. // // It reads the RULES rather than a flag, so it disappears the moment one is // switched on and comes back if every one is switched off again. A deployment // that deleted them all sees nothing, which is right: they made that choice. // // **Phase 11 added a second notice of exactly the same shape, for news** // (ENGAGEMENT.md §7.1 Q9). Publishing a news post used to tickle every subscriber // directly, and that call is now an emit through the engine, so news push stops // on upgrade until the seeded `news.post` rule is switched on. Two notices rather // than one generalised "some rules are off" banner, deliberately: each names a // capability that USED to work without configuration and now does not, which is // a different statement from "you have a disabled rule" — and a rule an operator // created and disabled themselves must never produce a warning. const TEAM_TRIGGERS = [ 'team.forum.post', 'team.announcement', 'team.member.joined', 'team.leadership.changed', ] const NEWS_TRIGGERS = ['news.post'] // One style for both notices, so the pair reads as one kind of message rather // than two that happen to look alike. const NOTICE_STYLE = { fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', marginBottom: 16, border: '1px solid #7a6440', color: '#e0b070', } const triggerOf = (rule) => rule.triggerId || rule.trigger_id // True only when rules for these triggers EXIST and every one of them is off. // Zero matching rules means the operator deleted them, which is a choice, not a // regression to warn about. function allOff(rules, triggers) { const group = rules.filter((r) => triggers.includes(triggerOf(r))) return group.length > 0 && group.every((r) => !r.enabled) } const teamRulesAllOff = (rules) => allOff(rules, TEAM_TRIGGERS) const newsRulesAllOff = (rules) => allOff(rules, NEWS_TRIGGERS) export default function EngagementRules() { const [catalog, setCatalog] = useState(null) const [segments, setSegments] = useState([]) const [rules, setRules] = useState(null) const [editing, setEditing] = useState(null) // null | { rule } | { rule: null } for new const [error, setError] = useState('') const [rowError, setRowError] = useState('') const load = useCallback(async () => { setError('') try { const [triggers, channels, segs, list] = await Promise.all([ api.admin.engagementTriggers(), api.admin.engagementChannels(), api.admin.listEngagementSegments(), api.admin.listEngagementRules(), ]) setCatalog({ triggers: triggers.triggers || [], ceilings: triggers.ceilings || [], operators: triggers.operators || [], channels: channels.channels || [], }) setSegments(segs.segments || []) setRules(list.rules || []) } catch { setError('Could not load the engagement rules.') } }, []) useEffect(() => { load() }, [load]) const segmentsById = useMemo( () => Object.fromEntries(segments.map((s) => [s.id, s])), [segments], ) async function toggle(rule) { setRowError('') try { await api.admin.setEngagementRuleEnabled(rule.id, !rule.enabled) await load() } catch (err) { setRowError(err.message || 'Could not change that rule.') } } async function remove(rule) { if (!window.confirm(`Delete “${rule.name}”? Its queued sends go with it; the send log does not.`)) return setRowError('') try { await api.admin.deleteEngagementRule(rule.id) await load() } catch (err) { setRowError(err.message || 'Could not delete that rule.') } } if (error) return if (!catalog || !rules) return if (editing) { return (
{ setEditing(null); await load() }} onCancel={() => setEditing(null)} />
) } return (
{teamRulesAllOff(rules) && (
Team notification emails are off. They used to be sent automatically; they are now rules, and the four below arrived switched off so that nothing starts mailing on its own. Switch on the ones this deployment wants — per-member preferences and per-Team mutes still apply above them, and unsubscribe links in mail already sent still work.
)} {newsRulesAllOff(rules) && (
News notifications are off. Publishing a news post used to send a push notification to everyone subscribed to it. That is now the “News posts” rule below, and it arrived switched off for the same reason the Team rules did. Switch it on to resume news push — it also carries email and the in-app inbox, each still subject to each person’s own preferences. The in-game town crier and the Discord announcement are unaffected either way.
)}

A rule turns an event into mail: which event, who hears about it, on which channels, and how often at most. Nothing sends until a rule is switched on.

{rowError && (

{rowError}

)}
{rules.length === 0 && ( )} {rules.map((rule) => ( ))}
Rule Trigger What it does State
No rules yet. Nothing is being sent.
{rule.name} {rule.trigger_id} {describeRule(rule, { segmentsById })} {rule.dormant && (
)}
{rules.some((r) => r.dormant) && (

A dormant rule names something that is not registered right now — usually a module that has been uninstalled. It is kept exactly as it is, it never fires, and it starts working again when the module comes back. It can still be switched off.

)}
) }