Files
paliad/internal/services/scenario_builder_service.go
mAi d913f4fc30 feat(builder): B5 — share + promote-to-project wizard (t-paliad-350)
Litigation Builder slice B5 (m/paliad#153 PRD §2.4 + §2.5 + §5.4 + §10).

Backend (internal/services/scenario_builder_service.go):
- ListSharedWithMe — scenarios shared read-only with the caller (the
  "Geteilt mit mir" bucket).
- PromoteScenario — transactional promote-to-project (PRD §10, no partial
  promotions). One Postgres tx: INSERT paliad.projects ('case',
  origin_scenario_id, proceeding_type_id + scenario_flags from the primary
  triplet) → creator team lead + wizard-selected colleagues → parties →
  deadlines (filed→completed, planned→pending with computed/actual date,
  skipped→none) → flip scenario to 'promoted' + promoted_project_id. The
  primary top-level proceeding + its spawned descendants form the one case
  file; additional standalone proceedings are reported via
  ProceedingsSkipped and stay in the scenario. Planned dates come from the
  injected FristenrechnerService.Calculate; court-set/undated planned
  events are skipped + counted.
- NewScenarioBuilderService gains a *FristenrechnerService dep (wired in
  cmd/server/main.go; nil in tests that don't promote).

Handlers/routes:
- GET /api/builder/scenarios/shared, POST /api/builder/scenarios/{id}/promote.

Frontend:
- builder-shares.ts — share modal (HLC user picker + current-shares list +
  revoke).
- builder-promote.ts — 3-step wizard (Bestätigen → Parteien ergänzen →
  Akte-Metadaten) → POST /promote → navigate to /projects/{id}.
- builder.ts — bucketed side panel (Aktiv / Geteilt mit mir / Als Projekt
  angelegt / Archiviert), read-only chrome (watermark + locked affordances)
  for shared/promoted scenarios, wired share + promote buttons, deep-link
  auto-load now covers shared scenarios.
- procedures.tsx — enabled buttons, bucket containers, readonly watermark slot.
- global.css — modal scaffold, share UI, promote wizard, buckets, readonly
  state. i18n.ts + i18n-keys.ts — DE+EN keys.

Tests: TestScenarioBuilderPromote (live-DB) pins the transactional cascade
+ readonly-after-promote + re-promote rejection. go build/vet/test + bun
build clean. Verified end-to-end via Playwright: Journey E (share → 2nd
user read-only watermark + locked canvas, incl. deep-link) and Journey D
(promote wizard 3 steps → project created with party → navigate → scenario
flipped to promoted).
2026-05-29 20:37:05 +02:00

1745 lines
68 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
// fristenrechner computes planned-deadline due dates during the B5
// promote-to-project cascade (PRD §5.4 — "due_date=computed"). nil in
// test setups that don't exercise promotion; the promote path then
// skips planned events that have no actual_date (it can't assert a
// date it didn't compute) and reports them via DeadlinesSkipped.
fristenrechner *FristenrechnerService
}
// NewScenarioBuilderService wires the service to the shared pool plus
// the project + scenario-flags services it leans on for the Akte-mode
// dual-write, and the Fristenrechner calc service the B5 promote path
// uses to compute planned-deadline dates. projects / flags / frist are
// optional in test setups (nil → the dual-write + promote-compute hooks
// short-circuit), but a production wiring should always pass them so
// Akte-backed scenarios stay in sync with project surfaces and
// promotion cascades real dates.
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService, frist *FristenrechnerService) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db, projects: projects, flags: flags, fristenrechner: frist}
}
// 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
}
// -----------------------------------------------------------------------------
// Shared-with-me listing (B5)
// -----------------------------------------------------------------------------
// ListSharedWithMe returns scenarios shared read-only with the caller
// (a paliad.scenario_shares row exists for shared_with_user_id = caller).
// The caller's own scenarios are excluded — they live in ListMyScenarios.
// Sorted by the share's created_at desc so the most-recently-shared sits
// on top. Promoted scenarios stay visible (read-only reference) just like
// in the owner's own list.
func (s *ScenarioBuilderService) ListSharedWithMe(ctx context.Context, userID uuid.UUID) ([]BuilderScenario, error) {
out := []BuilderScenario{}
if err := s.db.SelectContext(ctx, &out,
`SELECT sc.id, sc.owner_id, sc.name, sc.status, sc.origin_project_id,
sc.promoted_project_id, sc.stichtag, sc.notes,
sc.project_id, sc.description, sc.created_by,
sc.created_at, sc.updated_at
FROM paliad.scenarios sc
JOIN paliad.scenario_shares sh ON sh.scenario_id = sc.id
WHERE sh.shared_with_user_id = $1
AND (sc.owner_id IS NULL OR sc.owner_id <> $1)
ORDER BY sh.created_at DESC`, userID); err != nil {
return nil, fmt.Errorf("list shared scenarios: %w", err)
}
return out, nil
}
// -----------------------------------------------------------------------------
// Promote-to-project (B5, PRD §2.4 + §5.4 + §10)
// -----------------------------------------------------------------------------
// PromotePartyInput is one party row the wizard's "Parteien ergänzen"
// step contributes. Mirrors CreatePartyInput minus contact_info (the
// wizard collects names + roles; full contact data is filled in the Akte
// later).
type PromotePartyInput struct {
Name string `json:"name"`
Role *string `json:"role,omitempty"`
Representative *string `json:"representative,omitempty"`
}
// PromoteTeamMemberInput grants a colleague access to the new project at
// promote time. Responsibility defaults to 'member' when blank.
type PromoteTeamMemberInput struct {
UserID uuid.UUID `json:"user_id"`
Responsibility string `json:"responsibility,omitempty"`
}
// PromoteScenarioInput is the POST /api/builder/scenarios/{id}/promote
// body — the merged payload from wizard steps 2 (Parteien) + 3
// (Akte-Metadaten). The procedural shape (proceeding type, flags,
// perspective) + event states come from the scenario itself; the wizard
// only supplies the client-bound metadata the scenario can't know.
type PromoteScenarioInput struct {
Title string `json:"title"`
Reference *string `json:"reference,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ClientNumber *string `json:"client_number,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
Parties []PromotePartyInput `json:"parties,omitempty"`
TeamMembers []PromoteTeamMemberInput `json:"team_members,omitempty"`
}
// PromoteResult is the outcome the wizard navigates on.
type PromoteResult struct {
ProjectID uuid.UUID `json:"project_id"`
DeadlinesCreated int `json:"deadlines_created"`
DeadlinesSkipped int `json:"deadlines_skipped"`
PartiesCreated int `json:"parties_created"`
ProceedingsSkipped int `json:"proceedings_skipped"`
}
// PromoteScenario turns a scenario into a real paliad.projects 'case' row
// in a single transaction (PRD §10 — no partial promotions). It promotes
// the scenario's primary proceeding (the lowest-ordinal top-level
// triplet) plus its spawned descendants (the CCR child etc., whose rules
// fold into the primary's timeline under the active flags). Additional
// unrelated top-level proceedings are left in the scenario and reported
// via ProceedingsSkipped — v1 promotes one case file per call, matching
// the singular acceptance criterion (one project, navigate to one id);
// the scenario stays visible as 'promoted' for historical reference and
// can seed a second promotion later.
//
// The cascade, all inside the tx:
// 1. INSERT paliad.projects (type='case', client metadata from the
// wizard, proceeding_type_id + scenario_flags from the primary
// triplet, origin_scenario_id = scenario.id).
// 2. INSERT the creator as team lead + any wizard-selected colleagues.
// 3. INSERT parties from the wizard's step-2 payload.
// 4. For each event under the promoted proceedings: filed → a completed
// deadline (due_date + completed_at = actual_date); planned → an open
// ('pending') deadline with the computed due_date; skipped → no row.
// Planned events with no computable date (court-set / conditional /
// no actual_date) are skipped and counted.
// 5. UPDATE the scenario: status='promoted', promoted_project_id = new.
//
// Any error rolls the whole transaction back.
func (s *ScenarioBuilderService) PromoteScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PromoteScenarioInput) (*PromoteResult, 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 already promoted", ErrInvalidInput)
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
}
for i := range input.Parties {
if strings.TrimSpace(input.Parties[i].Name) == "" {
return nil, fmt.Errorf("%w: party %d has a blank name", ErrInvalidInput, i+1)
}
}
for _, tm := range input.TeamMembers {
if tm.UserID == uuid.Nil {
return nil, fmt.Errorf("%w: team member has an empty user_id", ErrInvalidInput)
}
if tm.Responsibility != "" && !IsValidResponsibility(tm.Responsibility) {
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, tm.Responsibility)
}
}
// Parent visibility (mirrors ProjectService.Create): a litigation
// parent the caller can't see would leak the new sub-tree.
if input.ParentID != nil && s.projects != nil {
if _, perr := s.projects.GetByID(ctx, userID, *input.ParentID); perr != nil {
return nil, fmt.Errorf("%w: litigation parent not visible", ErrForbidden)
}
}
// Load the proceeding + event tree.
proceedings := []BuilderProceeding{}
if err := s.db.SelectContext(ctx, &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 len(proceedings) == 0 {
return nil, fmt.Errorf("%w: scenario has no proceedings to promote", ErrInvalidInput)
}
// Primary = first top-level proceeding (lowest ordinal). Collect it +
// its spawned descendants; those form the one case file we promote.
var primary *BuilderProceeding
for i := range proceedings {
if proceedings[i].ParentScenarioProceedingID == nil {
primary = &proceedings[i]
break
}
}
if primary == nil {
return nil, fmt.Errorf("%w: scenario has no top-level proceeding", ErrInvalidInput)
}
promoteSet := collectProceedingSubtree(proceedings, primary.ID)
topLevelCount := 0
for i := range proceedings {
if proceedings[i].ParentScenarioProceedingID == nil {
topLevelCount++
}
}
// Resolve the primary proceeding's catalog code (the calc engine keys
// off code, not id).
var primaryCode string
if err := s.db.GetContext(ctx, &primaryCode,
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, primary.ProceedingTypeID); err != nil {
return nil, fmt.Errorf("resolve proceeding code: %w", err)
}
// Resolve our_side: explicit wizard value wins; otherwise fold the
// primary triplet's perspective down to the project axis.
ourSide := input.OurSide
if ourSide == nil {
ourSide = primary.PrimaryParty
}
// Compute the primary proceeding's timeline so planned events get real
// dates. The CCR child's rules fold into this timeline under the
// primary's flags (sub-track routing), so one calc covers the whole
// promoted subtree. Keyed by lowercased rule id → display name/code/date.
type computed struct {
name string
code string
dueDate string
}
timelineByRule := map[string]computed{}
if s.fristenrechner != nil {
stichtag := promoteStichtag(primary, sc)
opts := CalcOptions{Flags: scenarioFlagsTruthyKeys(primary.ScenarioFlags)}
tl, cerr := s.fristenrechner.Calculate(ctx, primaryCode, stichtag, opts)
if cerr != nil {
// A calc failure is not fatal — filed events still carry their
// own actual_date. Planned events then fall to DeadlinesSkipped.
tl = nil
}
if tl != nil {
for _, e := range tl.Deadlines {
if e.RuleID == "" {
continue
}
timelineByRule[strings.ToLower(e.RuleID)] = computed{
name: e.Name, code: e.Code, dueDate: e.DueDate,
}
}
}
}
// Load events for the promoted proceedings only.
events := []BuilderEvent{}
if err := s.db.SelectContext(ctx, &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)
}
result := &PromoteResult{
ProceedingsSkipped: topLevelCount - 1,
}
newProjectID := uuid.New()
err = s.withAuditTx(ctx, "scenario_builder: promote scenario", func(tx *sqlx.Tx) error {
now := time.Now().UTC()
// 1. Project row. path is filled by the BEFORE INSERT trigger
// (projects_sync_path); '' satisfies the NOT NULL constraint.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, reference, status, created_by,
case_number, client_number, proceeding_type_id, our_side,
scenario_flags, origin_scenario_id, metadata, created_at, updated_at)
VALUES ($1, 'case', $2, '', $3, $4, 'active', $5,
$6, $7, $8, $9, $10::jsonb, $11, '{}'::jsonb, $12, $12)`,
newProjectID, input.ParentID, title, input.Reference, userID,
nullableTrimmed(stringPtrOrNil(input.CaseNumber)),
nullableTrimmed(input.ClientNumber),
primary.ProceedingTypeID, nullableOurSide(ourSide),
[]byte(primary.ScenarioFlags), scenarioID, now); err != nil {
return fmt.Errorf("insert project: %w", err)
}
// 2a. Creator as team lead (RLS visibility, matches Create).
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`, newProjectID, userID); err != nil {
return fmt.Errorf("insert creator team row: %w", err)
}
// 2b. Wizard-selected colleagues.
for _, tm := range input.TeamMembers {
if tm.UserID == userID {
continue // creator already added as lead
}
resp := tm.Responsibility
if resp == "" {
resp = ResponsibilityMember
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, $3, $4, false, $5)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role, responsibility = EXCLUDED.responsibility`,
newProjectID, tm.UserID, legacyRoleFromResponsibility(resp), resp, userID); err != nil {
return fmt.Errorf("insert team member: %w", err)
}
}
// 3. Parties.
for _, p := range input.Parties {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.parties (project_id, name, role, representative, contact_info)
VALUES ($1, $2, $3, $4, '{}'::jsonb)`,
newProjectID, strings.TrimSpace(p.Name), p.Role, p.Representative); err != nil {
return fmt.Errorf("insert party: %w", err)
}
result.PartiesCreated++
}
// 4. Deadlines from the promoted proceedings' events.
for _, ev := range events {
if !promoteSet[ev.ScenarioProceedingID] {
continue
}
if ev.State == "skipped" {
continue
}
if ev.SequencingRuleID == nil {
// Free-form / procedural-event-only cards have no rule to
// anchor a deadline on in v1 — skip (counts as skipped only
// when it was a dated plan; here just leave it out).
continue
}
ruleKey := strings.ToLower(ev.SequencingRuleID.String())
comp := timelineByRule[ruleKey]
title := comp.name
if strings.TrimSpace(title) == "" {
title = "Litigation-Builder Frist"
}
ruleCode := comp.code
if ev.State == "filed" && ev.ActualDate != nil {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(project_id, title, rule_code, due_date, sequencing_rule_id,
status, completed_at, source, approval_status)
VALUES ($1, $2, $3, $4::date, $5, 'completed', $6::timestamptz, 'rule', 'legacy')`,
newProjectID, title, nullableTrimmed(&ruleCode), *ev.ActualDate,
*ev.SequencingRuleID, *ev.ActualDate); err != nil {
return fmt.Errorf("insert filed deadline: %w", err)
}
result.DeadlinesCreated++
continue
}
// planned — need a date. Prefer an explicit actual_date
// (court-set override the user pinned), else the computed date.
var dueDate *time.Time
if ev.ActualDate != nil {
dueDate = ev.ActualDate
} else if comp.dueDate != "" {
if d, perr := time.Parse("2006-01-02", comp.dueDate); perr == nil {
dueDate = &d
}
}
if dueDate == nil {
result.DeadlinesSkipped++
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(project_id, title, rule_code, due_date, sequencing_rule_id,
status, source, approval_status)
VALUES ($1, $2, $3, $4::date, $5, 'pending', 'rule', 'legacy')`,
newProjectID, title, nullableTrimmed(&ruleCode), *dueDate,
*ev.SequencingRuleID); err != nil {
return fmt.Errorf("insert planned deadline: %w", err)
}
result.DeadlinesCreated++
}
// 5. Flip the scenario to promoted.
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.scenarios
SET status = 'promoted', promoted_project_id = $1, updated_at = now()
WHERE id = $2`, newProjectID, scenarioID); err != nil {
return fmt.Errorf("mark scenario promoted: %w", err)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("promote scenario: %w", err)
}
result.ProjectID = newProjectID
return result, nil
}
// collectProceedingSubtree returns the set of proceeding ids rooted at
// rootID (inclusive), walking parent_scenario_proceeding_id downwards.
func collectProceedingSubtree(all []BuilderProceeding, rootID uuid.UUID) map[uuid.UUID]bool {
set := map[uuid.UUID]bool{rootID: true}
// Iterate to a fixpoint; depth is tiny (<=2 today) so a few passes suffice.
for changed := true; changed; {
changed = false
for i := range all {
p := &all[i]
if p.ParentScenarioProceedingID != nil && set[*p.ParentScenarioProceedingID] && !set[p.ID] {
set[p.ID] = true
changed = true
}
}
}
return set
}
// promoteStichtag picks the calc anchor for the promote timeline: the
// primary proceeding's own stichtag, else the scenario default, else today.
func promoteStichtag(primary *BuilderProceeding, sc *BuilderScenario) string {
if primary.Stichtag != nil {
return primary.Stichtag.Format("2006-01-02")
}
if sc.Stichtag != nil {
return sc.Stichtag.Format("2006-01-02")
}
return time.Now().UTC().Format("2006-01-02")
}
// scenarioFlagsTruthyKeys returns the flag keys set to boolean true in the
// builder's scenario_flags jsonb — the array shape the calc engine's
// CalcOptions.Flags consumes.
func scenarioFlagsTruthyKeys(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil
}
out := []string{}
for k, v := range m {
if b, ok := v.(bool); ok && b {
out = append(out, k)
}
}
return out
}
// stringPtrOrNil normalises a *string so an all-whitespace value becomes
// nil before nullableTrimmed sees it (case_number empty → NULL column).
func stringPtrOrNil(p *string) *string {
if p == nil {
return nil
}
if strings.TrimSpace(*p) == "" {
return nil
}
return p
}
// -----------------------------------------------------------------------------
// 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
}