// ── 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) } }