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
348 lines
12 KiB
Go
348 lines
12 KiB
Go
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)
|