t-paliad-340 — B0 of edison's 7-slice train (PRD §7.1). DB-only: schema + RLS land, dev-only test route exercises the surface, no user-facing change. B1 wires the actual builder UI on top. Migration 157 (additive on the legacy mig-145 scenarios table — 0 rows in prod, safe to relax): - paliad.scenarios gets owner_id / status / origin_project_id / promoted_project_id / stichtag / notes. spec drops NOT NULL and the scenarios_unique_per_scope constraint drops (the builder allows multiple scratch + Unbenanntes Szenario rows per user). - New tables: scenario_proceedings, scenario_events, scenario_shares. - paliad.projects.origin_scenario_id for the promote-to-project audit trail (the FK lands now; the wizard ships in B5). - paliad.can_see_scenario(uuid) STABLE SECURITY DEFINER helper covering owner / share / global_admin / two legacy paths. - Replacement RLS on scenarios + RLS on the three new tables; legacy service + handlers stay live and unchanged. PRD §5.1 deviations called out in the migration header: - proceeding_type_id is integer (live schema), not uuid (PRD draft). - FK target is paliad.users, matching the rest of paliad's schema. Go surface: - ScenarioBuilderService — list/create/get-deep/patch scenarios, add/patch/delete proceedings, add/patch/delete events, add/delete shares. Writes wrap in transactions with set_config( paliad.audit_reason, ..., true) per event_choice_service.go pattern. - /api/builder/scenarios/* — handlers register under a builder/ prefix so the legacy /api/scenarios surface still works. - /dev/scenario-builder — single-page HTML form gated to PaliadinOwnerEmail, exercises the B0 surface without Postman. - Live-DB integration test (TEST_DATABASE_URL gated) covers create + list + deep-get + share + visibility negatives + patch. Audit-first: every DDL block ran clean via BEGIN/ROLLBACK against the live DB before commit; end-to-end sanity (insert chain + CHECK constraints + CASCADE-on-delete) verified via the Supabase MCP. bun build clean. go vet + go test -short ./... green.
937 lines
36 KiB
Go
937 lines
36 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
// ScenarioBuilderService owns the t-paliad-340 / m/paliad#153 B0 surface
|
|
// — CRUD over the new normalised builder shape (paliad.scenarios with
|
|
// owner_id + status, paliad.scenario_proceedings, paliad.scenario_events,
|
|
// paliad.scenario_shares). The legacy spec-jsonb service
|
|
// (ScenarioService) keeps serving m/paliad#124 Slice D callers; this
|
|
// service strictly handles builder-owned rows (owner_id IS NOT NULL).
|
|
//
|
|
// Visibility is enforced both in code (the owner / share / can_see_project
|
|
// fall-through) and at the row level via the migration-157 RLS policies.
|
|
// The application-level check is the load-bearing one — the service
|
|
// connects with the service-role credential, which bypasses RLS.
|
|
type ScenarioBuilderService struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
// NewScenarioBuilderService wires the service to the shared pool.
|
|
func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService {
|
|
return &ScenarioBuilderService{db: db}
|
|
}
|
|
|
|
// ErrScenarioBuilderNotVisible is returned when the caller is neither
|
|
// owner, an accepted share recipient, nor a global_admin / legacy
|
|
// editor for the scenario.
|
|
var ErrScenarioBuilderNotVisible = errors.New("scenario not visible to caller")
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Row types — flat shapes matching the table columns. Deep tree (scenario +
|
|
// proceedings + events) is composed at the GET-by-id endpoint.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// BuilderScenario is one paliad.scenarios row from the builder's perspective.
|
|
// Legacy columns (project_id, description, spec, created_by) are still
|
|
// returned so a UI can detect a legacy row and refuse to mutate it.
|
|
type BuilderScenario struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
OwnerID *uuid.UUID `db:"owner_id" json:"owner_id,omitempty"`
|
|
Name string `db:"name" json:"name"`
|
|
Status string `db:"status" json:"status"`
|
|
OriginProjectID *uuid.UUID `db:"origin_project_id" json:"origin_project_id,omitempty"`
|
|
PromotedProjectID *uuid.UUID `db:"promoted_project_id" json:"promoted_project_id,omitempty"`
|
|
Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"`
|
|
Notes *string `db:"notes" json:"notes,omitempty"`
|
|
LegacyProjectID *uuid.UUID `db:"project_id" json:"legacy_project_id,omitempty"`
|
|
LegacyDescription *string `db:"description" json:"legacy_description,omitempty"`
|
|
LegacyCreatedBy *uuid.UUID `db:"created_by" json:"legacy_created_by,omitempty"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// BuilderProceeding is one paliad.scenario_proceedings row.
|
|
type BuilderProceeding struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"`
|
|
ProceedingTypeID int `db:"proceeding_type_id" json:"proceeding_type_id"`
|
|
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
|
ScenarioFlags json.RawMessage `db:"scenario_flags" json:"scenario_flags"`
|
|
ParentScenarioProceedingID *uuid.UUID `db:"parent_scenario_proceeding_id" json:"parent_scenario_proceeding_id,omitempty"`
|
|
SpawnAnchorEventID *uuid.UUID `db:"spawn_anchor_event_id" json:"spawn_anchor_event_id,omitempty"`
|
|
Ordinal int `db:"ordinal" json:"ordinal"`
|
|
Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"`
|
|
Detailgrad string `db:"detailgrad" json:"detailgrad"`
|
|
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
|
|
Collapsed bool `db:"collapsed" json:"collapsed"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// BuilderEvent is one paliad.scenario_events row.
|
|
type BuilderEvent struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ScenarioProceedingID uuid.UUID `db:"scenario_proceeding_id" json:"scenario_proceeding_id"`
|
|
SequencingRuleID *uuid.UUID `db:"sequencing_rule_id" json:"sequencing_rule_id,omitempty"`
|
|
ProceduralEventID *uuid.UUID `db:"procedural_event_id" json:"procedural_event_id,omitempty"`
|
|
CustomLabel *string `db:"custom_label" json:"custom_label,omitempty"`
|
|
State string `db:"state" json:"state"`
|
|
ActualDate *time.Time `db:"actual_date" json:"actual_date,omitempty"`
|
|
SkipReason *string `db:"skip_reason" json:"skip_reason,omitempty"`
|
|
Notes *string `db:"notes" json:"notes,omitempty"`
|
|
HorizonOptional int `db:"horizon_optional" json:"horizon_optional"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// BuilderShare is one paliad.scenario_shares row.
|
|
type BuilderShare struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"`
|
|
SharedWithUserID uuid.UUID `db:"shared_with_user_id" json:"shared_with_user_id"`
|
|
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
}
|
|
|
|
// BuilderScenarioDeep bundles a scenario with its proceedings + events
|
|
// for the GET /api/builder/scenarios/{id} response. Proceedings sort by
|
|
// ordinal asc; events sort by created_at asc within a proceeding.
|
|
type BuilderScenarioDeep struct {
|
|
BuilderScenario
|
|
Proceedings []BuilderProceeding `json:"proceedings"`
|
|
Events []BuilderEvent `json:"events"`
|
|
Shares []BuilderShare `json:"shares"`
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Scenario CRUD
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// CreateBuilderScenarioInput is the POST /api/builder/scenarios body.
|
|
// Name defaults to "Unbenanntes Szenario" when blank (PRD §5.1).
|
|
type CreateBuilderScenarioInput struct {
|
|
Name string `json:"name,omitempty"`
|
|
Stichtag *time.Time `json:"stichtag,omitempty"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
OriginProjectID *uuid.UUID `json:"origin_project_id,omitempty"`
|
|
}
|
|
|
|
// CreateScenario inserts a new builder-owned scenario. owner_id is set to
|
|
// the caller; status defaults to 'active'. Audit reason is set inside the
|
|
// write tx so any future audit trigger picks it up.
|
|
func (s *ScenarioBuilderService) CreateScenario(ctx context.Context, userID uuid.UUID, input CreateBuilderScenarioInput) (*BuilderScenario, error) {
|
|
name := strings.TrimSpace(input.Name)
|
|
if name == "" {
|
|
name = "Unbenanntes Szenario"
|
|
}
|
|
|
|
var out BuilderScenario
|
|
err := s.withAuditTx(ctx, "scenario_builder: create scenario", func(tx *sqlx.Tx) error {
|
|
return tx.GetContext(ctx, &out,
|
|
`INSERT INTO paliad.scenarios
|
|
(owner_id, name, status, stichtag, notes, origin_project_id)
|
|
VALUES ($1, $2, 'active', $3, $4, $5)
|
|
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
|
|
stichtag, notes,
|
|
project_id, description, created_by,
|
|
created_at, updated_at`,
|
|
userID, name, input.Stichtag, input.Notes, input.OriginProjectID)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create builder scenario: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ListMyScenarios returns the caller's owned scenarios filtered by status.
|
|
// Status "" (or "all") returns every status; otherwise filters by the
|
|
// given enum value. Sorted by updated_at desc.
|
|
func (s *ScenarioBuilderService) ListMyScenarios(ctx context.Context, userID uuid.UUID, status string) ([]BuilderScenario, error) {
|
|
switch status {
|
|
case "", "all":
|
|
// no filter
|
|
case "active", "archived", "promoted":
|
|
// ok
|
|
default:
|
|
return nil, fmt.Errorf("%w: status %q must be one of {active,archived,promoted,all}",
|
|
ErrInvalidInput, status)
|
|
}
|
|
|
|
q := `SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
|
|
stichtag, notes,
|
|
project_id, description, created_by,
|
|
created_at, updated_at
|
|
FROM paliad.scenarios
|
|
WHERE owner_id = $1`
|
|
args := []any{userID}
|
|
if status != "" && status != "all" {
|
|
q += ` AND status = $2`
|
|
args = append(args, status)
|
|
}
|
|
q += ` ORDER BY updated_at DESC`
|
|
|
|
out := []BuilderScenario{}
|
|
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
|
|
return nil, fmt.Errorf("list builder scenarios: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetScenarioDeep returns the scenario + proceedings + events + shares.
|
|
// Visibility: owner, share recipient, global_admin, or legacy editor.
|
|
func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenarioDeep, error) {
|
|
sc, err := s.getScenarioRow(ctx, scenarioID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
visible, err := s.canSeeScenario(ctx, userID, sc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !visible {
|
|
return nil, ErrScenarioBuilderNotVisible
|
|
}
|
|
|
|
deep := &BuilderScenarioDeep{BuilderScenario: *sc}
|
|
|
|
if err := s.db.SelectContext(ctx, &deep.Proceedings, `
|
|
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
|
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
|
stichtag, detailgrad, appeal_target, collapsed,
|
|
created_at, updated_at
|
|
FROM paliad.scenario_proceedings
|
|
WHERE scenario_id = $1
|
|
ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
|
|
return nil, fmt.Errorf("load proceedings: %w", err)
|
|
}
|
|
|
|
if err := s.db.SelectContext(ctx, &deep.Events, `
|
|
SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
|
|
e.procedural_event_id, e.custom_label, e.state, e.actual_date,
|
|
e.skip_reason, e.notes, e.horizon_optional,
|
|
e.created_at, e.updated_at
|
|
FROM paliad.scenario_events e
|
|
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
|
|
WHERE sp.scenario_id = $1
|
|
ORDER BY e.created_at ASC`, scenarioID); err != nil {
|
|
return nil, fmt.Errorf("load events: %w", err)
|
|
}
|
|
|
|
if err := s.db.SelectContext(ctx, &deep.Shares, `
|
|
SELECT id, scenario_id, shared_with_user_id, created_by, created_at
|
|
FROM paliad.scenario_shares
|
|
WHERE scenario_id = $1
|
|
ORDER BY created_at ASC`, scenarioID); err != nil {
|
|
return nil, fmt.Errorf("load shares: %w", err)
|
|
}
|
|
|
|
return deep, nil
|
|
}
|
|
|
|
// PatchBuilderScenarioInput is the PATCH /api/builder/scenarios/{id} body.
|
|
// Any nil field means "don't change".
|
|
type PatchBuilderScenarioInput struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Status *string `json:"status,omitempty"`
|
|
Stichtag *time.Time `json:"stichtag,omitempty"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// PatchScenario updates one or more fields. Status flips to 'promoted'
|
|
// are reserved for the B5 wizard (we accept only active⇄archived here).
|
|
func (s *ScenarioBuilderService) PatchScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PatchBuilderScenarioInput) (*BuilderScenario, error) {
|
|
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sc.Status == "promoted" {
|
|
return nil, fmt.Errorf("%w: scenario is promoted; mutations are blocked", ErrInvalidInput)
|
|
}
|
|
|
|
if input.Status != nil {
|
|
switch *input.Status {
|
|
case "active", "archived":
|
|
// ok
|
|
case "promoted":
|
|
return nil, fmt.Errorf("%w: status='promoted' is set by the promote-to-project wizard, not PATCH",
|
|
ErrInvalidInput)
|
|
default:
|
|
return nil, fmt.Errorf("%w: status %q must be one of {active,archived}",
|
|
ErrInvalidInput, *input.Status)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
n := strings.TrimSpace(*input.Name)
|
|
if n == "" {
|
|
return nil, fmt.Errorf("%w: name cannot be blank", ErrInvalidInput)
|
|
}
|
|
add("name = $%d", n)
|
|
}
|
|
if input.Status != nil {
|
|
add("status = $%d", *input.Status)
|
|
}
|
|
if input.Stichtag != nil {
|
|
add("stichtag = $%d", *input.Stichtag)
|
|
}
|
|
if input.Notes != nil {
|
|
add("notes = $%d", *input.Notes)
|
|
}
|
|
if len(sets) == 0 {
|
|
return sc, nil
|
|
}
|
|
|
|
args = append(args, scenarioID)
|
|
q := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
|
|
WHERE id = $%d
|
|
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
|
|
stichtag, notes,
|
|
project_id, description, created_by,
|
|
created_at, updated_at`,
|
|
strings.Join(sets, ", "), len(args))
|
|
var out BuilderScenario
|
|
err = s.withAuditTx(ctx, "scenario_builder: patch scenario", func(tx *sqlx.Tx) error {
|
|
return tx.GetContext(ctx, &out, q, args...)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("patch builder scenario: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Proceedings
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// AddProceedingInput is the POST /api/builder/scenarios/{id}/proceedings body.
|
|
type AddProceedingInput struct {
|
|
ProceedingTypeID int `json:"proceeding_type_id"`
|
|
PrimaryParty *string `json:"primary_party,omitempty"`
|
|
ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"`
|
|
ParentScenarioProceedingID *uuid.UUID `json:"parent_scenario_proceeding_id,omitempty"`
|
|
SpawnAnchorEventID *uuid.UUID `json:"spawn_anchor_event_id,omitempty"`
|
|
Ordinal *int `json:"ordinal,omitempty"`
|
|
Stichtag *time.Time `json:"stichtag,omitempty"`
|
|
Detailgrad *string `json:"detailgrad,omitempty"`
|
|
AppealTarget *string `json:"appeal_target,omitempty"`
|
|
}
|
|
|
|
// AddProceeding appends a proceeding row to the scenario. The caller must
|
|
// own the scenario (or be a legacy editor). Ordinal defaults to max+1.
|
|
func (s *ScenarioBuilderService) AddProceeding(ctx context.Context, userID, scenarioID uuid.UUID, input AddProceedingInput) (*BuilderProceeding, error) {
|
|
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
|
return nil, err
|
|
}
|
|
if input.ProceedingTypeID == 0 {
|
|
return nil, fmt.Errorf("%w: proceeding_type_id is required", ErrInvalidInput)
|
|
}
|
|
if input.PrimaryParty != nil {
|
|
switch *input.PrimaryParty {
|
|
case "claimant", "defendant":
|
|
default:
|
|
return nil, fmt.Errorf("%w: primary_party %q must be claimant or defendant",
|
|
ErrInvalidInput, *input.PrimaryParty)
|
|
}
|
|
}
|
|
detailgrad := "selected"
|
|
if input.Detailgrad != nil {
|
|
switch *input.Detailgrad {
|
|
case "selected", "all_options":
|
|
detailgrad = *input.Detailgrad
|
|
default:
|
|
return nil, fmt.Errorf("%w: detailgrad %q must be selected or all_options",
|
|
ErrInvalidInput, *input.Detailgrad)
|
|
}
|
|
}
|
|
flags := input.ScenarioFlags
|
|
if len(flags) == 0 {
|
|
flags = json.RawMessage(`{}`)
|
|
}
|
|
|
|
// Resolve ordinal: caller's value or max+1 within the same scenario.
|
|
var ordinal int
|
|
if input.Ordinal != nil {
|
|
ordinal = *input.Ordinal
|
|
} else {
|
|
if err := s.db.GetContext(ctx, &ordinal,
|
|
`SELECT COALESCE(MAX(ordinal), -1) + 1
|
|
FROM paliad.scenario_proceedings
|
|
WHERE scenario_id = $1`, scenarioID); err != nil {
|
|
return nil, fmt.Errorf("compute ordinal: %w", err)
|
|
}
|
|
}
|
|
|
|
var out BuilderProceeding
|
|
err := s.withAuditTx(ctx, "scenario_builder: add proceeding", func(tx *sqlx.Tx) error {
|
|
if err := tx.GetContext(ctx, &out,
|
|
`INSERT INTO paliad.scenario_proceedings
|
|
(scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
|
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
|
stichtag, detailgrad, appeal_target)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
|
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
|
stichtag, detailgrad, appeal_target, collapsed,
|
|
created_at, updated_at`,
|
|
scenarioID, input.ProceedingTypeID, input.PrimaryParty, []byte(flags),
|
|
input.ParentScenarioProceedingID, input.SpawnAnchorEventID, ordinal,
|
|
input.Stichtag, detailgrad, input.AppealTarget); err != nil {
|
|
return err
|
|
}
|
|
// touch the scenario's updated_at so the side panel re-orders correctly.
|
|
_, err := tx.ExecContext(ctx,
|
|
`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add proceeding: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// PatchProceedingInput accepts a subset of mutable proceeding fields.
|
|
type PatchProceedingInput struct {
|
|
PrimaryParty *string `json:"primary_party,omitempty"`
|
|
ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"`
|
|
Ordinal *int `json:"ordinal,omitempty"`
|
|
Stichtag *time.Time `json:"stichtag,omitempty"`
|
|
Detailgrad *string `json:"detailgrad,omitempty"`
|
|
AppealTarget *string `json:"appeal_target,omitempty"`
|
|
Collapsed *bool `json:"collapsed,omitempty"`
|
|
}
|
|
|
|
// PatchProceeding updates fields on one proceeding row.
|
|
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
|
|
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); 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.PrimaryParty != nil {
|
|
switch *input.PrimaryParty {
|
|
case "claimant", "defendant", "":
|
|
default:
|
|
return nil, fmt.Errorf("%w: primary_party %q invalid", ErrInvalidInput, *input.PrimaryParty)
|
|
}
|
|
if *input.PrimaryParty == "" {
|
|
add("primary_party = $%d", nil)
|
|
} else {
|
|
add("primary_party = $%d", *input.PrimaryParty)
|
|
}
|
|
}
|
|
if len(input.ScenarioFlags) > 0 {
|
|
add("scenario_flags = $%d", []byte(input.ScenarioFlags))
|
|
}
|
|
if input.Ordinal != nil {
|
|
add("ordinal = $%d", *input.Ordinal)
|
|
}
|
|
if input.Stichtag != nil {
|
|
add("stichtag = $%d", *input.Stichtag)
|
|
}
|
|
if input.Detailgrad != nil {
|
|
switch *input.Detailgrad {
|
|
case "selected", "all_options":
|
|
default:
|
|
return nil, fmt.Errorf("%w: detailgrad %q invalid", ErrInvalidInput, *input.Detailgrad)
|
|
}
|
|
add("detailgrad = $%d", *input.Detailgrad)
|
|
}
|
|
if input.AppealTarget != nil {
|
|
if *input.AppealTarget == "" {
|
|
add("appeal_target = $%d", nil)
|
|
} else {
|
|
add("appeal_target = $%d", *input.AppealTarget)
|
|
}
|
|
}
|
|
if input.Collapsed != nil {
|
|
add("collapsed = $%d", *input.Collapsed)
|
|
}
|
|
if len(sets) == 0 {
|
|
// nothing to do — re-fetch and return.
|
|
return s.getProceedingRow(ctx, scenarioID, proceedingID)
|
|
}
|
|
|
|
args = append(args, proceedingID, scenarioID)
|
|
q := fmt.Sprintf(`UPDATE paliad.scenario_proceedings SET %s
|
|
WHERE id = $%d AND scenario_id = $%d
|
|
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
|
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
|
stichtag, detailgrad, appeal_target, collapsed,
|
|
created_at, updated_at`,
|
|
strings.Join(sets, ", "), len(args)-1, len(args))
|
|
var out BuilderProceeding
|
|
err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
|
|
return tx.GetContext(ctx, &out, q, args...)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
|
|
ErrNotVisible, proceedingID, scenarioID)
|
|
}
|
|
return nil, fmt.Errorf("patch proceeding: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// DeleteProceeding removes a proceeding (and cascades to events + children).
|
|
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
|
|
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
|
return err
|
|
}
|
|
var n int64
|
|
err := s.withAuditTx(ctx, "scenario_builder: delete proceeding", func(tx *sqlx.Tx) error {
|
|
res, err := tx.ExecContext(ctx,
|
|
`DELETE FROM paliad.scenario_proceedings
|
|
WHERE id = $1 AND scenario_id = $2`,
|
|
proceedingID, scenarioID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ = res.RowsAffected()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("delete proceeding: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("%w: proceeding %s not in scenario %s",
|
|
ErrNotVisible, proceedingID, scenarioID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Events
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// AddEventInput is the POST .../proceedings/{pid}/events body. At least
|
|
// one of {SequencingRuleID, ProceduralEventID, CustomLabel} must be set,
|
|
// matching the scenario_events_one_anchor CHECK constraint.
|
|
type AddEventInput struct {
|
|
SequencingRuleID *uuid.UUID `json:"sequencing_rule_id,omitempty"`
|
|
ProceduralEventID *uuid.UUID `json:"procedural_event_id,omitempty"`
|
|
CustomLabel *string `json:"custom_label,omitempty"`
|
|
State *string `json:"state,omitempty"`
|
|
ActualDate *time.Time `json:"actual_date,omitempty"`
|
|
SkipReason *string `json:"skip_reason,omitempty"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
HorizonOptional *int `json:"horizon_optional,omitempty"`
|
|
}
|
|
|
|
// AddEvent inserts an event card under the given proceeding. The
|
|
// proceeding must belong to the addressed scenario.
|
|
func (s *ScenarioBuilderService) AddEvent(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input AddEventInput) (*BuilderEvent, error) {
|
|
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
|
return nil, err
|
|
}
|
|
if input.SequencingRuleID == nil && input.ProceduralEventID == nil &&
|
|
(input.CustomLabel == nil || strings.TrimSpace(*input.CustomLabel) == "") {
|
|
return nil, fmt.Errorf("%w: at least one of sequencing_rule_id, procedural_event_id, custom_label must be set",
|
|
ErrInvalidInput)
|
|
}
|
|
if err := s.assertProceedingInScenario(ctx, scenarioID, proceedingID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
state := "planned"
|
|
if input.State != nil {
|
|
switch *input.State {
|
|
case "planned", "filed", "skipped":
|
|
state = *input.State
|
|
default:
|
|
return nil, fmt.Errorf("%w: state %q must be one of {planned,filed,skipped}",
|
|
ErrInvalidInput, *input.State)
|
|
}
|
|
}
|
|
horizon := 0
|
|
if input.HorizonOptional != nil {
|
|
if *input.HorizonOptional < 0 {
|
|
return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
|
|
}
|
|
horizon = *input.HorizonOptional
|
|
}
|
|
|
|
var out BuilderEvent
|
|
err := s.withAuditTx(ctx, "scenario_builder: add event", func(tx *sqlx.Tx) error {
|
|
if err := tx.GetContext(ctx, &out,
|
|
`INSERT INTO paliad.scenario_events
|
|
(scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
|
custom_label, state, actual_date, skip_reason, notes, horizon_optional)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
|
custom_label, state, actual_date, skip_reason, notes,
|
|
horizon_optional, created_at, updated_at`,
|
|
proceedingID, input.SequencingRuleID, input.ProceduralEventID,
|
|
input.CustomLabel, state, input.ActualDate, input.SkipReason,
|
|
input.Notes, horizon); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.ExecContext(ctx,
|
|
`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add event: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// PatchEventInput is the PATCH body for an event card.
|
|
type PatchEventInput struct {
|
|
State *string `json:"state,omitempty"`
|
|
ActualDate *time.Time `json:"actual_date,omitempty"`
|
|
SkipReason *string `json:"skip_reason,omitempty"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
HorizonOptional *int `json:"horizon_optional,omitempty"`
|
|
}
|
|
|
|
// PatchEvent updates fields on one event card. The card's parent
|
|
// proceeding must belong to the addressed scenario.
|
|
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
|
|
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.assertEventInScenario(ctx, scenarioID, eventID); 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.State != nil {
|
|
switch *input.State {
|
|
case "planned", "filed", "skipped":
|
|
default:
|
|
return nil, fmt.Errorf("%w: state %q invalid", ErrInvalidInput, *input.State)
|
|
}
|
|
add("state = $%d", *input.State)
|
|
}
|
|
if input.ActualDate != nil {
|
|
add("actual_date = $%d", *input.ActualDate)
|
|
}
|
|
if input.SkipReason != nil {
|
|
add("skip_reason = $%d", *input.SkipReason)
|
|
}
|
|
if input.Notes != nil {
|
|
add("notes = $%d", *input.Notes)
|
|
}
|
|
if input.HorizonOptional != nil {
|
|
if *input.HorizonOptional < 0 {
|
|
return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
|
|
}
|
|
add("horizon_optional = $%d", *input.HorizonOptional)
|
|
}
|
|
if len(sets) == 0 {
|
|
return s.getEventRow(ctx, eventID)
|
|
}
|
|
|
|
args = append(args, eventID)
|
|
q := fmt.Sprintf(`UPDATE paliad.scenario_events SET %s
|
|
WHERE id = $%d
|
|
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
|
custom_label, state, actual_date, skip_reason, notes,
|
|
horizon_optional, created_at, updated_at`,
|
|
strings.Join(sets, ", "), len(args))
|
|
var out BuilderEvent
|
|
err := s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
|
|
return tx.GetContext(ctx, &out, q, args...)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("patch event: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// DeleteEvent removes one event card.
|
|
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
|
|
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
|
|
return err
|
|
}
|
|
err := s.withAuditTx(ctx, "scenario_builder: delete event", func(tx *sqlx.Tx) error {
|
|
_, err := tx.ExecContext(ctx,
|
|
`DELETE FROM paliad.scenario_events WHERE id = $1`, eventID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("delete event: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Shares
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// AddShare grants read-only access to another paliad user.
|
|
func (s *ScenarioBuilderService) AddShare(ctx context.Context, userID, scenarioID, recipientID uuid.UUID) (*BuilderShare, error) {
|
|
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
|
return nil, err
|
|
}
|
|
if recipientID == uuid.Nil {
|
|
return nil, fmt.Errorf("%w: shared_with_user_id is required", ErrInvalidInput)
|
|
}
|
|
if recipientID == userID {
|
|
return nil, fmt.Errorf("%w: cannot share a scenario with yourself", ErrInvalidInput)
|
|
}
|
|
var out BuilderShare
|
|
err := s.withAuditTx(ctx, "scenario_builder: add share", func(tx *sqlx.Tx) error {
|
|
return tx.GetContext(ctx, &out,
|
|
`INSERT INTO paliad.scenario_shares (scenario_id, shared_with_user_id, created_by)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (scenario_id, shared_with_user_id) DO UPDATE
|
|
SET created_at = paliad.scenario_shares.created_at
|
|
RETURNING id, scenario_id, shared_with_user_id, created_by, created_at`,
|
|
scenarioID, recipientID, userID)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add share: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// DeleteShare revokes a share row.
|
|
func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenarioID, shareID uuid.UUID) error {
|
|
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
|
return err
|
|
}
|
|
var n int64
|
|
err := s.withAuditTx(ctx, "scenario_builder: delete share", func(tx *sqlx.Tx) error {
|
|
res, err := tx.ExecContext(ctx,
|
|
`DELETE FROM paliad.scenario_shares
|
|
WHERE id = $1 AND scenario_id = $2`, shareID, scenarioID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ = res.RowsAffected()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("delete share: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("%w: share %s not in scenario %s", ErrNotVisible, shareID, scenarioID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func (s *ScenarioBuilderService) getScenarioRow(ctx context.Context, scenarioID uuid.UUID) (*BuilderScenario, error) {
|
|
var out BuilderScenario
|
|
err := s.db.GetContext(ctx, &out,
|
|
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
|
|
stichtag, notes,
|
|
project_id, description, created_by,
|
|
created_at, updated_at
|
|
FROM paliad.scenarios
|
|
WHERE id = $1`, scenarioID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: scenario %s not found", ErrNotVisible, scenarioID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get scenario: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (s *ScenarioBuilderService) getProceedingRow(ctx context.Context, scenarioID, proceedingID uuid.UUID) (*BuilderProceeding, error) {
|
|
var out BuilderProceeding
|
|
err := s.db.GetContext(ctx, &out,
|
|
`SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
|
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
|
stichtag, detailgrad, appeal_target, collapsed,
|
|
created_at, updated_at
|
|
FROM paliad.scenario_proceedings
|
|
WHERE id = $1 AND scenario_id = $2`, proceedingID, scenarioID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
|
|
ErrNotVisible, proceedingID, scenarioID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get proceeding: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (s *ScenarioBuilderService) getEventRow(ctx context.Context, eventID uuid.UUID) (*BuilderEvent, error) {
|
|
var out BuilderEvent
|
|
err := s.db.GetContext(ctx, &out,
|
|
`SELECT id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
|
custom_label, state, actual_date, skip_reason, notes,
|
|
horizon_optional, created_at, updated_at
|
|
FROM paliad.scenario_events
|
|
WHERE id = $1`, eventID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: event %s not found", ErrNotVisible, eventID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get event: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (s *ScenarioBuilderService) assertProceedingInScenario(ctx context.Context, scenarioID, proceedingID uuid.UUID) error {
|
|
var exists bool
|
|
if err := s.db.GetContext(ctx, &exists,
|
|
`SELECT EXISTS (SELECT 1 FROM paliad.scenario_proceedings
|
|
WHERE id = $1 AND scenario_id = $2)`,
|
|
proceedingID, scenarioID); err != nil {
|
|
return fmt.Errorf("check proceeding membership: %w", err)
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("%w: proceeding %s not in scenario %s",
|
|
ErrNotVisible, proceedingID, scenarioID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ScenarioBuilderService) assertEventInScenario(ctx context.Context, scenarioID, eventID uuid.UUID) error {
|
|
var exists bool
|
|
if err := s.db.GetContext(ctx, &exists,
|
|
`SELECT EXISTS (
|
|
SELECT 1 FROM paliad.scenario_events e
|
|
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
|
|
WHERE e.id = $1 AND sp.scenario_id = $2
|
|
)`,
|
|
eventID, scenarioID); err != nil {
|
|
return fmt.Errorf("check event membership: %w", err)
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("%w: event %s not in scenario %s",
|
|
ErrNotVisible, eventID, scenarioID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// canSeeScenario mirrors the SQL paliad.can_see_scenario(...) function in
|
|
// Go. The service connection bypasses RLS, so this check is the
|
|
// authoritative gate.
|
|
func (s *ScenarioBuilderService) canSeeScenario(ctx context.Context, userID uuid.UUID, sc *BuilderScenario) (bool, error) {
|
|
// owner — fast path
|
|
if sc.OwnerID != nil && *sc.OwnerID == userID {
|
|
return true, nil
|
|
}
|
|
// global_admin
|
|
var isAdmin bool
|
|
if err := s.db.GetContext(ctx, &isAdmin,
|
|
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1 AND global_role = 'global_admin')`,
|
|
userID); err != nil {
|
|
return false, fmt.Errorf("check global_admin: %w", err)
|
|
}
|
|
if isAdmin {
|
|
return true, nil
|
|
}
|
|
// share recipient
|
|
var shared bool
|
|
if err := s.db.GetContext(ctx, &shared,
|
|
`SELECT EXISTS (SELECT 1 FROM paliad.scenario_shares
|
|
WHERE scenario_id = $1 AND shared_with_user_id = $2)`,
|
|
sc.ID, userID); err != nil {
|
|
return false, fmt.Errorf("check share: %w", err)
|
|
}
|
|
if shared {
|
|
return true, nil
|
|
}
|
|
// legacy project-scoped — visible via project team membership
|
|
if sc.OwnerID == nil && sc.LegacyProjectID != nil {
|
|
var ok bool
|
|
if err := s.db.GetContext(ctx, &ok,
|
|
`SELECT paliad.can_see_project($1::uuid)`,
|
|
*sc.LegacyProjectID); err == nil && ok {
|
|
return true, nil
|
|
}
|
|
}
|
|
// legacy abstract — owner-only via created_by
|
|
if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
|
|
*sc.LegacyCreatedBy == userID {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// requireOwnerOrLegacyEditor fetches the scenario and validates that the
|
|
// caller has write rights. Returns the loaded row for downstream use.
|
|
func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenario, error) {
|
|
sc, err := s.getScenarioRow(ctx, scenarioID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// owner
|
|
if sc.OwnerID != nil && *sc.OwnerID == userID {
|
|
return sc, nil
|
|
}
|
|
// legacy project-scoped editor
|
|
if sc.OwnerID == nil && sc.LegacyProjectID != nil {
|
|
var ok bool
|
|
if err := s.db.GetContext(ctx, &ok,
|
|
`SELECT paliad.can_see_project($1::uuid)`,
|
|
*sc.LegacyProjectID); err == nil && ok {
|
|
return sc, nil
|
|
}
|
|
}
|
|
// legacy abstract creator
|
|
if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
|
|
*sc.LegacyCreatedBy == userID {
|
|
return sc, nil
|
|
}
|
|
return nil, ErrScenarioBuilderNotVisible
|
|
}
|
|
|
|
// withAuditTx opens a transaction, stamps paliad.audit_reason via
|
|
// set_config(..., true) so the reason persists for the duration of the
|
|
// tx (matching the mig-079 audit-trigger pattern used by event_choice_
|
|
// service.go), invokes fn, and commits. Any error returned by fn rolls
|
|
// back. The audit reason is appended with the task slug so audit-log
|
|
// readers can trace writes back to t-paliad-340.
|
|
func (s *ScenarioBuilderService) withAuditTx(ctx context.Context, reason string, fn func(tx *sqlx.Tx) error) error {
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`SELECT set_config('paliad.audit_reason', $1, true)`,
|
|
fmt.Sprintf("%s (t-paliad-340)", reason)); err != nil {
|
|
return fmt.Errorf("set audit_reason: %w", err)
|
|
}
|
|
if err := fn(tx); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|