diff --git a/cmd/server/main.go b/cmd/server/main.go index 1b2b7c7..339be28 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -221,6 +221,8 @@ func main() { Export: services.NewExportService(pool, branding.Name), // t-paliad-265 / m/paliad#96 — per-event-card optional choices. EventChoice: services.NewEventChoiceService(pool, projectSvc, users), + // Slice D (m/paliad#124 §5, mig 145) — named scenario compositions. + Scenario: services.NewScenarioService(pool, projectSvc, rules), } // t-paliad-246 Slice A — Backup Mode runner. Wired only when diff --git a/internal/db/migrations/145_scenarios.down.sql b/internal/db/migrations/145_scenarios.down.sql new file mode 100644 index 0000000..8d41a8c --- /dev/null +++ b/internal/db/migrations/145_scenarios.down.sql @@ -0,0 +1,13 @@ +-- 145_scenarios — DOWN +-- +-- Reverses mig 145. Drops the FK on paliad.projects, the table, the +-- trigger function, and the RLS policies (CASCADE on table drop kills +-- policies). Any data in paliad.scenarios is lost on down. + +ALTER TABLE paliad.projects + DROP COLUMN IF EXISTS active_scenario_id; + +DROP TRIGGER IF EXISTS scenarios_touch_updated_at_trg ON paliad.scenarios; +DROP FUNCTION IF EXISTS paliad.scenarios_touch_updated_at(); + +DROP TABLE IF EXISTS paliad.scenarios CASCADE; diff --git a/internal/db/migrations/145_scenarios.up.sql b/internal/db/migrations/145_scenarios.up.sql new file mode 100644 index 0000000..2331f75 --- /dev/null +++ b/internal/db/migrations/145_scenarios.up.sql @@ -0,0 +1,170 @@ +-- 145_scenarios — Slice D, m/paliad#124 §5 (revised) +-- +-- Creates paliad.scenarios + paliad.projects.active_scenario_id FK. +-- A scenario is a named composition of existing proceedings + flags +-- + per-card choices + anchor dates the user can switch between for +-- a project (project_id NOT NULL) OR save as an abstract template on +-- /tools/verfahrensablauf (project_id IS NULL). +-- +-- m's 2026-05-26 picks (AskUserQuestion round, doc commit 6e58595): +-- Q1: composition shape → primary+spawned (v1); multi-proceeding +-- peer compose is the v2 goal. spec.jsonb +-- architected for N entries from day 1. +-- Q2: scope → per-project + abstract. +-- Q3: trigger dates → per-anchor overrides over one base date. +-- Q4: storage → NEW paliad.scenarios table with jsonb +-- spec (NOT a project_event_choices column +-- extension). +-- +-- "users should not add their own rules" (m, t-paliad-301) — scenarios +-- compose existing rules, never author new ones. spec.proceedings[*].code +-- must resolve to an existing active paliad.proceeding_types row; +-- spec.proceedings[*].anchor_overrides keys must resolve to existing +-- submission_codes. Validation happens at the application layer +-- (ScenarioService.validateSpec) — not in DB CHECK constraints (too +-- expensive to express in pure SQL). +-- +-- Migration number: 145. Coordination check 2026-05-26 17:38: curie's +-- B.2-B.6 migrations land in the 139-143 range. 144 reserved as buffer. +-- 145 is the next safe claim. +-- +-- ADDITIVE ONLY: CREATE TABLE, ALTER ADD COLUMN, indexes, RLS policies. +-- Down drops everything. No backfill (zero existing scenarios on day 1). +-- +-- See docs/design-litigation-planner-2026-05-26.md §5 + §18.4 for the +-- design. + +-- --------------------------------------------------------------- +-- 1. The scenarios table +-- --------------------------------------------------------------- + +CREATE TABLE paliad.scenarios ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + -- project_id NULL = abstract scenario (saved Verfahrensablauf + -- template, no Akte). project_id NOT NULL = scenario attached to + -- a real Akte. + project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE, + name text NOT NULL, + description text NULL, + -- spec carries the full composition. Shape documented in the + -- design doc §5; the application validates structure before write. + spec jsonb NOT NULL, + created_by uuid NULL REFERENCES paliad.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + -- Within a single project, scenario names are unique. Abstract + -- scenarios are unique per (created_by, name) so two users can + -- each keep a "with_ccr" template without colliding. NULLS NOT + -- DISTINCT means a single user can have one "name" per + -- (project_id, created_by) tuple, where NULL project_id + + -- NULL created_by is a single global namespace (used only by + -- seed / system scenarios — none today). + CONSTRAINT scenarios_unique_per_scope + UNIQUE NULLS NOT DISTINCT (project_id, created_by, name), + + -- Non-empty name. + CONSTRAINT scenarios_name_nonempty CHECK (char_length(name) > 0), + + -- Non-empty spec — at least an object. The application checks + -- structure (version, proceedings[], base_trigger_date format). + CONSTRAINT scenarios_spec_object CHECK (jsonb_typeof(spec) = 'object') +); + +CREATE INDEX scenarios_project_id_idx + ON paliad.scenarios(project_id) WHERE project_id IS NOT NULL; + +CREATE INDEX scenarios_abstract_user_idx + ON paliad.scenarios(created_by) WHERE project_id IS NULL; + +COMMENT ON TABLE paliad.scenarios IS + 'Named compositions of existing proceedings + flags + per-card ' + 'choices + anchor dates. project_id NULL = abstract template; ' + 'project_id NOT NULL = attached to an Akte. Design: ' + 'docs/design-litigation-planner-2026-05-26.md §5. (Slice D)'; + +COMMENT ON COLUMN paliad.scenarios.spec IS + 'jsonb composition spec. Shape: {version: int, base_trigger_date: ' + 'ISO date, proceedings: [{code, role, flags[], per_card_choices, ' + 'anchor_overrides, skip_rules[]}, ...]}. Validated at write-time ' + 'by ScenarioService.validateSpec.'; + +-- --------------------------------------------------------------- +-- 2. paliad.projects.active_scenario_id FK +-- +-- NULL = use today's ad-hoc per-card choice state from +-- paliad.project_event_choices (pre-scenario behaviour preserved). +-- Non-NULL = the project's current SmartTimeline / Akte-Fristenrechner +-- render reads from this scenario's spec instead. +-- --------------------------------------------------------------- + +ALTER TABLE paliad.projects + ADD COLUMN active_scenario_id uuid NULL + REFERENCES paliad.scenarios(id) ON DELETE SET NULL; + +COMMENT ON COLUMN paliad.projects.active_scenario_id IS + 'FK to paliad.scenarios. NULL = read choices from ' + 'paliad.project_event_choices (legacy). Non-NULL = read from the ' + 'pointed scenario.spec.'; + +-- --------------------------------------------------------------- +-- 3. RLS — mirror paliad.project_event_choices's pattern (mig 129). +-- +-- Project-scoped scenarios (project_id NOT NULL) inherit team visibility +-- via paliad.can_see_project. Abstract scenarios (project_id IS NULL) +-- are private to created_by — only the author can read / write them. +-- --------------------------------------------------------------- + +ALTER TABLE paliad.scenarios ENABLE ROW LEVEL SECURITY; + +-- Project-scoped: team visibility. +DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios; +CREATE POLICY scenarios_project_select ON paliad.scenarios + FOR SELECT + USING (project_id IS NOT NULL AND paliad.can_see_project(project_id)); + +DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios; +CREATE POLICY scenarios_project_mutate ON paliad.scenarios + FOR ALL + USING (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id)); + +-- Abstract: owner-only. +DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios; +CREATE POLICY scenarios_abstract_select ON paliad.scenarios + FOR SELECT + USING (project_id IS NULL AND created_by = auth.uid()); + +DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios; +CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios + FOR ALL + USING (project_id IS NULL AND created_by = auth.uid()) + WITH CHECK (project_id IS NULL AND created_by = auth.uid()); + +-- --------------------------------------------------------------- +-- 4. updated_at trigger (mirrors other paliad tables that carry +-- updated_at — keep it in lockstep with row mutations). +-- --------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION paliad.scenarios_touch_updated_at() + RETURNS trigger AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER scenarios_touch_updated_at_trg + BEFORE UPDATE ON paliad.scenarios + FOR EACH ROW + EXECUTE FUNCTION paliad.scenarios_touch_updated_at(); + +-- --------------------------------------------------------------- +-- 5. Informational NOTICE — schema-only migration, zero rows added. +-- --------------------------------------------------------------- + +DO $$ +BEGIN + RAISE NOTICE '[mig 145] paliad.scenarios created (0 rows; awaits API usage)'; + RAISE NOTICE '[mig 145] paliad.projects.active_scenario_id added (all rows NULL initially)'; +END $$; diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c9fa532..11714af 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -120,6 +120,11 @@ type Services struct { // the Verfahrensablauf timeline. EventChoice *services.EventChoiceService + // Slice D (m/paliad#124 §5, mig 145) — named scenario compositions + // per project or as abstract templates. Nil when DATABASE_URL is + // unset; the /api/scenarios routes return 503 in that case. + Scenario *services.ScenarioService + // Paliadin is wired when DATABASE_URL is set. The concrete backend // is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST // (remote → mRiver via SSH) or local tmux availability. Stays nil @@ -184,6 +189,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc backup: svc.Backup, submissionDraft: svc.SubmissionDraft, eventChoice: svc.EventChoice, + scenario: svc.Scenario, } } @@ -446,6 +452,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice) protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice) + // Slice D (m/paliad#124 §5, mig 145) — named scenario compositions + // per project or as abstract templates on /tools/verfahrensablauf. + protected.HandleFunc("GET /api/scenarios", handleScenariosList) + protected.HandleFunc("GET /api/scenarios/{id}", handleScenarioGet) + protected.HandleFunc("POST /api/scenarios", handleScenarioCreate) + protected.HandleFunc("PATCH /api/scenarios/{id}", handleScenarioPatch) + protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete) + protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario) + // Partner units (structural partner-led units; legacy "Dezernate"). protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits) protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit) diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 208f64c..e28c8f9 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -71,6 +71,9 @@ type dbServices struct { // t-paliad-265 — per-event-card optional choices. eventChoice *services.EventChoiceService + + // Slice D — named scenario compositions (m/paliad#124 §5). + scenario *services.ScenarioService } var dbSvc *dbServices diff --git a/internal/handlers/scenarios.go b/internal/handlers/scenarios.go new file mode 100644 index 0000000..ff91f2c --- /dev/null +++ b/internal/handlers/scenarios.go @@ -0,0 +1,216 @@ +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= — 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= 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= 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": ""} 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) +} diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index fd870d8..8384f81 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -516,6 +516,61 @@ func computeDepths( return depths } +// LoadScenarios lists scenarios visible to the caller (Slice D, +// m/paliad#124 §5, mig 145). RLS on paliad.scenarios enforces: +// project-scoped rows require paliad.can_see_project(project_id); +// abstract rows require created_by = auth.uid(). The filter narrows +// the SELECT (project_id-bound, abstract-for-user, or all). +func (c *paliadCatalog) LoadScenarios(ctx context.Context, filter lp.ScenarioFilter) ([]lp.Scenario, error) { + where := []string{} + args := []any{} + add := func(clause string, val any) { + args = append(args, val) + where = append(where, fmt.Sprintf(clause, len(args))) + } + if filter.ProjectID != nil { + add("project_id = $%d", *filter.ProjectID) + } + if filter.AbstractForUser != nil { + where = append(where, "project_id IS NULL") + add("created_by = $%d", *filter.AbstractForUser) + } + query := `SELECT id, project_id, name, description, spec, + created_by, created_at, updated_at + FROM paliad.scenarios` + if len(where) > 0 { + query += " WHERE " + strings.Join(where, " AND ") + } + query += " ORDER BY created_at DESC" + + var rows []lp.Scenario + if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil { + return nil, fmt.Errorf("load scenarios: %w", err) + } + return rows, nil +} + +// MatchScenario returns the scenario with the given id, or +// lp.ErrUnknownScenario if not visible / not found. RLS gates +// visibility; a not-found result could mean "doesn't exist" OR +// "exists but you can't see it" — either way the caller treats it +// as unknown. +func (c *paliadCatalog) MatchScenario(ctx context.Context, id uuid.UUID) (*lp.Scenario, error) { + var s lp.Scenario + err := c.rules.db.GetContext(ctx, &s, + `SELECT id, project_id, name, description, spec, + created_by, created_at, updated_at + FROM paliad.scenarios + WHERE id = $1`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, lp.ErrUnknownScenario + } + if err != nil { + return nil, fmt.Errorf("match scenario %q: %w", id, err) + } + return &s, nil +} + // _ proves paliadCatalog satisfies lp.Catalog at compile time. var _ lp.Catalog = (*paliadCatalog)(nil) diff --git a/internal/services/scenario_service.go b/internal/services/scenario_service.go new file mode 100644 index 0000000..3c83af7 --- /dev/null +++ b/internal/services/scenario_service.go @@ -0,0 +1,347 @@ +package services + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/paliad/internal/models" + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" +) + +// ScenarioService reads + writes paliad.scenarios — named compositions +// of existing proceedings + flags + per-card choices + anchor dates, +// switchable per project or saved as abstract templates on +// /tools/verfahrensablauf. Slice D, m/paliad#124 §5, mig 145. +// +// Visibility: +// - Project-scoped scenarios (project_id NOT NULL): require +// can_see_project on the bound project (mirrors +// EventChoiceService.requireProjectVisible). +// - Abstract scenarios (project_id IS NULL): owner-only. Only +// created_by can read / mutate. +// +// The service applies these checks in application code; paliad.scenarios +// also has RLS policies (mig 145) as defense-in-depth for callers that +// connect through Supabase Auth's auth.uid() session. +type ScenarioService struct { + db *sqlx.DB + projects *ProjectService + rules *DeadlineRuleService +} + +// NewScenarioService wires the service to its dependencies. +func NewScenarioService(db *sqlx.DB, projects *ProjectService, rules *DeadlineRuleService) *ScenarioService { + return &ScenarioService{db: db, projects: projects, rules: rules} +} + +// Sentinel errors. Mirrors EventChoiceService + the lp package errors +// so handlers can map cleanly to HTTP statuses. +var ( + ErrScenarioNotVisible = errors.New("scenario not visible to caller") +) + +// CreateScenarioInput is the payload for POST /api/scenarios. project_id +// nil = abstract scenario (saved Verfahrensablauf template). +type CreateScenarioInput struct { + ProjectID *uuid.UUID `json:"project_id,omitempty"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Spec json.RawMessage `json:"spec"` +} + +// Create inserts a new scenario after validating the spec. +func (s *ScenarioService) Create(ctx context.Context, userID uuid.UUID, input CreateScenarioInput) (*lp.Scenario, error) { + if input.Name == "" { + return nil, fmt.Errorf("%w: name required", ErrInvalidInput) + } + if err := s.validateSpec(ctx, input.Spec); err != nil { + return nil, err + } + if input.ProjectID != nil { + if err := s.requireProjectVisible(ctx, userID, *input.ProjectID); err != nil { + return nil, err + } + } + + var out lp.Scenario + err := s.db.GetContext(ctx, &out, + `INSERT INTO paliad.scenarios (project_id, name, description, spec, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, project_id, name, description, spec, created_by, + created_at, updated_at`, + input.ProjectID, input.Name, input.Description, + []byte(input.Spec), userID) + if err != nil { + return nil, fmt.Errorf("create scenario: %w", err) + } + return &out, nil +} + +// Get returns one scenario by id after a visibility check. +func (s *ScenarioService) Get(ctx context.Context, userID, scenarioID uuid.UUID) (*lp.Scenario, error) { + var sc lp.Scenario + err := s.db.GetContext(ctx, &sc, + `SELECT id, project_id, name, description, spec, created_by, + created_at, updated_at + FROM paliad.scenarios + WHERE id = $1`, scenarioID) + if errors.Is(err, sql.ErrNoRows) { + return nil, lp.ErrUnknownScenario + } + if err != nil { + return nil, fmt.Errorf("get scenario: %w", err) + } + if err := s.requireVisible(ctx, userID, &sc); err != nil { + return nil, err + } + return &sc, nil +} + +// ListForProject returns scenarios attached to one project, ordered by +// created_at desc. +func (s *ScenarioService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]lp.Scenario, error) { + if err := s.requireProjectVisible(ctx, userID, projectID); err != nil { + return nil, err + } + out := []lp.Scenario{} + err := s.db.SelectContext(ctx, &out, + `SELECT id, project_id, name, description, spec, created_by, + created_at, updated_at + FROM paliad.scenarios + WHERE project_id = $1 + ORDER BY created_at DESC`, projectID) + if err != nil { + return nil, fmt.Errorf("list scenarios for project: %w", err) + } + return out, nil +} + +// ListAbstractForUser returns the calling user's abstract scenarios. +func (s *ScenarioService) ListAbstractForUser(ctx context.Context, userID uuid.UUID) ([]lp.Scenario, error) { + out := []lp.Scenario{} + err := s.db.SelectContext(ctx, &out, + `SELECT id, project_id, name, description, spec, created_by, + created_at, updated_at + FROM paliad.scenarios + WHERE project_id IS NULL AND created_by = $1 + ORDER BY created_at DESC`, userID) + if err != nil { + return nil, fmt.Errorf("list abstract scenarios: %w", err) + } + return out, nil +} + +// PatchScenarioInput is the payload for PATCH /api/scenarios/{id}. Any +// field nil means "don't change". Spec replacement re-runs validation. +type PatchScenarioInput struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Spec json.RawMessage `json:"spec,omitempty"` +} + +// Patch updates one or more scenario fields. Visibility check fires +// first (the caller must already see the scenario to mutate it). +func (s *ScenarioService) Patch(ctx context.Context, userID, scenarioID uuid.UUID, input PatchScenarioInput) (*lp.Scenario, error) { + current, err := s.Get(ctx, userID, scenarioID) + if err != nil { + return nil, err + } + if len(input.Spec) > 0 { + if err := s.validateSpec(ctx, input.Spec); err != nil { + return nil, err + } + } + + sets := []string{} + args := []any{} + add := func(clause string, val any) { + args = append(args, val) + sets = append(sets, fmt.Sprintf(clause, len(args))) + } + if input.Name != nil { + add("name = $%d", *input.Name) + } + if input.Description != nil { + add("description = $%d", *input.Description) + } + if len(input.Spec) > 0 { + add("spec = $%d", []byte(input.Spec)) + } + if len(sets) == 0 { + return current, nil + } + args = append(args, scenarioID) + query := fmt.Sprintf(`UPDATE paliad.scenarios SET %s + WHERE id = $%d + RETURNING id, project_id, name, description, spec, created_by, + created_at, updated_at`, joinSets(sets), len(args)) + var out lp.Scenario + if err := s.db.GetContext(ctx, &out, query, args...); err != nil { + return nil, fmt.Errorf("patch scenario: %w", err) + } + return &out, nil +} + +// SetActive points a project at one of its scenarios. Pass nil to +// clear (revert to ad-hoc per-card choice state). +func (s *ScenarioService) SetActive(ctx context.Context, userID, projectID uuid.UUID, scenarioID *uuid.UUID) error { + if err := s.requireProjectVisible(ctx, userID, projectID); err != nil { + return err + } + if scenarioID != nil { + // Ensure scenario exists + belongs to this project. A scenario + // from a different project (or an abstract one) can't be the + // active scenario on this project. + sc, err := s.Get(ctx, userID, *scenarioID) + if err != nil { + return err + } + if sc.ProjectID == nil || *sc.ProjectID != projectID { + return fmt.Errorf("%w: scenario %s is not attached to project %s", + ErrInvalidInput, *scenarioID, projectID) + } + } + _, err := s.db.ExecContext(ctx, + `UPDATE paliad.projects SET active_scenario_id = $1 WHERE id = $2`, + scenarioID, projectID) + if err != nil { + return fmt.Errorf("set active scenario: %w", err) + } + return nil +} + +// Delete removes a scenario. Project's active_scenario_id is cleared +// automatically via the FK's ON DELETE SET NULL. +func (s *ScenarioService) Delete(ctx context.Context, userID, scenarioID uuid.UUID) error { + // Visibility check via Get — also resolves the existence question. + if _, err := s.Get(ctx, userID, scenarioID); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, + `DELETE FROM paliad.scenarios WHERE id = $1`, scenarioID); err != nil { + return fmt.Errorf("delete scenario: %w", err) + } + return nil +} + +// requireVisible enforces the per-row visibility rule: +// - project_id NOT NULL → caller must see the project +// - project_id IS NULL → caller must be the row's created_by +func (s *ScenarioService) requireVisible(ctx context.Context, userID uuid.UUID, sc *lp.Scenario) error { + if sc.ProjectID != nil { + return s.requireProjectVisible(ctx, userID, *sc.ProjectID) + } + if sc.CreatedBy == nil || *sc.CreatedBy != userID { + return ErrScenarioNotVisible + } + return nil +} + +// requireProjectVisible mirrors EventChoiceService.requireProjectVisible +// (visibility via can_see_project). Cheap re-implementation — keeps the +// call-graph small + avoids a cross-service dep. +func (s *ScenarioService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error { + var visible bool + err := s.db.GetContext(ctx, &visible, + `SELECT EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = $1 AND u.global_role = 'global_admin' + ) OR EXISTS ( + SELECT 1 FROM paliad.projects p + JOIN paliad.project_teams pt ON pt.project_id = ANY( + string_to_array(p.path, '.')::uuid[] + ) + WHERE p.id = $2 AND pt.user_id = $1 + )`, userID, projectID) + if err != nil { + return fmt.Errorf("check project visibility: %w", err) + } + if !visible { + return ErrScenarioNotVisible + } + return nil +} + +// validateSpec checks the jsonb spec is well-formed, has the right +// version, and that every referenced proceeding code + submission code +// resolves to an active row in the live catalog. Surfaces friendly +// errors wrapping ErrInvalidInput so the handler can map to a 400. +func (s *ScenarioService) validateSpec(ctx context.Context, raw json.RawMessage) error { + if len(raw) == 0 { + return fmt.Errorf("%w: spec is required", ErrInvalidInput) + } + parsed, err := lp.ParseSpec(lp.NullableJSON(raw)) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + if _, err := parsed.PrimaryProceeding(); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + if parsed.BaseTriggerDate != "" { + if _, err := time.Parse("2006-01-02", parsed.BaseTriggerDate); err != nil { + return fmt.Errorf("%w: base_trigger_date %q is not YYYY-MM-DD", ErrInvalidInput, parsed.BaseTriggerDate) + } + } + for i, p := range parsed.Proceedings { + if p.Code == "" { + return fmt.Errorf("%w: proceedings[%d].code is empty", ErrInvalidInput, i) + } + if p.Role != lp.ScenarioRolePrimary && p.Role != lp.ScenarioRolePeer { + return fmt.Errorf("%w: proceedings[%d].role=%q must be 'primary' or 'peer'", + ErrInvalidInput, i, p.Role) + } + if p.AppealTarget != "" && !lp.IsValidAppealTarget(p.AppealTarget) { + return fmt.Errorf("%w: proceedings[%d].appeal_target=%q not in %v", + ErrInvalidInput, i, p.AppealTarget, lp.AppealTargets) + } + if p.TriggerDateOverride != "" { + if _, err := time.Parse("2006-01-02", p.TriggerDateOverride); err != nil { + return fmt.Errorf("%w: proceedings[%d].trigger_date_override %q is not YYYY-MM-DD", + ErrInvalidInput, i, p.TriggerDateOverride) + } + } + for code, dateStr := range p.AnchorOverrides { + if _, err := time.Parse("2006-01-02", dateStr); err != nil { + return fmt.Errorf("%w: proceedings[%d].anchor_overrides[%q]=%q is not YYYY-MM-DD", + ErrInvalidInput, i, code, dateStr) + } + } + // Resolve code against active proceedings. + var exists bool + if err := s.db.GetContext(ctx, &exists, + `SELECT EXISTS(SELECT 1 FROM paliad.proceeding_types + WHERE code = $1 AND is_active = true)`, + p.Code); err != nil { + return fmt.Errorf("validate spec proceedings[%d]: %w", i, err) + } + if !exists { + return fmt.Errorf("%w: proceedings[%d].code=%q is not an active proceeding_type", + ErrInvalidInput, i, p.Code) + } + } + return nil +} + +// joinSets joins SET clauses with ", ". Tiny utility, kept here to +// avoid cross-package strings.Join indirection. +func joinSets(sets []string) string { + out := "" + for i, s := range sets { + if i > 0 { + out += ", " + } + out += s + } + return out +} + +// Suppress unused-import diagnostic when models isn't referenced +// (kept for future shape-evolution; canonical scenario row lives in lp). +var _ = models.NullableJSON(nil) diff --git a/pkg/litigationplanner/before_court_set_anchor_test.go b/pkg/litigationplanner/before_court_set_anchor_test.go index 5e92d8f..1c380a3 100644 --- a/pkg/litigationplanner/before_court_set_anchor_test.go +++ b/pkg/litigationplanner/before_court_set_anchor_test.go @@ -67,6 +67,12 @@ func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[ func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) { return nil, nil } +func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) { + return nil, nil +} +func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) { + return nil, ErrUnknownScenario +} // noOpHolidays never adjusts dates — the test fixture doesn't care about // weekends or holidays, only about which base date the engine resolves. diff --git a/pkg/litigationplanner/catalog.go b/pkg/litigationplanner/catalog.go index efdc3b6..e91a80f 100644 --- a/pkg/litigationplanner/catalog.go +++ b/pkg/litigationplanner/catalog.go @@ -1,6 +1,10 @@ package litigationplanner -import "context" +import ( + "context" + + "github.com/google/uuid" +) // Catalog supplies proceeding-type metadata + rules for the calculator. // @@ -59,4 +63,17 @@ type Catalog interface { // (proceeding_type_id, sequence_order) so the frontend can render // without re-sorting. LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error) + + // LoadScenarios lists scenarios visible to the caller, narrowed by + // the filter (Slice D, m/paliad#124 §5). Returns an empty slice + // (NOT an error) when no scenarios match. paliad-side impl applies + // RLS (paliad.can_see_project for project-scoped, created_by for + // abstract); snapshot-backed catalogs return an empty list. + LoadScenarios(ctx context.Context, filter ScenarioFilter) ([]Scenario, error) + + // MatchScenario returns the scenario with the given id, or + // ErrUnknownScenario if not found / not visible. The engine adapter + // (CalculateFromScenario) calls this to fetch a scenario by id and + // then unpacks its spec via ParseSpec. + MatchScenario(ctx context.Context, id uuid.UUID) (*Scenario, error) } diff --git a/pkg/litigationplanner/embedded/upc/snapshot.go b/pkg/litigationplanner/embedded/upc/snapshot.go index 5747c26..ff276c4 100644 --- a/pkg/litigationplanner/embedded/upc/snapshot.go +++ b/pkg/litigationplanner/embedded/upc/snapshot.go @@ -292,6 +292,20 @@ func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxe return out, nil } +// LoadScenarios returns an empty slice. The snapshot catalog has no +// scenarios — youpc.org (the consumer today) doesn't carry a project / +// user model. Future snapshot variants could ship demo scenarios, but +// v1 returns nothing. +func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) { + return []lp.Scenario{}, nil +} + +// MatchScenario always returns ErrUnknownScenario — the snapshot has +// no scenarios to match against. +func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) { + return nil, lp.ErrUnknownScenario +} + // Compile-time assertion that SnapshotCatalog satisfies lp.Catalog. var _ lp.Catalog = (*SnapshotCatalog)(nil) diff --git a/pkg/litigationplanner/scenarios.go b/pkg/litigationplanner/scenarios.go new file mode 100644 index 0000000..58ca6cf --- /dev/null +++ b/pkg/litigationplanner/scenarios.go @@ -0,0 +1,215 @@ +package litigationplanner + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Slice D scenarios — m/paliad#124 §5 (revised), mig 145. +// +// A Scenario is a named composition of existing proceedings + flags + +// per-card choices + anchor dates. v1 ships with one primary proceeding +// per scenario; the spec.proceedings[] array is architected to absorb +// multi-peer compose (v2) without a schema migration. +// +// "users should not add their own rules" (m, t-paliad-301) — the spec +// references existing rules by submission_code; it never creates new +// ones. ValidateSpec checks every code/submission resolves against the +// current catalog before a save is accepted. + +// Scenario is one row of paliad.scenarios. Wire shape doubles as the +// API request/response payload for /api/scenarios. +type Scenario struct { + ID uuid.UUID `db:"id" json:"id"` + ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description,omitempty"` + // Spec carries the jsonb composition. Stored raw so we can ship + // shape evolutions without schema churn; ParseSpec gives the + // structured view. + Spec NullableJSON `db:"spec" json:"spec"` + CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// ScenarioSpec is the parsed view of Scenario.Spec. v1 = version 1. +// Future shape changes bump the version; ParseSpec rejects unknown +// versions so an old client doesn't silently misread a future-shape +// scenario. +type ScenarioSpec struct { + Version int `json:"version"` + BaseTriggerDate string `json:"base_trigger_date"` + Proceedings []ScenarioProceeding `json:"proceedings"` +} + +// ScenarioProceeding is one entry under spec.proceedings[]. v1 honours +// exactly one with role="primary" (additional entries with role="peer" +// are reserved for v2 multi-proceeding compose and silently ignored +// by the engine today). +type ScenarioProceeding struct { + Code string `json:"code"` + Role string `json:"role"` // "primary" | "peer" (v2) + TriggerDateOverride string `json:"trigger_date_override,omitempty"` + Flags []string `json:"flags,omitempty"` + PerCardChoices map[string]ScenarioCardChoice `json:"per_card_choices,omitempty"` + AnchorOverrides map[string]string `json:"anchor_overrides,omitempty"` + SkipRules []string `json:"skip_rules,omitempty"` + AppealTarget string `json:"appeal_target,omitempty"` +} + +// ScenarioCardChoice is one entry under +// spec.proceedings[*].per_card_choices. Mirrors the t-paliad-265 choice +// kinds; not every kind is populated on every card. +type ScenarioCardChoice struct { + Appellant string `json:"appellant,omitempty"` + IncludeCCR *bool `json:"include_ccr,omitempty"` + Skip *bool `json:"skip,omitempty"` +} + +// Spec version constant. +const ScenarioSpecVersion = 1 + +// Sentinel errors for scenarios. +var ( + ErrUnknownScenario = errors.New("unknown scenario") + ErrInvalidScenario = errors.New("invalid scenario spec") + ErrScenarioNoPrimary = errors.New("scenario spec has no proceeding with role='primary'") +) + +// ScenarioRole* are the canonical role slugs for ScenarioProceeding.Role. +const ( + ScenarioRolePrimary = "primary" + ScenarioRolePeer = "peer" +) + +// ParseSpec decodes Scenario.Spec into a structured ScenarioSpec. Used +// by the engine adapter + the rule-editor preview. Surfaces a friendly +// error wrapping ErrInvalidScenario on malformed JSON / unknown version +// so the handler can map to a 400. +func ParseSpec(raw NullableJSON) (*ScenarioSpec, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("%w: spec is empty", ErrInvalidScenario) + } + var s ScenarioSpec + if err := json.Unmarshal([]byte(raw), &s); err != nil { + return nil, fmt.Errorf("%w: decode spec: %v", ErrInvalidScenario, err) + } + if s.Version != ScenarioSpecVersion { + return nil, fmt.Errorf("%w: spec.version=%d, want %d", + ErrInvalidScenario, s.Version, ScenarioSpecVersion) + } + return &s, nil +} + +// PrimaryProceeding returns the entry from spec.proceedings[] with +// role="primary". Returns ErrScenarioNoPrimary if absent — every spec +// must carry exactly one primary entry. (Multiple primaries are also +// rejected: the engine consumes one.) +func (s *ScenarioSpec) PrimaryProceeding() (*ScenarioProceeding, error) { + var primary *ScenarioProceeding + for i := range s.Proceedings { + if s.Proceedings[i].Role == ScenarioRolePrimary { + if primary != nil { + return nil, fmt.Errorf("%w: multiple proceedings with role='primary'", ErrInvalidScenario) + } + primary = &s.Proceedings[i] + } + } + if primary == nil { + return nil, ErrScenarioNoPrimary + } + return primary, nil +} + +// CalcOptionsFromSpec builds a CalcOptions from the scenario's primary +// entry. The caller still needs the proceeding code + the trigger date, +// both returned alongside. +// +// v1: only the primary entry is honoured. v2 will iterate over peer +// entries; the multi-peer merge lives in the paliad-side +// ProjectionService (one Calculate call per entry, merged + sorted by +// date). +func (s *ScenarioSpec) CalcOptionsFromSpec() (proceedingCode, triggerDate string, opts CalcOptions, err error) { + primary, err := s.PrimaryProceeding() + if err != nil { + return "", "", CalcOptions{}, err + } + td := s.BaseTriggerDate + if primary.TriggerDateOverride != "" { + td = primary.TriggerDateOverride + } + if td == "" { + return "", "", CalcOptions{}, fmt.Errorf("%w: no base_trigger_date and no per-proceeding override", ErrInvalidScenario) + } + + perCardAppellant := make(map[string]string, len(primary.PerCardChoices)) + skipRules := make(map[string]struct{}, len(primary.SkipRules)) + includeCCRFor := make(map[string]struct{}, len(primary.PerCardChoices)) + for code, choice := range primary.PerCardChoices { + if choice.Appellant != "" { + perCardAppellant[code] = choice.Appellant + } + if choice.IncludeCCR != nil && *choice.IncludeCCR { + includeCCRFor[code] = struct{}{} + } + if choice.Skip != nil && *choice.Skip { + skipRules[code] = struct{}{} + } + } + for _, code := range primary.SkipRules { + skipRules[code] = struct{}{} + } + + return primary.Code, td, CalcOptions{ + Flags: primary.Flags, + AnchorOverrides: primary.AnchorOverrides, + AppealTarget: primary.AppealTarget, + PerCardAppellant: perCardAppellant, + SkipRules: skipRules, + IncludeCCRFor: includeCCRFor, + }, nil +} + +// ScenarioFilter narrows Catalog.LoadScenarios. All fields optional: +// +// - ProjectID non-nil: only scenarios attached to that project +// (project_id = filter.ProjectID). +// - AbstractForUser non-nil: only abstract scenarios (project_id IS +// NULL) created by that user. +// - Both nil: list every scenario the caller can see (RLS-gated). +type ScenarioFilter struct { + ProjectID *uuid.UUID + AbstractForUser *uuid.UUID +} + +// CalculateFromScenario is the high-level engine entry for scenario- +// driven rendering. Unpacks the spec, builds CalcOptions, and delegates +// to Calculate. +// +// v1: surfaces only the primary proceeding's timeline. v2 multi-peer +// expansion lives on the paliad-side ProjectionService (per-entry +// Calculate + client-side merge); the package doesn't own that +// orchestration. +func CalculateFromScenario( + ctx context.Context, + scenario *Scenario, + catalog Catalog, + holidays HolidayCalendar, + courts CourtRegistry, +) (*Timeline, error) { + spec, err := ParseSpec(scenario.Spec) + if err != nil { + return nil, err + } + code, triggerDate, opts, err := spec.CalcOptionsFromSpec() + if err != nil { + return nil, err + } + return Calculate(ctx, code, triggerDate, opts, catalog, holidays, courts) +} diff --git a/pkg/litigationplanner/scenarios_test.go b/pkg/litigationplanner/scenarios_test.go new file mode 100644 index 0000000..a42a40e --- /dev/null +++ b/pkg/litigationplanner/scenarios_test.go @@ -0,0 +1,207 @@ +package litigationplanner + +import ( + "strings" + "testing" +) + +// TestParseSpec_Roundtrip pins the spec-decoder contract: well-formed +// jsonb with version=1 parses; unknown versions and malformed JSON +// surface ErrInvalidScenario. +func TestParseSpec_Roundtrip(t *testing.T) { + cases := []struct { + name string + spec string + wantErr bool + }{ + { + "v1 primary-only", + `{"version":1,"base_trigger_date":"2026-05-26","proceedings":[{"code":"upc.inf.cfi","role":"primary"}]}`, + false, + }, + { + "v1 with full primary entry", + `{"version":1,"base_trigger_date":"2026-05-26","proceedings":[ + {"code":"upc.inf.cfi","role":"primary","flags":["with_ccr"], + "anchor_overrides":{"inf.reply":"2026-08-15"}, + "skip_rules":["inf.r30_amend"]} + ]}`, + false, + }, + { + "v2 spec rejected — unknown version", + `{"version":2,"proceedings":[]}`, + true, + }, + { + "empty spec", + ``, + true, + }, + { + "malformed json", + `{"version":1,"proceedings":[}`, + true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ParseSpec(NullableJSON(c.spec)) + if c.wantErr && err == nil { + t.Errorf("ParseSpec(%s): want error, got nil", c.spec) + } + if !c.wantErr && err != nil { + t.Errorf("ParseSpec(%s): unexpected error %v", c.spec, err) + } + }) + } +} + +// TestScenarioSpec_PrimaryProceeding pins the "exactly one primary" +// invariant: zero → ErrScenarioNoPrimary; multiple → ErrInvalidScenario. +func TestScenarioSpec_PrimaryProceeding(t *testing.T) { + t.Run("zero primary → ErrScenarioNoPrimary", func(t *testing.T) { + s := &ScenarioSpec{ + Version: 1, + Proceedings: []ScenarioProceeding{ + {Code: "upc.inf.cfi", Role: ScenarioRolePeer}, + }, + } + _, err := s.PrimaryProceeding() + if err != ErrScenarioNoPrimary { + t.Errorf("want ErrScenarioNoPrimary, got %v", err) + } + }) + + t.Run("two primaries rejected", func(t *testing.T) { + s := &ScenarioSpec{ + Version: 1, + Proceedings: []ScenarioProceeding{ + {Code: "upc.inf.cfi", Role: ScenarioRolePrimary}, + {Code: "upc.rev.cfi", Role: ScenarioRolePrimary}, + }, + } + _, err := s.PrimaryProceeding() + if err == nil || !strings.Contains(err.Error(), "multiple proceedings with role='primary'") { + t.Errorf("want multi-primary error, got %v", err) + } + }) + + t.Run("single primary picked", func(t *testing.T) { + s := &ScenarioSpec{ + Version: 1, + Proceedings: []ScenarioProceeding{ + {Code: "upc.inf.cfi", Role: ScenarioRolePeer}, + {Code: "upc.rev.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_amend"}}, + }, + } + p, err := s.PrimaryProceeding() + if err != nil { + t.Fatalf("PrimaryProceeding: %v", err) + } + if p.Code != "upc.rev.cfi" { + t.Errorf("primary code = %q, want upc.rev.cfi", p.Code) + } + if len(p.Flags) != 1 || p.Flags[0] != "with_amend" { + t.Errorf("primary.Flags = %v, want [with_amend]", p.Flags) + } + }) +} + +// TestScenarioSpec_CalcOptionsFromSpec covers the unpack from spec +// jsonb into the CalcOptions the engine consumes. Pins: +// - base_trigger_date used when no per-proceeding override +// - trigger_date_override wins when set +// - flags + anchor_overrides + appeal_target passed through verbatim +// - per_card_choices unpacked into PerCardAppellant / SkipRules / +// IncludeCCRFor maps +func TestScenarioSpec_CalcOptionsFromSpec(t *testing.T) { + includeTrue := true + skipTrue := true + s := &ScenarioSpec{ + Version: 1, + BaseTriggerDate: "2026-05-26", + Proceedings: []ScenarioProceeding{{ + Code: "upc.inf.cfi", + Role: ScenarioRolePrimary, + Flags: []string{"with_ccr"}, + AnchorOverrides: map[string]string{"inf.reply": "2026-08-15"}, + AppealTarget: "endentscheidung", + SkipRules: []string{"explicit_skip_code"}, + PerCardChoices: map[string]ScenarioCardChoice{ + "inf.r30_amend": {Appellant: "claimant"}, + "inf.rejoin": {IncludeCCR: &includeTrue}, + "inf.amend_other": {Skip: &skipTrue}, + }, + }}, + } + code, td, opts, err := s.CalcOptionsFromSpec() + if err != nil { + t.Fatalf("CalcOptionsFromSpec: %v", err) + } + if code != "upc.inf.cfi" { + t.Errorf("code = %q, want upc.inf.cfi", code) + } + if td != "2026-05-26" { + t.Errorf("triggerDate = %q, want 2026-05-26", td) + } + if len(opts.Flags) != 1 || opts.Flags[0] != "with_ccr" { + t.Errorf("opts.Flags = %v, want [with_ccr]", opts.Flags) + } + if opts.AppealTarget != "endentscheidung" { + t.Errorf("opts.AppealTarget = %q, want endentscheidung", opts.AppealTarget) + } + if got := opts.AnchorOverrides["inf.reply"]; got != "2026-08-15" { + t.Errorf("opts.AnchorOverrides[inf.reply] = %q, want 2026-08-15", got) + } + if got := opts.PerCardAppellant["inf.r30_amend"]; got != "claimant" { + t.Errorf("opts.PerCardAppellant[inf.r30_amend] = %q, want claimant", got) + } + if _, ok := opts.IncludeCCRFor["inf.rejoin"]; !ok { + t.Error("opts.IncludeCCRFor missing inf.rejoin") + } + if _, ok := opts.SkipRules["inf.amend_other"]; !ok { + t.Error("opts.SkipRules missing inf.amend_other (from per_card_choices.skip)") + } + if _, ok := opts.SkipRules["explicit_skip_code"]; !ok { + t.Error("opts.SkipRules missing explicit_skip_code (from skip_rules[])") + } +} + +// TestScenarioSpec_TriggerDateOverride pins the per-proceeding override +// path (v2-ready — primary entry honours trigger_date_override too). +func TestScenarioSpec_TriggerDateOverride(t *testing.T) { + s := &ScenarioSpec{ + Version: 1, + BaseTriggerDate: "2026-05-26", + Proceedings: []ScenarioProceeding{{ + Code: "upc.inf.cfi", + Role: ScenarioRolePrimary, + TriggerDateOverride: "2026-12-01", + }}, + } + _, td, _, err := s.CalcOptionsFromSpec() + if err != nil { + t.Fatalf("CalcOptionsFromSpec: %v", err) + } + if td != "2026-12-01" { + t.Errorf("triggerDate = %q, want override 2026-12-01", td) + } +} + +// TestScenarioSpec_NoBaseTrigger pins the safety check that a spec +// without base_trigger_date AND without per-proceeding override +// surfaces ErrInvalidScenario (the engine can't render without a date). +func TestScenarioSpec_NoBaseTrigger(t *testing.T) { + s := &ScenarioSpec{ + Version: 1, + Proceedings: []ScenarioProceeding{{ + Code: "upc.inf.cfi", + Role: ScenarioRolePrimary, + }}, + } + _, _, _, err := s.CalcOptionsFromSpec() + if err == nil { + t.Fatal("want ErrInvalidScenario, got nil") + } +}