feat(events): the authoring UI proper (Phase 13)
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:
@@ -33,6 +33,7 @@ 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 price = require('../../../events/price')
|
||||
|
||||
const asId = (raw) => {
|
||||
const n = Number(raw)
|
||||
@@ -466,6 +467,44 @@ exports.verify = async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/events/price — the live cap meter (Phase 13).
|
||||
*
|
||||
* `admin, editor`, exactly as the dry run is and for the same reason: an author
|
||||
* should be able to find out what their plan would cost before asking an admin
|
||||
* to commit the deployment to it.
|
||||
*
|
||||
* **The spec is in the BODY, not looked up by id**, and that is the whole point
|
||||
* of the route. The meter answers a question about the plan in the author's
|
||||
* hands — half-typed, unsaved, and quite possibly not publishable yet — so a
|
||||
* route that read the stored draft would be answering about a spec the author is
|
||||
* no longer looking at.
|
||||
*
|
||||
* It dispatches nothing, unlike `verify`, and it records nothing, unlike a dry
|
||||
* run that passes against a version — which is the stamp §K's unattended-start
|
||||
* gate reads. Those two absences are exactly what make it safe to call while
|
||||
* somebody is still typing.
|
||||
*
|
||||
* A body core cannot make sense of is a `400`; a plan that is over the caps is a
|
||||
* **200**, for the dry run's reason — *"this asks for 45 and you allow 30"* is
|
||||
* an answer, not a failed request.
|
||||
*
|
||||
* Not logged to the activity trail. It is a read that changes nothing and it
|
||||
* fires on a debounce while a form is edited; an audit line per keystroke would
|
||||
* bury the acts that matter under the act of looking.
|
||||
*/
|
||||
exports.price = async (req, res) => {
|
||||
const result = await price.priceSpec(req.body || {})
|
||||
if (!result.ok) return res.status(400).json({ error: result.error })
|
||||
return res.json({
|
||||
steps: result.steps,
|
||||
priced: result.priced,
|
||||
cost: result.cost,
|
||||
phases: result.phases,
|
||||
unpriced: result.unpriced,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/events/actions — the deployment's switchboard.
|
||||
*
|
||||
|
||||
@@ -83,6 +83,30 @@ eventsRouter.get(
|
||||
controller.options,
|
||||
)
|
||||
|
||||
// ── The live cap meter (Phase 13) ──────────────────────────────────
|
||||
//
|
||||
// A literal path for the same reason `/actions` is one, and `admin, editor` for
|
||||
// the same reason `verify` is: it dispatches nothing and it prices an author's
|
||||
// own work.
|
||||
//
|
||||
// **It takes a spec rather than an id**, which is what separates it from the dry
|
||||
// run. A meter has to answer about the form as it stands, and the form is not
|
||||
// saved between keystrokes.
|
||||
|
||||
eventsRouter.post(
|
||||
'/price',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Price an unsaved spec against the per-run caps, dispatching nothing'
|
||||
// #swagger.description = 'EVENTS.md I, the step editor live cap meter (Phase 13). What would this plan spend, and what does this deployment allow? The spec is in the BODY rather than looked up by id, and that is the whole point: the meter answers about the plan in the author hands -- half-typed, unsaved, quite possibly not publishable yet -- so a route that read the stored draft would be answering about a spec the author is no longer looking at. It is NOT the dry run and must not read as a substitute for one: nothing is dispatched, so nothing here knows whether the landmark exists or the shard is reachable, and nothing is recorded, so it never stamps the verification that EVENTS.md K unattended-start gate reads. Those two absences are exactly what make it safe to call on a debounce while somebody types. A step core cannot price is reported in `unpriced` rather than counted as free -- no module registers the action, its cost() failed its own contract, or it spends a dimension nobody declares -- because a meter that silently under-counts is worse than no meter. `phases` is the per-phase draw the timeline renders beside each phase. A plan over the caps is a 200, for the dry run reason: asking for 45 when 30 is allowed is an answer, not a failed request.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { phases: { type: "array", items: { type: "object", properties: { key: { type: "string" }, steps: { type: "array", items: { type: "object", properties: { actionId: { type: "string" }, params: { type: "object", additionalProperties: true } } } } } } } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The draw per dimension, the draw per phase, and every step that could not be priced', content: { "application/json": { schema: { type: "object", properties: { steps: { type: "integer" }, priced: { type: "integer" }, cost: { type: "array", items: { type: "object", properties: { dimension: { type: "string" }, total: { type: "integer" }, cap: { type: "integer" }, from: { type: "string" }, over: { type: "boolean" } } } }, phases: { type: "array", items: { type: "object", additionalProperties: true } }, unpriced: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The body is over the spec size limits', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.price,
|
||||
)
|
||||
|
||||
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
|
||||
//
|
||||
// A literal path, so it is declared up here with `/catalog` rather than beside
|
||||
|
||||
Reference in New Issue
Block a user