ENGAGEMENT.md Phase 14, the last phase of the workstream. Four engagement
tables grew on every fire and nothing had ever deleted from any of them.
Three of them now have a horizon, swept nightly by one worker
(utils/engagementRetentionPrune.js — setInterval + unref + stop(), batched
1000 x 50, each table's failure caught on its own so a lock timeout on one
does not leave the other two unbounded):
engagement_sends 180 days engagement_sends_retain_days (7-3650)
engagement_cooldowns 30 days engagement_cooldowns_retain_days (2-3650)
engagement_outbox 30 days engagement_outbox_retain_days (2-3650)
The fourth, engagement_suppressions, does not expire, and that is the
recorded decision rather than an omission: a suppression is a standing
decision, and ageing out a hard bounce re-mails an address that already
bounced. The way out stays deliberate, and is now reachable per row.
Six decisions were settled by the org lead before any code. Two of them
widened the phase past what was offered:
* the send-log horizon is admin-configurable, so retention got a SCREEN
(Admin -> Engagement -> Retention) where team_activity and
user_notifications keep theirs in invisible settings rows. The send-log
horizon changes what an operator-facing page is able to show, so it has
to be visible; the other two came with it, because "what does this
deployment keep" is one question.
* the suppression purge, which cost a Phase 9 decision. The list
deliberately stripped address_hash from every row, so the only way out
was a window.prompt asking the operator to retype an address the screen
has never shown them. The row had no handle at all. The hash is now
returned: this route is admin-only and an admin can already suppress and
unsuppress any address they can name, so it grants no capability they
lack. GET /sends still strips its own.
The outbox sweep is TERMINAL-ONLY and that is a correctness rule: a
scheduled row is a send this deployment still intends to make (delay_seconds
can put one a day out) and a sending row may be mid-flight.
One shipped defect had to be fixed for the sweep to be a bound at all.
reclaimStale returned every stale sending row to scheduled, and MAX_ATTEMPTS
is consulted only on a graceful retry outcome — so a send that killed the
process mid-flight cycled sending -> scheduled -> sending forever, never
terminal, therefore never eligible for any sweep. It now fails an exhausted
row BEFORE reclaiming the rest; the order is the fix.
Two indexes (idx_engo_sweep, idx_engs_sweep): every existing index on those
tables has created_at in second position, which serves a per-rule window and
is useless to a whole-table horizon.
Proved twice: engagementRetentionSql.test.js against a real MariaDB (7
tests, incl. the acceptance case and the wrong reclaim order run
deliberately), and the live stack, where a 90-day-old cancelled row was
swept and a 90-day-old scheduled row survived.
Co-Authored-By: Claude <noreply@anthropic.com>
429 lines
41 KiB
JavaScript
429 lines
41 KiB
JavaScript
// 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`. Every route re-gates to `admin`.
|
|
//
|
|
// 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.
|
|
//
|
|
// Templates and the send log arrive under this same prefix in Phase 5.
|
|
|
|
const express = require('express')
|
|
|
|
const controller = require('./engagement.controller')
|
|
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, 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,
|
|
)
|
|
|
|
engagementRouter.get(
|
|
'/audiences',
|
|
// #swagger.tags = ['Admin · Engagement']
|
|
// #swagger.summary = 'List every declared audience a rule may be pointed at'
|
|
// #swagger.description = 'Module-declared named sets of users, resolved over the module own data. The resolver itself is never served — an audience answers with user ids on the server side only.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.responses[200] = { description: 'The declared audiences and the audience-ceiling vocabulary', content: { "application/json": { schema: { type: "object", properties: { audiences: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
|
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
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,
|
|
)
|
|
|
|
|
|
// -- Templates (Phase 5b) --------------------------------------------------
|
|
//
|
|
// The editor's routes. Two of them are POSTs that write nothing -- preview and
|
|
// test-send -- because both act on the body in the request rather than on the
|
|
// stored row: an editor that could only preview what was already saved would make
|
|
// saving the way to find out whether a change was right.
|
|
|
|
engagementRouter.get(
|
|
'/templates',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'List every message template, annotated'
|
|
// #swagger.description = 'Each row carries three flags the list renders as warnings. `dormant`: the template is pinned to a trigger no installed module declares, so its variable palette cannot be checked. `triggerBehind`: the module is installed but has moved its declaration on past the version this template was authored against. `seedBehind`: a newer shipped default exists for the seed this row came from, and was NOT applied because a person had edited it.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.responses[200] = { description: 'The templates', content: { "application/json": { schema: { type: "object", properties: { templates: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
|
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.listTemplates,
|
|
)
|
|
|
|
engagementRouter.get(
|
|
'/templates/:id',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'One template, with the variables it may reference'
|
|
// #swagger.description = 'The `variables` array is the editor palette and comes from the trigger declaration (or, for a template tied to no trigger, from the shipped seed) merged with the ambient variables every template may use. It is served with the row so the editor never guesses what is legal.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.responses[200] = { description: 'The template', content: { "application/json": { schema: { type: "object", properties: { template: { type: "object", additionalProperties: true } } } } } } */
|
|
/* #swagger.responses[404] = { description: 'No such template', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.getTemplate,
|
|
)
|
|
|
|
engagementRouter.put(
|
|
'/templates/:id',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Edit a template, including a shipped default'
|
|
// #swagger.description = 'A seeded template is edited IN PLACE; the save sets `customized = 1`, which is what stops a later seed bump from taking the edit back. `key` and `channel` cannot be changed and a request that tries is refused rather than ignored - mailer renders by key, so a rename would break the message it names with no error anywhere. Two refusals are the point of this route: a token naming a variable the trigger does not declare is refused WITH THE VARIABLE NAMED, and a template published with no plain-text part is refused, because the text part is checked by rendering rather than by inspecting the blocks.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, status: { type: "string", enum: ["draft", "published"] }, triggerId: { type: "string", nullable: true } } } } } } */
|
|
/* #swagger.responses[200] = { description: 'The updated template', content: { "application/json": { schema: { type: "object", properties: { template: { 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 template', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.updateTemplate,
|
|
)
|
|
|
|
engagementRouter.post(
|
|
'/templates/:id/duplicate',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Copy a template under a new key'
|
|
// #swagger.description = 'The only way a template that is not a shipped seed comes into being, so every template on a deployment descends from one that renders. The copy always starts as a DRAFT whatever the original was, is never protected, and inherits the source seed reference - which is what keeps its variable palette, not bookkeeping.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { key: { type: "string" }, name: { type: "string" }, triggerId: { type: "string", nullable: true } }, required: ["key"] } } } } */
|
|
/* #swagger.responses[201] = { description: 'The new template', content: { "application/json": { schema: { type: "object", properties: { template: { type: "object", additionalProperties: true } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'The key is not a legal template key', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[409] = { description: 'That key is already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.duplicateTemplate,
|
|
)
|
|
|
|
engagementRouter.delete(
|
|
'/templates/:id',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Delete a template'
|
|
// #swagger.description = 'Refused with 409 for a protected template - the system breaks without a password-reset body, so those are editable and not deletable - and refused with 409 while any rule points at the key, naming the rules. The second is the answer a segment in use already gets, for the same reason: the alternative is a rule that silently stops producing mail.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.responses[204] = { description: 'Deleted' } */
|
|
/* #swagger.responses[409] = { description: 'Protected, or still used by a rule', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.deleteTemplate,
|
|
)
|
|
|
|
engagementRouter.post(
|
|
'/templates/:id/preview',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Render the draft on screen, without saving it'
|
|
// #swagger.description = 'Renders the body in the REQUEST, using the example value each variable declares, so no live game event is needed - which is why `example` is a required part of a trigger declaration rather than documentation. The HTML comes back as a JSON string and the client must render it inside a sandboxed iframe with no allow-scripts: operator-authored HTML served as a document from this origin would run under the site CSP with access to its cookies.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, triggerId: { type: "string", nullable: true } } } } } } */
|
|
/* #swagger.responses[200] = { description: 'Both parts, plus the variable palette and any variable with no value', content: { "application/json": { schema: { type: "object", properties: { subject: { type: "string" }, html: { type: "string" }, text: { type: "string" }, missing: { type: "array", items: { type: "string" } }, variables: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'The draft is not renderable; `errors` says why', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.previewTemplate,
|
|
)
|
|
|
|
engagementRouter.post(
|
|
'/templates/:id/test-send',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Send the draft on screen to one address'
|
|
// #swagger.description = 'Sends what is on screen, saved or not, through the configured transport, and records the attempt in the send log under a synthetic `core.admin.test-send` trigger - including when it fails, which is the outcome an operator most needs a record of. It deliberately does not consult channel preferences or the suppression list: the address is typed by an admin about their own deployment and is not derived from a user.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { to: { type: "string" }, subject: { type: "string", nullable: true }, blocks: { type: "array", items: { type: "object", additionalProperties: true } }, textBody: { type: "string", nullable: true }, triggerId: { type: "string", nullable: true } }, required: ["to"] } } } } */
|
|
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'No address, or the draft is not renderable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[409] = { description: 'Email is not configured on this deployment', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[502] = { description: 'The transport refused the message; the message is the relay reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.testSendTemplate,
|
|
)
|
|
|
|
// -- Send log (Phase 5b) ---------------------------------------------------
|
|
|
|
engagementRouter.get(
|
|
'/sends',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'The send log, newest first'
|
|
// #swagger.description = 'G15 answered: every terminal delivery outcome, success and failure alike, with the reason. `address_hash` is stored but never returned - the log keeps it so a bounce can be correlated back to a recipient, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
// #swagger.parameters['limit'] = { in: 'query', description: 'Page size, 1-200 (default 50)', required: false, schema: { type: 'integer' } }
|
|
// #swagger.parameters['offset'] = { in: 'query', description: 'Rows to skip', required: false, schema: { type: 'integer' } }
|
|
// #swagger.parameters['triggerId'] = { in: 'query', description: 'Only sends caused by this trigger', required: false, schema: { type: 'string' } }
|
|
// #swagger.parameters['ruleId'] = { in: 'query', description: 'Only sends made by this rule', required: false, schema: { type: 'integer' } }
|
|
// #swagger.parameters['userId'] = { in: 'query', description: 'Only sends to this user', required: false, schema: { type: 'integer' } }
|
|
// #swagger.parameters['status'] = { in: 'query', description: 'sent, failed, suppressed, bounced or complained', required: false, schema: { type: 'string' } }
|
|
/* #swagger.responses[200] = { description: 'One page of the log, with the total matching the same filters', content: { "application/json": { schema: { type: "object", properties: { sends: { type: "array", items: { type: "object", additionalProperties: true } }, total: { type: "integer" }, limit: { type: "integer" }, offset: { type: "integer" }, testSendTrigger: { type: "string" } } } } } } */
|
|
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.listSends,
|
|
)
|
|
|
|
// -- Suppressions (Phase 9) ------------------------------------------------
|
|
|
|
engagementRouter.get(
|
|
'/suppressions',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Addresses this deployment has stopped mailing'
|
|
// #swagger.description = 'G16. Rows carry `address_masked` (`d***@example.com`) and never `address_hash` - the same rule the send log follows, and for the same reason: a sha256 of every address on the deployment, handed to a browser, is an offline dictionary attack. The mask keeps the domain intact so a whole-domain delivery failure is visible, and destroys the local part so the list cannot be turned back into an address book. `byReason` is deliberately unfiltered - it is the summary strip above the table.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
// #swagger.parameters['limit'] = { in: 'query', description: 'Page size, 1-200 (default 50)', required: false, schema: { type: 'integer' } }
|
|
// #swagger.parameters['offset'] = { in: 'query', description: 'Rows to skip', required: false, schema: { type: 'integer' } }
|
|
// #swagger.parameters['reason'] = { in: 'query', description: 'bounce, complaint, manual or unverified', required: false, schema: { type: 'string' } }
|
|
// #swagger.parameters['channel'] = { in: 'query', description: 'Only this channel (default: all)', required: false, schema: { type: 'string' } }
|
|
// #swagger.parameters['search'] = { in: 'query', description: 'Substring of the masked address - a domain is what this is for', required: false, schema: { type: 'string' } }
|
|
/* #swagger.responses[200] = { description: 'One page of the list, with per-reason totals', content: { "application/json": { schema: { type: "object", properties: { suppressions: { type: "array", items: { type: "object", additionalProperties: true } }, total: { type: "integer" }, limit: { type: "integer" }, offset: { type: "integer" }, byReason: { type: "object", additionalProperties: { type: "integer" } }, reasons: { type: "array", items: { type: "string" } } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'Unknown reason', 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.listSuppressions,
|
|
)
|
|
|
|
engagementRouter.post(
|
|
'/suppressions',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Suppress an address by hand'
|
|
// #swagger.description = 'For a bounce or a complaint reported out of band. The reason is forced to `manual` rather than read from the body: an admin typing an address is not evidence of a bounce, and a `reason` column that sometimes means "the relay said so" and sometimes means "somebody chose this word" cannot diagnose anything. An address already on the list answers 200 with `created: false` rather than 409 - the operator asked for it to be suppressed and it is.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { address: { type: "string" }, detail: { type: "string", nullable: true } }, required: ["address"] } } } } */
|
|
/* #swagger.responses[201] = { description: 'Suppressed', content: { "application/json": { schema: { type: "object", properties: { created: { type: "boolean" }, address: { type: "string", nullable: true } } } } } } */
|
|
/* #swagger.responses[200] = { description: 'Already suppressed; nothing changed', content: { "application/json": { schema: { type: "object", properties: { created: { type: "boolean" }, address: { type: "string", nullable: true } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'Not a valid address', 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.createSuppression,
|
|
)
|
|
|
|
engagementRouter.delete(
|
|
'/suppressions',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Lift a suppression'
|
|
// #swagger.description = 'The only way out of the list, and the reason the screen exists: a hard bounce is written by a background worker with no human in the loop, so a mistyped-then-corrected mailbox would otherwise be silenced permanently. The address goes in the BODY, not the path - a path parameter lands in the access log, the browser history and every proxy in front of the deployment, and this one belongs to a real person. The hash cannot be used instead because the screen is never given one.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { address: { type: "string" }, channel: { type: "string", nullable: true } }, required: ["address"] } } } } */
|
|
/* #swagger.responses[200] = { description: 'Lifted', content: { "application/json": { schema: { type: "object", properties: { removed: { type: "boolean" } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'No address given', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[404] = { description: 'That address is not suppressed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.deleteSuppression,
|
|
)
|
|
|
|
engagementRouter.delete(
|
|
'/suppressions/by-hash/:hash',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Lift a suppression by its row handle'
|
|
// #swagger.description = 'The per-row Lift button (Phase 14). Same effect as the route above, different input: the screen shows a mask, so the operator does not know the address and can only act on the row handle the list gives them. The handle IS safe in the path where an address is not - it is a sha256 already served only to an admin session, so an access log or proxy that captures it learns nothing new. 404 rather than 200 when nothing matched, so a stale screen (two admins, one list) says so instead of claiming success.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
// #swagger.parameters['hash'] = { in: 'path', description: 'The address_hash the list returns for that row, 64 hex characters', required: true, schema: { type: 'string' } }
|
|
// #swagger.parameters['channel'] = { in: 'query', description: 'Defaults to email', required: false, schema: { type: 'string' } }
|
|
/* #swagger.responses[200] = { description: 'Lifted', content: { "application/json": { schema: { type: "object", properties: { removed: { type: "boolean" } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'Not a suppression handle', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[404] = { description: 'That address is not suppressed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.deleteSuppressionByHash,
|
|
)
|
|
|
|
// ── Retention (Phase 14) ───────────────────────────────────────────────────
|
|
|
|
engagementRouter.get(
|
|
'/retention',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Read the engagement retention policy'
|
|
// #swagger.description = 'The three horizons the nightly sweep uses, in days, with the bounds each is validated against. `engagement_suppressions` is deliberately absent: a suppression is a standing decision and does not expire, because ageing out a hard bounce re-mails an address that already bounced. `warnings` carries the one check that cannot be a static bound - a cooldown horizon shorter than the longest cooldown on an ENABLED rule, which would let that rule send twice.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.responses[200] = { description: 'The current policy', content: { "application/json": { schema: { type: "object", properties: { retention: { type: "object", properties: { sends: { type: "integer" }, cooldowns: { type: "integer" }, outbox: { type: "integer" } } }, limits: { type: "object", additionalProperties: true }, longestCooldownSeconds: { type: "integer" }, warnings: { type: "array", items: { type: "string" } } } } } } } */
|
|
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
controller.getRetention,
|
|
)
|
|
|
|
engagementRouter.put(
|
|
'/retention',
|
|
// #swagger.tags = ['Admin - Engagement']
|
|
// #swagger.summary = 'Set the engagement retention policy'
|
|
// #swagger.description = 'Sparse: only the horizons named in the body are written, so saving one select cannot clobber a value another admin changed between load and save. Out of range is a 400 rather than a clamp - storing something other than what was typed would leave the screen describing a policy the deployment is not running. The floors are not UI niceties: below 2 days a pruned cooldown row makes the next fire a FIRST fire (a duplicate send), and the send log is counted by the per-rule hourly ceiling.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { sends: { type: "integer", nullable: true }, cooldowns: { type: "integer", nullable: true }, outbox: { type: "integer", nullable: true } } } } } } */
|
|
/* #swagger.responses[200] = { description: 'The policy as it now stands', content: { "application/json": { schema: { type: "object", properties: { retention: { type: "object", additionalProperties: { type: "integer" } }, limits: { type: "object", additionalProperties: true }, longestCooldownSeconds: { type: "integer" }, warnings: { type: "array", items: { type: "string" } } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'A horizon was not a whole number of days, or was out of range', 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.putRetention,
|
|
)
|
|
|
|
module.exports = engagementRouter
|