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. // // B4 (t-paliad-347 / m/paliad#153) adds the Akte-mode dual-write: // project-backed scenarios (origin_project_id IS NOT NULL) write flag // toggles through to paliad.projects.scenario_flags and "filed" event // toggles through to paliad.deadlines, so the project's Verlauf / Frist // rail reflect builder activity without a separate sync step. The // scenario row itself records canvas view-state (ordinal, collapsed, // per-card horizon, notes); the SSoT for project-bound actuals stays // paliad.deadlines / paliad.projects.scenario_flags (PRD §2.3 + §10). type ScenarioBuilderService struct { db *sqlx.DB projects *ProjectService flags *ScenarioFlagsService } // NewScenarioBuilderService wires the service to the shared pool plus // the project + scenario-flags services it leans on for the Akte-mode // dual-write. projects + flags are optional in test setups (nil → the // dual-write hooks short-circuit), but a production wiring should // always pass them so Akte-backed scenarios stay in sync with project // surfaces. func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService) *ScenarioBuilderService { return &ScenarioBuilderService{db: db, projects: projects, flags: flags} } // 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, // Initialise to empty so the JSON response always carries arrays, // not null — the builder frontend's renderCanvas calls .filter on // proceedings/events unconditionally once state.active is set. Proceedings: []BuilderProceeding{}, Events: []BuilderEvent{}, Shares: []BuilderShare{}, } 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. // // Dual-write (B4): when the parent scenario is project-backed // (scenarios.origin_project_id IS NOT NULL) and the patched proceeding // is the top-level triplet (parent_scenario_proceeding_id IS NULL) and // the patch includes scenario_flags, the merged flag delta also lands on // paliad.projects.scenario_flags via ScenarioFlagsService.Patch. Top- // level only because child triplets (CCR child etc.) represent spawned // sub-proceedings whose flags don't belong on the parent project row; // the spawned proceeding will get its own project record when (and if) // the scenario is promoted via the B5 wizard. func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) { sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID) if 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) } // B4 dual-write: if the scenario is Akte-backed and we just // changed scenario_flags on the top-level triplet, mirror the // merged delta onto paliad.projects.scenario_flags. The PATCH // fires after the scenario_proceedings UPDATE commits — a failure // here logs but doesn't roll back the builder write (the builder // state is the user-visible canvas; the project mirror is a // convenience). if sc.OriginProjectID != nil && out.ParentScenarioProceedingID == nil && len(input.ScenarioFlags) > 0 && s.flags != nil { if delta, derr := flagDeltaFromBuilder(input.ScenarioFlags); derr == nil && len(delta) > 0 { if _, perr := s.flags.Patch(ctx, userID, *sc.OriginProjectID, delta); perr != nil { // Don't fail the builder PATCH — log via the audit // reason that landed in the tx and surface the // error through fmt so callers can still inspect. return nil, fmt.Errorf("dual-write to project scenario_flags: %w", perr) } } } return &out, nil } // flagDeltaFromBuilder converts the builder's scenario_flags jsonb // (Record) into the partial delta shape expected by // ScenarioFlagsService.Patch (map[string]*bool, where nil deletes the // key). Non-bool values are skipped; the builder only writes booleans // through its UI but defensive parsing keeps the dual-write honest if // a stray null sneaks in. func flagDeltaFromBuilder(raw json.RawMessage) (map[string]*bool, error) { if len(raw) == 0 { return nil, nil } var src map[string]any if err := json.Unmarshal(raw, &src); err != nil { return nil, fmt.Errorf("decode flag delta: %w", err) } out := make(map[string]*bool, len(src)) for k, v := range src { switch val := v.(type) { case bool: b := val out[k] = &b case nil: out[k] = nil } } 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. // // Dual-write (B4): when the parent scenario is project-backed // (scenarios.origin_project_id IS NOT NULL), the event's sequencing // rule is set, and the patch transitions the card to state='filed' // with an actual_date, the same fact lands on paliad.deadlines // (status='completed', completed_at=actual_date). If a deadline row // already exists for the (project_id, sequencing_rule_id) pair it's // updated in place; otherwise a fresh row is inserted carrying the // rule's display name + due_date=actual_date. The dual-write runs in // the same transaction as the scenario_events UPDATE so canvas and // project surfaces never diverge mid-flight. func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) { sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID) if 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 { if err := tx.GetContext(ctx, &out, q, args...); err != nil { return err } // B4 dual-write: project-backed scenarios reflect "filed" // transitions on paliad.deadlines so the project's Verlauf / // Frist rail picks them up without a separate writer. We // only act when state explicitly flipped to 'filed' on this // patch — earlier rows that were already filed don't get // re-stamped. if sc.OriginProjectID != nil && input.State != nil && *input.State == "filed" && out.SequencingRuleID != nil && out.ActualDate != nil { if err := s.dualWriteFiledDeadlineTx(ctx, tx, *sc.OriginProjectID, *out.SequencingRuleID, *out.ActualDate); err != nil { return fmt.Errorf("dual-write filed deadline: %w", err) } } return nil }) if err != nil { return nil, fmt.Errorf("patch event: %w", err) } return &out, nil } // dualWriteFiledDeadlineTx upserts a paliad.deadlines row for the // (project_id, sequencing_rule_id) pair so a builder-filed event // surfaces on the project's deadline rail. If a row exists, it's // flipped to status='completed' + completed_at; otherwise a fresh row // is inserted with the rule's display name, due_date=actual_date, and // source='litigation_builder'. The whole thing runs inside the caller // transaction so the canvas event and the deadline never diverge. func (s *ScenarioBuilderService) dualWriteFiledDeadlineTx(ctx context.Context, tx *sqlx.Tx, projectID, ruleID uuid.UUID, actualDate time.Time) error { // Try update first — keeps any existing approval / event_type // hydration intact for deadlines created via the regular Akten // path. We touch only the columns the builder owns: // status / completed_at / updated_at. res, err := tx.ExecContext(ctx, `UPDATE paliad.deadlines SET status = 'completed', completed_at = $1, updated_at = now() WHERE project_id = $2 AND sequencing_rule_id = $3 AND status <> 'completed'`, actualDate, projectID, ruleID) if err != nil { return fmt.Errorf("update existing deadline: %w", err) } if n, _ := res.RowsAffected(); n > 0 { return nil } // Already-completed rows: leave them alone, the builder isn't // reopening anything. Detect via a count probe so we don't // double-insert. var existing int if err := tx.GetContext(ctx, &existing, `SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1 AND sequencing_rule_id = $2`, projectID, ruleID); err != nil { return fmt.Errorf("probe deadline row: %w", err) } if existing > 0 { return nil } // No existing row — insert a fresh deadline. The title comes from // paliad.procedural_events.name joined via sequencing_rules. // procedural_event_id (sequencing_rules itself doesn't carry a // display label — the name lives on the procedural_event row). // rule_code falls back when the event has no name; the literal // "Litigation-Builder Event" is the last resort for rules that // have no procedural_event_id either. source='rule' (already // allowed by deadlines_source_check) since the row is rule-backed // — the Litigation Builder doesn't get its own source bucket; the // audit_reason on the surrounding tx tells the audit log who // inserted it. var title string if err := tx.GetContext(ctx, &title, `SELECT COALESCE(NULLIF(pe.name, ''), NULLIF(sr.rule_code, ''), 'Litigation-Builder Event') FROM paliad.sequencing_rules sr LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id WHERE sr.id = $1`, ruleID); err != nil { if errors.Is(err, sql.ErrNoRows) { title = "Litigation-Builder Event" } else { return fmt.Errorf("load rule name: %w", err) } } if _, err := tx.ExecContext(ctx, `INSERT INTO paliad.deadlines (project_id, title, due_date, sequencing_rule_id, status, completed_at, source, approval_status) VALUES ($1, $2, $3::date, $4, 'completed', $5::timestamptz, 'rule', 'legacy')`, projectID, title, actualDate, ruleID, actualDate); err != nil { return fmt.Errorf("insert builder deadline: %w", err) } return 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 } // ----------------------------------------------------------------------------- // Akte mode — project-backed scenarios (B4, t-paliad-347) // ----------------------------------------------------------------------------- // CreateScenarioFromProject builds a fresh project-backed scenario from // a paliad.projects row: the scenario's origin_project_id points at the // project, one top-level proceeding mirrors the project's // proceeding_type_id + our_side + scenario_flags, and every existing // paliad.deadlines row with a sequencing_rule_id surfaces as a // scenario_events row (state='filed' when the deadline is completed, // 'planned' otherwise). // // The scenario is the canvas view-state; paliad.projects.scenario_flags // + paliad.deadlines remain the SSoT for project-bound actuals (PRD // §2.3 + §10). Subsequent PatchProceeding / PatchEvent calls on this // scenario route their writes through to those SSoT tables via the // dual-write hooks below. // // Visibility: the caller must be able to see the project; the project's // type must be 'case' (it's the proceeding-bearing project rung) and // must have a proceeding_type_id set (otherwise there's nothing to seed // the builder with). Returns ErrInvalidInput when those preconditions // don't hold. func (s *ScenarioBuilderService) CreateScenarioFromProject(ctx context.Context, userID, projectID uuid.UUID) (*BuilderScenarioDeep, error) { if s.projects == nil { return nil, fmt.Errorf("%w: project service not wired", ErrInvalidInput) } proj, err := s.projects.GetByID(ctx, userID, projectID) if err != nil { return nil, err } if proj == nil { return nil, ErrNotVisible } if proj.ProceedingTypeID == nil || *proj.ProceedingTypeID <= 0 { return nil, fmt.Errorf("%w: project %s has no proceeding_type_id — Akte-mode requires one", ErrInvalidInput, projectID) } // Read the project's persisted scenario_flags. The column is jsonb // NOT NULL DEFAULT '{}' (mig 154) so an empty map is always safe. var rawFlags []byte if err := s.db.GetContext(ctx, &rawFlags, `SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil { return nil, fmt.Errorf("read project scenario_flags: %w", err) } if len(rawFlags) == 0 { rawFlags = []byte(`{}`) } // Pull every active+published sequencing_rule deadline row on the // project so the canvas can render filed/planned actuals as event // cards from first paint. CCR sub-projects are reached separately // when the user toggles with_ccr; the seed only covers the addressed // project's deadlines. type deadlineRow struct { ID uuid.UUID `db:"id"` SequencingRuleID *uuid.UUID `db:"sequencing_rule_id"` Status string `db:"status"` DueDate time.Time `db:"due_date"` CompletedAt *time.Time `db:"completed_at"` } var deadlines []deadlineRow if err := s.db.SelectContext(ctx, &deadlines, `SELECT id, sequencing_rule_id, status, due_date, completed_at FROM paliad.deadlines WHERE project_id = $1 AND sequencing_rule_id IS NOT NULL`, projectID); err != nil { return nil, fmt.Errorf("read project deadlines: %w", err) } // Derive the builder-side primary_party from the project's // our_side. The Project.OurSide column accepts the wider sub-role // set (claimant / applicant / appellant; defendant / respondent; // third_party / other) but the builder triplet has a binary // claimant|defendant axis per PRD §3.3 — fold the wider set down, // drop third_party / other to NULL (no perspective preselected). primaryParty := mapProjectOurSideToTripletParty(proj.OurSide) name := strings.TrimSpace(proj.Title) if name == "" { name = "Akte" } deep := &BuilderScenarioDeep{ Proceedings: []BuilderProceeding{}, Events: []BuilderEvent{}, Shares: []BuilderShare{}, } err = s.withAuditTx(ctx, "scenario_builder: create from project", func(tx *sqlx.Tx) error { // 1. Insert the scenario header. origin_project_id pins the // Akte link; promotion later overwrites promoted_project_id // independently. if err := tx.GetContext(ctx, &deep.BuilderScenario, `INSERT INTO paliad.scenarios (owner_id, name, status, origin_project_id) VALUES ($1, $2, 'active', $3) 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, projectID); err != nil { return fmt.Errorf("insert scenario row: %w", err) } // 2. Insert one top-level proceeding mirroring the project's // procedural shape + flags. scenario_flags is copied // verbatim from the project — subsequent toggles on the // builder propagate back via PatchProceeding's dual-write. var proc BuilderProceeding if err := tx.GetContext(ctx, &proc, `INSERT INTO paliad.scenario_proceedings (scenario_id, proceeding_type_id, primary_party, scenario_flags, ordinal, detailgrad) VALUES ($1, $2, $3, $4::jsonb, 0, 'selected') 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`, deep.BuilderScenario.ID, *proj.ProceedingTypeID, primaryParty, rawFlags); err != nil { return fmt.Errorf("insert seed proceeding: %w", err) } deep.Proceedings = append(deep.Proceedings, proc) // 3. One scenario_events row per project deadline. Filed // deadlines render with state='filed' + actual_date = // completed_at (falling back to due_date when the column // was never set). Pending / approved deadlines render // planned. Skipped is not derivable from the deadline row // shape; users mark skip on the canvas via PatchEvent. for _, d := range deadlines { state := "planned" var actualDate *time.Time if d.Status == "completed" { state = "filed" if d.CompletedAt != nil { actualDate = d.CompletedAt } else { due := d.DueDate actualDate = &due } } var ev BuilderEvent if err := tx.GetContext(ctx, &ev, `INSERT INTO paliad.scenario_events (scenario_proceeding_id, sequencing_rule_id, state, actual_date) VALUES ($1, $2, $3, $4) 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`, proc.ID, *d.SequencingRuleID, state, actualDate); err != nil { return fmt.Errorf("insert seed event: %w", err) } deep.Events = append(deep.Events, ev) } return nil }) if err != nil { return nil, fmt.Errorf("create scenario from project: %w", err) } return deep, nil } // mapProjectOurSideToTripletParty folds paliad.projects.our_side (which // allows the wider claimant/applicant/appellant + defendant/respondent // + third_party/other set, mig 112) down to the builder triplet's // binary claimant|defendant axis (PRD §3.3). Returns nil when the // project hasn't picked a side or the role doesn't map (third_party / // other) — the canvas shows both columns equally in that case. func mapProjectOurSideToTripletParty(side *string) *string { if side == nil { return nil } switch *side { case "claimant", "applicant", "appellant": s := "claimant" return &s case "defendant", "respondent": s := "defendant" return &s } return nil } // 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 }