Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled before any code that land in core rather than in module-uo. Pairs with Module-uo#22 and docs#194. ## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff` Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone, uo.world.saved) are described as admin-audience everywhere, and the narrowest value the lattice had was `staff` -- which ceilings.js defines as admin, editor AND moderator. Ceilinging them there would have let an operator save a rule that mails the staff audit digest to every moderator in it. `admin` is the ONLY genuine refinement in the tree -- every admin is staff, which is exactly the containment every other pair of branches lacks -- so it is a child rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the new PARENT entry. **The one non-obvious consequence, and the reason for ROLE_CEILINGS.** notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was correct while `staff` was the only role-gated value, and the day `admin` arrived it would have silently published every admin-ceilinged id -- the staff audit digest, the economy thresholds -- to every player's preferences screen by name. It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry fails closed instead. An EDITOR is the viewer that tells the two rules apart, and the new tests use one. MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid under 1.7.0 is valid now and no stored value changes. ## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle `news.post` has been a declared payload contract with no caller since Phase 2, so a rule naming it could never fire. utils/newsNotify.js is the caller; announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on the same enqueueIfNeeded job id -- the single "newly published news" transition signal, not re-derived. **News push therefore stops on upgrade** until an operator enables the seeded rule. That is the org lead's decision, taken over keeping the raw call beside the emit "for one release": an exception with a deadline nobody owns, which Phase 6 already refused for Teams. The Rules screen gains a second migration notice naming news, and Phase 13's release note carries it as an upgrade step. **The seed needed its own one-shot key, and this is the trap worth recording.** `engagement_team_rules_seeded` is already stamped on every deployment that has booted since Phase 6, and the guard reads its presence -- so appending news to RULES would have seeded it on fresh installs only, and on exactly the upgrades that lose their raw push, never. One key per seed GROUP is now the rule; seedGroup() is the shared implementation and seedCoreRules() is what boot calls. Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path App.jsx does not mount. An example is what the template editor previews and test-sends with, so a wrong one is a preview that looks right and a mail that is not. It is `/site/news`, the list, which is what the Discord and town-crier announcements have always linked. 1550 tests pass (16 new), 327 client tests pass, client builds, check:modules clean -- core still names no module identifier with module-uo now registering 24 UO-named triggers. Co-Authored-By: Claude <noreply@anthropic.com>
717 lines
28 KiB
JavaScript
717 lines
28 KiB
JavaScript
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 (
|
||
<span
|
||
className="badge"
|
||
title={reasons.join('\n')}
|
||
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||
>
|
||
Dormant
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||
<div className="field-label" style={{ marginBottom: 14 }}>
|
||
{isNew ? 'New rule' : `Editing “${rule.name}”`}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||
<label style={{ flex: '1 1 280px' }}>
|
||
<span className="field-label">Trigger</span>
|
||
{isNew ? (
|
||
<select className="select" value={form.triggerId} onChange={(e) => pickTrigger(e.target.value)}>
|
||
<option value="">Choose an event…</option>
|
||
{catalog.triggers.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.label} ({t.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<input className="input" value={form.triggerId} readOnly disabled />
|
||
)}
|
||
{!isNew && (
|
||
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||
A rule keeps its trigger — its cooldowns, queued sends and history are all about this one.
|
||
</span>
|
||
)}
|
||
</label>
|
||
<label style={{ flex: '1 1 280px' }}>
|
||
<span className="field-label">Name</span>
|
||
<input
|
||
className="input"
|
||
value={form.name}
|
||
onChange={(e) => set({ name: e.target.value })}
|
||
placeholder="IDOC warning to the owner"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
{trigger?.description && (
|
||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--muted)' }}>
|
||
{trigger.description}
|
||
</p>
|
||
)}
|
||
|
||
{/* ── Audience ── */}
|
||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Who it reaches</div>
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||
<label style={{ flex: '1 1 220px' }}>
|
||
<span className="field-label">Audience</span>
|
||
<select
|
||
className="select"
|
||
value={form.audienceSegmentId ? '' : form.audience}
|
||
disabled={Boolean(form.audienceSegmentId) || !audienceChoices.length}
|
||
onChange={(e) => { set({ audience: e.target.value, audienceSegmentId: null }); setPreview(null) }}
|
||
>
|
||
{/* Without a trigger there is no ceiling, so there is nothing this
|
||
may legitimately offer — and a select with zero options renders
|
||
as a control that is broken rather than as one that is waiting. */}
|
||
{!audienceChoices.length && <option value="">Choose a trigger first…</option>}
|
||
{Boolean(form.audienceSegmentId) && <option value="">Using the saved audience →</option>}
|
||
{audienceChoices.map((c) => (
|
||
<option key={c.id} value={c.id}>{c.label}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label style={{ flex: '1 1 220px' }}>
|
||
<span className="field-label">…or a saved audience</span>
|
||
<select
|
||
className="select"
|
||
value={form.audienceSegmentId || ''}
|
||
onChange={(e) => {
|
||
set({ audienceSegmentId: e.target.value ? Number(e.target.value) : null })
|
||
setPreview(null)
|
||
}}
|
||
>
|
||
<option value="">None — use the audience on the left</option>
|
||
{segmentChoices.map((s) => (
|
||
<option key={s.id} value={s.id}>{s.name}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button type="button" className="btn btn-sq" disabled={previewing || !form.triggerId} onClick={runPreview}>
|
||
{previewing ? 'Counting…' : 'Preview reach'}
|
||
</button>
|
||
</div>
|
||
{preview && (
|
||
<p
|
||
className="sans"
|
||
style={{
|
||
margin: '10px 0 0',
|
||
fontSize: '0.84rem',
|
||
color: preview.permitted === false || preview.dormant ? '#d98b84' : 'var(--muted)',
|
||
}}
|
||
>
|
||
{describeReach(preview)}
|
||
</p>
|
||
)}
|
||
{/* 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) && (
|
||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--accent)' }}>
|
||
{audienceWarning(form)}
|
||
</p>
|
||
)}
|
||
{trigger && audienceChoices.length <= 1 && (
|
||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||
This event only permits “{trigger.ceiling}”. The audience a rule may use is capped by the
|
||
event itself, not by the rule.
|
||
</p>
|
||
)}
|
||
|
||
{/* ── Channels ── */}
|
||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>How it is delivered</div>
|
||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||
{catalog.channels.map((c) => (
|
||
<div key={c.id} style={{ flex: '0 1 260px' }}>
|
||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={form.channels.includes(c.id)} onChange={() => toggleChannel(c.id)} />
|
||
{c.label}
|
||
</label>
|
||
{form.channels.includes(c.id) && (
|
||
<input
|
||
className="input"
|
||
style={{ marginTop: 6, width: '100%' }}
|
||
placeholder="template key (optional)"
|
||
value={form.templateKeys[c.id] || ''}
|
||
onChange={(e) => set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||
Every channel is opt-in: a rule reaches only the people who turned that channel on for this
|
||
event in their own notification settings.
|
||
</p>
|
||
|
||
{/* ── Conditions ── */}
|
||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Only when…</div>
|
||
{!conditionState.editable ? (
|
||
<div>
|
||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||
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.
|
||
</p>
|
||
<pre
|
||
style={{ background: 'var(--panel-flat)', border: '1px solid var(--line)', borderRadius: 6, padding: 10, fontSize: '0.76rem', overflowX: 'auto' }}
|
||
>
|
||
{JSON.stringify(form.conditions, null, 2)}
|
||
</pre>
|
||
<button
|
||
type="button"
|
||
className="pill"
|
||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||
onClick={() => { set({ conditions: null }); setConditionState({ op: 'and', rows: [], editable: true }) }}
|
||
>
|
||
Clear and start again
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{conditionState.rows.length > 1 && (
|
||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||
<span className="field-label">Match</span>
|
||
<select
|
||
className="select"
|
||
style={{ maxWidth: 220 }}
|
||
value={conditionState.op}
|
||
onChange={(e) => setConditionState((s) => ({ ...s, op: e.target.value }))}
|
||
>
|
||
<option value="and">all of these</option>
|
||
<option value="or">any of these</option>
|
||
</select>
|
||
</label>
|
||
)}
|
||
{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 (
|
||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||
<select
|
||
className="select"
|
||
style={{ flex: '1 1 160px' }}
|
||
value={row.variable}
|
||
onChange={(e) => patch({ variable: e.target.value })}
|
||
>
|
||
<option value="">Variable…</option>
|
||
{variables.map((v) => (
|
||
<option key={v.name} value={v.name}>{v.name}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="select"
|
||
style={{ flex: '1 1 160px' }}
|
||
value={row.cmp}
|
||
onChange={(e) => patch({ cmp: e.target.value })}
|
||
>
|
||
<option value="">Is…</option>
|
||
{ops.map((o) => (
|
||
<option key={o.cmp} value={o.cmp}>{o.label}</option>
|
||
))}
|
||
</select>
|
||
{takesValue && (
|
||
<input
|
||
className="input"
|
||
style={{ flex: '2 1 200px' }}
|
||
value={row.value}
|
||
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'comma, separated, values' : 'value'}
|
||
onChange={(e) => patch({ value: e.target.value })}
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="pill"
|
||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||
onClick={() => setConditionState((s) => ({ ...s, rows: s.rows.filter((_, j) => j !== i) }))}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
<button
|
||
type="button"
|
||
className="btn btn-sq"
|
||
disabled={!variables.length}
|
||
onClick={() =>
|
||
setConditionState((s) => ({ ...s, rows: [...s.rows, { variable: '', cmp: '', value: '' }] }))
|
||
}
|
||
>
|
||
Add a condition
|
||
</button>
|
||
{!variables.length && (
|
||
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||
Choose a trigger first — its declared variables are what a condition can talk about.
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ── Timing and the ceiling ── */}
|
||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Timing</div>
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||
<label style={{ flex: '1 1 160px' }}>
|
||
<span className="field-label">Wait before sending (seconds)</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min="0"
|
||
value={form.delaySeconds}
|
||
onChange={(e) => set({ delaySeconds: Number(e.target.value) })}
|
||
/>
|
||
</label>
|
||
<label style={{ flex: '1 1 160px' }}>
|
||
<span className="field-label">At most once per (seconds)</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min="0"
|
||
value={form.cooldownSeconds}
|
||
onChange={(e) => set({ cooldownSeconds: Number(e.target.value) })}
|
||
/>
|
||
</label>
|
||
<label style={{ flex: '1 1 160px' }}>
|
||
<span className="field-label">Hard cap (sends per hour)</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min="1"
|
||
value={form.maxSendsPerHour}
|
||
onChange={(e) => set({ maxSendsPerHour: Number(e.target.value) })}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||
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.
|
||
</p>
|
||
|
||
{form.delaySeconds > 0 && (
|
||
<label style={{ display: 'block', marginTop: 14 }}>
|
||
<span className="field-label">Cancel the wait if any of these happen</span>
|
||
<select
|
||
className="select"
|
||
multiple
|
||
size={Math.min(5, Math.max(2, catalog.triggers.length))}
|
||
value={form.cancelOn}
|
||
onChange={(e) => set({ cancelOn: [...e.target.selectedOptions].map((o) => o.value) })}
|
||
>
|
||
{catalog.triggers.map((t) => (
|
||
<option key={t.id} value={t.id}>{t.label}</option>
|
||
))}
|
||
</select>
|
||
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||
Only meaningful with a wait — there is no window to cancel otherwise, and the save says so.
|
||
</span>
|
||
</label>
|
||
)}
|
||
|
||
{errors.length > 0 && (
|
||
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
|
||
{errors.map((e) => <li key={e}>{e}</li>)}
|
||
</ul>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||
{busy ? 'Saving…' : isNew ? 'Create rule (off)' : 'Save changes'}
|
||
</button>
|
||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||
{isNew && (
|
||
<span className="sans" style={{ alignSelf: 'center', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||
A new rule is created switched off. Turn it on from the list when you are happy with it.
|
||
</span>
|
||
)}
|
||
</div>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
// ── 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 <ErrorState message={error} />
|
||
if (!catalog || !rules) return <Loading />
|
||
|
||
if (editing) {
|
||
return (
|
||
<section>
|
||
<RuleEditor
|
||
catalog={catalog}
|
||
segments={segments}
|
||
rule={editing.rule}
|
||
onSaved={async () => { setEditing(null); await load() }}
|
||
onCancel={() => setEditing(null)}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section>
|
||
{teamRulesAllOff(rules) && (
|
||
<div className="sans" style={NOTICE_STYLE}>
|
||
<strong>Team notification emails are off.</strong> 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.
|
||
</div>
|
||
)}
|
||
|
||
{newsRulesAllOff(rules) && (
|
||
<div className="sans" style={NOTICE_STYLE}>
|
||
<strong>News notifications are off.</strong> 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.
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
|
||
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.
|
||
</p>
|
||
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ rule: null })}>
|
||
New rule
|
||
</button>
|
||
</div>
|
||
|
||
{rowError && (
|
||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||
)}
|
||
|
||
<div className="panel-flat">
|
||
<table className="adm-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="adm-th">Rule</th>
|
||
<th className="adm-th">Trigger</th>
|
||
<th className="adm-th">What it does</th>
|
||
<th className="adm-th">State</th>
|
||
<th className="adm-th" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rules.length === 0 && (
|
||
<tr>
|
||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||
No rules yet. Nothing is being sent.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{rules.map((rule) => (
|
||
<tr key={rule.id}>
|
||
<td className="adm-td" style={{ color: 'var(--text)' }}>{rule.name}</td>
|
||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{rule.trigger_id}</td>
|
||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||
{describeRule(rule, { segmentsById })}
|
||
</td>
|
||
<td className="adm-td">
|
||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={Boolean(rule.enabled)} onChange={() => toggle(rule)} />
|
||
{rule.enabled ? 'On' : 'Off'}
|
||
</label>
|
||
{rule.dormant && (
|
||
<div style={{ marginTop: 4 }}><Dormant reasons={rule.dormantReasons || []} /></div>
|
||
)}
|
||
</td>
|
||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||
<button
|
||
type="button"
|
||
className="pill"
|
||
style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||
onClick={() => setEditing({ rule })}
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="pill"
|
||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||
onClick={() => remove(rule)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{rules.some((r) => r.dormant) && (
|
||
<p className="sans" style={{ marginTop: 12, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||
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.
|
||
</p>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|