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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user