Files
paliad/internal/handlers/scenario_builder.go
mAi d913f4fc30 feat(builder): B5 — share + promote-to-project wizard (t-paliad-350)
Litigation Builder slice B5 (m/paliad#153 PRD §2.4 + §2.5 + §5.4 + §10).

Backend (internal/services/scenario_builder_service.go):
- ListSharedWithMe — scenarios shared read-only with the caller (the
  "Geteilt mit mir" bucket).
- PromoteScenario — transactional promote-to-project (PRD §10, no partial
  promotions). One Postgres tx: INSERT paliad.projects ('case',
  origin_scenario_id, proceeding_type_id + scenario_flags from the primary
  triplet) → creator team lead + wizard-selected colleagues → parties →
  deadlines (filed→completed, planned→pending with computed/actual date,
  skipped→none) → flip scenario to 'promoted' + promoted_project_id. The
  primary top-level proceeding + its spawned descendants form the one case
  file; additional standalone proceedings are reported via
  ProceedingsSkipped and stay in the scenario. Planned dates come from the
  injected FristenrechnerService.Calculate; court-set/undated planned
  events are skipped + counted.
- NewScenarioBuilderService gains a *FristenrechnerService dep (wired in
  cmd/server/main.go; nil in tests that don't promote).

Handlers/routes:
- GET /api/builder/scenarios/shared, POST /api/builder/scenarios/{id}/promote.

Frontend:
- builder-shares.ts — share modal (HLC user picker + current-shares list +
  revoke).
- builder-promote.ts — 3-step wizard (Bestätigen → Parteien ergänzen →
  Akte-Metadaten) → POST /promote → navigate to /projects/{id}.
- builder.ts — bucketed side panel (Aktiv / Geteilt mit mir / Als Projekt
  angelegt / Archiviert), read-only chrome (watermark + locked affordances)
  for shared/promoted scenarios, wired share + promote buttons, deep-link
  auto-load now covers shared scenarios.
- procedures.tsx — enabled buttons, bucket containers, readonly watermark slot.
- global.css — modal scaffold, share UI, promote wizard, buckets, readonly
  state. i18n.ts + i18n-keys.ts — DE+EN keys.

Tests: TestScenarioBuilderPromote (live-DB) pins the transactional cascade
+ readonly-after-promote + re-promote rejection. go build/vet/test + bun
build clean. Verified end-to-end via Playwright: Journey E (share → 2nd
user read-only watermark + locked canvas, incl. deep-link) and Journey D
(promote wizard 3 steps → project created with party → navigate → scenario
flipped to promoted).
2026-05-29 20:37:05 +02:00

729 lines
24 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})
}
// ---------------------------------------------------------------------------
// Akte mode (B4, t-paliad-347)
// ---------------------------------------------------------------------------
// handleBuilderScenarioFromProject — POST /api/builder/scenarios/from-project
//
// Body: {"project_id": "<uuid>"}
//
// Creates a fresh project-backed scenario by snapshotting the project's
// proceeding_type_id + our_side + scenario_flags into one top-level
// triplet, and seeds scenario_events from every existing
// paliad.deadlines row tied to a sequencing_rule. The new scenario's
// origin_project_id pins the Akte link so subsequent edits dual-write
// through to paliad.deadlines + paliad.projects.scenario_flags (PRD §2.3).
//
// Visibility: caller must be able to see the project. Bad input
// (missing proceeding_type_id, invisible project) returns 400 / 404
// via the standard service-error mapping.
func handleBuilderScenarioFromProject(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var body struct {
ProjectID uuid.UUID `json:"project_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
if body.ProjectID == uuid.Nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id ist erforderlich"})
return
}
out, err := dbSvc.scenarioBuilder.CreateScenarioFromProject(r.Context(), uid, body.ProjectID)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// ---------------------------------------------------------------------------
// 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)
}
// ---------------------------------------------------------------------------
// Shared-with-me + Promote (B5, m/paliad#153)
// ---------------------------------------------------------------------------
// handleBuilderScenariosShared — GET /api/builder/scenarios/shared
//
// Lists scenarios shared read-only with the caller (the "Geteilt mit mir"
// side-panel bucket, PRD §2.5). The caller's own scenarios are excluded.
func handleBuilderScenariosShared(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
out, err := dbSvc.scenarioBuilder.ListSharedWithMe(r.Context(), uid)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioPromote — POST /api/builder/scenarios/{id}/promote
//
// Body: PromoteScenarioInput (wizard steps 2 + 3). Promotes the scenario
// into a real paliad.projects 'case' row transactionally (PRD §10 — no
// partial promotions) and returns PromoteResult with the new project id
// the wizard navigates to (/projects/{project_id}).
func handleBuilderScenarioPromote(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.PromoteScenarioInput
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.PromoteScenario(r.Context(), uid, sid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// ---------------------------------------------------------------------------
// 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>`