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

@@ -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
}
}
}
}