feat(events): the authoring UI proper (Phase 13)
Some checks failed
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Failing after 9m1s

Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.

One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.

A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.

Two defects fixed in already-merged code:

  * Creating an event has been impossible since Phase 6. `events/new` was added
    beside `events/:id` and binds no param, and React Router ranks a static
    segment above a dynamic one whatever the order -- so the editor was handed no
    id and fetched /admin/events/undefined. Worse, the failure was invisible:
    `!form` is true for every failed load, so the error state sat behind a
    spinner that never stopped.
  * 12b's searchable sources had no consumer. The server half shipped and the
    only UI that reads a source never sent a term, so the 6,707-entry spawner
    list was picked from a 2,000-entry truncation with nothing saying so.

Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-07 16:34:03 -05:00
parent db8e01e868
commit 8453762e3b
13 changed files with 1913 additions and 149 deletions

View File

@@ -24,6 +24,10 @@
// both buttons, would behave badly in exactly the case the moderator role
// exists for.
//
// Phase 13 adds the meter beside `verify`, and it is the one route here that
// neither dispatches nor records — which is what makes it safe to call on a
// debounce while a form is edited.
//
// Phase 6 adds the switchboard to the `admin` column — §K puts it in the same row
// as the world-changing actions it governs — and `verify` to the `admin, editor`
// one, because a dry run dispatches nothing and the author who wrote the
@@ -154,6 +158,9 @@ const SURFACE = [
['DELETE', '/events/series/1', ['admin', 'editor']],
// Phase 6. A dry run dispatches nothing and changes nothing.
['POST', '/events/1/verify', ['admin', 'editor']],
// Phase 13's meter, in the same column and for a stronger version of the
// same reason: it dispatches nothing AND records nothing.
['POST', '/events/price', ['admin', 'editor']],
// Committing the deployment: admin only (§N2).
['POST', '/events/1/publish', ['admin']],
@@ -214,7 +221,9 @@ test('start and stop are NOT the same gate, and that is the point', async () =>
test('an editor may price an event but not publish or start it', async () => {
// Phase 6's addition to the same shape: the author who wrote the definition can
// find out what it would cost before asking an admin to commit the deployment.
// Phase 13 gave the same author the meter, on the same argument.
assert.equal(await forbidden('POST', '/events/1/verify', 'editor'), false)
assert.equal(await forbidden('POST', '/events/price', 'editor'), false)
assert.equal(await forbidden('POST', '/events/1/publish', 'editor'), true)
assert.equal(await forbidden('POST', '/events/1/runs', 'editor'), true)
})