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 }