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).
1745 lines
68 KiB
Go
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
|
|
}
|