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

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

View File

@@ -103,6 +103,27 @@ const update = (id, rule) =>
],
)
/**
* Flip `enabled` and nothing else (Phase 4b).
*
* Deliberately NOT a call through `validate`: turning a rule OFF is the panic
* button, and it has to work on a rule the registries would now refuse — one
* whose module was uninstalled, or whose trigger has since narrowed its ceiling
* underneath a saved audience. Re-validating on the way to `enabled = 0` would
* make exactly the rules an operator most wants to stop the ones they cannot.
*
* Turning a rule ON is safe without re-validation for a different reason: the
* engine re-runs the ceiling check at send time (audiences.permitted), so an
* enabled-but-no-longer-permitted rule resolves to nobody rather than to the
* wrong people.
*/
const setEnabled = (id, enabled, updatedBy = null) =>
query('UPDATE engagement_rules SET enabled = ?, updated_by = ? WHERE id = ?', [
enabled ? 1 : 0,
updatedBy,
id,
])
const remove = (id) => query('DELETE FROM engagement_rules WHERE id = ?', [id])
/** Does any rule still point at this segment? The check before a segment delete. */
@@ -121,6 +142,7 @@ module.exports = {
enabledCancelledBy,
insert,
update,
setEnabled,
remove,
countUsingSegment,
parseJson,

View File

@@ -23,6 +23,7 @@ const segmentsDb = require('./engagementSegments.db')
const registries = require('../../modules/registries')
const ceilings = require('../../modules/ceilings')
const channels = require('../../engagement/channels')
const segmentExpressions = require('../../engagement/segments')
const conditions = require('../../engagement/conditions')
// A day. Longer than this and "cooldown" is really "send once", which a rule
@@ -192,6 +193,48 @@ async function update(id, input) {
return { ok: true, rule: await db.getById(id) }
}
/**
* Why this rule cannot currently fire, as a list of sentences. Empty = it can.
*
* **Three ways, not two.** A rule can be dormant because its trigger is gone,
* because a channel it names is gone, or because its AUDIENCE is gone - and the
* audience case has two shapes that a screen must not collapse into one:
*
* • the segment row was deleted out from under it (§7.3), or
* • the segment still exists and every audience in it belongs to a module that
* has been uninstalled (§5.1a rule 4).
*
* Both leave the rule reaching nobody. Only the first leaves nothing behind, and
* a check that asks only "does the row exist" reports the first and misses the
* second - which shows an enabled, healthy-looking rule that cannot fire. Found
* by uninstalling a module under a live rule while building Phase 4b's screen.
*
* @param {Map<number, {expression: object}>} segments every segment, by id
*/
function dormancyReasons(rule, segments) {
const reasons = []
if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`)
if (rule.audience_segment_id) {
const segment = segments.get(rule.audience_segment_id)
if (!segment) reasons.push('its audience segment no longer exists')
else {
const missing = segmentExpressions.missingAudiences(segment.expression)
if (missing.length) {
reasons.push(`its audience "${segment.name}" uses ${missing.join(', ')}, which nothing registers`)
}
}
}
for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`)
return reasons
}
const annotate = (rule, segments) => {
const reasons = dormancyReasons(rule, segments)
return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons }
}
const segmentsById = async () => new Map((await segmentsDb.list()).map((s) => [s.id, s]))
/**
* List every rule, each annotated with whether it can currently fire.
*
@@ -202,23 +245,59 @@ async function update(id, input) {
*/
async function listAnnotated() {
const rows = await db.list()
const segments = new Map((await segmentsDb.list()).map((s) => [s.id, s]))
return rows.map((rule) => {
const reasons = []
if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`)
if (rule.audience_segment_id && !segments.has(rule.audience_segment_id)) {
reasons.push('its audience segment no longer exists')
}
for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`)
return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons }
})
const segments = await segmentsById()
return rows.map((rule) => annotate(rule, segments))
}
/** One rule with the same dormancy annotation the list carries, or null. */
async function getAnnotated(id) {
const rule = await db.getById(id)
if (!rule) return null
return annotate(rule, await segmentsById())
}
/**
* Turn one rule on or off, writing that column and no other (Phase 4b).
*
* This is the one write path that does NOT go through `validate`, and the
* asymmetry is deliberate. Switching a rule OFF must always be possible - a rule
* whose module has been uninstalled, or whose trigger has since narrowed its
* ceiling under a saved audience, is exactly the rule an operator most urgently
* wants stopped, and it is exactly the rule `validate` would now refuse. The
* full editor still re-validates on save, and the engine re-checks the ceiling at
* send time, so nothing is loosened by having a switch that is only a switch.
*/
async function setEnabled(id, enabled, updatedBy = null) {
const existing = await db.getById(id)
if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true }
await db.setEnabled(id, enabled, updatedBy)
return { ok: true, rule: await getAnnotated(id) }
}
/**
* Delete a rule.
*
* Its cooldown rows and any still-pending outbox rows go with it (both carry an
* ON DELETE CASCADE), and that is the right blast radius: neither means anything
* without the rule. `engagement_sends` deliberately does NOT — its `rule_id`
* carries no foreign key — so the send log outlives the rule and the record of
* what was actually mailed survives an operator tidying up.
*/
async function remove(id) {
const existing = await db.getById(id)
if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true }
await db.remove(id)
return { ok: true }
}
module.exports = {
validate,
create,
update,
setEnabled,
remove,
listAnnotated,
getAnnotated,
MAX_COOLDOWN_SECONDS,
MAX_DELAY_SECONDS,
MAX_SENDS_PER_HOUR,

View File

@@ -11,7 +11,6 @@
const db = require('./engagementSegments.db')
const rulesDb = require('./engagementRules.db')
const registries = require('../../modules/registries')
const segments = require('../../engagement/segments')
async function save(input, { id = null } = {}) {
@@ -56,7 +55,7 @@ async function remove(id) {
return {
ok: false,
inUse,
errors: [`${inUse} rule${inUse === 1 ? '' : 's'} still use this segment`],
errors: [`${inUse} rule${inUse === 1 ? ' still uses' : 's still use'} this segment`],
}
}
await db.remove(id)
@@ -73,14 +72,11 @@ async function remove(id) {
async function listAnnotated() {
const rows = await db.list()
return rows.map((segment) => {
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(segment.expression)
return { ...segment, dormant: missing.length > 0, missingAudiences: [...new Set(missing)] }
// The walk lives in segments.js so the rule list can ask the same question:
// a rule pointing at a DORMANT segment is dormant too, and asking only
// whether the segment row still exists misses that (§5.1a rule 4).
const missing = segments.missingAudiences(segment.expression)
return { ...segment, dormant: missing.length > 0, missingAudiences: missing }
})
}

View File

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

View File

@@ -1,16 +1,16 @@
// Admin · Engagement — the declared event catalog (ENGAGEMENT.md Phase 2).
// Admin · Engagement — the declared event catalog (Phase 2) and the rules and
// audience segments an operator configures over it (Phase 4b).
//
// Mounted at /api/v1/admin/engagement by admin/index.js, which has already
// applied `noindex, isLoggedIn, staffOnly`. Both routes re-gate to `admin`.
// applied `noindex, isLoggedIn, staffOnly`. Every route re-gates to `admin`.
//
// Admin rather than staff-wide, deliberately. Nothing here is writable yet, but
// this is the entry point of the screen that decides who receives mail, and the
// declarations it serves name every variable a template may interpolate. A
// Admin rather than staff-wide, deliberately. This is the group that decides who
// receives mail: the declarations it serves name every variable a template may
// interpolate, and the writes below are how a deployment starts sending. A
// capability is easier to widen later with a reason than to narrow after an
// editor has been using it.
//
// Rules, templates and the send log arrive under this same prefix in Phases 4
// and 5, which is why the group exists now with two read routes in it.
// Templates and the send log arrive under this same prefix in Phase 5.
const express = require('express')
@@ -20,13 +20,15 @@ const { requireRole } = require('../../../utils/auth')
const engagementRouter = express.Router()
const adminOnly = requireRole('admin')
// ── The catalog: three read routes, all served from the registries ─────────
engagementRouter.get(
'/triggers',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every declared event trigger, with its payload contract and audience ceiling'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The declared triggers, the audience-ceiling vocabulary, and the variable types', content: { "application/json": { schema: { type: "object", properties: { triggers: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } }, variableTypes: { type: "array", items: { type: "string" } }, kinds: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[200] = { description: 'The declared triggers, the audience-ceiling vocabulary, the variable types and the condition operators', content: { "application/json": { schema: { type: "object", properties: { triggers: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } }, variableTypes: { type: "array", items: { type: "string" } }, kinds: { type: "array", items: { type: "string" } }, operators: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listTriggers,
@@ -44,4 +46,169 @@ engagementRouter.get(
controller.listAudiences,
)
engagementRouter.get(
'/channels',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every registered delivery channel a rule may send on'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.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" } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listChannels,
)
// ── Reach preview ─────────────────────────────────────────────────────────
//
// Declared ahead of /rules/:id so the literal path is never read as an id.
engagementRouter.get(
'/audience-preview',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Count how many users an audience or segment reaches right now'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['audience'] = { in: 'query', description: 'A ceiling name (owner, staff, subscribers, members, authenticated, everyone). Ignored when audienceSegmentId is given.', required: false, schema: { type: 'string' } }
// #swagger.parameters['audienceSegmentId'] = { in: 'query', description: 'A saved segment to resolve instead of a plain audience', required: false, schema: { type: 'integer' } }
// #swagger.parameters['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' } }
/* #swagger.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 } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown audience name, or a non-integer segment id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.previewAudience,
)
// ── Rules ─────────────────────────────────────────────────────────────────
engagementRouter.get(
'/rules',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every engagement rule, annotated with dormancy'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The rules', content: { "application/json": { schema: { type: "object", properties: { rules: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listRules,
)
engagementRouter.post(
'/rules',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Create an engagement rule'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.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"] } } } } */
/* #swagger.responses[201] = { description: 'The created rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.createRule,
)
engagementRouter.get(
'/rules/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Read one engagement rule'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.getRule,
)
engagementRouter.put(
'/rules/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Update an engagement rule'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.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" } } } } } } */
/* #swagger.responses[200] = { description: 'The updated rule', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.updateRule,
)
engagementRouter.patch(
'/rules/:id/enabled',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Turn one rule on or off'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { enabled: { type: "boolean" } }, required: ["enabled"] } } } } */
/* #swagger.responses[200] = { description: 'The rule, with its new state', content: { "application/json": { schema: { type: "object", properties: { rule: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'enabled was not a boolean', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.setRuleEnabled,
)
engagementRouter.delete(
'/rules/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Delete an engagement rule'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[404] = { description: 'No such rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.deleteRule,
)
// ── Audience segments ─────────────────────────────────────────────────────
engagementRouter.get(
'/segments',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every saved audience segment, annotated with dormancy'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The segments', content: { "application/json": { schema: { type: "object", properties: { segments: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listSegments,
)
engagementRouter.post(
'/segments',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Save a new audience segment'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, expression: { type: "object", additionalProperties: true } }, required: ["name", "expression"] } } } } */
/* #swagger.responses[201] = { description: 'The created segment, with its derived ceiling', content: { "application/json": { schema: { type: "object", properties: { segment: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.createSegment,
)
engagementRouter.put(
'/segments/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Update an audience segment'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, expression: { type: "object", additionalProperties: true } }, required: ["name", "expression"] } } } } */
/* #swagger.responses[200] = { description: 'The updated segment', content: { "application/json": { schema: { type: "object", properties: { segment: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'Validation failed; `errors` lists every problem', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such segment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.updateSegment,
)
engagementRouter.delete(
'/segments/:id',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'Delete an audience segment'
// #swagger.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.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[409] = { description: 'Rules still use this segment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.deleteSegment,
)
module.exports = engagementRouter