Files
paliad/internal/services/scenario_builder_service.go
mAi 9679a98666
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(builder): B4 — Akte mode + project-backed scenarios (m/paliad#153)
PRD §2.3 + §10. Implements the dual-write rule (load-bearing
complexity per PRD §10): project-backed scenarios mirror flag
toggles to paliad.projects.scenario_flags and filed event states
to paliad.deadlines, while kontextfrei scenarios continue writing
only to paliad.scenario_events. Visible affordances: page-header
Akte picker, enabled "Aus Akte" mode tab, Akte banner on the
project-backed canvas, cross-surface scenario-flag-changed
dispatch + listener for live peer-surface coherence.

Backend
- ScenarioBuilderService takes ProjectService + ScenarioFlagsService
  deps so dual-write hits live tables.
- CreateScenarioFromProject seeds a scenario from a project: copies
  proceeding_type_id + scenario_flags, normalises our_side to the
  builder's binary claimant|defendant axis, surfaces existing
  rule-bound deadlines as scenario_events (filed when completed,
  planned otherwise).
- PatchProceeding on a project-backed top-level triplet dual-writes
  scenario_flags to projects.scenario_flags via flagDeltaFromBuilder.
- PatchEvent transitioning to state='filed' on a project-backed
  scenario upserts paliad.deadlines (status='completed', completed_
  at, source='rule') inside the same tx as the scenario_events
  UPDATE — canvas and project surfaces never diverge mid-flight.
- POST /api/builder/scenarios/from-project handler wires the entry
  point.

Frontend
- builder-akte.ts: project list fetch + dropdown render, Akte
  banner, createScenarioFromProject POST helper.
- builder.ts: mode branching — picking an Akte (search hit or
  page-header pick) creates a project-backed scenario and loads it;
  loaded scenarios reflect their origin_project_id on the picker +
  banner; flag toggles on Akte-backed top-level triplets dispatch
  scenario-flag-changed so the Verfahrensablauf strip / project
  surfaces refresh; the builder listens to inbound scenario-flag-
  changed and refetches its scenario when the changed project
  matches origin_project_id.
- procedures.tsx: enable the previously-disabled Aus Akte tab.
- i18n + CSS: builder.akte.banner.prefix key (DE+EN); lime-tinted
  banner styling.

Tests
- TestScenarioBuilderAkteDualWrite (live DB) pins the dual-write
  contract: Akte flag toggle → projects.scenario_flags updated,
  Akte filed event → deadlines row inserted; kontextfrei flag
  toggle leaves projects.scenario_flags untouched, kontextfrei
  filed event leaves deadlines untouched.
- Existing TestScenarioBuilderService passes against the new
  signature (nil deps short-circuit dual-write paths).

Verification: go test ./... + go vet ./... + bun run build all
clean. Playwright smoke against the static dist build confirms
the Akte tab + picker render correctly, fetchAkteProjects fires
on mount, and the scenario-flag-changed CustomEvent dispatches +
receives without runtime errors.

t-paliad-347
2026-05-28 10:44:33 +02:00

1305 lines
51 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.
//
// 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<string, unknown>) 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
}