feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b)
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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user