Files
paliad/internal/services/scenario_service.go
mAi cd5f752a0e
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(litigationplanner): scenarios — paliad.scenarios jsonb table + Catalog API + engine adapter (Slice D, t-paliad-306, m/paliad#124 §5)
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
2026-05-26 17:48:56 +02:00

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)