feat(engagement): Admin → Engagement → Rules and Audiences (engagement Phase 4b) #171

Merged
whitlocktech merged 2 commits from feature/engagement-rules-admin into edge 2026-08-29 17:28:56 +00:00
17 changed files with 3874 additions and 30 deletions

View File

@@ -42,6 +42,8 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
@@ -184,6 +186,22 @@ export default function App() {
actions that publish a game-written name is applied per request
on the server, from the caller's live role (TEAMS.md 2.9). */}
<Route path="teams" element={<TeamsAdmin />} />
{/* Engagement (ENGAGEMENT.md Phase 4b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`
on top of the group's staff gate, because this is the group that
decides who receives mail. */}
<Route
path="engagement"
element={
<RoleGate roles={['admin']}>
<Outlet />
</RoleGate>
}
>
<Route index element={<Navigate to="rules" replace />} />
<Route path="rules" element={<EngagementRules />} />
<Route path="audiences" element={<EngagementAudiences />} />
</Route>
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth

View File

@@ -357,6 +357,38 @@ export const api = {
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
// Engagement (docs/website/ENGAGEMENT.md Phase 4b). The first three are the
// catalog — triggers, audiences and channels, all served from the registries
// rather than from tables, so an installed module's declarations appear here
// without a client release.
//
// `setEngagementRuleEnabled` is its own call rather than a `saveEngagementRule`
// with one field, because the route is its own route: turning a rule off must
// work on a rule the registries would now refuse, which is exactly the rule an
// operator most wants stopped.
//
// `previewEngagementReach` answers with a COUNT and never a list of people.
engagementTriggers: () => req('/admin/engagement/triggers'),
engagementAudiences: () => req('/admin/engagement/audiences'),
engagementChannels: () => req('/admin/engagement/channels'),
listEngagementRules: () => req('/admin/engagement/rules'),
createEngagementRule: (body) => req('/admin/engagement/rules', { method: 'POST', body }),
updateEngagementRule: (id, body) => req(`/admin/engagement/rules/${id}`, { method: 'PUT', body }),
setEngagementRuleEnabled: (id, enabled) =>
req(`/admin/engagement/rules/${id}/enabled`, { method: 'PATCH', body: { enabled } }),
deleteEngagementRule: (id) => req(`/admin/engagement/rules/${id}`, { method: 'DELETE' }),
listEngagementSegments: () => req('/admin/engagement/segments'),
createEngagementSegment: (body) => req('/admin/engagement/segments', { method: 'POST', body }),
updateEngagementSegment: (id, body) => req(`/admin/engagement/segments/${id}`, { method: 'PUT', body }),
deleteEngagementSegment: (id) => req(`/admin/engagement/segments/${id}`, { method: 'DELETE' }),
previewEngagementReach: ({ audience, audienceSegmentId, triggerId } = {}) => {
const qs = new URLSearchParams()
if (audienceSegmentId) qs.set('audienceSegmentId', String(audienceSegmentId))
else if (audience) qs.set('audience', audience)
if (triggerId) qs.set('triggerId', triggerId)
return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`)
},
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.

View File

@@ -0,0 +1,348 @@
// 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`
}

View File

@@ -46,6 +46,8 @@ const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
const IconMail = () => <Icon><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3.5 6.5L12 13l8.5-6.5" /></Icon>
const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1.2" /><circle cx="4" cy="12" r="1.2" /><circle cx="4" cy="18" r="1.2" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -89,6 +91,19 @@ export const NAV = [
{ to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
],
},
{
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
// Settings. Settings is already one long page of sections, and the screens
// that join this group in Phase 5 - Triggers, Templates and the send log -
// are a catalog, an editor and a paged table, none of which is a settings
// section. Email Delivery stays under Settings: configuring a transport is
// not the same job as deciding who gets mail.
title: 'Engagement',
items: [
{ to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] },
{ to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] },
],
},
{
title: 'System',
items: [
@@ -157,6 +172,8 @@ const TITLES = {
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
'/admin/engagement/rules': 'Engagement Rules',
'/admin/engagement/audiences': 'Engagement Audiences',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
@@ -176,6 +193,7 @@ function moduleTitle(baseNav, pathname) {
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/users/')) return 'User'
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
return 'Admin'
}

View File

@@ -0,0 +1,433 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { describeExpression, describeReach, notPlacementError } from '../../../lib/engagementRules.js'
// Admin → Engagement → Audiences (ENGAGEMENT.md §5.1a, Phase 4b).
//
// A module declares named sets of users over its own data — "members of a team",
// "the governors" — and an operator combines them here into a saved audience a
// rule can point at. Core learns no game vocabulary: it knows an id, a label and
// a resolver it may call.
//
// **Composition narrows and never widens**, and that is the whole security
// content of this screen:
//
// • the saved ceiling is DERIVED from the tightest audience in the expression,
// not chosen — including for "any of", where the intuitive answer (the widest
// of the two) is the wrong one. A ceiling says what an expression is allowed
// to reach, not what it will resolve to, so the boolean operator makes no
// difference to it.
// • two ceilings with no ordering between them (staff and owner, say) have no
// answer at all, and the save is refused rather than guessing a side.
// • "none of" is only available inside an "all of" group. On its own it would
// have to mean "everyone except…" — a broadcast built out of one narrow list.
// The composer does not offer it anywhere else, and the server refuses it
// anyway.
//
// The three-level composer here is deliberate: one top-level all-of/any-of, one
// level of groups inside it, and audiences at the leaves. The stored grammar
// allows more nesting; anything deeper is left to the rule that made it and shown
// read-only, the same way the rule editor treats a nested condition.
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
/** A fresh, empty top-level group. */
const blankExpression = () => ({ op: 'and', nodes: [] })
/** Is this tree one the composer can render — a single group of leaves and not-groups? */
function isComposable(node) {
if (!node || typeof node !== 'object') return false
if (!node.op) return true
if (node.op === 'not') return (node.nodes || []).every((n) => n && !n.op)
if (node.op !== 'and' && node.op !== 'or') return false
return (node.nodes || []).every((n) => n && (!n.op || (n.op === 'not' && (n.nodes || []).every((c) => !c.op))))
}
/** The composer edits a top-level group; a bare leaf is lifted into one. */
const toGroup = (expression) =>
!expression ? blankExpression() : expression.op ? expression : { op: 'and', nodes: [expression] }
// ── One leaf: an audience and its declared parameters ──────────────────────
function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate, first }) {
const declared = audiences.find((a) => a.id === node.audienceId)
return (
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<label style={{ flex: '1 1 240px' }}>
{/* The heading belongs to the group, not to every line in it. */}
{first && <span className="field-label">Audience</span>}
<select
className="select"
value={node.audienceId || ''}
onChange={(e) => onChange({ audienceId: e.target.value, params: {} })}
>
<option value="">Choose</option>
{audiences.map((a) => (
<option key={a.id} value={a.id}>{a.label} reaches at most {a.ceiling}</option>
))}
</select>
</label>
{(declared?.params || []).map((p) => (
<label key={p.id} style={{ flex: '0 1 160px' }}>
<span className="field-label">{p.id}{p.required ? ' *' : ''}</span>
<input
className="input"
value={node.params?.[p.id] ?? ''}
onChange={(e) =>
onChange({
...node,
params: {
...node.params,
// `int` params are sent as numbers: the server type-checks each
// declared param, and "3" against an int is a refusal.
[p.id]: p.type === 'int' && e.target.value !== '' ? Number(e.target.value) : e.target.value,
},
})
}
/>
</label>
))}
{canNegate && (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, paddingBottom: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={negated} onChange={onToggleNegate} />
exclude
</label>
)}
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.72rem', marginBottom: 6 }} onClick={onRemove}>
Remove
</button>
</div>
)
}
// ── The composer ───────────────────────────────────────────────────────────
function SegmentEditor({ audiences, segment, onSaved, onCancel }) {
const [name, setName] = useState(segment?.name || '')
const [group, setGroup] = useState(() => toGroup(segment?.expression))
const [errors, setErrors] = useState([])
const [busy, setBusy] = useState(false)
const isNew = !segment
// `not` is only offered under "all of" (§5.1a). Under "any of" the checkbox
// disappears rather than being offered and refused.
const canNegate = group.op === 'and'
function setNodes(nodes) {
setGroup((g) => ({ ...g, nodes }))
}
function addLeaf() {
setNodes([...group.nodes, { audienceId: '', params: {} }])
}
function replaceAt(i, next) {
setNodes(group.nodes.map((n, j) => (i === j ? next : n)))
}
function toggleNegate(i) {
const node = group.nodes[i]
replaceAt(i, node.op === 'not' ? node.nodes[0] : { op: 'not', nodes: [node] })
}
function changeOp(op) {
// Switching to "any of" drops the exclusions rather than sending a tree the
// server will refuse — and says so, because silently keeping them and failing
// at save would be worse than either.
const nodes = op === 'or' ? group.nodes.map((n) => (n.op === 'not' ? n.nodes[0] : n)) : group.nodes
setGroup({ op, nodes })
}
const expression = useMemo(() => {
const nodes = group.nodes.filter((n) => (n.op === 'not' ? n.nodes[0]?.audienceId : n.audienceId))
if (!nodes.length) return null
if (nodes.length === 1 && !nodes[0].op) return nodes[0]
return { op: group.op, nodes }
}, [group])
const localError = expression ? notPlacementError(expression) : null
async function submit(e) {
e.preventDefault()
setErrors([])
if (!expression) return setErrors(['Add at least one audience.'])
if (localError) return setErrors([localError])
setBusy(true)
try {
const body = { name: name.trim(), expression }
if (isNew) await api.admin.createEngagementSegment(body)
else await api.admin.updateEngagementSegment(segment.id, body)
await onSaved()
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save that audience.'])
} finally {
setBusy(false)
}
}
return (
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
<div className="field-label" style={{ marginBottom: 14 }}>
{isNew ? 'New saved audience' : `Editing “${segment.name}`}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 280px' }}>
<span className="field-label">Name</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Governors" />
</label>
<label style={{ flex: '0 1 200px' }}>
<span className="field-label">Combine with</span>
<select className="select" value={group.op} onChange={(e) => changeOp(e.target.value)}>
<option value="and">all of these</option>
<option value="or">any of these</option>
</select>
</label>
</div>
<div style={{ marginTop: 18 }}>
{group.nodes.length === 0 && (
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.84rem', color: 'var(--muted)' }}>
No audiences yet. A saved audience is built out of the lists installed modules declare.
</p>
)}
{group.nodes.map((node, i) => {
const negated = node.op === 'not'
const leaf = negated ? node.nodes[0] : node
return (
<LeafRow
key={i}
first={i === 0}
audiences={audiences}
node={leaf}
negated={negated}
canNegate={canNegate}
onToggleNegate={() => toggleNegate(i)}
onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)}
onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))}
/>
)
})}
<button type="button" className="btn btn-sq" onClick={addLeaf} disabled={!audiences.length}>
Add an audience
</button>
{!audiences.length && (
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
No module currently declares any. Install one, or use a plain audience on the rule itself.
</span>
)}
</div>
{canNegate ? (
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
Exclude removes people from what the other rows produced. It is only available under all
of: on its own it would mean everyone except, which is a way to reach the whole
deployment from one narrow list.
</p>
) : (
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
Any of takes the tightest limit of the audiences in it, not the widest combining two
lists never reaches further than the narrower one allows.
</p>
)}
{(errors.length > 0 || localError) && (
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
{(errors.length ? errors : [localError]).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' : 'Save changes'}
</button>
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function EngagementAudiences() {
const [audiences, setAudiences] = useState([])
const [segments, setSegments] = useState(null)
const [editing, setEditing] = useState(null) // null | { segment } | { segment: null }
const [error, setError] = useState('')
const [rowError, setRowError] = useState('')
const [reach, setReach] = useState({}) // segment id -> preview
const load = useCallback(async () => {
setError('')
try {
const [declared, saved] = await Promise.all([
api.admin.engagementAudiences(),
api.admin.listEngagementSegments(),
])
setAudiences(declared.audiences || [])
setSegments(saved.segments || [])
} catch {
setError('Could not load audiences.')
}
}, [])
useEffect(() => { load() }, [load])
const audiencesById = useMemo(
() => Object.fromEntries(audiences.map((a) => [a.id, a])),
[audiences],
)
async function preview(segment) {
try {
const counted = await api.admin.previewEngagementReach({ audienceSegmentId: segment.id })
setReach((r) => ({ ...r, [segment.id]: counted }))
} catch (err) {
setReach((r) => ({ ...r, [segment.id]: { count: 0, dormant: true, reason: err.message } }))
}
}
async function remove(segment) {
if (!window.confirm(`Delete “${segment.name}”?`)) return
setRowError('')
try {
await api.admin.deleteEngagementSegment(segment.id)
await load()
} catch (err) {
// A 409 here is the interesting case and the message carries the count:
// deleting a segment a rule still points at would leave that rule reaching
// a different set of people, so it is refused rather than cascaded.
setRowError(err.message || 'Could not delete that audience.')
}
}
if (error) return <ErrorState message={error} />
if (!segments) return <Loading />
if (editing) {
return (
<section>
<SegmentEditor
audiences={audiences}
segment={editing.segment}
onSaved={async () => { setEditing(null); await load() }}
onCancel={() => setEditing(null)}
/>
</section>
)
}
return (
<section>
<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 }}>
Named sets of people a rule can be pointed at, built out of the lists installed modules
declare. A saved audience can only ever narrow combining two lists never reaches further
than the tighter of them allows.
</p>
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ segment: null })}>
New audience
</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">Name</th>
<th className="adm-th">Made of</th>
<th className="adm-th">Reaches at most</th>
<th className="adm-th">Right now</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{segments.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No saved audiences yet.
</td>
</tr>
)}
{segments.map((s) => (
<tr key={s.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>
{s.name}
{s.dormant && (
<div>
<span
className="badge"
title={`Not declared right now: ${(s.missingAudiences || []).join(', ')}`}
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
Dormant
</span>
</div>
)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{describeExpression(s.expression, audiencesById)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{s.ceiling}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{reach[s.id] ? (
describeReach(reach[s.id])
) : (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => preview(s)}>
Count
</button>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', marginRight: 6 }}
disabled={!isComposable(s.expression)}
title={isComposable(s.expression) ? undefined : 'Nested more deeply than this composer renders'}
onClick={() => setEditing({ segment: s })}
>
Edit
</button>
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => remove(s)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="panel" style={{ padding: 18, marginTop: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>What modules currently declare</div>
{audiences.length === 0 ? (
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
Nothing. Audiences come from installed modules core declares none, because core knows no
game vocabulary.
</p>
) : (
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.84rem', color: 'var(--muted)' }}>
{audiences.map((a) => (
<li key={a.id}>
<span style={{ color: 'var(--text)' }}>{a.label}</span> <code>{a.id}</code>, reaches at
most {a.ceiling}
{(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''}
</li>
))}
</ul>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,643 @@
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 ─────────────────────────────────────────────────────────────
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>
<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>
)
}

View File

@@ -0,0 +1,307 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
formFromRule,
ruleToPayload,
audienceChoicesFor,
segmentChoicesFor,
describeReach,
describeRule,
describeExpression,
notPlacementError,
audienceWarning,
operatorWords,
conditionRowsFrom,
conditionsFromRows,
operatorsForType,
coerceLiteral,
humanSeconds,
} from '../src/lib/engagementRules.js'
// lib/engagementRules.js — what the two Engagement screens say and what they let
// an operator pick (ENGAGEMENT.md Phase 4b).
//
// None of this is a boundary: the server's `engagementRules.model` decides what
// may be saved and the engine re-checks the audience ceiling at send time. What
// is tested here is the part that would be wrong SILENTLY — a form that sends a
// string where the trigger declared an int, a composer that flattens a nested
// condition into one that fires on different events, an editor that offers an
// audience the save is going to refuse.
const CEILINGS = [
{ id: 'everyone', label: 'Everyone', permits: ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'owner'] },
{ id: 'authenticated', label: 'Signed-in users', permits: ['authenticated', 'subscribers', 'members', 'staff', 'owner'] },
{ id: 'subscribers', label: 'Subscribers', permits: ['subscribers'] },
{ id: 'members', label: 'A module list', permits: ['members'] },
{ id: 'staff', label: 'Staff', permits: ['staff'] },
{ id: 'owner', label: 'The person it is about', permits: ['owner'] },
]
const TRIGGER = {
id: 'uo.house.idoc_warning',
label: 'House approaching collapse',
ceiling: 'owner',
audience: 'owner',
subjectKey: 'house',
variables: [
{ name: 'house', type: 'string', required: true },
{ name: 'daysLeft', type: 'int', required: false },
{ name: 'insured', type: 'boolean', required: false },
],
}
const OPERATORS = [
{ cmp: 'eq', label: 'is', types: ['string', 'int', 'boolean'], arity: 1 },
{ cmp: 'gt', label: 'is greater than', types: ['int'], arity: 1 },
{ cmp: 'in', label: 'is one of', types: ['string', 'int'], arity: 'list' },
{ cmp: 'present', label: 'is present', types: ['string', 'int', 'boolean'], arity: 0 },
]
const row = (over = {}) => ({
id: 3,
trigger_id: 'uo.house.idoc_warning',
name: 'IDOC warning',
enabled: 1,
audience: 'owner',
audience_segment_id: null,
channels: ['email'],
template_keys: { email: 'idoc-warning' },
conditions: null,
cooldown_seconds: 86400,
delay_seconds: 0,
cancel_on: [],
max_sends_per_hour: 100,
...over,
})
// ── The form round trip ────────────────────────────────────────────────────
test('a rule row round-trips through the form without changing what it means', () => {
const payload = ruleToPayload(formFromRule(row()))
assert.equal(payload.triggerId, 'uo.house.idoc_warning')
assert.equal(payload.enabled, true)
assert.deepEqual(payload.channels, ['email'])
assert.deepEqual(payload.templateKeys, { email: 'idoc-warning' })
assert.equal(payload.cooldownSeconds, 86400)
assert.equal(payload.maxSendsPerHour, 100)
})
test('unticking a channel drops its template key, rather than sending one the server refuses', () => {
const form = formFromRule(row({ channels: ['email', 'push'], template_keys: { email: 'a', push: 'b' } }))
form.channels = ['email']
const payload = ruleToPayload(form)
// The server refuses `templateKeys` naming a channel the rule does not have.
// Leaving it in would produce an error about a field the operator cannot see.
assert.deepEqual(payload.templateKeys, { email: 'a' })
})
// ── The audience the editor may offer ──────────────────────────────────────
test('the editor offers only what the trigger ceiling permits', () => {
const choices = audienceChoicesFor(TRIGGER, CEILINGS).map((c) => c.id)
assert.deepEqual(choices, ['owner'])
})
test('a wider trigger offers more, in lattice order', () => {
const choices = audienceChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS).map((c) => c.id)
assert.deepEqual(choices, ['authenticated', 'subscribers', 'members', 'staff', 'owner'])
})
test('an unknown trigger offers nothing — failing closed, like the server', () => {
// This is a dormant rule, whose module has been uninstalled. Offering the full
// vocabulary would be the widening the whole ceiling design exists to prevent.
assert.deepEqual(audienceChoicesFor({ ...TRIGGER, ceiling: 'nonsense' }, CEILINGS), [])
assert.deepEqual(audienceChoicesFor(null, CEILINGS), [])
})
test('segments are filtered by their STORED ceiling, not re-derived', () => {
const segments = [
{ id: 1, name: 'Governors', ceiling: 'members' },
{ id: 2, name: 'Watchers', ceiling: 'authenticated' },
]
const wide = segmentChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS, segments)
assert.deepEqual(wide.map((s) => s.id), [1, 2])
const narrow = segmentChoicesFor({ ...TRIGGER, ceiling: 'members' }, CEILINGS, segments)
assert.deepEqual(narrow.map((s) => s.id), [1])
})
// ── The reach preview ──────────────────────────────────────────────────────
test('a capped count reads as a floor, never as a total', () => {
const said = describeReach({ count: 5000, capped: true, dormant: false, reason: null, permitted: true })
assert.match(said, /At least 5000/)
})
test('a count the trigger would refuse says so, instead of looking healthy', () => {
const said = describeReach({ count: 12, capped: false, dormant: false, reason: null, permitted: false })
assert.match(said, /will be refused/)
})
test('a dormant segment says why, rather than reading as "nobody"', () => {
const said = describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' })
assert.match(said, /dormant/)
})
test('an owner audience carries its reason forward', () => {
const said = describeReach({ count: 0, dormant: false, reason: 'event carries no ownerUserId', permitted: true })
assert.match(said, /ownerUserId/)
})
// ── Conditions ─────────────────────────────────────────────────────────────
test('operators narrow to the variable type that was picked', () => {
assert.deepEqual(operatorsForType(OPERATORS, 'boolean').map((o) => o.cmp), ['eq', 'present'])
assert.deepEqual(operatorsForType(OPERATORS, 'int').map((o) => o.cmp), ['eq', 'gt', 'in', 'present'])
})
test('a literal is coerced to the type the trigger DECLARED', () => {
// Every value in an HTML input is a string, and `{ cmp: 'gt', value: "5" }`
// against an int variable is refused by the server — rightly, because a
// comparison between a number and a string quietly never matches.
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'gt', value: '5' }], TRIGGER.variables)
assert.deepEqual(built, { variable: 'daysLeft', cmp: 'gt', value: 5 })
})
test('a value that does not parse is passed through, so the server names the field', () => {
// NOT NaN, and not 0: a rule that saves cleanly having silently compared
// against a number nobody typed is worse than a refusal that says which
// variable it was.
assert.equal(coerceLiteral('int', 'soon'), 'soon')
assert.equal(coerceLiteral('boolean', 'yes'), 'yes')
assert.equal(coerceLiteral('boolean', 'true'), true)
assert.equal(coerceLiteral('float', '1.5'), 1.5)
})
test('a list operator splits on commas and types each item', () => {
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'in', value: '1, 2, 3' }], TRIGGER.variables)
assert.deepEqual(built.value, [1, 2, 3])
})
test('present and absent carry no value at all', () => {
const built = conditionsFromRows('and', [{ variable: 'house', cmp: 'present', value: 'ignored' }], TRIGGER.variables)
assert.deepEqual(built, { variable: 'house', cmp: 'present' })
})
test('no rows means no conditions — not an empty group that matches nothing', () => {
assert.equal(conditionsFromRows('and', [], TRIGGER.variables), null)
assert.equal(conditionsFromRows('and', [{ variable: '', cmp: '' }], TRIGGER.variables), null)
})
test('a flat stored tree opens editable; a nested one opens read-only', () => {
const flat = conditionRowsFrom({
op: 'and',
nodes: [{ variable: 'house', cmp: 'eq', value: 'x' }, { variable: 'daysLeft', cmp: 'gt', value: 5 }],
})
assert.equal(flat.editable, true)
assert.equal(flat.rows.length, 2)
// `A AND (B OR C)` flattened to `A AND B AND C` fires on different events, and
// the operator would have no way to know the save had done it.
const nested = conditionRowsFrom({
op: 'and',
nodes: [
{ variable: 'house', cmp: 'eq', value: 'x' },
{ op: 'or', nodes: [{ variable: 'daysLeft', cmp: 'gt', value: 5 }] },
],
})
assert.equal(nested.editable, false)
assert.deepEqual(nested.rows, [])
})
test('a single stored comparison is one editable row', () => {
const one = conditionRowsFrom({ variable: 'house', cmp: 'eq', value: 'x' })
assert.equal(one.editable, true)
assert.deepEqual(one.rows, [{ variable: 'house', cmp: 'eq', value: 'x' }])
})
// ── Segment composition ────────────────────────────────────────────────────
test('a members audience with no saved audience is warned about BEFORE the save', () => {
// The trap the browser walk found: it is the default the moment a
// members-ceiling trigger is chosen, and the rule it produces saves, switches
// on and mails nobody. Nothing on the screen said so unless you pressed
// Preview.
assert.match(audienceWarning({ audience: 'members', audienceSegmentId: null }), /reaches nobody/)
assert.equal(audienceWarning({ audience: 'members', audienceSegmentId: 4 }), null)
assert.equal(audienceWarning({ audience: 'owner', audienceSegmentId: null }), null)
})
test('the server says "segment"; the screens say "saved audience"', () => {
// One word for one table in the API, the schema and the docs. But an operator
// meets the concept under a heading that says "Audiences", and a sentence that
// switches vocabulary mid-screen reads as being about something else.
assert.equal(operatorWords('audience segment is dormant'), 'audience saved audience is dormant')
assert.match(describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' }), /saved audience/)
// and it does not maul a word that merely contains it
assert.equal(operatorWords('segmented data'), 'segmented data')
})
test('a list of nothing but exclusions is refused before the round trip', () => {
// One checkbox away at all times, because the composer offers "exclude" on
// every row including the only one. The server refuses it correctly — but
// only after a save.
const err = notPlacementError({ op: 'and', nodes: [{ op: 'not', nodes: [{ audienceId: 'a' }] }] })
assert.match(err, /at least one audience/i)
})
test('a bare not is refused before it reaches the server', () => {
assert.ok(notPlacementError({ op: 'not', nodes: [{ audienceId: 'uo.governors' }] }))
assert.ok(notPlacementError({ op: 'or', nodes: [{ audienceId: 'a' }, { op: 'not', nodes: [{ audienceId: 'b' }] }] }))
})
test('a not under an "all of" is fine — that is the only universe that does not widen', () => {
assert.equal(
notPlacementError({
op: 'and',
nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }],
}),
null,
)
})
test('an expression describes itself with module labels where it has them', () => {
const byId = { 'uo.governors': { label: 'Governors' } }
const said = describeExpression(
{ op: 'and', nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }] },
byId,
)
assert.equal(said, 'Governors and not uo.flagged')
})
test('a leaf renders its parameters, so two rows built on the same audience are distinguishable', () => {
const said = describeExpression({ audienceId: 'uo.team.members', params: { teamId: 4 } }, {})
assert.equal(said, 'uo.team.members (teamId: 4)')
})
// ── The list summary ───────────────────────────────────────────────────────
test('a rule summarises to what it will do, and always names its hourly cap', () => {
const said = describeRule(row({ delay_seconds: 3600 }), { segmentsById: {} })
assert.match(said, /to owner/)
assert.match(said, /via email/)
assert.match(said, /after 1 hour/)
assert.match(said, /once per 1 day/)
assert.match(said, /100\/hour/)
})
test('a rule on a segment names the segment, not the ceiling column', () => {
// The `audience` column on such a rule holds the segment's ceiling, which is a
// fact about what it MAY reach and not about who it does.
const said = describeRule(row({ audience: 'members', audience_segment_id: 7 }), {
segmentsById: { 7: { name: 'Governors' } },
})
assert.match(said, /to Governors/)
})
test('humanSeconds picks the coarsest EXACT unit, and never rounds', () => {
assert.equal(humanSeconds(0), 'none')
assert.equal(humanSeconds(3600), '1 hour')
assert.equal(humanSeconds(86400), '1 day')
assert.equal(humanSeconds(7200), '2 hours')
assert.equal(humanSeconds(3660), '61 minutes')
assert.equal(humanSeconds(90), '90 seconds')
})

