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>
303 lines
13 KiB
JavaScript
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)
|
|
}
|
|
}
|