A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit 6e58595):
Q1 composition: primary + spawned (v1); multi-proceeding peer
compose is the v2 goal (spec.proceedings[] array)
Q2 scope: per-project + abstract (project_id NULL = abstract)
Q3 trigger: per-anchor overrides over one base date
Q4 storage: NEW paliad.scenarios table with jsonb spec
(NOT a project_event_choices column extension)
Migration 145 — additive only. Pre-flight coordination check:
- On-disk max: 138 (Berufung backfill, just merged).
- Live DB tracker: 106 (significantly behind — many migs pending
deploy).
- curie's #93 B.2-B.6 migs not pushed yet — reserved 139-143 + 144
as buffer; claimed 145 as the safe minimum that won't collide.
- paliad.scenarios has audit_reason NOT applicable (no audit
trigger on the table); updated_at trigger added on the table
itself.
- paliad.projects gains active_scenario_id uuid NULL FK with ON
DELETE SET NULL (mig 134 lesson — no updated_at clauses on
proceeding_types-style assumptions).
Schema:
paliad.scenarios (
id uuid pk,
project_id uuid NULL FK → projects(id) ON DELETE CASCADE,
name text NOT NULL CHECK char_length > 0,
description text NULL,
spec jsonb NOT NULL CHECK jsonb_typeof = 'object',
created_by uuid NULL FK → users(id) ON DELETE SET NULL,
created_at + updated_at timestamptz,
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name)
);
paliad.projects.active_scenario_id uuid NULL FK;
RLS: project-scoped → can_see_project; abstract → created_by = auth.uid();
Trigger: scenarios_touch_updated_at_trg.
pkg/litigationplanner additions:
- Scenario struct (db + json tags)
- ScenarioSpec / ScenarioProceeding / ScenarioCardChoice — parsed
view of the jsonb (version-1 today, v2 multi-peer-ready)
- ParseSpec(raw) + ScenarioSpec.PrimaryProceeding() + CalcOptionsFromSpec()
- ScenarioFilter + Catalog.LoadScenarios + Catalog.MatchScenario
- CalculateFromScenario(scenario, catalog, holidays, courts) — high-
level engine entry: parses spec → builds CalcOptions → delegates
to Calculate
- Sentinel errors: ErrUnknownScenario, ErrInvalidScenario,
ErrScenarioNoPrimary
paliadCatalog impl:
- LoadScenarios with progressively-built WHERE clauses (project-id
filter, abstract-for-user filter, or all)
- MatchScenario by id — returns ErrUnknownScenario on not-found
- Services connection bypasses RLS; ScenarioService enforces
visibility at the application layer (mirrors EventChoiceService
pattern from t-paliad-265)
SnapshotCatalog impl (embedded/upc):
- LoadScenarios returns empty slice (no scenarios in the snapshot)
- MatchScenario returns ErrUnknownScenario
internal/services/scenario_service.go:
- Create / Get / ListForProject / ListAbstractForUser / Patch /
SetActive / Delete with visibility checks
- validateSpec checks version, base_trigger_date format, every
proceedings[*].code resolves to an active paliad.proceeding_types
row, every appeal_target is valid, every anchor_overrides date
parses, every role ∈ {primary, peer}
- SetActive validates the scenario belongs to the requested project
(a scenario from a different project can't be active here)
- Returns ErrScenarioNotVisible for failed visibility checks
REST endpoints (registered in handlers.go):
GET /api/scenarios?project=<id> — list project's
GET /api/scenarios?abstract=true — list user's abstract
GET /api/scenarios/{id} — one
POST /api/scenarios — create
PATCH /api/scenarios/{id} — partial update
DELETE /api/scenarios/{id} — remove
PUT /api/projects/{id}/active-scenario — set / clear active
Handler error mapping:
- ErrUnknownScenario / ErrScenarioNotVisible → 404
- ErrInvalidInput / ErrInvalidScenario / ErrScenarioNoPrimary → 400
- everything else → 500
Tests:
- pkg/litigationplanner/scenarios_test.go: ParseSpec roundtrip
(well-formed + unknown version + malformed json),
PrimaryProceeding zero/multi/single, CalcOptionsFromSpec full
unpack, trigger_date_override path, no-base-trigger safety check.
8 cases total, all DB-free.
Wired in cmd/server/main.go alongside EventChoice — same pattern,
nil-safe when DATABASE_URL is unset (handlers 503 in that mode).
Acceptance:
- go build ./... clean
- go test ./... all green (incl. new scenarios tests)
- Pre-flight audit confirmed mig 145 number is safe vs curie's
pending B.2-B.6 range
217 lines
6.3 KiB
Go
217 lines
6.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
|
)
|
|
|
|
// Slice D (m/paliad#124 §5, mig 145) — REST endpoints for paliad.scenarios.
|
|
//
|
|
// Routes (registered in handlers.go):
|
|
//
|
|
// GET /api/scenarios?project=<id> — list project's scenarios
|
|
// GET /api/scenarios?abstract=true — list caller's abstract scenarios
|
|
// GET /api/scenarios/{id} — fetch one
|
|
// POST /api/scenarios — create
|
|
// PATCH /api/scenarios/{id} — partial update
|
|
// PUT /api/projects/{id}/active-scenario — set/clear active scenario
|
|
// DELETE /api/scenarios/{id} — remove
|
|
//
|
|
// All endpoints require auth; visibility is enforced by
|
|
// ScenarioService.requireProjectVisible / requireVisible.
|
|
|
|
func requireScenarioService(w http.ResponseWriter) bool {
|
|
if dbSvc == nil || dbSvc.scenario == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "Szenarien sind vorübergehend nicht verfügbar (keine Datenbank).",
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// scenarioErrorToStatus maps service errors to HTTP statuses. Mirrors
|
|
// the patterns in projects.go and event_choices.go.
|
|
func scenarioErrorToStatus(err error) (int, string) {
|
|
switch {
|
|
case errors.Is(err, lp.ErrUnknownScenario), errors.Is(err, services.ErrScenarioNotVisible):
|
|
return http.StatusNotFound, "Szenario nicht gefunden"
|
|
case errors.Is(err, services.ErrInvalidInput), errors.Is(err, lp.ErrInvalidScenario), errors.Is(err, lp.ErrScenarioNoPrimary):
|
|
return http.StatusBadRequest, err.Error()
|
|
}
|
|
return http.StatusInternalServerError, err.Error()
|
|
}
|
|
|
|
// handleScenariosList — GET /api/scenarios?project=<uuid> OR ?abstract=true.
|
|
func handleScenariosList(w http.ResponseWriter, r *http.Request) {
|
|
if !requireScenarioService(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
abstract := r.URL.Query().Get("abstract") == "true"
|
|
projectStr := r.URL.Query().Get("project")
|
|
switch {
|
|
case abstract:
|
|
out, err := dbSvc.scenario.ListAbstractForUser(r.Context(), uid)
|
|
if err != nil {
|
|
status, msg := scenarioErrorToStatus(err)
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
case projectStr != "":
|
|
pid, err := uuid.Parse(projectStr)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
|
|
return
|
|
}
|
|
out, err := dbSvc.scenario.ListForProject(r.Context(), uid, pid)
|
|
if err != nil {
|
|
status, msg := scenarioErrorToStatus(err)
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
default:
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "?project=<uuid> oder ?abstract=true erforderlich",
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleScenarioGet — GET /api/scenarios/{id}.
|
|
func handleScenarioGet(w http.ResponseWriter, r *http.Request) {
|
|
if !requireScenarioService(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.scenario.Get(r.Context(), uid, id)
|
|
if err != nil {
|
|
status, msg := scenarioErrorToStatus(err)
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// handleScenarioCreate — POST /api/scenarios.
|
|
func handleScenarioCreate(w http.ResponseWriter, r *http.Request) {
|
|
if !requireScenarioService(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input services.CreateScenarioInput
|
|
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.scenario.Create(r.Context(), uid, input)
|
|
if err != nil {
|
|
status, msg := scenarioErrorToStatus(err)
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, out)
|
|
}
|
|
|
|
// handleScenarioPatch — PATCH /api/scenarios/{id}.
|
|
func handleScenarioPatch(w http.ResponseWriter, r *http.Request) {
|
|
if !requireScenarioService(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.PatchScenarioInput
|
|
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.scenario.Patch(r.Context(), uid, id, input)
|
|
if err != nil {
|
|
status, msg := scenarioErrorToStatus(err)
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// handleScenarioDelete — DELETE /api/scenarios/{id}.
|
|
func handleScenarioDelete(w http.ResponseWriter, r *http.Request) {
|
|
if !requireScenarioService(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
|
|
}
|
|
if err := dbSvc.scenario.Delete(r.Context(), uid, id); err != nil {
|
|
status, msg := scenarioErrorToStatus(err)
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleSetActiveScenario — PUT /api/projects/{id}/active-scenario.
|
|
// Body: {"scenario_id": "<uuid>"} or {"scenario_id": null} to clear.
|
|
func handleSetActiveScenario(w http.ResponseWriter, r *http.Request) {
|
|
if !requireScenarioService(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
pid, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
|
|
return
|
|
}
|
|
var body struct {
|
|
ScenarioID *uuid.UUID `json:"scenario_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
|
return
|
|
}
|
|
if err := dbSvc.scenario.SetActive(r.Context(), uid, pid, body.ScenarioID); err != nil {
|
|
status, msg := scenarioErrorToStatus(err)
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|