// ── Admin: events ────────────────────────────────────────────────────────── // // EVENTS.md § API surface, Phase 1. Definitions CRUD, publish, archive, the // action catalog and the run reads. // // This file reads ids out of URLs and shapes responses; it validates nothing. // Every decision lives in `model/events/*.model.js` and in `events/spec.js`, so // a definition arriving from a future import or a restore gets the same answer // this screen does. // // **Phase 3 added the live run controls** at the bottom of this file: pause, // resume, cancel, and a step's confirm, skip and retry. What is still absent is // `advance`, `cleanup` and the action switchboard — `advance` has no honest // meaning until Phase 5 gives a phase an advance condition, `cleanup` has no // ledger to work over until Phase 8, and the switchboard is Phase 6's. Each of // them is absent rather than stubbed, for the reason the whole set was in Phase // 1: a control that returns 200 and does nothing is worse than one that is not // there. const registries = require('../../../modules/registries') const spec = require('../../../events/spec') const conditionGrammar = require('../../../engagement/conditions') const definitionsDb = require('../../../model/events/eventDefinitions.db') const definitions = require('../../../model/events/eventDefinitions.model') const versionsDb = require('../../../model/events/eventVersions.db') const seriesDb = require('../../../model/events/eventSeries.db') const series = require('../../../model/events/eventSeries.model') const calendarModel = require('../../../model/events/eventCalendar.model') const eventRunner = require('../../../utils/eventRunner') const runsDb = require('../../../model/events/eventRuns.db') const runs = require('../../../model/events/eventRuns.model') const controls = require('../../../model/events/eventRunControls.model') const logDb = require('../../../model/events/eventRunLog.db') const activity = require('../../../model/activity/activity.model') const settingsDb = require('../../../model/events/eventActionSettings.db') const authorize = require('../../../events/authorize') const asId = (raw) => { const n = Number(raw) return Number.isInteger(n) && n > 0 ? n : null } /** * The shape a definition takes on the wire. * * Explicit rather than the row, like every other admin surface here: the row * carries `created_by`, `updated_by` and the joined series columns, and a * response that spreads it is a response that gains a column the day somebody * adds one. */ const shapeDefinition = (d) => ({ id: d.id, title: d.title, slug: d.slug, summary: d.summary, body: d.body, imageUrl: d.image_url, ownerModule: d.owner_module, state: d.state, currentVersionId: d.current_version_id, currentVersion: d.current_version, // §K's gate, rendered where it can still be acted on. `null` on a draft -- // there is no version to have verified -- and a date once a dry run has passed // against the published one. currentVersionVerifiedAt: d.current_version_verified_at || null, seriesId: d.series_id, seriesName: d.series_name, seriesOrder: d.series_order, concurrencyKey: d.concurrency_key, graceSeconds: d.grace_seconds, timezone: d.timezone, spec: d.spec, createdAt: d.created_at, updatedAt: d.updated_at, }) const shapeRun = (r) => ({ id: r.id, definitionId: r.definition_id, definitionTitle: r.definition_title, definitionSlug: r.definition_slug, versionId: r.version_id, version: r.version_number, scope: r.scope, status: r.status, health: r.health, cleanupStatus: r.cleanup_status, currentPhase: r.current_phase, scheduledFor: r.scheduled_for, timezone: r.timezone, concurrencyKey: r.concurrency_key, params: r.params, rehearsal: r.rehearsal, startedAt: r.started_at, endedAt: r.ended_at, lastError: r.last_error, createdAt: r.created_at, // How many steps are parked on a human. Derived, not a column, and surfaced on // the LIST as well as the console because a cue nobody notices is a run that // never advances while looking perfectly healthy from the outside. waitingSteps: Number(r.waiting_steps || 0), }) const shapeStep = (s) => ({ id: s.id, runId: s.run_id, phase: s.phase, seq: s.seq, actionId: s.action_id, params: s.params, actionVersion: s.action_version, status: s.status, // `running` with no lease is a parked step (§E) — waiting on a human, with // nothing holding it. The console has to tell that apart from a step some // process is mid-dispatch on, and it must not do so by being shown the lease: // one derived boolean rather than `claimed_by` and `claim_expires_at`, which // are the runner's business and would invite a UI that reasoned about leases. parked: s.status === 'running' && !s.claim_expires_at, dueAt: s.due_at, attempts: s.attempts, onFailure: s.on_failure, idempotencyKey: s.idempotency_key, lastError: s.last_error, startedAt: s.started_at, finishedAt: s.finished_at, }) /** GET /api/v1/admin/events */ exports.list = async (req, res) => { const state = ['draft', 'ready', 'archived'].includes(req.query.state) ? req.query.state : null const rows = await definitionsDb.list({ state }) res.json({ events: rows.map(shapeDefinition) }) } /** * GET /api/v1/admin/events/catalog * * The registered actions, their param schemas, their risk classes and the * vocabularies over both — served from the registries, so there is no table * behind it and a module that was uninstalled simply stops appearing. Same * argument the engagement trigger catalog makes: the editor offers exactly the * set the save path checks against, so the two cannot drift. * * Budget dimensions are absent, and that is Phase 1 being honest rather than an * omission: `registerEventBudgets` is Phase 7's and nothing declares one yet. */ exports.catalog = (_req, res) => { res.json({ actions: registries.allEventActions(), risks: registries.ACTION_RISKS, reversible: registries.ACTION_REVERSIBLE, paramTypes: registries.ACTION_PARAM_TYPES, onFailure: spec.ON_FAILURE, onFailureByRisk: spec.ON_FAILURE_BY_RISK, scheduleKinds: spec.SCHEDULE_KINDS, // **The trigger catalog is served here too, and not borrowed from // `/admin/engagement/triggers`** (Phase 5). §C's claim is that the trigger // catalog a module already ships IS the catalog of things that can advance a // phase — so it is the same registry, read twice. What differs is who may // read it: the engagement route is `adminOnly`, and event definitions are // authored by `admin` AND `editor`. Pointing this editor at that route would // have left an editor writing a trigger id from memory into a field the save // path then refused. // // Each declaration is reduced to what the gate form needs — id, label and // the variables a `where` may name. Everything else on a trigger (its // audience, its ceiling, its subject key) is about who gets MAILED, which is // a different question and not this screen's. triggers: registries.allTriggers().map((t) => ({ id: t.id, label: t.label, description: t.description, owner: t.owner, variables: (t.variables || []).map((v) => ({ name: v.name, type: v.type, required: v.required, description: v.description, })), })), operators: conditionGrammar.vocabulary(), advanceKinds: spec.ADVANCE_KINDS, limits: { maxPhases: spec.MAX_PHASES, maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE, maxSteps: spec.MAX_STEPS, defaultBudgetMs: registries.DEFAULT_BUDGET_MS, maxAdvanceCount: spec.MAX_ADVANCE_COUNT, maxAfterSeconds: spec.MAX_AFTER_SECONDS, }, }) } const shapeSeries = (s) => ({ id: s.id, name: s.name, slug: s.slug, description: s.description, ordering: s.ordering, definitionCount: Number(s.definition_count || 0), }) /** GET /api/v1/admin/events/series */ exports.listSeries = async (_req, res) => { const rows = await seriesDb.list() res.json({ series: rows.map(shapeSeries) }) } /** POST /api/v1/admin/events/series */ exports.createSeries = async (req, res) => { const result = await series.create(req.body, req.user?.id) if (!result.ok) return res.status(result.status).json({ errors: result.errors }) await activity.log({ req, action: 'event.series.created', detail: { id: result.series.id, name: result.series.name }, }) res.status(201).json({ series: shapeSeries(result.series) }) } /** PUT /api/v1/admin/events/series/:seriesId */ exports.updateSeries = async (req, res) => { const id = asId(req.params.seriesId) if (!id) return res.status(404).json({ error: 'no such series' }) const result = await series.update(id, req.body, req.user?.id) if (!result.ok) return res.status(result.status).json({ errors: result.errors }) await activity.log({ req, action: 'event.series.updated', detail: { id, name: result.series.name } }) res.json({ series: shapeSeries(result.series) }) } /** * DELETE /api/v1/admin/events/series/:seriesId * * `detached` is in the response because the delete is not confined to the row: * `series_id` is `ON DELETE SET NULL`, so definitions that belonged to the arc * survive it without one. Saying how many is the difference between an operator * knowing and an operator finding out. */ exports.deleteSeries = async (req, res) => { const id = asId(req.params.seriesId) if (!id) return res.status(404).json({ error: 'no such series' }) const result = await series.remove(id) if (!result.ok) return res.status(result.status).json({ errors: result.errors }) await activity.log({ req, action: 'event.series.deleted', detail: { id, detached: result.detached } }) res.json({ ok: true, detached: result.detached }) } /** * GET /api/v1/admin/events/calendar * * `from` and `to` are UTC instants and the caller supplies both: a month grid * knows its own boundaries in the viewer's zone, and having the server guess * them would be the server guessing the viewer's zone. */ exports.calendar = async (req, res) => { const result = await calendarModel.calendar({ from: req.query.from, to: req.query.to, status: req.query.status || null, scope: req.query.scope || null, seriesId: asId(req.query.seriesId), horizonDays: eventRunner.HORIZON_DAYS, }) if (!result.ok) return res.status(result.status).json({ errors: result.errors }) res.json({ window: result.window, horizon: result.horizon, horizonDays: eventRunner.HORIZON_DAYS, entries: result.entries, truncated: result.truncated, }) } /** GET /api/v1/admin/events/runs */ exports.listRuns = async (req, res) => { const rows = await runsDb.list({ definitionId: asId(req.query.definitionId), status: req.query.status || null, limit: req.query.limit, }) res.json({ runs: rows.map(shapeRun) }) } /** GET /api/v1/admin/events/runs/:runId */ exports.getRun = async (req, res) => { const runId = asId(req.params.runId) if (!runId) return res.status(400).json({ error: 'bad run id' }) const found = await runs.detail(runId) if (!found) return res.status(404).json({ error: 'no such run' }) return res.json({ run: shapeRun(found.run), steps: found.steps.map(shapeStep), counts: found.counts, // Already rendered in the condition builder's own words (Phase 5). See // `eventRuns.model.detail` for why the sentence is built here and not in // the browser. gates: found.gates, // The caps this run was given and what it has spent (Phase 6). Copied into // the run when it was created, so it answers "what was THIS run allowed" // rather than "what is allowed now" — which is the question that survives // an admin moving a switch tomorrow. budget: found.budget, }) } /** GET /api/v1/admin/events/runs/:runId/log */ exports.getRunLog = async (req, res) => { const runId = asId(req.params.runId) if (!runId) return res.status(400).json({ error: 'bad run id' }) const run = await runsDb.getById(runId) if (!run) return res.status(404).json({ error: 'no such run' }) const lines = await logDb.listForRun(runId, { limit: req.query.limit }) return res.json({ log: lines.map((l) => ({ id: l.id, stepId: l.step_id, kind: l.kind, phase: l.phase, detail: l.detail, at: l.at, })), kinds: logDb.KINDS, }) } /** GET /api/v1/admin/events/:id */ exports.get = async (req, res) => { const id = asId(req.params.id) if (!id) return res.status(400).json({ error: 'bad event id' }) const row = await definitionsDb.getById(id) if (!row) return res.status(404).json({ error: 'no such event definition' }) return res.json({ event: shapeDefinition(row) }) } /** GET /api/v1/admin/events/:id/versions */ exports.listVersions = async (req, res) => { const id = asId(req.params.id) if (!id) return res.status(400).json({ error: 'bad event id' }) const row = await definitionsDb.getById(id) if (!row) return res.status(404).json({ error: 'no such event definition' }) const rows = await versionsDb.listForDefinition(id) return res.json({ versions: rows.map((v) => ({ id: v.id, version: v.version, publishedAt: v.published_at, publishedBy: v.published_by, publishedByUsername: v.published_by_username, current: v.id === row.current_version_id, })), }) } /** POST /api/v1/admin/events */ exports.create = async (req, res) => { const result = await definitions.create(req.body, req.user.id, { role: req.user.role }) if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) await activity.log({ req, action: 'event.definition.created', detail: { id: result.id, title: result.definition.title }, }) return res.status(201).json({ event: shapeDefinition(result.definition) }) } /** PUT /api/v1/admin/events/:id */ exports.update = async (req, res) => { const id = asId(req.params.id) if (!id) return res.status(400).json({ error: 'bad event id' }) const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role }) if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) await activity.log({ req, action: 'event.definition.updated', detail: { id, title: result.definition.title }, }) return res.json({ event: shapeDefinition(result.definition) }) } /** POST /api/v1/admin/events/:id/publish */ exports.publish = async (req, res) => { const id = asId(req.params.id) if (!id) return res.status(400).json({ error: 'bad event id' }) const result = await definitions.publish(id, req.user.id) if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) await activity.log({ req, action: 'event.definition.published', detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned }, }) return res.json({ event: shapeDefinition(result.definition), version: result.version, versionId: result.versionId, // How many already-materialised occurrences moved to this version. The // screen says so, because "my fix did not reach next Friday" is otherwise // found out on Friday. repinned: result.repinned, }) } /** * POST /api/v1/admin/events/:id/verify — the dry run. * * `admin, editor` rather than `admin` (§ API surface): a dry run dispatches * nothing and changes nothing, and the author who wrote the definition is * exactly who should be able to price it against the caps before asking an * admin to publish it. * * **A report with findings is a 200, not a 400.** The request succeeded; the * plan has problems. Answering 4xx would make "this event asks for 45 creatures * and you allow 30" indistinguishable to the client from "you sent a bad event * id", and the whole value of the screen is rendering the findings. */ exports.verify = async (req, res) => { const id = asId(req.params.id) if (!id) return res.status(400).json({ error: 'bad event id' }) const result = await definitions.verify(id, req.user) if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) await activity.log({ req, action: 'event.definition.verified', detail: { id, target: result.target, versionId: result.versionId, passed: result.report.ok, findings: result.report.findings.length, }, }) return res.json({ target: result.target, versionId: result.versionId, version: result.version, recorded: result.recorded, report: result.report, }) } /** * GET /api/v1/admin/events/actions — the deployment's switchboard. * * Every registered action, each with the deployment's stored opinion of it or, * where there is none, **the default its risk class implies**. The default is * computed by `authorize.isEnabled` rather than here, because a screen that * worked out the posture for itself would be a second copy of the posture, and * the copy that drifts is always the one on the screen. * * `configured` says whether a row exists, which the client needs to tell "an * admin turned this on" from "this has always been on" — the same fact, arrived * at two ways, and only one of them is a decision somebody made. */ exports.actions = async (_req, res) => { const all = registries.allEventActions() const stored = await settingsDb.byIds(all.map((a) => a.id)) return res.json({ actions: all.map((a) => { const row = stored.get(a.id) || null const full = registries.eventAction(a.id) return { ...a, enabled: authorize.isEnabled(full, row), configured: Boolean(row), changesWorld: authorize.changesWorld(full), // The dimensions this action can spend, so the screen can offer a cap // box per dimension. Discovered by pricing the action's own declared // examples until §F's `registerEventBudgets` lands in Phase 7 — see // `authorize.dimensionsOf`. dimensions: authorize.dimensionsOf(full), caps: row?.caps || {}, updatedAt: row?.updated_at || null, updatedBy: row?.updated_by_username || null, } }), // The rule the screen explains to the operator, served rather than written // into the client twice. worldChangingRisks: authorize.WORLD_CHANGING, }) } /** * PUT /api/v1/admin/events/actions — set one action's switch and caps. * * One action per request rather than the whole board: the board is rendered from * the registry and a whole-board PUT would have to say what an action MISSING * from the body means. On a screen listing what is registered right now, that is * "a module booted between the GET and the PUT", and answering it by writing a * default over an admin's stored choice is the kind of quiet data loss a sparse * write does not have. */ exports.saveAction = async (req, res) => { const actionId = String(req.body?.actionId || '') const action = registries.eventAction(actionId) if (!action) return res.status(404).json({ error: 'no module registers that action' }) if (typeof req.body?.enabled !== 'boolean') { return res.status(400).json({ error: 'enabled must be true or false' }) } // Caps are validated against the dimensions this action can actually spend. // A cap on a dimension it never names is not a harmless extra row — it is a // number an operator believes is protecting them, on a screen that would // render it back to them forever, bounding nothing. const known = new Set(authorize.dimensionsOf(action)) const caps = {} for (const [dimension, raw] of Object.entries(req.body?.caps || {})) { if (raw === null || raw === '') continue if (!known.has(dimension)) { return res.status(400).json({ error: `"${action.id}" does not spend "${dimension}"` }) } const n = Number(raw) if (!Number.isInteger(n) || n < 0) { return res.status(400).json({ error: `the cap for "${dimension}" must be a whole number of 0 or more` }) } caps[dimension] = n } const row = await settingsDb.put(actionId, { enabled: req.body.enabled, caps }, req.user.id) await activity.log({ req, action: 'event.action.configured', detail: { actionId, enabled: Boolean(req.body.enabled), caps }, }) return res.json({ action: { id: actionId, enabled: Boolean(row.enabled), configured: true, caps: row.caps, updatedAt: row.updated_at, }, }) } /** DELETE /api/v1/admin/events/:id — archive, never a hard delete */ exports.archive = async (req, res) => { const id = asId(req.params.id) if (!id) return res.status(400).json({ error: 'bad event id' }) const result = await definitions.archive(id, req.user.id) if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) await activity.log({ req, action: 'event.definition.archived', detail: { id } }) return res.json({ event: shapeDefinition(result.definition) }) } /** * POST /api/v1/admin/events/:id/runs * * Creates the occurrence. It stays `scheduled` until Phase 2's runner exists, * and the response says so through `pending: true` rather than by pretending * something started. */ exports.startRun = async (req, res) => { const id = asId(req.params.id) if (!id) return res.status(400).json({ error: 'bad event id' }) const result = await runs.create( id, { scope: req.body?.scope, scheduledFor: req.body?.scheduledFor, rehearsal: Boolean(req.body?.rehearsal), params: req.body?.params ?? null, }, req.user.id, ) if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors }) if (result.created) { await activity.log({ req, action: 'event.run.created', detail: { definitionId: id, runId: result.run.id, rehearsal: Boolean(req.body?.rehearsal), }, }) } return res.status(result.created ? 201 : 200).json({ run: shapeRun(result.run), created: result.created, }) } // ── Phase 3: the live run controls ───────────────────────────────────────── // // Six handlers, and each is the same four lines: read the ids out of the URL, // hand off to `eventRunControls`, log the manual transition to `activity_log`, // answer with the row. Every guard is in the model, where a control invoked from // anywhere else gets the same answer — which is the same division this file has // had since Phase 1. // // **The audit is written in two places on purpose, and they are not redundant.** // `event_run_log` is the run's own diagnostic record: queryable by phase and by // step, and it is what the console renders. `activity_log` is the deployment's // record of what staff did, and it is where "who cancelled the invasion" is // looked up months later by somebody who is not looking at that run. §J names // both. /** POST /api/v1/admin/events/runs/:runId/pause */ exports.pauseRun = async (req, res) => { const runId = asId(req.params.runId) if (!runId) return res.status(400).json({ error: 'bad run id' }) const result = await controls.pause(runId, { reason: req.body?.reason }, req.user.id) if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors }) await activity.log({ req, action: 'event.run.paused', detail: { runId, reason: req.body?.reason || null } }) return res.json({ run: shapeRun(result.run) }) } /** POST /api/v1/admin/events/runs/:runId/resume */ exports.resumeRun = async (req, res) => { const runId = asId(req.params.runId) if (!runId) return res.status(400).json({ error: 'bad run id' }) const result = await controls.resume(runId, {}, req.user.id) if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors }) await activity.log({ req, action: 'event.run.resumed', detail: { runId } }) return res.json({ run: shapeRun(result.run) }) } /** POST /api/v1/admin/events/runs/:runId/advance */ exports.advanceRunPhase = async (req, res) => { const runId = asId(req.params.runId) if (!runId) return res.status(400).json({ error: 'bad run id' }) const result = await controls.advancePhase(runId, { reason: req.body?.reason }, req.user.id) if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors }) await activity.log({ req, action: 'event.run.advanced', detail: { runId, phase: result.phase, reason: req.body?.reason || null }, }) return res.json({ run: shapeRun(result.run), phase: result.phase }) } /** POST /api/v1/admin/events/runs/:runId/cancel */ exports.cancelRun = async (req, res) => { const runId = asId(req.params.runId) if (!runId) return res.status(400).json({ error: 'bad run id' }) const result = await controls.cancel(runId, { reason: req.body?.reason }, req.user.id) if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors }) await activity.log({ req, action: 'event.run.cancelled', detail: { runId, reason: req.body?.reason || null, cancelledSteps: result.cancelledSteps }, }) return res.json({ run: shapeRun(result.run), cancelledSteps: result.cancelledSteps }) } /** POST /api/v1/admin/events/runs/:runId/steps/:stepId/confirm */ exports.confirmStep = async (req, res) => { const runId = asId(req.params.runId) const stepId = asId(req.params.stepId) if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' }) const result = await controls.confirmStep(runId, stepId, { note: req.body?.note }, req.user.id) if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors }) await activity.log({ req, action: 'event.step.confirmed', detail: { runId, stepId, action: result.step.action_id } }) return res.json({ step: shapeStep(result.step) }) } /** POST /api/v1/admin/events/runs/:runId/steps/:stepId/skip */ exports.skipStep = async (req, res) => { const runId = asId(req.params.runId) const stepId = asId(req.params.stepId) if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' }) const result = await controls.skipStep(runId, stepId, { reason: req.body?.reason }, req.user.id) if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors }) await activity.log({ req, action: 'event.step.skipped', detail: { runId, stepId, action: result.step.action_id, reason: req.body?.reason || null }, }) return res.json({ step: shapeStep(result.step) }) } /** POST /api/v1/admin/events/runs/:runId/steps/:stepId/retry */ exports.retryStep = async (req, res) => { const runId = asId(req.params.runId) const stepId = asId(req.params.stepId) if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' }) const result = await controls.retryStep(runId, stepId, {}, req.user.id) if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors }) await activity.log({ req, action: 'event.step.retried', detail: { runId, stepId, action: result.step.action_id } }) return res.json({ step: shapeStep(result.step), run: shapeRun(result.run), resumed: result.resumed }) }