feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 10m36s

The admin surface over the Phase 4a engine: two screens, twelve routes and the
reach preview. Nothing in the engine changed; what changed is that an operator
can now reach it.

Four decisions settled by the org lead before any code:

  - segments get their OWN nav entry, "Audiences", not a tab of the rules screen
  - the on/off switch is its own PATCH route, not a full PUT
  - the reach preview is a count only, on demand
  - a rule can be hard-deleted; the send log survives it

The switch is the one with real content in it. A PUT re-validates against the
registries as they are NOW, so the rules a re-validating toggle cannot switch
off are exactly the three an operator most wants stopped: a rule whose module
was uninstalled, one naming a channel that is gone, and one whose trigger has
since narrowed its ceiling under a saved audience. PATCH .../enabled writes one
column and always works. Switching ON unvalidated is safe because the engine
re-checks the ceiling at send time.

The preview calls the engine's own resolver rather than a second query that
agrees with it today, and answers a count and nothing else - the resolver's
output for a module-declared segment is a set of players derived from game data.
It reports `capped` at the 5000-row bound (the count is a floor, not a total),
`reason` for an `owner` audience (which resolves per event and has no advance
answer), and `permitted` so the editor cannot show a healthy number beside a
save the server will refuse.

Two defects found by walking it against a live server, both in Phase 4a's code:

  1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked
     only whether the segment ROW existed. The other shape of the same failure
     is a segment sitting exactly where it was whose every audience belongs to
     an uninstalled module: same outcome, nothing deleted. Uninstalling a module
     under an enabled rule produced a rule the screen showed as on and firing.
     The expression walk now lives in engagement/segments.js as
     `missingAudiences` and both lists ask it.
  2. "1 rule still use this segment" - the delete refusal pluralised the noun
     and not the verb, in the sentence an operator reads when told no.

Also: a rule's trigger is now a stated rule rather than an omission in the
UPDATE statement (its cooldowns, queued sends and history are all about one
trigger id); a condition tree the editor cannot render is shown read-only rather
than flattened, because flattening changes which events fire the rule; and
literals are coerced client-side to the type the trigger declared, with anything
that does not parse passed through unchanged so the server's refusal names the
variable.

Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones
(client/test/engagementRules.test.js), all green. The single failure in the
server suite (`the committed manifest matches the declarations in the tree`) is
the known Windows CRLF artifact and fails identically on clean edge.

Companion docs PR: docs#184.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:10:04 -05:00
parent 4d3f574480
commit 4b45eddb5d
17 changed files with 3777 additions and 30 deletions

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`.