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).
729 lines
24 KiB
Go
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>`
|