From 7b570c8ea1a6b269010e5a82f951e7f3aa905d11 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 08:39:35 -0500 Subject: [PATCH] feat(events): the minimal admin surface (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three screens, an Events nav group and the six live run controls Phase 1 left absent on purpose because nothing was in flight. An admin can now author, publish, start and watch an event that announces things and cues a human; a moderator can stop one that is going wrong. Six controls, not eight. `advance` is absent because a phase today advances when its steps go terminal — the per-step skip already does that — and Phase 5 is what gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`, until Phase 8's ledger exists. Every control is a compare-and-set on the status it may act from, so a console rendered thirty seconds ago cannot act on a run that has moved. Fixes a defect in the Phase 2 runner: `advanceRun` drained up to EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the tick, so a pause pressed mid-batch did nothing for up to 24 more steps. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- client/src/App.jsx | 15 + client/src/api/client.js | 39 ++ client/src/lib/eventAuthoring.js | 296 +++++++++ client/src/routes/admin/AdminLayout.jsx | 21 + client/src/routes/admin/views/EventEditor.jsx | 505 +++++++++++++++ client/src/routes/admin/views/EventRun.jsx | 352 +++++++++++ client/src/routes/admin/views/EventsAdmin.jsx | 260 ++++++++ client/test/eventAuthoring.test.js | 310 ++++++++++ server/routes.guards.json | 54 ++ server/routes.manifest.json | 24 + .../model/events/eventRunControls.model.js | 296 +++++++++ server/src/model/events/eventRunSteps.db.js | 129 ++++ server/src/model/events/eventRuns.db.js | 26 +- .../src/router/v1/admin/events.controller.js | 109 +++- server/src/router/v1/admin/events.router.js | 111 +++- server/src/utils/eventRunner.js | 8 + server/swagger/swagger-output.json | 583 ++++++++++++++++++ server/test/eventRunControls.test.js | 442 +++++++++++++ server/test/eventRunner.test.js | 50 ++ server/test/eventRunnerSql.test.js | 153 +++++ 20 files changed, 3775 insertions(+), 8 deletions(-) create mode 100644 client/src/lib/eventAuthoring.js create mode 100644 client/src/routes/admin/views/EventEditor.jsx create mode 100644 client/src/routes/admin/views/EventRun.jsx create mode 100644 client/src/routes/admin/views/EventsAdmin.jsx create mode 100644 client/test/eventAuthoring.test.js create mode 100644 server/src/model/events/eventRunControls.model.js create mode 100644 server/test/eventRunControls.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 2b0e598..aef41a0 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -49,6 +49,9 @@ import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx' import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx' import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx' import EngagementRetention from './routes/admin/views/EngagementRetention.jsx' +import EventsAdmin from './routes/admin/views/EventsAdmin.jsx' +import EventEditor from './routes/admin/views/EventEditor.jsx' +import EventRun from './routes/admin/views/EventRun.jsx' import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' @@ -192,6 +195,18 @@ export default function App() { actions that publish a game-written name is applied per request on the server, from the caller's live role (TEAMS.md 2.9). */} } /> + {/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement: + §K makes every read here `staff`, and the moderator's whole + power over this feature is the run console — cancelling a run + that is doing something wrong at 2am. The narrower gates are + applied per action instead: authoring is admin+editor, publish + and start are admin only (§N2), and each button follows the + route it calls. `runs/:runId` is declared before `:id` so the + literal segment is never read as a definition id. */} + } /> + } /> + } /> + } /> {/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the server: every route under /admin/engagement re-gates to `admin` on top of the group's staff gate, because this is the group that diff --git a/client/src/api/client.js b/client/src/api/client.js index 966adc4..a6e0a15 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -472,6 +472,45 @@ export const api = { setEngagementRetention: (body) => req('/admin/engagement/retention', { method: 'PUT', body }), + // Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring + // is admin+editor, publish and start are admin ONLY, and the six live + // controls are admin+moderator — the one gate in this feature wider than + // admin, because stopping a run at 2am is incident response and starting + // one is not (§N2). The buttons follow the same split, and the server + // re-checks every one of them. + listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`), + getEvent: (id) => req(`/admin/events/${id}`), + createEvent: (body) => req('/admin/events', { method: 'POST', body }), + updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }), + publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }), + archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }), + listEventVersions: (id) => req(`/admin/events/${id}/versions`), + eventCatalog: () => req('/admin/events/catalog'), + eventSeries: () => req('/admin/events/series'), + startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }), + listEventRuns: ({ definitionId, status, limit } = {}) => { + const qs = new URLSearchParams() + if (definitionId) qs.set('definitionId', String(definitionId)) + if (status) qs.set('status', status) + if (limit) qs.set('limit', String(limit)) + const suffix = qs.toString() + return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`) + }, + getEventRun: (runId) => req(`/admin/events/runs/${runId}`), + getEventRunLog: (runId, limit) => + req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`), + pauseEventRun: (runId, reason) => + req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }), + resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }), + cancelEventRun: (runId, reason) => + req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }), + confirmEventStep: (runId, stepId, note) => + req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }), + skipEventStep: (runId, stepId, reason) => + req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }), + retryEventStep: (runId, stepId) => + req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }), + // Teams (docs/website/TEAMS.md §2.11). Three of these mean something // different depending on who calls them: for a moderator, unhide and // setTeamDisplayName file a request and the response says `pending: true`. diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js new file mode 100644 index 0000000..8ce3179 --- /dev/null +++ b/client/src/lib/eventAuthoring.js @@ -0,0 +1,296 @@ +// ── What the three Events screens say, and what they let staff press ─────── +// +// EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server +// decides what may be saved, and the six control statements decide what may +// happen to a run — every one of them is a compare-and-set that re-checks the +// status this file only *predicted*. What is here is the part that would be +// wrong silently: a form that drops an authored step, a params box that posts a +// string where the action declared an int, and above all a console that offers a +// button the server is going to refuse. +// +// **The controls are modelled here rather than inline in the console for one +// reason: they can be tested against the server's rules.** A button that 409s is +// not a bug the way a wrong write is, but it is the failure mode an operator +// meets at 2am while the thing they are trying to stop keeps running — so the +// guards are written twice on purpose and the copy is checked. + +// A run that is over. Verbatim `eventRuns.db`'s TERMINAL. +export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed'] + +export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status) + +/** A step waiting on a human: `running`, with nothing holding it. */ +export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked) + +/** + * The highest `seq` of a step in this phase that is not still `pending` — the + * furthest the phase has got — or null when none of it has been attempted. + * + * The same rule as the server's `lastStartedSeq`, over the step list the console + * already has, and used only to decide whether to OFFER retry. The near miss is + * worth keeping in view: "the lowest step that is not finished" looks like the + * same thing and is not, because the runner steps OVER a failed step. Under that + * rule a phase that carried on past an `on_failure: skip` failure and then paused + * at a later one would offer retry on the wrong step. + */ +export function lastStartedSeqOf(steps, phase) { + const started = (steps || []) + .filter((s) => s.phase === phase && s.status !== 'pending') + .map((s) => Number(s.seq)) + return started.length ? Math.max(...started) : null +} + +/** + * Which run-level controls to offer. + * + * `pause` is `starting`/`running` only: a `scheduled` occurrence that should not + * happen is cancelled, not paused. `cancel` is everything non-terminal — "this + * is not happening" is a decision made before a run starts as often as during + * one. + */ +export function runControlsFor(run) { + if (!run) return { pause: false, resume: false, cancel: false } + const terminal = isTerminalRun(run.status) + return { + pause: ['starting', 'running'].includes(run.status), + resume: run.status === 'paused', + cancel: !terminal, + } +} + +/** + * Which step-level controls to offer, for one step of one run. + * + * `retry` carries the guard worth restating: only while the run is PAUSED, only + * on a `failed` step of the phase the run is currently in, and only when that + * step is the furthest one the phase has reached. A failed step under an + * `on_failure` of `skip` is one the run has already moved past, and re-queueing + * it would put a pending row behind the runner's cursor, where it would sit for + * ever. + */ +export function stepControlsFor(run, step, steps) { + const none = { confirm: false, skip: false, retry: false } + if (!run || !step) return none + if (isTerminalRun(run.status)) return none + + const parked = isParked(step) + const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null + + return { + confirm: parked, + skip: parked || step.status === 'pending', + retry: + run.status === 'paused' && + step.status === 'failed' && + step.phase === run.currentPhase && + furthest !== null && + Number(furthest) === Number(step.seq), + } +} + +// ── The definition form ──────────────────────────────────────────────────── + +export const BLANK_PHASE_KEY = 'phase' + +const nextPhaseKey = (phases) => { + const used = new Set((phases || []).map((p) => p.key)) + for (let n = 1; n < 100; n++) { + const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}` + if (!used.has(key)) return key + } + return `${BLANK_PHASE_KEY}-${Date.now()}` +} + +/** + * A new step, with its params box PREFILLED from the action's declared examples. + * + * Every param carries a required `example` — that requirement is the reason this + * works — so a fresh `core.announce` step arrives as a JSON object with the right + * keys and plausible values rather than as an empty `{}` an author has to guess + * the shape of. It is the nearest a raw JSON box gets to the schema-driven form + * Phase 13 replaces it with, and it costs nothing the catalog was not already + * serving. + */ +export function blankStep(action) { + const params = {} + for (const p of action?.params || []) { + if (p.required || p.example !== undefined) params[p.name] = p.example + } + return { + actionId: action?.id || '', + label: action?.label || '', + onFailure: '', + paramsText: JSON.stringify(params, null, 2), + } +} + +export function blankPhase(phases) { + return { key: nextPhaseKey(phases), label: 'New phase', steps: [] } +} + +/** The editor's working state, from what `GET /admin/events/:id` returned. */ +export function formFromDefinition(event) { + const spec = event?.spec || {} + return { + title: event?.title || '', + summary: event?.summary || '', + body: event?.body || '', + imageUrl: event?.imageUrl || '', + seriesId: event?.seriesId ? String(event.seriesId) : '', + seriesOrder: event?.seriesOrder ?? 0, + concurrencyKey: event?.concurrencyKey || '', + graceSeconds: event?.graceSeconds ?? 900, + timezone: event?.timezone || 'UTC', + scheduleKind: spec.schedule?.kind || 'manual', + phases: (spec.phases || []).map((p) => ({ + key: p.key || '', + label: p.label || '', + steps: (p.steps || []).map((s) => ({ + actionId: s.actionId || '', + label: s.label || '', + onFailure: s.onFailure || '', + dormant: Boolean(s.dormant), + actionVersion: s.actionVersion, + paramsText: JSON.stringify(s.params || {}, null, 2), + })), + })), + } +} + +/** + * The form, as a request body — or the list of everything wrong with it. + * + * Only the JSON parse is checked here, and only because a params box whose text + * is not JSON cannot be turned into a request at all. **Everything else is left + * to the server**: unknown params, wrong types, missing required ones, bad phase + * keys and duplicate keys all come back from `POST`/`PUT` as a list, and + * re-deciding any of them here would be a second validator drifting from the one + * that matters. + * + * `onFailure` is omitted when the author has not chosen one, so the server + * applies the action's risk-class default rather than being told a value the + * form invented. + */ +export function payloadFromForm(form) { + const errors = [] + const phases = (form.phases || []).map((phase, pi) => ({ + key: phase.key, + label: phase.label, + steps: (phase.steps || []).map((step, si) => { + const out = { actionId: step.actionId } + if (step.label) out.label = step.label + if (step.onFailure) out.onFailure = step.onFailure + const parsed = parseParams(step.paramsText) + if (parsed.error) { + errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`) + } else { + out.params = parsed.params + } + return out + }), + })) + + if (errors.length) return { ok: false, errors } + + return { + ok: true, + payload: { + title: form.title, + summary: form.summary || null, + body: form.body || null, + imageUrl: form.imageUrl || null, + seriesId: form.seriesId ? Number(form.seriesId) : null, + seriesOrder: Number(form.seriesOrder) || 0, + concurrencyKey: form.concurrencyKey || null, + graceSeconds: Number(form.graceSeconds), + timezone: form.timezone, + spec: { schedule: { kind: form.scheduleKind || 'manual' }, phases }, + }, + } +} + +/** An empty box is `{}`, not a parse error — a step may legitimately take none. */ +export function parseParams(text) { + const raw = (text || '').trim() + if (!raw) return { params: {} } + let value + try { + value = JSON.parse(raw) + } catch (err) { + return { error: `the params are not valid JSON (${err.message})` } + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { error: 'the params must be a JSON object' } + } + return { params: value } +} + +// ── Rendering what happened ──────────────────────────────────────────────── + +const STATUS_WORDS = { + scheduled: 'Scheduled', + starting: 'Starting', + running: 'Running', + paused: 'Paused', + ending: 'Winding down', + completed: 'Completed', + cancelled: 'Cancelled', + failed: 'Failed', + missed: 'Missed', +} + +export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown' + +const KIND_WORDS = { + 'run.created': 'Occurrence created', + 'run.status': 'Run status', + 'run.health': 'Health', + 'run.blocked': 'Held off', + 'phase.entered': 'Phase entered', + 'phase.completed': 'Phase completed', + 'step.status': 'Step', + 'step.retry': 'Step retried', + 'step.parked': 'Waiting on a human', + note: 'Note', +} + +export const logKindWord = (kind) => KIND_WORDS[kind] || kind + +/** + * One log line as a sentence. + * + * The `detail` of a human control carries `control` and `by`, which is what + * separates "the runner paused this because a world write failed" from "somebody + * pressed pause" — the two are the same transition and the console has to be + * able to tell them apart at a glance. + */ +export function describeLogLine(line) { + const d = line?.detail || {} + const by = d.by ? ' by staff' : '' + switch (line?.kind) { + case 'run.status': + return d.control + ? `${runStatusWord(d.to)}${by} — ${d.control}${d.reason ? `: ${d.reason}` : ''}` + : `${d.from ? `${runStatusWord(d.from)} → ` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}` + case 'run.health': + return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}` + case 'run.blocked': + return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"` + case 'phase.entered': + return `Entered ${line.phase} (${d.steps ?? '?'} steps)` + case 'phase.completed': + return `${line.phase} finished` + case 'step.parked': + return `${d.action} is waiting on a human` + case 'step.retry': + return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}` + case 'step.status': + return d.control + ? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}` + : `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}` + case 'run.created': + return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}` + default: + return logKindWord(line?.kind) + } +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 3010eac..d489c5e 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -53,6 +53,7 @@ const IconList = () => const IconSpark = () => const IconLog = () => +const IconCalendar = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -120,6 +121,20 @@ export const NAV = [ { to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] }, ], }, + { + // Its own top-level group rather than a row under Content, and staff-wide + // rather than admin-only. Both follow EVENTS.md §K: every read here is + // `staff`, and the moderator's entire power over this feature is the run + // console — the thing they open when an event is doing something wrong at + // 2am. Hiding it from them would leave the one role that exists for incident + // response unable to see the incident. The narrower gates live on the + // actions: authoring is admin+editor and publish/start are admin only, both + // enforced server-side and mirrored on the buttons. + title: 'Events', + items: [ + { to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] }, + ], + }, { title: 'System', items: [ @@ -202,6 +217,8 @@ const TITLES = { '/admin/engagement/suppressions': 'Suppressions', '/admin/engagement/sends': 'Send Log', '/admin/engagement/retention': 'Retention', + '/admin/events': 'Events', + '/admin/events/new': 'New event', } // An installed module's admin pages are not in TITLES and cannot be — core does @@ -222,6 +239,10 @@ function sectionTitle(pathname) { if (pathname.startsWith('/admin/moderation')) return 'Moderation' if (pathname.startsWith('/admin/users/')) return 'User' if (pathname.startsWith('/admin/engagement')) return 'Engagement' + // /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both + // belong to the same section as far as the page title is concerned. + if (pathname.startsWith('/admin/events/runs/')) return 'Event run' + if (pathname.startsWith('/admin/events/')) return 'Event' return 'Admin' } diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx new file mode 100644 index 0000000..3ab4d99 --- /dev/null +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -0,0 +1,505 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAuth } from '../../../contexts/AuthContext.jsx' +import { api } from '../../../api/client.js' +import { + formFromDefinition, + payloadFromForm, + blankPhase, + blankStep, +} from '../../../lib/eventAuthoring.js' + +// Admin → Events → the definition editor (EVENTS.md §I, Phase 3). +// +// **A vertical timeline, not a node graph**, and that is a decision about what +// the engine can actually do rather than a matter of taste. The condition +// grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at +// depth five — so a canvas would promise power this project has never handed an +// operator. Phases in order, each with its steps in order, says exactly what the +// runner does with them. +// +// **Core renders no game word here.** Every label on a step comes from the +// action's own registration — its `label`, its params' names, their descriptions +// and their examples — so an installed module's vocabulary appears without core +// knowing any of it, and `check:modules` already fails core's build on a UO +// identifier. +// +// **The params box is a raw JSON field and it is captioned as a placeholder**, +// because that is what it is: Phase 13 replaces it with the schema-driven form +// the condition builder already models. What makes it usable in the meantime is +// that a new step arrives PREFILLED from the action's declared examples, and the +// declaration is rendered beside the box — every param's name, type, whether it +// is required, and what a value looks like. All of that was already in the +// catalog; none of it is a second copy of anything. + +const DORMANT_NOTE = + 'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.' + +export default function EventEditor() { + const { id } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const isNew = id === 'new' + + const [form, setForm] = useState(null) + const [event, setEvent] = useState(null) + const [catalog, setCatalog] = useState(null) + const [series, setSeries] = useState([]) + const [versions, setVersions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [problems, setProblems] = useState([]) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(false) + + const isAdmin = user?.role === 'admin' + // Reads here are staff-wide (§K), so a moderator reaches this screen legitimately + // — the run console is their whole job and a definition is what a run is OF. But + // authoring is `admin` + `editor`, so Save has to follow the route it calls. + // Offering it and letting the server answer 403 is the shape §K calls "a gate + // nobody notices was missing", only inverted: a button that does nothing. + const mayAuthor = isAdmin || user?.role === 'editor' + + const load = useCallback(async () => { + const [cat, ser] = await Promise.all([api.admin.eventCatalog(), api.admin.eventSeries()]) + setCatalog(cat) + setSeries(ser.series || []) + if (isNew) { + setEvent(null) + setVersions([]) + setForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } })) + return + } + const [{ event: loaded }, { versions: history }] = await Promise.all([ + api.admin.getEvent(id), + api.admin.listEventVersions(id), + ]) + setEvent(loaded) + setVersions(history || []) + setForm(formFromDefinition(loaded)) + }, [id, isNew]) + + useEffect(() => { + let alive = true + ;(async () => { + setLoading(true) + try { + await load() + if (alive) setError(null) + } catch (err) { + if (alive) setError(err.message) + } finally { + if (alive) setLoading(false) + } + })() + return () => { + alive = false + } + }, [load]) + + const actions = useMemo(() => catalog?.actions || [], [catalog]) + const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions]) + + const set = (patch) => setForm((f) => ({ ...f, ...patch })) + + const setPhase = (pi, patch) => + setForm((f) => ({ + ...f, + phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)), + })) + + const setStep = (pi, si, patch) => + setForm((f) => ({ + ...f, + phases: f.phases.map((p, i) => + i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p, + ), + })) + + const movePhase = (pi, delta) => + setForm((f) => { + const next = [...f.phases] + const to = pi + delta + if (to < 0 || to >= next.length) return f + ;[next[pi], next[to]] = [next[to], next[pi]] + return { ...f, phases: next } + }) + + const moveStep = (pi, si, delta) => + setForm((f) => ({ + ...f, + phases: f.phases.map((p, i) => { + if (i !== pi) return p + const steps = [...p.steps] + const to = si + delta + if (to < 0 || to >= steps.length) return p + ;[steps[si], steps[to]] = [steps[to], steps[si]] + return { ...p, steps } + }), + })) + + /** + * Changing a step's action REPLACES its params with the new action's examples. + * + * The alternative — keeping what was typed — leaves an object whose keys belong + * to a different action, and the save refuses it with "x is not a param of y" + * for every one of them. Replacing is the honest move and it is not + * destructive in any way an author minds: they have just said this step does + * something else. + */ + const changeAction = (pi, si, actionId) => { + const action = actionById.get(actionId) + const fresh = blankStep(action) + setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' }) + } + + const save = async () => { + setBusy(true) + setProblems([]) + setNotice(null) + const built = payloadFromForm(form) + if (!built.ok) { + setProblems(built.errors) + setBusy(false) + return + } + try { + if (isNew) { + const result = await api.admin.createEvent(built.payload) + navigate(`/admin/events/${result.event.id}`, { replace: true }) + } else { + const result = await api.admin.updateEvent(id, built.payload) + setEvent(result.event) + setForm(formFromDefinition(result.event)) + setNotice('Saved.') + } + } catch (err) { + // The server answers with every problem rather than the first, so an author + // fixing a spec does it in one pass rather than six round trips. + setProblems(err.body?.errors || [err.message]) + } finally { + setBusy(false) + } + } + + const publish = async () => { + setBusy(true) + setProblems([]) + setNotice(null) + try { + const result = await api.admin.publishEvent(id) + setEvent(result.event) + setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || [])) + setNotice(`Published as v${result.version}.`) + } catch (err) { + setProblems(err.body?.errors || [err.message]) + } finally { + setBusy(false) + } + } + + const start = async () => { + setBusy(true) + setProblems([]) + try { + const result = await api.admin.startEventRun(id, {}) + navigate(`/admin/events/runs/${result.run.id}`) + } catch (err) { + setProblems(err.body?.errors || [err.message]) + } finally { + setBusy(false) + } + } + + if (loading || !form) return + if (error) return + + const archived = event?.state === 'archived' + + return ( +
+
+
+

+ {isNew ? 'New event' : event?.title} +

+

+ {isNew ? ( + 'The slug is derived from the title once and frozen afterwards — the public event page lives at it.' + ) : ( + <> + {event?.slug} · {event?.state} + {event?.currentVersion ? ` · published v${event.currentVersion}` : ' · never published'} + + )} +

+
+
+ {mayAuthor && ( + + )} + {/* Publish and start are admin ONLY (§N2) and not the same gate as the + live controls: publishing commits a definition a schedule will later + start unattended. */} + {!isNew && isAdmin && ( + + )} + {!isNew && isAdmin && event?.state === 'ready' && ( + + )} +
+
+ + {!mayAuthor && ( +

+ You can read this definition and watch its runs. Editing and publishing an event are an + admin or editor's, and starting one is an admin's alone — live control of a run already in + flight is yours. +

+ )} + + {archived && ( +

+ This definition is archived. It is kept so its past runs can still be explained, and it + cannot be edited or run again. +

+ )} + + {notice &&

{notice}

} + + {problems.length > 0 && ( +
+

That did not save:

+
    + {problems.map((p) =>
  • {p}
  • )} +
+
+ )} + + {/* ── Basics ── */} +
+
+ + + + + +
+ +