Files
paliad/internal/handlers/scenario_builder.go
mAi 46dc4ec94b
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (m/paliad#153)
Builds on B1 (commit 6c1d8cc). After this slice a user can compose a
multi-proceeding scenario kontextfrei: stack proceedings, flip
perspective per-triplet, toggle scenario flags, auto-spawn child
proceedings on flag transitions, and mark individual event cards as
planned / filed / skipped — all auto-saved to paliad.scenario_*.

PRD §7.1 B2 acceptance shipped:
  - Multi-triplet stack: top-level proceedings sorted by ordinal,
    child proceedings nested inline with a left lime border.
  - Per-triplet controls bar: perspective radio (none / claimant /
    defendant), Detailgrad pill (selected / all options), Entfernen
    action. Each control PATCHes the proceeding row and re-renders the
    affected triplet.
  - Per-triplet flag strip: every paliad.scenario_flag_catalog row
    rendered as a checkbox, bound to scenario_proceedings.scenario_flags.
    Active flags also surface as chips in the triplet header for quick
    legibility.
  - Spawn nesting: when `with_ccr` flips ON on upc.inf.cfi the builder
    auto-POSTs an upc.ccr.cfi child proceeding linked via
    parent_scenario_proceeding_id; flip OFF deletes the child (events
    cascade via the schema). The SPAWN_MAP table is data-driven so
    future spawn flags slot in.
  - 3-state event cards (planned / filed / skipped):
    overlayEventStates walks the rendered .fr-col-item nodes (the
    data-rule-id hook added to verfahrensablauf-core in this slice)
    and stamps each card with data-builder-state + per-state action
    buttons (File / Skip / Reset to planned). Filed cards prompt for
    a date; skipped cards prompt for an optional reason. POSTs or
    PATCHes paliad.scenario_events keyed by sequencing_rule_id.
  - Per-card optional horizon chip: stores horizon_optional on the
    scenario_event row, increment / decrement chip on every card.
    The full surface awaits a calc-engine "optionals available"
    counter (PRD §3.4 follow-up); the persistence layer + UX hook are
    in place so the wiring lands without another schema touch.
  - Page-header Stichtag drives default dates for every triplet (the
    triplet's per-stichtag override path is wired but the per-triplet
    Stichtag input is a B3+ affordance).

verfahrensablauf-core.renderColumnsBody now stamps data-rule-id (and
data-submission-code as a future hook) on every .fr-col-item root —
non-breaking enhancement; the legacy /tools/* pages don't read either
attribute. Verified by re-running the existing 57-test suite.

Backend: one new read-only endpoint
GET /api/builder/scenario-flag-catalog passes through
ScenarioFlagsService.ListCatalog so the builder doesn't need a
per-project round-trip to render flag toggles.

bun run build clean (3050 i18n keys), go vet ./... clean, go test ./...
clean, frontend bun test (verfahrensablauf-core suite) 57 / 57 pass.
2026-05-28 00:28:48 +02:00

628 lines
21 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-340 / m/paliad#153 B0 — REST endpoints over the new normalised
// scenario builder shape (paliad.scenarios with owner_id, +
// paliad.scenario_proceedings / scenario_events / scenario_shares).
//
// Endpoints live under /api/builder/scenarios/* to avoid clashing with
// the legacy /api/scenarios/* endpoints from m/paliad#124 Slice D. The
// B6 cleanup slice retires the legacy surface; until then both shapes
// coexist on the same paliad.scenarios table (the legacy paths require
// project_id IS NOT NULL OR an abstract created_by = caller; the builder
// paths require owner_id = caller).
//
// All handlers gate by requireScenarioBuilderService — 503 when the
// service is nil (DATABASE_URL unset). Auth is checked via requireUser;
// per-row visibility is enforced inside the service.
func requireScenarioBuilderService(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.scenarioBuilder == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Litigation-Builder ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return false
}
return true
}
// scenarioBuilderErrorToStatus maps service errors to HTTP statuses.
func scenarioBuilderErrorToStatus(err error) (int, string) {
switch {
case errors.Is(err, services.ErrScenarioBuilderNotVisible),
errors.Is(err, services.ErrNotVisible):
return http.StatusNotFound, "Szenario nicht gefunden"
case errors.Is(err, services.ErrInvalidInput):
return http.StatusBadRequest, err.Error()
}
return http.StatusInternalServerError, err.Error()
}
func writeBuilderError(w http.ResponseWriter, err error) {
status, msg := scenarioBuilderErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------------------------------------------------------------------------
// Scenario CRUD
// ---------------------------------------------------------------------------
// handleBuilderScenariosList — GET /api/builder/scenarios?status=<active|archived|promoted|all>
func handleBuilderScenariosList(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
status := r.URL.Query().Get("status")
out, err := dbSvc.scenarioBuilder.ListMyScenarios(r.Context(), uid, status)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioCreate — POST /api/builder/scenarios
func handleBuilderScenarioCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateBuilderScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.CreateScenario(r.Context(), uid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderScenarioGet — GET /api/builder/scenarios/{id}
func handleBuilderScenarioGet(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
out, err := dbSvc.scenarioBuilder.GetScenarioDeep(r.Context(), uid, id)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioPatch — PATCH /api/builder/scenarios/{id}
func handleBuilderScenarioPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
var input services.PatchBuilderScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchScenario(r.Context(), uid, id, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Proceedings
// ---------------------------------------------------------------------------
// handleBuilderProceedingCreate — POST /api/builder/scenarios/{id}/proceedings
func handleBuilderProceedingCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var input services.AddProceedingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddProceeding(r.Context(), uid, sid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderProceedingPatch — PATCH /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.PatchProceedingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchProceeding(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderProceedingDelete — DELETE /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteProceeding(r.Context(), uid, sid, pid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
// handleBuilderEventCreate — POST /api/builder/scenarios/{id}/proceedings/{pid}/events
func handleBuilderEventCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.AddEventInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddEvent(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderEventPatch — PATCH /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
var input services.PatchEventInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchEvent(r.Context(), uid, sid, eid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderEventDelete — DELETE /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteEvent(r.Context(), uid, sid, eid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Shares
// ---------------------------------------------------------------------------
// handleBuilderShareCreate — POST /api/builder/scenarios/{id}/shares
// Body: {"shared_with_user_id": "<uuid>"}
func handleBuilderShareCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var body struct {
SharedWithUserID uuid.UUID `json:"shared_with_user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddShare(r.Context(), uid, sid, body.SharedWithUserID)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderShareDelete — DELETE /api/builder/scenarios/{id}/shares/{sid}
func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
scid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
shid, err := uuid.Parse(r.PathValue("sid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Share-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteShare(r.Context(), uid, scid, shid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Scenario flag catalog passthrough (m/paliad#153 B2)
// ---------------------------------------------------------------------------
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
//
// Returns every row of paliad.scenario_flag_catalog so the Litigation
// Builder can render per-triplet flag toggles without a per-project
// round-trip. The catalog itself is global (no jurisdiction or
// proceeding scope baked into the table); which flags actually apply
// to a given proceeding type is decided by the calc engine via
// condition_expr at calculation time. The client renders every catalog
// flag and lets the user toggle them — flags with no effect on the
// active proceeding's rules simply have no condition_expr referencing
// them, so toggling is a no-op.
//
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
// visibility checks aren't needed because the catalog is global.
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.scenarioFlags == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
if _, ok := requireUser(w, r); !ok {
return
}
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Flag-Katalog konnte nicht geladen werden",
})
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Dev-only test route
// ---------------------------------------------------------------------------
// handleBuilderDevTestPage — GET /dev/scenario-builder
//
// Gated to services.PaliadinOwnerEmail (the same single-owner gate the
// /paliadin route uses). Every other authenticated user gets 404. Pure
// HTML — no JS bundle — so the page works even before B1 wires the real
// builder shell. Renders curl-equivalent forms for the B0 surface so the
// schema can be exercised end-to-end without Postman / shell scripts.
//
// This is the "dev-only test route" the head's task spec asked for. It
// disappears in B6 cleanup once the production builder UI ships at
// /tools/procedures.
func handleBuilderDevTestPage(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(builderDevTestHTML))
}
const builderDevTestHTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Scenario Builder — Dev Test (B0)</title>
<style>
body { font-family: ui-monospace, Menlo, monospace; max-width: 880px; margin: 2em auto;
padding: 0 1em; color: #222; background: #fafaf7; }
h1, h2 { font-family: ui-sans-serif, system-ui, sans-serif; }
h1 { border-bottom: 4px solid #c6f41c; padding-bottom: .2em; }
section { background: #fff; border: 1px solid #ddd; border-radius: 4px;
padding: 1em 1.2em; margin: 1em 0; }
label { display: block; margin: .4em 0 .15em; font-size: .85em; color: #555; }
input, textarea, select, button { font: inherit; padding: .35em .5em; box-sizing: border-box; }
input[type="text"], input[type="number"], textarea { width: 100%; }
button { background: #c6f41c; border: 1px solid #9ec61f; cursor: pointer;
padding: .4em 1em; border-radius: 3px; margin: .2em 0; }
button.secondary { background: #eee; border-color: #ccc; }
pre.out { background: #1e1e1e; color: #e6e6e6; padding: .8em 1em; border-radius: 4px;
overflow: auto; max-height: 30em; font-size: .85em; }
.note { color: #777; font-size: .9em; }
.row { display: flex; gap: .5em; }
.row > * { flex: 1; }
</style>
</head>
<body>
<h1>Scenario Builder — Dev Test (B0)</h1>
<p class="note">t-paliad-340 / m/paliad#153 — DB-only slice. Exercises
paliad.scenarios (builder rows), scenario_proceedings, scenario_events,
scenario_shares via /api/builder/scenarios/*. Gated to PaliadinOwnerEmail.</p>
<section>
<h2>1. Liste meine Szenarien</h2>
<label>Status filter</label>
<select id="list-status">
<option value="">(default: alle)</option>
<option value="active">active</option>
<option value="archived">archived</option>
<option value="promoted">promoted</option>
<option value="all">all (explicit)</option>
</select>
<button onclick="listScenarios()">GET /api/builder/scenarios</button>
<pre class="out" id="list-out"></pre>
</section>
<section>
<h2>2. Szenario anlegen</h2>
<label>Name</label>
<input type="text" id="create-name" placeholder="(leer = Unbenanntes Szenario)">
<label>Notes (optional)</label>
<textarea id="create-notes" rows="2"></textarea>
<button onclick="createScenario()">POST /api/builder/scenarios</button>
<pre class="out" id="create-out"></pre>
</section>
<section>
<h2>3. Szenario abrufen (deep)</h2>
<label>Scenario ID</label>
<input type="text" id="get-id">
<button onclick="getScenario()">GET /api/builder/scenarios/{id}</button>
<pre class="out" id="get-out"></pre>
</section>
<section>
<h2>4. Verfahren hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="proc-sid">
<label>proceeding_type_id (integer)</label>
<input type="number" id="proc-pt-id" placeholder="z.B. 7 für upc.inf.cfi">
<label>primary_party</label>
<select id="proc-party">
<option value="">(none)</option>
<option value="claimant">claimant</option>
<option value="defendant">defendant</option>
</select>
<button onclick="addProceeding()">POST .../proceedings</button>
<pre class="out" id="proc-out"></pre>
</section>
<section>
<h2>5. Event-Karte hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="ev-sid">
<label>Proceeding ID</label>
<input type="text" id="ev-pid">
<label>custom_label (oder sequencing_rule_id / procedural_event_id)</label>
<input type="text" id="ev-label" placeholder="freitext-Karte">
<label>state</label>
<select id="ev-state">
<option value="planned">planned</option>
<option value="filed">filed</option>
<option value="skipped">skipped</option>
</select>
<button onclick="addEvent()">POST .../proceedings/{pid}/events</button>
<pre class="out" id="ev-out"></pre>
</section>
<section>
<h2>6. Status patchen (archive / restore)</h2>
<label>Scenario ID</label>
<input type="text" id="patch-sid">
<label>new status</label>
<select id="patch-status">
<option value="active">active</option>
<option value="archived">archived</option>
</select>
<button onclick="patchStatus()">PATCH /api/builder/scenarios/{id}</button>
<pre class="out" id="patch-out"></pre>
</section>
<script>
const j = (id, payload) =>
document.getElementById(id).textContent = JSON.stringify(payload, null, 2);
async function call(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
const text = await r.text();
let parsed = text;
try { parsed = JSON.parse(text); } catch (_) {}
return { status: r.status, body: parsed };
}
async function listScenarios() {
const status = document.getElementById('list-status').value;
const q = status ? '?status=' + encodeURIComponent(status) : '';
j('list-out', await call('GET', '/api/builder/scenarios' + q));
}
async function createScenario() {
const name = document.getElementById('create-name').value;
const notes = document.getElementById('create-notes').value;
const body = {};
if (name) body.name = name;
if (notes) body.notes = notes;
j('create-out', await call('POST', '/api/builder/scenarios', body));
}
async function getScenario() {
const id = document.getElementById('get-id').value.trim();
if (!id) return j('get-out', { error: 'ID erforderlich' });
j('get-out', await call('GET', '/api/builder/scenarios/' + id));
}
async function addProceeding() {
const sid = document.getElementById('proc-sid').value.trim();
const ptID = parseInt(document.getElementById('proc-pt-id').value, 10);
const party = document.getElementById('proc-party').value;
if (!sid || !ptID) return j('proc-out', { error: 'sid + proceeding_type_id erforderlich' });
const body = { proceeding_type_id: ptID };
if (party) body.primary_party = party;
j('proc-out', await call('POST', '/api/builder/scenarios/' + sid + '/proceedings', body));
}
async function addEvent() {
const sid = document.getElementById('ev-sid').value.trim();
const pid = document.getElementById('ev-pid').value.trim();
const label = document.getElementById('ev-label').value.trim();
const state = document.getElementById('ev-state').value;
if (!sid || !pid || !label) return j('ev-out', { error: 'sid + pid + custom_label erforderlich' });
j('ev-out', await call('POST',
'/api/builder/scenarios/' + sid + '/proceedings/' + pid + '/events',
{ custom_label: label, state }));
}
async function patchStatus() {
const sid = document.getElementById('patch-sid').value.trim();
const status = document.getElementById('patch-status').value;
if (!sid) return j('patch-out', { error: 'sid erforderlich' });
j('patch-out', await call('PATCH', '/api/builder/scenarios/' + sid, { status }));
}
</script>
</body>
</html>`