Files
website/server/src/router/v1/admin/engagement.controller.js
wtclaude 4b45eddb5d
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
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>
2026-08-29 12:10:04 -05:00

303 lines
13 KiB
JavaScript

// ── Admin: engagement ──────────────────────────────────────────────────────
//
// ENGAGEMENT.md Phase 2, G3 — the event catalog surface the admin UI needs in
// order to enumerate triggers. **Read-only, and entirely from the registries.**
// There is no table behind either route: a trigger is DECLARED in code by core
// or by a module (§4.3), so the catalog is whatever registered on this boot, and
// a module that was uninstalled simply stops appearing.
//
// That is also what makes the answer honest about dormancy later. §7.3's rule is
// that a rule pointing at an unregistered trigger shows as dormant, never as an
// error and never auto-deleted; a catalog served from a table would have to
// decide whether to delete rows on uninstall, and there is no right answer to
// that question. Serving it from the registry means there is no question.
//
// The rule and template editors (Phases 4 and 5) read these two endpoints: the
// variable list is what makes the editor's autocomplete real rather than blind
// 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
// client, because the client would be a second copy of a security rule and a
// second copy is a copy that drifts. The server is still the boundary — Phase 4
// re-checks every rule save against `ceilings.permits` — this is so the editor
// does not offer a choice it knows will be refused.
const ceilingVocabulary = () =>
ceilings.CEILINGS.map((id) => ({
id,
label: ceilings.LABELS[id],
permits: ceilings.CEILINGS.filter((other) => ceilings.permits(id, other)),
}))
/** GET /api/v1/admin/engagement/triggers */
exports.listTriggers = (req, res) => {
res.json({
triggers: registries.allTriggers(),
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(),
})
}
/** GET /api/v1/admin/engagement/audiences */
exports.listAudiences = (req, res) => {
// `allAudiences()` has already stripped each `resolve`. That stripping is in
// the registry rather than here for the same reason a slash command's handler
// is stripped there: it is the boundary the function must not cross, and a
// 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)
}
}