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