View File

@@ -167,6 +167,15 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audience-preview",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences",
@@ -176,6 +185,105 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/channels",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/rules",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/rules/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/rules/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/admin/engagement/rules/:id/enabled",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/segments",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/segments",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/segments/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/segments/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers",

View File

@@ -73,10 +73,58 @@
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audience-preview"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/channels"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/rules"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/rules/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/rules/:id"
},
{
"method": "PATCH",
"path": "/api/v1/admin/engagement/rules/:id/enabled"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/segments"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/segments"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/segments/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/segments/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers"

View File

@@ -229,4 +229,30 @@ async function resolve(expression) {
return { dormant, userIds: dormant ? [] : [...set] }
}
module.exports = { validate, resolve, MAX_DEPTH, MAX_NODES }
/**
* Which audience ids in this expression nobody registers right now?
*
* The static half of the dormancy answer `resolve` gives at send time, and it
* lives here so the two cannot disagree. Two callers need it and neither may
* require the other: the segment list annotates itself with it, and the RULE
* list needs it to say that a rule pointing at a dormant segment is itself
* dormant — which is §5.1a rule 4, and which the first version of the rule
* annotation missed by asking only whether the segment ROW still existed.
*
* The difference is the whole point. A deleted segment and a segment whose
* module is gone both leave the rule reaching nobody; only one of them leaves a
* row behind. A screen that reports the first and not the second shows an
* enabled, healthy-looking rule that cannot fire.
*/
function missingAudiences(expression) {
const missing = []
const walk = (node) => {
if (!node || typeof node !== 'object') return
if (node.op) (node.nodes || []).forEach(walk)
else if (!registries.audience(node.audienceId)) missing.push(node.audienceId)
}
walk(expression)
return [...new Set(missing)]
}
module.exports = { validate, resolve, missingAudiences, MAX_DEPTH, MAX_NODES }

View File

@@ -103,6 +103,27 @@ const update = (id, rule) =>
],
)
/**
* Flip `enabled` and nothing else (Phase 4b).
*
* Deliberately NOT a call through `validate`: turning a rule OFF is the panic
* button, and it has to work on a rule the registries would now refuse — one
* whose module was uninstalled, or whose trigger has since narrowed its ceiling
* underneath a saved audience. Re-validating on the way to `enabled = 0` would
* make exactly the rules an operator most wants to stop the ones they cannot.
*
* Turning a rule ON is safe without re-validation for a different reason: the
* engine re-runs the ceiling check at send time (audiences.permitted), so an
* enabled-but-no-longer-permitted rule resolves to nobody rather than to the
* wrong people.
*/
const setEnabled = (id, enabled, updatedBy = null) =>
query('UPDATE engagement_rules SET enabled = ?, updated_by = ? WHERE id = ?', [
enabled ? 1 : 0,
updatedBy,
id,
])
const remove = (id) => query('DELETE FROM engagement_rules WHERE id = ?', [id])
/** Does any rule still point at this segment? The check before a segment delete. */
@@ -121,6 +142,7 @@ module.exports = {
enabledCancelledBy,
insert,
update,
setEnabled,
remove,
countUsingSegment,
parseJson,

View File

@@ -23,6 +23,7 @@ const segmentsDb = require('./engagementSegments.db')
const registries = require('../../modules/registries')
const ceilings = require('../../modules/ceilings')
const channels = require('../../engagement/channels')
const segmentExpressions = require('../../engagement/segments')
const conditions = require('../../engagement/conditions')
// A day. Longer than this and "cooldown" is really "send once", which a rule
@@ -192,6 +193,48 @@ async function update(id, input) {
return { ok: true, rule: await db.getById(id) }
}
/**
* Why this rule cannot currently fire, as a list of sentences. Empty = it can.
*
* **Three ways, not two.** A rule can be dormant because its trigger is gone,
* because a channel it names is gone, or because its AUDIENCE is gone - and the
* audience case has two shapes that a screen must not collapse into one:
*
* • the segment row was deleted out from under it (§7.3), or
* • the segment still exists and every audience in it belongs to a module that
* has been uninstalled (§5.1a rule 4).
*
* Both leave the rule reaching nobody. Only the first leaves nothing behind, and
* a check that asks only "does the row exist" reports the first and misses the
* second - which shows an enabled, healthy-looking rule that cannot fire. Found
* by uninstalling a module under a live rule while building Phase 4b's screen.
*
* @param {Map<number, {expression: object}>} segments every segment, by id
*/
function dormancyReasons(rule, segments) {
const reasons = []
if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`)
if (rule.audience_segment_id) {
const segment = segments.get(rule.audience_segment_id)
if (!segment) reasons.push('its audience segment no longer exists')
else {
const missing = segmentExpressions.missingAudiences(segment.expression)
if (missing.length) {
reasons.push(`its audience "${segment.name}" uses ${missing.join(', ')}, which nothing registers`)
}
}
}
for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`)
return reasons
}
const annotate = (rule, segments) => {
const reasons = dormancyReasons(rule, segments)
return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons }
}
const segmentsById = async () => new Map((await segmentsDb.list()).map((s) => [s.id, s]))
/**
* List every rule, each annotated with whether it can currently fire.
*
@@ -202,23 +245,59 @@ async function update(id, input) {
*/
async function listAnnotated() {
const rows = await db.list()
const segments = new Map((await segmentsDb.list()).map((s) => [s.id, s]))
return rows.map((rule) => {
const reasons = []
if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`)
if (rule.audience_segment_id && !segments.has(rule.audience_segment_id)) {
reasons.push('its audience segment no longer exists')
}
for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`)
return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons }
})
const segments = await segmentsById()
return rows.map((rule) => annotate(rule, segments))
}
/** One rule with the same dormancy annotation the list carries, or null. */
async function getAnnotated(id) {
const rule = await db.getById(id)
if (!rule) return null
return annotate(rule, await segmentsById())
}
/**
* Turn one rule on or off, writing that column and no other (Phase 4b).
*
* This is the one write path that does NOT go through `validate`, and the
* asymmetry is deliberate. Switching a rule OFF must always be possible - a rule
* whose module has been uninstalled, or whose trigger has since narrowed its
* ceiling under a saved audience, is exactly the rule an operator most urgently
* wants stopped, and it is exactly the rule `validate` would now refuse. The
* full editor still re-validates on save, and the engine re-checks the ceiling at
* send time, so nothing is loosened by having a switch that is only a switch.
*/
async function setEnabled(id, enabled, updatedBy = null) {
const existing = await db.getById(id)
if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true }
await db.setEnabled(id, enabled, updatedBy)
return { ok: true, rule: await getAnnotated(id) }
}
/**
* Delete a rule.
*
* Its cooldown rows and any still-pending outbox rows go with it (both carry an
* ON DELETE CASCADE), and that is the right blast radius: neither means anything
* without the rule. `engagement_sends` deliberately does NOT — its `rule_id`
* carries no foreign key — so the send log outlives the rule and the record of
* what was actually mailed survives an operator tidying up.
*/
async function remove(id) {
const existing = await db.getById(id)
if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true }
await db.remove(id)
return { ok: true }
}
module.exports = {
validate,
create,
update,
setEnabled,
remove,
listAnnotated,
getAnnotated,
MAX_COOLDOWN_SECONDS,
MAX_DELAY_SECONDS,
MAX_SENDS_PER_HOUR,

View File

@@ -11,7 +11,6 @@
const db = require('./engagementSegments.db')
const rulesDb = require('./engagementRules.db')
const registries = require('../../modules/registries')
const segments = require('../../engagement/segments')
async function save(input, { id = null } = {}) {
@@ -56,7 +55,7 @@ async function remove(id) {
return {
ok: false,
inUse,
errors: [`${inUse} rule${inUse === 1 ? '' : 's'} still use this segment`],
errors: [`${inUse} rule${inUse === 1 ? ' still uses' : 's still use'} this segment`],
}
}
await db.remove(id)
@@ -73,14 +72,11 @@ async function remove(id) {
async function listAnnotated() {
const rows = await db.list()
return rows.map((segment) => {
const missing = []
const walk = (node) => {
if (!node || typeof node !== 'object') return
if (node.op) (node.nodes || []).forEach(walk)
else if (!registries.audience(node.audienceId)) missing.push(node.audienceId)
}
walk(segment.expression)
return { ...segment, dormant: missing.length > 0, missingAudiences: [...new Set(missing)] }
// The walk lives in segments.js so the rule list can ask the same question:
// a rule pointing at a DORMANT segment is dormant too, and asking only
// whether the segment row still exists misses that (§5.1a rule 4).
const missing = segments.missingAudiences(segment.expression)
return { ...segment, dormant: missing.length > 0, missingAudiences: missing }
})
}

View File

@@ -17,9 +17,19 @@
// interpolation (§4.3 property 2), the `example` on each variable is what makes
// preview and test-send possible without a live game event, and the ceilings are
// what the rule editor has to obey when it offers an audience (G24).
//
// **Phase 4b adds the writes**: rules and segments CRUD, the enable switch and
// the reach preview, all below. Every one of them goes through the model — this
// file reads ids out of URLs and shapes responses, and validates nothing.
const registries = require('../../../modules/registries')
const ceilings = require('../../../modules/ceilings')
const channels = require('../../../engagement/channels')
const conditions = require('../../../engagement/conditions')
const audiences = require('../../../engagement/audiences')
const rules = require('../../../model/engagement/engagementRules.model')
const segments = require('../../../model/engagement/engagementSegments.model')
const recipients = require('../../../model/engagement/engagementRecipients.db')
// The lattice, flattened for a client: for each ceiling, the ones a rule may
// choose under it. Served with the catalog rather than hardcoded in the admin
@@ -41,6 +51,12 @@ exports.listTriggers = (req, res) => {
ceilings: ceilingVocabulary(),
variableTypes: registries.VARIABLE_TYPES,
kinds: registries.TRIGGER_KINDS,
// The condition operators, each with the variable types it applies to, so
// the editor's operator dropdown narrows itself to the variable that was
// picked instead of offering "is greater than" on a boolean. Same argument
// as the ceilings: one copy of the grammar, served from the file that
// evaluates it.
operators: conditions.vocabulary(),
})
}
@@ -52,3 +68,235 @@ exports.listAudiences = (req, res) => {
// second caller must not have to remember.
res.json({ audiences: registries.allAudiences(), ceilings: ceilingVocabulary() })
}
// The catalog's third leg: the channels a rule may name. Same argument as the
// ceilings above — the rule editor offers a set and the save path checks the
// same set, so serving it means the two cannot drift, and a module that
// registers a channel gets an editor that knows about it with no client release.
const channelVocabulary = () =>
channels.all().map(({ id, label, defaultMode }) => ({ id, label, defaultMode }))
/** GET /api/v1/admin/engagement/channels */
exports.listChannels = (req, res) => {
res.json({ channels: channelVocabulary() })
}
// ── Rules (Phase 4b) ───────────────────────────────────────────────────────
//
// Every write goes through `engagementRules.model`, which is the boundary. The
// screen re-implements some of the same checks for the sake of a good inline
// error and that second copy is expected to drift — which is exactly why it is
// not the one that decides.
// A model refusal is `{ ok: false, errors: [...] }` with an optional `notFound`.
// One helper so every write answers in the same shape: `message` is the first
// sentence for a toast, `errors` is the whole list for a form that wants to put
// each one beside the field it is about.
const refuse = (res, result, status = 400) =>
res.status(result.notFound ? 404 : status).json({
message: result.errors?.[0] || 'The request was refused',
errors: result.errors || [],
})
/** GET /api/v1/admin/engagement/rules */
exports.listRules = async (req, res, next) => {
try {
res.json({ rules: await rules.listAnnotated() })
} catch (err) {
next(err)
}
}
/** GET /api/v1/admin/engagement/rules/:id */
exports.getRule = async (req, res, next) => {
try {
const rule = await rules.getAnnotated(Number(req.params.id))
if (!rule) return res.status(404).json({ message: 'Not found' })
res.json({ rule })
} catch (err) {
next(err)
}
}
/** POST /api/v1/admin/engagement/rules */
exports.createRule = async (req, res, next) => {
try {
const result = await rules.create({ ...req.body, updatedBy: req.user?.id ?? null })
if (!result.ok) return refuse(res, result)
res.status(201).json({ rule: result.rule })
} catch (err) {
next(err)
}
}
/**
* PUT /api/v1/admin/engagement/rules/:id
*
* `trigger_id` is not in the model's UPDATE statement and that is not an
* oversight: a rule's cooldown rows, its pending outbox rows and its send-log
* history are all about one trigger, and re-pointing a rule at another one
* silently re-attributes every one of them. Changing the trigger means a new
* rule, and the editor shows the field read-only once the rule exists.
*/
exports.updateRule = async (req, res, next) => {
try {
const result = await rules.update(Number(req.params.id), {
...req.body,
updatedBy: req.user?.id ?? null,
})
if (!result.ok) return refuse(res, result)
res.json({ rule: result.rule })
} catch (err) {
next(err)
}
}
/**
* PATCH /api/v1/admin/engagement/rules/:id/enabled
*
* Its own route rather than a PUT, because turning a rule off is the panic button
* and must not be blocked by the rule failing validation now. See the model for
* the whole argument; the short version is that a rule whose module has been
* uninstalled is the one an operator most wants to stop and the one a
* re-validating PUT would refuse to save.
*/
exports.setRuleEnabled = async (req, res, next) => {
try {
if (typeof req.body?.enabled !== 'boolean') {
const message = 'enabled must be true or false'
return res.status(400).json({ message, errors: [message] })
}
const result = await rules.setEnabled(Number(req.params.id), req.body.enabled, req.user?.id ?? null)
if (!result.ok) return refuse(res, result)
res.json({ rule: result.rule })
} catch (err) {
next(err)
}
}
/** DELETE /api/v1/admin/engagement/rules/:id */
exports.deleteRule = async (req, res, next) => {
try {
const result = await rules.remove(Number(req.params.id))
if (!result.ok) return refuse(res, result)
res.status(204).end()
} catch (err) {
next(err)
}
}
// ── Segments (Phase 4b) ────────────────────────────────────────────────────
/** GET /api/v1/admin/engagement/segments */
exports.listSegments = async (req, res, next) => {
try {
res.json({ segments: await segments.listAnnotated() })
} catch (err) {
next(err)
}
}
/** POST /api/v1/admin/engagement/segments */
exports.createSegment = async (req, res, next) => {
try {
const result = await segments.save({ ...req.body, updatedBy: req.user?.id ?? null })
if (!result.ok) return refuse(res, result)
res.status(201).json({ segment: result.segment })
} catch (err) {
next(err)
}
}
/** PUT /api/v1/admin/engagement/segments/:id */
exports.updateSegment = async (req, res, next) => {
try {
const result = await segments.save(
{ ...req.body, updatedBy: req.user?.id ?? null },
{ id: Number(req.params.id) },
)
if (!result.ok) return refuse(res, result)
res.json({ segment: result.segment })
} catch (err) {
next(err)
}
}
/**
* DELETE /api/v1/admin/engagement/segments/:id
*
* 409, not 400, when a rule still points at it: the request is well-formed and
* the refusal is about the state of something else. The count travels in the
* message because "3 rules still use this segment" is the whole of what the
* operator needs in order to decide what to do next. The database is not doing
* this — `audience_segment_id` carries no foreign key on purpose, because both
* of the options SQL offers here (CASCADE, SET NULL) destroy something.
*/
exports.deleteSegment = async (req, res, next) => {
try {
const result = await segments.remove(Number(req.params.id))
if (!result.ok) return refuse(res, result, 409)
res.status(204).end()
} catch (err) {
next(err)
}
}
// ── Reach preview ──────────────────────────────────────────────────────────
/**
* GET /api/v1/admin/engagement/audience-preview
*
* "How many people does this reach right now?", answered by calling the SAME
* resolver the engine calls (`audiences.resolveForRule`) rather than a second
* query that agrees with it today. A preview built out of its own SQL is a
* preview that can be wrong about the only thing it exists to say.
*
* **A count and nothing else.** Not a sample, not a list of names: the resolver's
* output for a module-declared segment is a set of players derived from game
* data, and an editor that rendered those names would be a user-enumeration
* surface reached from a screen about mail scheduling.
*
* Three honesty requirements, each of them a way this number could lie:
*
* - **`capped`** — every audience query is bounded at `MAX_AUDIENCE` (5000), so a
* count that lands exactly on the bound is a floor and not a total. Rendering
* it as "5000" understates a large deployment by an unknown amount.
* - **`owner`** resolves per event, from an id the event carries, so there is no
* number to give in advance. It answers 0 with the reason saying so, which is
* the truth; a blank or a dash would read as "nobody".
* - **`permitted`** — whether the trigger's declared ceiling allows this audience
* at all. Without it the editor shows a healthy count beside a save the server
* will refuse, which reads as a bug in the save rather than as the G24 ceiling
* doing its job.
*/
exports.previewAudience = async (req, res, next) => {
try {
const segmentId = req.query.audienceSegmentId ? Number(req.query.audienceSegmentId) : null
if (segmentId !== null && !Number.isInteger(segmentId)) {
return res.status(400).json({ message: 'audienceSegmentId must be an integer' })
}
const audience = typeof req.query.audience === 'string' ? req.query.audience : 'owner'
if (segmentId === null && !ceilings.isCeiling(audience)) {
return res.status(400).json({ message: `audience must be one of ${ceilings.CEILINGS.join(', ')}` })
}
const triggerId = typeof req.query.triggerId === 'string' ? req.query.triggerId : null
const resolved = await audiences.resolveForRule(
{ audience, audience_segment_id: segmentId },
// No `ownerUserId`, because there is no event here — which is precisely
// why an `owner` audience has no advance answer to give.
{ triggerId, ownerUserId: null },
)
res.json({
count: resolved.userIds.length,
capped: resolved.userIds.length >= recipients.MAX_AUDIENCE,
ceiling: resolved.ceiling,
dormant: resolved.dormant,
reason: resolved.reason,
permitted: triggerId && resolved.ceiling ? audiences.permitted(triggerId, resolved.ceiling) : null,
})
} catch (err) {
next(err)
}
}

View File

@@ -1,16 +1,16 @@
// Admin · Engagement — the declared event catalog (ENGAGEMENT.md Phase 2).
// Admin · Engagement — the declared event catalog (Phase 2) and the rules and
// audience segments an operator configures over it (Phase 4b).
//
// Mounted at /api/v1/admin/engagement by admin/index.js, which has already
// applied `noindex, isLoggedIn, staffOnly`. Both routes re-gate to `admin`.
// applied `noindex, isLoggedIn, staffOnly`. Every route re-gates to `admin`.
//
// Admin rather than staff-wide, deliberately. Nothing here is writable yet, but
// this is the entry point of the screen that decides who receives mail, and the
// declarations it serves name every variable a template may interpolate. A
// Admin rather than staff-wide, deliberately. This is the group that decides who
// receives mail: the declarations it serves name every variable a template may
// interpolate, and the writes below are how a deployment starts sending. A
// capability is easier to widen later with a reason than to narrow after an
// editor has been using it.
//
// Rules, templates and the send log arrive under this same prefix in Phases 4
// and 5, which is why the group exists now with two read routes in it.
// Templates and the send log arrive under this same prefix in Phase 5.
const express = require('express')
@@ -20,13 +20,15 @@ const { requireRole } = require('../../../utils/auth')
const engagementRouter = express.Router()
const adminOnly = requireRole('admin')
// ── The catalog: three read routes, all served from the registries ─────────
engagementRouter.get(
'/triggers',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every declared event trigger, with its payload contract and audience ceiling'
// #swagger.description = 'Served from the module registries, not from a table: a trigger is declared in code by core or by an installed module, so this is whatever registered on this boot. Each declaration carries the variables a template may interpolate (with an example per variable, for preview and test-send) and the widest audience a rule may ever give it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The declared triggers, the audience-ceiling vocabulary, and the variable types', content: { "application/json": { schema: { type: "object", properties: { triggers: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } }, variableTypes: { type: "array", items: { type: "string" } }, kinds: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[200] = { description: 'The declared triggers, the audience-ceiling vocabulary, the variable types and the condition operators', content: { "application/json": { schema: { type: "object", properties: { triggers: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } }, variableTypes: { type: "array", items: { type: "string" } }, kinds: { type: "array", items: { type: "string" } }, operators: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listTriggers,
@@ -44,4 +46,169 @@ engagementRouter.get(
controller.listAudiences,
)
engagementRouter.get(
'/channels',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every registered delivery channel a rule may send on'
// #swagger.description = 'From the delivery-channel registry, so the rule editor offers exactly the set the save path checks against. A channel registered by a module appears here without a client release.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The registered channels', content: { "application/json": { schema: { type: "object", properties: { channels: { type: "array", items: { type: "object", properties: { id: { type: "string" }, label: { type: "string" }, defaultMode: { type: "string" } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listChannels,
)
// ── Reach preview ─────────────────────────────────────────────────────────
//
// Declared ahead of /rules/:id so the literal path is never read as an id.
engagementRouter.get(
'/audience-preview',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Count how many users an audience or segment reaches right now'
// #swagger.description = 'Runs the same resolver the engine runs, and returns a COUNT ONLY — never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true when the count hit the 5000-row audience bound and is therefore a floor rather than a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['audience'] = { in: 'query', description: 'A ceiling name (owner, staff, subscribers, members, authenticated, everyone). Ignored when audienceSegmentId is given.', required: false, schema: { type: 'string' } }
// #swagger.parameters['audienceSegmentId'] = { in: 'query', description: 'A saved segment to resolve instead of a plain audience', required: false, schema: { type: 'integer' } }
// #swagger.parameters['triggerId'] = { in: 'query', description: 'The rule trigger, used to resolve a subscribers audience and to report whether the trigger ceiling permits this reach', required: false, schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The reach', content: { "application/json": { schema: { type: "object", properties: { count: { type: "integer" }, capped: { type: "boolean" }, ceiling: { type: "string", nullable: true }, dormant: { type: "boolean" }, reason: { type: "string", nullable: true }, permitted: { type: "boolean", nullable: true } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown audience name, or a non-integer segment id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.previewAudience,
)
// ── Rules ─────────────────────────────────────────────────────────────────
engagementRouter.get(
'/rules',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every engagement rule, annotated with dormancy'
// #swagger.description = 'A rule whose trigger, channel or audience segment is not registered right now is listed with `dormant: true` and the reasons why, never deleted and never auto-disabled — an uninstalled module must not destroy an operator configuration.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The rules', content: { "application/json": { schema: { type: "object", properties: { rules: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listRules,
)
engagementRouter.post(
'/rules',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Create an engagement rule'
// #swagger.description = 'A new rule must name a trigger that is registered right now — there is nothing to preserve and a typo should be caught here. It arrives with `enabled` false unless asked otherwise, and its audience is checked against the trigger declared ceiling: an operator may narrow a rule reach and may never widen it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { triggerId: { type: "string" }, name: { type: "string" }, enabled: { type: "boolean" }, audience: { type: "string" }, audienceSegmentId: { type: "integer", nullable: true }, channels: { type: "array", items: { type: "string" } }, templateKeys: { type: "object", additionalProperties: { type: "string" } }, conditions: { type: "object", nullable: true, additionalProperties: true }, cooldownSeconds: { type: "integer" }, delaySeconds: { type: "integer" }, cancelOn: { type: "array", items: { type: "string" } }, maxSendsPerHour: { type: "integer" } }, required: ["triggerId", "name", "channels"] } } } } */
/* #swagger.responses[201] = { description: 'The created rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.createRule,
)
engagementRouter.get(
'/rules/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Read one engagement rule'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.getRule,
)
engagementRouter.put(
'/rules/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Update an engagement rule'
// #swagger.description = 'The trigger is NOT updatable: a rule cooldowns, its pending outbox rows and its send-log history are all about one trigger, and re-pointing the rule silently re-attributes them. An existing rule may keep naming a trigger nobody currently registers, so that a dormant rule stays editable until its module comes back.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, enabled: { type: "boolean" }, audience: { type: "string" }, audienceSegmentId: { type: "integer", nullable: true }, channels: { type: "array", items: { type: "string" } }, templateKeys: { type: "object", additionalProperties: { type: "string" } }, conditions: { type: "object", nullable: true, additionalProperties: true }, cooldownSeconds: { type: "integer" }, delaySeconds: { type: "integer" }, cancelOn: { type: "array", items: { type: "string" } }, maxSendsPerHour: { type: "integer" } } } } } } */
/* #swagger.responses[200] = { description: 'The updated rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.updateRule,
)
engagementRouter.patch(
'/rules/:id/enabled',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Turn one rule on or off'
// #swagger.description = 'Writes that column and nothing else, without re-validating the rule. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating update would refuse to save. Turning one on is safe without re-validation because the engine re-checks the ceiling at send time.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { enabled: { type: "boolean" } }, required: ["enabled"] } } } } */
/* #swagger.responses[200] = { description: 'The rule, with its new state', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'enabled was not a boolean', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.setRuleEnabled,
)
engagementRouter.delete(
'/rules/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Delete an engagement rule'
// #swagger.description = 'Its cooldown rows and any still-pending outbox rows go with it, and neither means anything without the rule. The send log does NOT — `engagement_sends.rule_id` carries no foreign key — so the record of what was actually mailed outlives the rule.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.deleteRule,
)
// ── Audience segments ─────────────────────────────────────────────────────
engagementRouter.get(
'/segments',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every saved audience segment, annotated with dormancy'
// #swagger.description = 'A segment naming an audience whose module has been uninstalled is dormant: it is listed with the missing ids, it resolves to nobody, and it works again when the module comes back.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The segments', content: { "application/json": { schema: { type: "object", properties: { segments: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listSegments,
)
engagementRouter.post(
'/segments',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Save a new audience segment'
// #swagger.description = 'The expression is a boolean tree of module-declared audiences. `not` is legal only as a child of `and`, because a complement needs a universe and the only one that does not widen is the set its siblings produced. The ceiling is DERIVED as the narrowest in the tree and is never taken from the caller; two incomparable ceilings have no meet and the composition is refused rather than guessed.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, expression: { type: "object", additionalProperties: true } }, required: ["name", "expression"] } } } } */
/* #swagger.responses[201] = { description: 'The created segment, with its derived ceiling', content: { "application/json": { schema: { type: "object", properties: { segment: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.createSegment,
)
engagementRouter.put(
'/segments/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Update an audience segment'
// #swagger.description = 'The ceiling is re-derived from the new expression. A rule already pointing at this segment took the ceiling stored at ITS save time, so narrowing a segment does not retroactively widen anything and the engine re-checks at send time either way.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, expression: { type: "object", additionalProperties: true } }, required: ["name", "expression"] } } } } */
/* #swagger.responses[200] = { description: 'The updated segment', content: { "application/json": { schema: { type: "object", properties: { segment: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such segment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.updateSegment,
)
engagementRouter.delete(
'/segments/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Delete an audience segment'
// #swagger.description = 'Refused with 409 while any rule still points at it, and the message carries the count. There is no foreign key doing this: CASCADE would delete an operator rules and SET NULL would silently fall each rule back to its plain audience column, which reaches a DIFFERENT set of people.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[409] = { description: 'Rules still use this segment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.deleteSegment,
)
module.exports = engagementRouter

View File

@@ -1137,6 +1137,107 @@
}
}
},
"/api/v1/admin/engagement/audience-preview": {
"get": {
"tags": [
"Admin · Engagement"
],
"summary": "Count how many users an audience or segment reaches right now",
"description": "Runs the same resolver the engine runs, and returns a COUNT ONLY — never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true when the count hit the 5000-row audience bound and is therefore a floor rather than a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries.",
"parameters": [
{
"name": "audience",
"in": "query",
"description": "A ceiling name (owner, staff, subscribers, members, authenticated, everyone). Ignored when audienceSegmentId is given.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "audienceSegmentId",
"in": "query",
"description": "A saved segment to resolve instead of a plain audience",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "triggerId",
"in": "query",
"description": "The rule trigger, used to resolve a subscribers audience and to report whether the trigger ceiling permits this reach",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The reach",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"capped": {
"type": "boolean"
},
"ceiling": {
"type": "string",
"nullable": true
},
"dormant": {
"type": "boolean"
},
"reason": {
"type": "string",
"nullable": true
},
"permitted": {
"type": "boolean",
"nullable": true
}
}
}
}
}
},
"400": {
"description": "Unknown audience name, or a non-integer segment id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/engagement/audiences": {
"get": {
"tags": [
@@ -1192,6 +1293,780 @@
]
}
},
"/api/v1/admin/engagement/channels": {
"get": {
"tags": [
"Admin · Engagement"
],
"summary": "List every registered delivery channel a rule may send on",
"description": "From the delivery-channel registry, so the rule editor offers exactly the set the save path checks against. A channel registered by a module appears here without a client release.",
"responses": {
"200": {
"description": "The registered channels",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"channels": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"defaultMode": {
"type": "string"
}
}
}
}
}
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/engagement/rules": {
"get": {
"tags": [
"Admin · Engagement"
],
"summary": "List every engagement rule, annotated with dormancy",
"description": "A rule whose trigger, channel or audience segment is not registered right now is listed with `dormant: true` and the reasons why, never deleted and never auto-disabled — an uninstalled module must not destroy an operator configuration.",
"responses": {
"200": {
"description": "The rules",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"rules": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Admin · Engagement"
],
"summary": "Create an engagement rule",
"description": "A new rule must name a trigger that is registered right now — there is nothing to preserve and a typo should be caught here. It arrives with `enabled` false unless asked otherwise, and its audience is checked against the trigger declared ceiling: an operator may narrow a rule reach and may never widen it.",
"responses": {
"201": {
"description": "The created rule",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"rule": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Validation failed; `errors` lists every problem",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"triggerId": {
"type": "string"
},
"name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"audience": {
"type": "string"
},
"audienceSegmentId": {
"type": "integer",
"nullable": true
},
"channels": {
"type": "array",
"items": {
"type": "string"
}
},
"templateKeys": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"conditions": {
"type": "object",
"nullable": true,
"additionalProperties": true
},
"cooldownSeconds": {
"type": "integer"
},
"delaySeconds": {
"type": "integer"
},
"cancelOn": {
"type": "array",
"items": {
"type": "string"
}
},
"maxSendsPerHour": {
"type": "integer"
}
},
"required": [
"triggerId",
"name",
"channels"
]
}
}
}
}
}
},
"/api/v1/admin/engagement/rules/{id}": {
"get": {
"tags": [
"Admin · Engagement"
],
"summary": "Read one engagement rule",
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The rule",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"rule": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"404": {
"description": "No such rule",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"put": {
"tags": [
"Admin · Engagement"
],
"summary": "Update an engagement rule",
"description": "The trigger is NOT updatable: a rule cooldowns, its pending outbox rows and its send-log history are all about one trigger, and re-pointing the rule silently re-attributes them. An existing rule may keep naming a trigger nobody currently registers, so that a dormant rule stays editable until its module comes back.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The updated rule",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"rule": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Validation failed; `errors` lists every problem",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such rule",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"audience": {
"type": "string"
},
"audienceSegmentId": {
"type": "integer",
"nullable": true
},
"channels": {
"type": "array",
"items": {
"type": "string"
}
},
"templateKeys": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"conditions": {
"type": "object",
"nullable": true,
"additionalProperties": true
},
"cooldownSeconds": {
"type": "integer"
},
"delaySeconds": {
"type": "integer"
},
"cancelOn": {
"type": "array",
"items": {
"type": "string"
}
},
"maxSendsPerHour": {
"type": "integer"
}
}
}
}
}
}
},
"delete": {
"tags": [
"Admin · Engagement"
],
"summary": "Delete an engagement rule",
"description": "Its cooldown rows and any still-pending outbox rows go with it, and neither means anything without the rule. The send log does NOT — `engagement_sends.rule_id` carries no foreign key — so the record of what was actually mailed outlives the rule.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Deleted"
},
"404": {
"description": "No such rule",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/engagement/rules/{id}/enabled": {
"patch": {
"tags": [
"Admin · Engagement"
],
"summary": "Turn one rule on or off",
"description": "Writes that column and nothing else, without re-validating the rule. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating update would refuse to save. Turning one on is safe without re-validation because the engine re-checks the ceiling at send time.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The rule, with its new state",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"rule": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "enabled was not a boolean",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such rule",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
]
}
}
}
}
}
},
"/api/v1/admin/engagement/segments": {
"get": {
"tags": [
"Admin · Engagement"
],
"summary": "List every saved audience segment, annotated with dormancy",
"description": "A segment naming an audience whose module has been uninstalled is dormant: it is listed with the missing ids, it resolves to nobody, and it works again when the module comes back.",
"responses": {
"200": {
"description": "The segments",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"segments": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Admin · Engagement"
],
"summary": "Save a new audience segment",
"description": "The expression is a boolean tree of module-declared audiences. `not` is legal only as a child of `and`, because a complement needs a universe and the only one that does not widen is the set its siblings produced. The ceiling is DERIVED as the narrowest in the tree and is never taken from the caller; two incomparable ceilings have no meet and the composition is refused rather than guessed.",
"responses": {
"201": {
"description": "The created segment, with its derived ceiling",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"segment": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Validation failed; `errors` lists every problem",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"expression": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"name",
"expression"
]
}
}
}
}
}
},
"/api/v1/admin/engagement/segments/{id}": {
"put": {
"tags": [
"Admin · Engagement"
],
"summary": "Update an audience segment",
"description": "The ceiling is re-derived from the new expression. A rule already pointing at this segment took the ceiling stored at ITS save time, so narrowing a segment does not retroactively widen anything and the engine re-checks at send time either way.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The updated segment",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"segment": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Validation failed; `errors` lists every problem",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such segment",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"expression": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"name",
"expression"
]
}
}
}
}
},
"delete": {
"tags": [
"Admin · Engagement"
],
"summary": "Delete an audience segment",
"description": "Refused with 409 while any rule still points at it, and the message carries the count. There is no foreign key doing this: CASCADE would delete an operator rules and SET NULL would silently fall each rule back to its plain audience column, which reaches a DIFFERENT set of people.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Deleted"
},
"409": {
"description": "Rules still use this segment",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/engagement/triggers": {
"get": {
"tags": [
@@ -1201,7 +2076,7 @@
"description": "Served from the module registries, not from a table: a trigger is declared in code by core or by an installed module, so this is whatever registered on this boot. Each declaration carries the variables a template may interpolate (with an example per variable, for preview and test-send) and the widest audience a rule may ever give it.",
"responses": {
"200": {
"description": "The declared triggers, the audience-ceiling vocabulary, and the variable types",
"description": "The declared triggers, the audience-ceiling vocabulary, the variable types and the condition operators",
"content": {
"application/json": {
"schema": {
@@ -1232,6 +2107,13 @@
"items": {
"type": "string"
}
},
"operators": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}

View File

@@ -0,0 +1,469 @@
// ── The engagement admin surface (ENGAGEMENT.md Phase 4b) ──────────────────
//
// Phase 4a built the engine and the save-path validation with **no HTTP surface
// at all**; this is the surface, and these tests are about the things the routes
// decide rather than the things the model already decided. `engagementEngine`
// covers validation, ceilings and dormancy at the model layer — re-asserting
// them here would be a second copy of a test rather than a second test.
//
// What is genuinely new, and what each of these is about:
//
// • **the enable switch does not re-validate.** Turning a rule OFF is the panic
// button, and it has to work on the rule an operator most wants stopped — one
// whose module has been uninstalled, or whose trigger has since narrowed its
// ceiling under a saved audience. Those are exactly the rules a re-validating
// PUT refuses to save, so a toggle built on PUT is broken in precisely the
// case it is needed.
// • **the trigger is not updatable.** A rule's cooldowns, its pending outbox
// rows and its send-log history are all about one trigger id.
// • **deleting a segment a rule uses is 409, with the count**, because the
// database is deliberately not doing this (no foreign key: CASCADE deletes an
// operator's rules, SET NULL silently mails a different set of people).
// • **the reach preview is a count and never a list**, it says when it hit the
// 5000-row audience bound, and it says when the trigger's ceiling would
// refuse the audience it just counted.
//
// The `.db` layer is stubbed in-memory and the real models and controllers run
// against it, the shape `engagementEngine.test.js` uses.
//
// Point the DB at a closed port before requiring anything: the registries reach
// utils/discordAnnounce, which builds the pool at require time.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const channels = require('../src/engagement/channels')
const ctrl = require('../src/router/v1/admin/engagement.controller')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const segmentsDb = require('../src/model/engagement/engagementSegments.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const db = require('../src/utils/db')
after(() => db.close())
// ── In-memory stand-ins for the two tables the surface writes ──────────────
let store
const originals = {}
for (const [name, mod] of [['rulesDb', rulesDb], ['segmentsDb', segmentsDb], ['recipients', recipients]]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
}
function installStubs() {
store = { rules: new Map(), segments: new Map(), users: new Map(), nextRule: 1, nextSegment: 1 }
rulesDb.list = async () => [...store.rules.values()].map((r) => ({ ...r }))
rulesDb.getById = async (id) => (store.rules.has(id) ? { ...store.rules.get(id) } : null)
rulesDb.insert = async (rule) => {
const id = store.nextRule++
store.rules.set(id, { id, ...rule })
return id
}
// Mirrors the real UPDATE statement, which does NOT carry trigger_id. That
// omission is the behaviour one of the tests below is about, so the stub has
// to reproduce it rather than helpfully assign the whole object.
rulesDb.update = async (id, rule) => {
const existing = store.rules.get(id)
if (!existing) return
const { trigger_id: _ignored, ...rest } = rule
Object.assign(existing, rest)
}
rulesDb.setEnabled = async (id, enabled, updatedBy) => {
const existing = store.rules.get(id)
if (existing) Object.assign(existing, { enabled: Boolean(enabled), updated_by: updatedBy })
}
rulesDb.remove = async (id) => store.rules.delete(id)
rulesDb.countUsingSegment = async (segmentId) =>
[...store.rules.values()].filter((r) => r.audience_segment_id === segmentId).length
segmentsDb.list = async () => [...store.segments.values()].map((s) => ({ ...s }))
segmentsDb.getById = async (id) => (store.segments.has(id) ? { ...store.segments.get(id) } : null)
segmentsDb.insert = async (segment) => {
const id = store.nextSegment++
store.segments.set(id, { id, ...segment })
return id
}
segmentsDb.update = async (id, segment) => Object.assign(store.segments.get(id) || {}, segment)
segmentsDb.remove = async (id) => store.segments.delete(id)
const activeIds = () => [...store.users.values()].filter((u) => u.status === 'active').map((u) => u.id)
recipients.active = async (limit = recipients.MAX_AUDIENCE) => activeIds().slice(0, limit)
recipients.staff = async (roles, limit = recipients.MAX_AUDIENCE) =>
[...store.users.values()]
.filter((u) => u.status === 'active' && roles.includes(u.role))
.map((u) => u.id)
.slice(0, limit)
recipients.subscribers = async () => []
recipients.filterActive = async (ids) =>
[...new Set(ids)].filter((id) => store.users.get(id)?.status === 'active')
}
// ── Fixtures ───────────────────────────────────────────────────────────────
const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', ...over })
function register(owner, fn) {
const api = registries.stage(owner)
fn(api)
registries.apply(api.staged)
}
const IDOC_TRIGGER = {
id: 'uo.house.idoc_warning',
label: 'House approaching collapse',
ceiling: 'owner',
audience: 'owner',
subjectKey: 'house',
variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }],
}
const registerUoTrigger = (over = {}) =>
register('uo', (api) => api.registerEventTriggers([{ ...IDOC_TRIGGER, ...over }]))
function registerChannels() {
channels._reset()
delete require.cache[require.resolve('../src/engagement/coreChannels')]
// eslint-disable-next-line global-require
require('../src/engagement/coreChannels')
}
/** The controller signature is (req, res, next); this is the res half of it. */
function mockRes() {
return {
statusCode: 200,
body: null,
ended: false,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
end() { this.ended = true; return this },
}
}
/** Call a controller and fail the test on an unexpected throw, not silently. */
async function call(handler, req) {
const res = mockRes()
let thrown = null
await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => {
thrown = err
})
if (thrown) throw thrown
return res
}
const validRule = (over = {}) => ({
triggerId: 'uo.house.idoc_warning',
name: 'IDOC warning',
channels: ['email'],
...over,
})
beforeEach(() => {
registries._reset()
registerChannels()
installStubs()
registerUoTrigger()
})
afterEach(() => {
registries._reset()
restoreOriginals()
})
// ── Rules: create, list, update ────────────────────────────────────────────
test('a created rule arrives disabled unless it says otherwise', async () => {
const res = await call(ctrl.createRule, { body: validRule() })
assert.equal(res.statusCode, 201)
assert.equal(res.body.rule.enabled, false)
assert.equal(res.body.rule.trigger_id, 'uo.house.idoc_warning')
// §7.1 Q3: rules-as-data is only safe because of the hourly cap, so a rule
// that never mentions one still has one.
assert.equal(res.body.rule.max_sends_per_hour, 100)
})
test('an audience wider than the trigger permits is refused, and the reason is in errors[]', async () => {
const res = await call(ctrl.createRule, { body: validRule({ audience: 'everyone' }) })
assert.equal(res.statusCode, 400)
assert.ok(Array.isArray(res.body.errors) && res.body.errors.length)
assert.match(res.body.errors.join(' '), /wider than trigger/)
// `message` is the first sentence, for a toast; `errors` is the whole list,
// for a form putting each one beside its field.
assert.equal(res.body.message, res.body.errors[0])
})
test('the rules list flags a rule whose trigger is no longer registered, and does not drop it', async () => {
await call(ctrl.createRule, { body: validRule() })
registries._reset()
const res = await call(ctrl.listRules, {})
assert.equal(res.body.rules.length, 1)
assert.equal(res.body.rules[0].dormant, true)
assert.match(res.body.rules[0].dormantReasons.join(' '), /is not registered/)
})
test('updating a rule cannot re-point it at another trigger', async () => {
register('uo', (api) =>
api.registerEventTriggers([{ ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'Repaired' }]),
)
const created = await call(ctrl.createRule, { body: validRule() })
const id = created.body.rule.id
const res = await call(ctrl.updateRule, {
params: { id: String(id) },
body: { ...validRule({ triggerId: 'uo.house.repaired' }), name: 'renamed' },
})
assert.equal(res.statusCode, 200)
assert.equal(res.body.rule.name, 'renamed')
// A rule's cooldown rows, pending outbox rows and send-log history are all
// about one trigger. Re-pointing it would silently re-attribute all three.
assert.equal(res.body.rule.trigger_id, 'uo.house.idoc_warning')
})
// ── The enable switch: the property that made it its own route ─────────────
test('a rule whose module is gone can still be switched OFF', async () => {
const created = await call(ctrl.createRule, { body: validRule({ enabled: true }) })
const id = created.body.rule.id
// The module is uninstalled. This rule is now dormant, and it is also the rule
// an operator is most likely to want stopped.
registries._reset()
const res = await call(ctrl.setRuleEnabled, { params: { id: String(id) }, body: { enabled: false } })
assert.equal(res.statusCode, 200)
assert.equal(res.body.rule.enabled, false)
assert.equal(res.body.rule.dormant, true)
})
test('a full update of that same rule is refused — which is why the switch is not a PUT', async () => {
const created = await call(ctrl.createRule, { body: validRule({ enabled: true }) })
const id = created.body.rule.id
registerChannels()
channels._reset() // the module took its channel with it, too
const res = await call(ctrl.updateRule, { params: { id: String(id) }, body: validRule() })
assert.equal(res.statusCode, 400)
assert.match(res.body.errors.join(' '), /no channel "email" is registered/)
})
test('enabled must be a boolean, not a truthy string', async () => {
const created = await call(ctrl.createRule, { body: validRule() })
const res = await call(ctrl.setRuleEnabled, {
params: { id: String(created.body.rule.id) },
body: { enabled: 'false' },
})
assert.equal(res.statusCode, 400)
assert.equal(store.rules.get(created.body.rule.id).enabled, false)
})
test('toggling a rule that does not exist is 404, not a silent no-op', async () => {
const res = await call(ctrl.setRuleEnabled, { params: { id: '99' }, body: { enabled: false } })
assert.equal(res.statusCode, 404)
})
// ── Delete ─────────────────────────────────────────────────────────────────
test('deleting a rule answers 204 and removes it; deleting it twice is 404', async () => {
const created = await call(ctrl.createRule, { body: validRule() })
const id = String(created.body.rule.id)
const first = await call(ctrl.deleteRule, { params: { id } })
assert.equal(first.statusCode, 204)
assert.equal(store.rules.size, 0)
const second = await call(ctrl.deleteRule, { params: { id } })
assert.equal(second.statusCode, 404)
})
// ── Segments ───────────────────────────────────────────────────────────────
function registerAudiences() {
register('uo', (api) =>
api.registerAudiences([
{ id: 'uo.governors', label: 'Governors', ceiling: 'members', resolve: async () => [11, 12] },
{ id: 'uo.watchers', label: 'Watchers', ceiling: 'authenticated', resolve: async () => [10, 13] },
]),
)
}
test('a saved segment stores the DERIVED ceiling, never one the caller asked for', async () => {
registerAudiences()
const res = await call(ctrl.createSegment, {
body: {
name: 'Governors or watchers',
ceiling: 'everyone', // ignored: the ceiling is not the caller's to state
expression: { op: 'or', nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.watchers' }] },
},
})
assert.equal(res.statusCode, 201)
// members is below authenticated, so OR takes the TIGHTER of the two.
assert.equal(res.body.segment.ceiling, 'members')
})
test('a bare `not` is refused at save, with the sentence saying why', async () => {
registerAudiences()
const res = await call(ctrl.createSegment, {
body: { name: 'Everyone but governors', expression: { op: 'not', nodes: [{ audienceId: 'uo.governors' }] } },
})
assert.equal(res.statusCode, 400)
assert.match(res.body.errors.join(' '), /not/i)
})
test('deleting a segment a rule still uses is 409, and the count is in the message', async () => {
// A rule pointing at a `members` segment needs a trigger whose ceiling permits
// one, so this test re-registers the catalog rather than taking the default.
registries._reset()
registerUoTrigger({ ceiling: 'members', audience: 'members' })
registerAudiences()
const segment = await call(ctrl.createSegment, {
body: { name: 'Governors', expression: { audienceId: 'uo.governors' } },
})
const segmentId = segment.body.segment.id
await call(ctrl.createRule, { body: validRule({ audienceSegmentId: segmentId }) })
const refused = await call(ctrl.deleteSegment, { params: { id: String(segmentId) } })
assert.equal(refused.statusCode, 409)
assert.match(refused.body.message, /1 rule still use|1 rule/)
assert.equal(store.segments.size, 1)
})
test('the same segment deletes once no rule points at it', async () => {
registerAudiences()
const segment = await call(ctrl.createSegment, {
body: { name: 'Governors', expression: { audienceId: 'uo.governors' } },
})
const res = await call(ctrl.deleteSegment, { params: { id: String(segment.body.segment.id) } })
assert.equal(res.statusCode, 204)
assert.equal(store.segments.size, 0)
})
test('a rule whose segment still EXISTS but is dormant is itself dormant', async () => {
// The case a row-existence check misses, and the one the live walk found: the
// segment is still there, every audience in it belongs to a module that has
// been uninstalled, and the rule reaches nobody. Reported as healthy, it is an
// enabled rule that cannot fire and says nothing about it.
registries._reset()
registerUoTrigger({ ceiling: 'members', audience: 'members' })
registerAudiences()
const segment = await call(ctrl.createSegment, {
body: { name: 'Governors', expression: { audienceId: 'uo.governors' } },
})
await call(ctrl.createRule, {
body: validRule({ audienceSegmentId: segment.body.segment.id, enabled: true }),
})
// The module goes; the segment ROW stays exactly where it was.
registries._reset()
registerUoTrigger()
registerChannels()
const res = await call(ctrl.listRules, {})
assert.equal(store.segments.size, 1, 'the segment row is still there')
assert.equal(res.body.rules[0].dormant, true)
assert.match(res.body.rules[0].dormantReasons.join(' '), /uo\.governors/)
})
test('a segment naming an audience whose module is gone is listed as dormant, not dropped', async () => {
registerAudiences()
await call(ctrl.createSegment, {
body: { name: 'Governors', expression: { audienceId: 'uo.governors' } },
})
registries._reset()
const res = await call(ctrl.listSegments, {})
assert.equal(res.body.segments.length, 1)
assert.equal(res.body.segments[0].dormant, true)
assert.deepEqual(res.body.segments[0].missingAudiences, ['uo.governors'])
})
// ── Reach preview ──────────────────────────────────────────────────────────
test('the preview counts, and returns no identities of any kind', async () => {
addUser(1, { role: 'admin' })
addUser(2, { role: 'moderator' })
addUser(3)
const res = await call(ctrl.previewAudience, { query: { audience: 'staff' } })
assert.equal(res.body.count, 2)
assert.equal(res.body.ceiling, 'staff')
// Whatever else this response grows, it must never grow a list of people: the
// resolver's answer for a module-declared segment is a set of players derived
// from game data, and the rule editor is not a user-enumeration surface.
const serialised = JSON.stringify(res.body)
assert.equal(serialised.includes('userIds'), false)
assert.equal(/"(users|names|ids|sample)"/.test(serialised), false)
})
test('a count that hit the audience bound says so, rather than reading as a total', async () => {
for (let id = 1; id <= recipients.MAX_AUDIENCE; id += 1) addUser(id)
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
assert.equal(res.body.count, recipients.MAX_AUDIENCE)
assert.equal(res.body.capped, true)
})
test('an `owner` audience previews as 0 with the reason, because it resolves per event', async () => {
addUser(1)
const res = await call(ctrl.previewAudience, {
query: { audience: 'owner', triggerId: 'uo.house.idoc_warning' },
})
assert.equal(res.body.count, 0)
assert.match(res.body.reason, /ownerUserId/)
assert.equal(res.body.permitted, true)
})
test('the preview reports when the trigger ceiling would refuse what it just counted', async () => {
addUser(1, { role: 'admin' })
const res = await call(ctrl.previewAudience, {
query: { audience: 'staff', triggerId: 'uo.house.idoc_warning' },
})
// The count is real — those people exist — but this trigger is ceilinged
// `owner`, so saving a rule with it would be refused. Showing a healthy number
// with no other signal reads as a bug in the save.
assert.equal(res.body.count, 1)
assert.equal(res.body.permitted, false)
})
test('an audience name the lattice does not know is 400, not an empty count', async () => {
const res = await call(ctrl.previewAudience, { query: { audience: 'admins' } })
assert.equal(res.statusCode, 400)
})
// ── The catalog's third leg ────────────────────────────────────────────────
test('the channel catalog is served from the registry, defaults included', async () => {
const res = await call(ctrl.listChannels, {})
const email = res.body.channels.find((c) => c.id === 'email')
assert.ok(email, 'core registers an email channel')
// §7.1 Q1 / §3.1: every channel is opt-IN. The editor has to be able to say so.
assert.equal(email.defaultMode, 'off')
})