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": ""} // // 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= 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": ""} 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 = ` Scenario Builder — Dev Test (B0)

Scenario Builder — Dev Test (B0)

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.

1. Liste meine Szenarien


2. Szenario anlegen


3. Szenario abrufen (deep)


4. Verfahren hinzufügen


5. Event-Karte hinzufügen


6. Status patchen (archive / restore)


`