Compare commits

..

15 Commits

Author SHA1 Message Date
mAi
1c77cb6e67 fix(builder): surface proceeding_type id so add-proceeding POST works (t-paliad-345)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (pull_request) Has been cancelled
Paliad CI gate / test-go (pull_request) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / deploy (pull_request) Has been cancelled
The Litigation Builder's "+ Verfahren hinzufügen" silently failed in
prod after t-paliad-343 B2 shipped — clicking a Verfahren chip in the
picker did nothing, no visible error.

Root cause: the wire shape FristenrechnerType (the response of
/api/tools/proceeding-types) carried code+name+nameEN+group but not
id. Builder.ts mountAddProceedingPicker's callback POSTed
`{proceeding_type_id: meta.id}` to
/api/builder/scenarios/{id}/proceedings — meta.id was undefined,
JSON.stringify dropped the key, the server returned 400 ("invalid
input: proceeding_type_id is required"), and fetchJSON swallowed the
error to console. The user saw "nothing happens".

Fix:
- Add `ID int json:"id"` to lp.FristenrechnerType.
- SELECT id in FristenrechnerService.ListProceedings + Scan into the
  new field.
- Defensive guard in builder.ts openAddProceedingPicker — refuse to
  POST without a positive integer id and log a clear error, so a
  future wire-shape regression cannot recreate the silent-fail.
- Regression test in pkg/litigationplanner/types_wire_test.go pins the
  contract (id present in JSON, round-trips as integer).

Side-benefit: fristenrechner-wizard.ts:599-628 documented this exact
gap as a known limitation ("S5/follow-up can extend the wire shape to
include id"). That workaround can now be retired in a follow-up.

Refs m/paliad#153 (Litigation Builder)
2026-05-28 09:48:32 +02:00
mAi
1f6e586c63 Merge: t-paliad-344 — fix stale deadlines.rule_id refs + builder null-guards (m/paliad#154)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:48:17 +02:00
mAi
a4b865d6bd fix(builder): initialise scenario sub-arrays + client null-guard (t-paliad-344)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
GetScenarioDeep returned nil slices for proceedings/events/shares when
a scenario had zero rows, which Go's encoding/json serialises as `null`
rather than `[]`. The builder's renderCanvas then unconditionally calls
`state.active.proceedings.filter(...)` on a null and dies with
`procedures.js:101 TypeError: Cannot read properties of null
(reading 'filter')` — every cold-open scenario crashed the page before
the empty canvas could render.

Backend (root cause): initialise Proceedings / Events / Shares to empty
slices in BuilderScenarioDeep before SelectContext, so the wire shape
is always arrays. Existing rows still load via SelectContext, which
truncates the placeholder and refills from the DB.

Frontend (defence in depth): on loadScenario(), normalise each of the
three arrays to `[]` if the server response is not an array. Catches a
future regression (or an older deployed build) without re-introducing
the same crash class.

bun build clean, go vet + go test ./... green.
2026-05-28 00:47:19 +02:00
mAi
a905911cf4 fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:

  - internal/services/deadline_service.go:268 — DeadlineService.
    ListVisibleForUser. Powers /api/events?type=deadline (dashboard
    deadline rail, /deadlines page, every status bucket). Threw
    `pq: column f.rule_id does not exist` on every request → 500
    for any authenticated user hitting the dashboard.

  - internal/services/projection_service.go:1250 — collectActualsForOverrides.
    Same column on `paliad.deadlines d`. Logged once per projection
    pass (`ERROR service: projection: deadlines: ...`); aliased the
    rename to `rule_id` so the receiving struct tag still scans.

Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).

Root cause: mig 140 commit (1129bab) renamed the JOIN to
`f.sequencing_rule_id` but left the SELECT clause on the older name.
The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so
the wire shape is unchanged — only the column reference flips.

bun build clean, go vet ./... clean, go test ./... green.
2026-05-28 00:47:08 +02:00
mAi
88c03e922f Merge: t-paliad-343 B2 — multi-triplet + spawn + per-event state (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:29:50 +02:00
mAi
6bcac2dd20 Merge: t-paliad-343 B1 — Litigation Builder shell + cold-open (m/paliad#153) 2026-05-28 00:29:50 +02:00
mAi
46dc4ec94b feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Builds on B1 (commit 6c1d8cc). After this slice a user can compose a
multi-proceeding scenario kontextfrei: stack proceedings, flip
perspective per-triplet, toggle scenario flags, auto-spawn child
proceedings on flag transitions, and mark individual event cards as
planned / filed / skipped — all auto-saved to paliad.scenario_*.

PRD §7.1 B2 acceptance shipped:
  - Multi-triplet stack: top-level proceedings sorted by ordinal,
    child proceedings nested inline with a left lime border.
  - Per-triplet controls bar: perspective radio (none / claimant /
    defendant), Detailgrad pill (selected / all options), Entfernen
    action. Each control PATCHes the proceeding row and re-renders the
    affected triplet.
  - Per-triplet flag strip: every paliad.scenario_flag_catalog row
    rendered as a checkbox, bound to scenario_proceedings.scenario_flags.
    Active flags also surface as chips in the triplet header for quick
    legibility.
  - Spawn nesting: when `with_ccr` flips ON on upc.inf.cfi the builder
    auto-POSTs an upc.ccr.cfi child proceeding linked via
    parent_scenario_proceeding_id; flip OFF deletes the child (events
    cascade via the schema). The SPAWN_MAP table is data-driven so
    future spawn flags slot in.
  - 3-state event cards (planned / filed / skipped):
    overlayEventStates walks the rendered .fr-col-item nodes (the
    data-rule-id hook added to verfahrensablauf-core in this slice)
    and stamps each card with data-builder-state + per-state action
    buttons (File / Skip / Reset to planned). Filed cards prompt for
    a date; skipped cards prompt for an optional reason. POSTs or
    PATCHes paliad.scenario_events keyed by sequencing_rule_id.
  - Per-card optional horizon chip: stores horizon_optional on the
    scenario_event row, increment / decrement chip on every card.
    The full surface awaits a calc-engine "optionals available"
    counter (PRD §3.4 follow-up); the persistence layer + UX hook are
    in place so the wiring lands without another schema touch.
  - Page-header Stichtag drives default dates for every triplet (the
    triplet's per-stichtag override path is wired but the per-triplet
    Stichtag input is a B3+ affordance).

verfahrensablauf-core.renderColumnsBody now stamps data-rule-id (and
data-submission-code as a future hook) on every .fr-col-item root —
non-breaking enhancement; the legacy /tools/* pages don't read either
attribute. Verified by re-running the existing 57-test suite.

Backend: one new read-only endpoint
GET /api/builder/scenario-flag-catalog passes through
ScenarioFlagsService.ListCatalog so the builder doesn't need a
per-project round-trip to render flag toggles.

bun run build clean (3050 i18n keys), go vet ./... clean, go test ./...
clean, frontend bun test (verfahrensablauf-core suite) 57 / 57 pass.
2026-05-28 00:28:48 +02:00
mAi
6c1d8cc0cf feat(builder): B1 — Litigation Builder shell + cold-open mode (m/paliad#153)
Replaces cronus's U0-U4 catalog at /tools/procedures with a
persistence-backed builder shell on top of B0's API surface
(/api/builder/scenarios/*, t-paliad-340).

PRD §7.1 B1 acceptance shipped:
  - Page header: scenario picker, name action, Akte picker stub,
    Stichtag input, search input, save status indicator.
  - Entry-mode radio (cold-open active; event-triggered + akte
    rendered disabled for B3/B4 layout stability).
  - Empty canvas with "Neues Szenario starten" CTA and a 5-most-recent
    list rendered when the user has saved scenarios.
  - Side panel "Meine Szenarien" with the Aktiv bucket; clicking an
    item loads the scenario into the canvas.
  - Add-proceeding inline picker (Forum chip row → Verfahren chip row
    → Hinzufügen). UPC v1; other forums chipped but disabled.
  - First proceeding triplet renders end-to-end via
    verfahrensablauf-core.calculateDeadlines + renderColumnsBody (the
    existing 3-column proaktiv|court|reaktiv body, read-only in B1).
  - Auto-save with 500ms debounce on name + stichtag patches; save
    status flips idle → saving → saved/error in the page header.

New client modules under frontend/src/client/:
  - builder.ts       — orchestrator (URL state, fetch, auto-save loop,
                       canvas render, scenario-list re-paint).
  - builder-picker.ts — inline Forum/Verfahren popover for the
                       add-proceeding affordance.
  - builder-triplet.ts — single-triplet header + body wrapper.

procedures.tsx rewritten as the shell scaffolding (sidebar, page
header, mode radio, two-column body); procedures.ts now boots the
builder instead of toggling the 4-tab catalog.

Legacy U0-U4 modules (verfahrensablauf.ts, verfahrensablauf-state.ts,
VerfahrensablaufBody.tsx, procedures' tab toggle in client/procedures.ts,
fristenrechner-* mounts) are no longer reachable from /tools/procedures
but kept in the tree for the B6 cleanup sweep per PRD §7.4.

i18n.ts grew 60 keys × 2 langs under builder.*. global.css grew a
self-contained .builder-* block at the file tail.

bun run build, go vet ./..., and go test ./... all green.
2026-05-28 00:20:46 +02:00
mAi
0c857026a2 Merge: pkg/litigationplanner respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:05:37 +02:00
mAi
3c840c0366 fix(litigationplanner): respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two paired engine semantics fixes:

1. trigger_event_id is now the authoritative semantic anchor. When a
   rule carries trigger_event_id, the engine no longer falls back to
   the proceeding's trigger date — it resolves the anchor via
   CalcOptions.TriggerEventAnchors keyed by paliad.trigger_events.code.
   Missing anchor renders the rule as IsConditional (empty date) and
   propagates via courtSet so descendants also surface as conditional.
   Fixes the RoP.109.5 bug where the engine fabricated a date 2 weeks
   before the user's SoC instead of waiting for the oral_hearing date.

2. priority='optional' rules are suppressed from the default
   Calculate output. Callers (paliad /tools/procedures,
   youpc.org/deadlines) opt in via CalcOptions.IncludeOptional=true to
   restore the legacy "show optional applications" behaviour. The
   suppression cascades through skippedIDs so child rules drop too.

Wire shape additions:

  - CalcOptions.IncludeOptional bool
  - CalcOptions.TriggerEventAnchors map[string]string
  - Timeline.RulesAwaitingAnchor int (count of suppressed-by-missing-
    anchor rules, for caller telemetry / "N rules need an anchor" UX)

Existing before-court-set-anchor tests opt in to IncludeOptional=true
to preserve their non-optional-related test intent.

Refs: youpcorg/head delegations #2568 + #2570, m/paliad#153 (Litigation
Builder PRD path).
2026-05-28 00:04:30 +02:00
mAi
1b4b2e4758 Merge: submission-md placeholder underscores preserved
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:01:30 +02:00
mAi
b78a984a7c fix(submission-md): preserve {{...}} placeholders verbatim through inline scanner
The Markdown inline scanner (parseInlineSpans) treats _ and * as
italic delimiters. A placeholder like {{project.case_number}} fed
through the scanner had its underscores consumed as italic markers,
leaving {{project.casenumber}} in the composed OOXML. The v1
placeholder pass then looked up the wrong key, surfacing
[KEIN WERT: project.casenumber] in the preview. The form ↔ preview
highlighting also stopped working because data-var attributes
mismatched between the input (snake_case) and the rendered span
(stripped).

parseInlineSpans now detects {{ at the cursor and skips ahead to
the matching }}, copying the entire placeholder verbatim into the
current text run. Unmatched {{ falls through to the existing
character handling so legal prose with stray braces still renders.

Tests: regression test for underscored keys (single + multiple +
mixed-with-italics), direct guard on parseInlineSpans, and an
italic-around-placeholder structural test.
2026-05-28 00:01:30 +02:00
mAi
1844df3ae6 Merge: t-paliad-340 B0 — Scenario DB foundation (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 23:53:49 +02:00
mAi
0f3c30a647 feat(scenario-builder): B0 schema foundation + minimal API (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
t-paliad-340 — B0 of edison's 7-slice train (PRD §7.1). DB-only: schema
+ RLS land, dev-only test route exercises the surface, no user-facing
change. B1 wires the actual builder UI on top.

Migration 157 (additive on the legacy mig-145 scenarios table — 0 rows
in prod, safe to relax):
- paliad.scenarios gets owner_id / status / origin_project_id /
  promoted_project_id / stichtag / notes. spec drops NOT NULL and the
  scenarios_unique_per_scope constraint drops (the builder allows
  multiple scratch + Unbenanntes Szenario rows per user).
- New tables: scenario_proceedings, scenario_events, scenario_shares.
- paliad.projects.origin_scenario_id for the promote-to-project audit
  trail (the FK lands now; the wizard ships in B5).
- paliad.can_see_scenario(uuid) STABLE SECURITY DEFINER helper covering
  owner / share / global_admin / two legacy paths.
- Replacement RLS on scenarios + RLS on the three new tables; legacy
  service + handlers stay live and unchanged.

PRD §5.1 deviations called out in the migration header:
- proceeding_type_id is integer (live schema), not uuid (PRD draft).
- FK target is paliad.users, matching the rest of paliad's schema.

Go surface:
- ScenarioBuilderService — list/create/get-deep/patch scenarios,
  add/patch/delete proceedings, add/patch/delete events,
  add/delete shares. Writes wrap in transactions with set_config(
  paliad.audit_reason, ..., true) per event_choice_service.go pattern.
- /api/builder/scenarios/* — handlers register under a builder/
  prefix so the legacy /api/scenarios surface still works.
- /dev/scenario-builder — single-page HTML form gated to
  PaliadinOwnerEmail, exercises the B0 surface without Postman.
- Live-DB integration test (TEST_DATABASE_URL gated) covers
  create + list + deep-get + share + visibility negatives + patch.

Audit-first: every DDL block ran clean via BEGIN/ROLLBACK against
the live DB before commit; end-to-end sanity (insert chain + CHECK
constraints + CASCADE-on-delete) verified via the Supabase MCP.

bun build clean. go vet + go test -short ./... green.
2026-05-27 23:50:14 +02:00
mAi
2c2b93bc7c Merge: t-paliad-339 — PRD for Procedures Litigation Builder (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 23:04:07 +02:00
27 changed files with 5502 additions and 292 deletions

View File

@@ -246,6 +246,10 @@ func main() {
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
// rendering and per-rule selection state (`rule:<uuid>` keys).
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
// CRUD over the new normalised scenarios + scenario_proceedings
// + scenario_events + scenario_shares tables.
ScenarioBuilder: services.NewScenarioBuilderService(pool),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when

View File

@@ -0,0 +1,147 @@
// Add-proceeding inline picker for the Litigation Builder.
//
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
// gates the Verfahren chip row, click → callback. Designed for B1's
// single-triplet flow and B2's multi-triplet stacking with no shape
// change between slices.
import { t } from "./i18n";
export interface ProceedingTypeMeta {
id: number;
code: string;
name: string;
nameEN: string;
// group / jurisdiction. The proceeding-types API returns "UPC" /
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
// only renders UPC.
group?: string;
jurisdiction?: string;
}
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
let activePopover: HTMLElement | null = null;
export function mountAddProceedingPicker(
anchor: HTMLElement,
types: ProceedingTypeMeta[],
onPick: OnPick,
): void {
closeActive();
const pop = document.createElement("div");
pop.className = "builder-picker-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("builder.picker.aria"));
const header = document.createElement("div");
header.className = "builder-picker-header";
header.innerHTML = `
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
`;
pop.appendChild(header);
// Forum row — UPC only for v1. Disabled chips render greyed.
const forumRow = document.createElement("div");
forumRow.className = "builder-picker-row";
forumRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
<div class="builder-picker-chips">
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
</div>
`;
pop.appendChild(forumRow);
const procRow = document.createElement("div");
procRow.className = "builder-picker-row";
procRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
`;
pop.appendChild(procRow);
const empty = document.createElement("p");
empty.className = "builder-picker-empty";
empty.hidden = true;
empty.textContent = t("builder.picker.empty");
pop.appendChild(empty);
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
const lang = document.documentElement.lang === "en" ? "en" : "de";
for (const meta of types) {
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-picker-chip builder-picker-chip--proc";
chip.setAttribute("data-code", meta.code);
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
chip.addEventListener("click", () => {
closeActive();
void onPick(meta);
});
procHost.appendChild(chip);
}
if (types.length === 0) empty.hidden = false;
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
closeActive();
});
// Position the popover under the anchor button.
positionUnder(pop, anchor);
document.body.appendChild(pop);
activePopover = pop;
document.addEventListener("click", onOutsideClick, true);
document.addEventListener("keydown", onEscape, true);
}
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
const rect = anchor.getBoundingClientRect();
pop.style.position = "absolute";
const top = rect.bottom + window.scrollY + 6;
// Default left = anchor's left; clamp so popover stays in viewport.
const left = Math.max(8, rect.left + window.scrollX);
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
pop.style.zIndex = "60";
}
function onOutsideClick(ev: Event): void {
if (!activePopover) return;
const target = ev.target as Node;
if (activePopover.contains(target)) return;
closeActive();
}
function onEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") closeActive();
}
function closeActive(): void {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
document.removeEventListener("click", onOutsideClick, true);
document.removeEventListener("keydown", onEscape, true);
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,271 @@
// ProceedingTriplet renderer for the Litigation Builder.
//
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
// body.
//
// B2 wires the live controls — perspective radio, scenario-flag strip,
// remove button, collapse — and the per-event-card overlays (3-state
// machine, action buttons, optional-horizon chip). The 3-column body
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
// per-card overlays are layered on top after innerHTML write via the
// data-rule-id hooks added in the same slice.
import { t, tDyn, getLang } from "./i18n";
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
import type { BuilderProceeding, BuilderEvent } from "./builder";
import type { ProceedingTypeMeta } from "./builder-picker";
export interface ScenarioFlagCatalogEntry {
flag_key: string;
label_de: string;
label_en: string;
description?: string;
hidden_unless_set: boolean;
}
export interface TripletViewInput {
proceeding: BuilderProceeding;
meta: ProceedingTypeMeta;
data: DeadlineResponse | null;
side: Side;
// Flag catalog filtered to the keys the active proceeding actually
// references via its rules' condition_expr. B2 passes the global
// catalog and lets the user toggle any — flags that don't gate any
// rule are simply no-ops on this triplet.
flagCatalog: ScenarioFlagCatalogEntry[];
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
// for the per-card state machine. Cards whose rule is absent default
// to "planned".
eventsByRule: Map<string, BuilderEvent>;
// Per-card optional-horizon registry. Each rule with optional
// children carries a `+N Optionen` chip; the chip's count comes from
// here (defaults to scenario_events.horizon_optional, falls back to
// proceeding-level when not stored per-card).
columnsHtml: string;
isChild: boolean;
}
// Triplet header + controls + columns body. Pure-string render; the
// caller (builder.ts) wires click handlers on top.
export function renderTriplet(input: TripletViewInput): string {
const lang = getLang();
const procLabel = lang === "en"
? (input.meta.nameEN || input.meta.name)
: (input.meta.name || input.meta.nameEN);
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
const body = input.data
? input.columnsHtml
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
const controls = renderControls(input);
const flagStrip = renderFlagStrip(input);
return `
<header class="builder-triplet-header">
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
${flagsBadge}
</header>
${controls}
${flagStrip}
<div class="builder-triplet-body">
${body}
</div>
`;
}
function renderControls(input: TripletViewInput): string {
const perspective = input.side ?? "";
const detailgrad = input.proceeding.detailgrad || "selected";
const radio = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-perspective-btn${active}"
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
const detailBtn = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
return `<div class="builder-triplet-controls">
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
<div class="builder-triplet-perspective">
${radio("", "builder.triplet.perspective.none", perspective)}
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
</div>
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
<div class="builder-triplet-detailgrad">
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
</div>
<button type="button" class="builder-triplet-remove" data-action="remove">
${escHtml(t("builder.triplet.remove"))}
</button>
</div>`;
}
function renderFlagStrip(input: TripletViewInput): string {
// B2 ships the full global catalog. Flags that don't gate any of the
// active proceeding's rules are still toggle-able but have no effect
// on the calc result (the engine simply doesn't read them).
const lang = getLang();
const flags = input.proceeding.scenario_flags || {};
if (input.flagCatalog.length === 0) {
return `<div class="builder-triplet-flagstrip">
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
</div>`;
}
const toggles = input.flagCatalog.map((entry) => {
const label = lang === "en" ? entry.label_en : entry.label_de;
const isOn = flags[entry.flag_key] === true;
return `<label class="builder-triplet-flag-toggle">
<input type="checkbox"
data-action="flag"
data-flag-key="${escAttr(entry.flag_key)}"
${isOn ? "checked" : ""} />
<span>${escHtml(label)}</span>
</label>`;
}).join("");
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
}
function jurisdictionFor(meta: ProceedingTypeMeta): string {
if (meta.jurisdiction) return meta.jurisdiction;
if (meta.group) return meta.group;
const dot = meta.code.indexOf(".");
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
return meta.code.toUpperCase();
}
function activeFlagsBadge(flags: Record<string, unknown>): string {
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
if (active.length === 0) return "";
const label = t("builder.triplet.flags.label");
const chips = active.map((f) =>
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
).join("");
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
}
// overlayEventStates walks the rendered .fr-col-item nodes and:
// - sets data-builder-state from eventsByRule lookup;
// - appends a per-card action row (file / skip / reset);
// - shows a +N Optionen chip when the rule has optional children
// (the chip placeholder; B2 ships the per-card horizon control —
// the actual horizon-count→render expansion lands when the calc
// engine surfaces "available optionals" for a parent rule, which
// pasteur's Options.IncludeOptional flag already exposes server-
// side; full wiring is a follow-up). Cards without optional
// children get no chip.
export function overlayEventStates(
root: HTMLElement,
eventsByRule: Map<string, BuilderEvent>,
on: {
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
onHorizon: (ruleId: string, delta: 1 | -1) => void;
},
): void {
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
items.forEach((item) => {
const ruleId = item.getAttribute("data-rule-id");
if (!ruleId) return;
const ev = eventsByRule.get(ruleId.toLowerCase());
const state = ev?.state || "planned";
item.setAttribute("data-builder-state", state);
// Append actions (idempotent: clear any prior overlay first).
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
const actions = document.createElement("div");
actions.className = "builder-event-actions";
actions.innerHTML = actionButtonsHtml(state);
item.appendChild(actions);
actions.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
if (!btn) return;
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
if (!action) return;
ev.stopPropagation();
if (action === "file") {
const today = new Date().toISOString().slice(0, 10);
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
if (v === null) return;
on.onAction(ruleId, "file", { date: v.trim() || today });
} else if (action === "skip") {
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
if (reason === null) return;
on.onAction(ruleId, "skip", { reason: reason.trim() });
} else {
on.onAction(ruleId, "reset");
}
});
// Per-card optional horizon chip. The PRD §3.4 places the chip on
// every card with optional children; until the calc surface exposes
// an "optionals available count" on each parent rule, the chip is
// shown only when the card has a stored non-zero horizon (so the
// user can see and reduce a previously-set horizon). This is the
// graceful B2 baseline; the full surface lands once the engine
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
const horizonCount = ev?.horizon_optional ?? 0;
if (horizonCount > 0) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-toggle");
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, -1);
});
item.appendChild(chip);
} else {
// Inline "+ Optionen" affordance — adds a horizon entry when
// first clicked. Tagged as data-builder-feature so the cleanup
// sweep can rip it out if the calc surface lands a counter.
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-add");
chip.setAttribute("data-builder-feature", "horizon-add");
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, 1);
});
item.appendChild(chip);
}
});
}
function actionButtonsHtml(state: BuilderEvent["state"]): string {
// Re-render the action row per state. Cards in the planned state
// show "File / Skip"; filed/skipped cards show "Reset to planned".
if (state === "planned") {
return `
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
`;
}
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,981 @@
// Litigation Builder client (m/paliad#153 PRD §3, B1).
//
// Boots /tools/procedures. Talks to the B0 surface
// (/api/builder/scenarios/*) for persistence and reuses
// verfahrensablauf-core for the per-triplet calc + 3-column render.
//
// B1 ships:
// - Cold-open empty canvas + "Neues Szenario starten" CTA + recent list.
// - Scenario picker, name action, Stichtag, auto-save (500ms debounce).
// - Add-proceeding picker (Forum chip row → Verfahren chip row → Hinzufügen).
// - Single triplet renders end-to-end with calc.
// - Side panel "Meine Szenarien" with Aktiv bucket.
//
// B2 extends:
// - Multi-triplet stack with `+ Verfahren hinzufügen`.
// - Per-triplet perspective + flag strip.
// - Spawn child triplets render inline.
// - 3-state event cards (planned/filed/skipped) + per-card optional horizon.
import { t, getLang } from "./i18n";
import {
calculateDeadlines,
renderColumnsBody,
type DeadlineResponse,
type Side,
} from "./views/verfahrensablauf-core";
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
// parent proceeding, the builder auto-creates a child proceeding row
// linked via parent_scenario_proceeding_id. When the flag flips back
// OFF, the child is deleted (its events cascade via the schema's ON
// DELETE CASCADE). Today's data has 2-deep nesting at most, with
// `with_ccr` on `upc.inf.cfi` as the load-bearing case; the map is
// data-driven so future flags slot in by adding rows.
//
// Entries are picked up by syncSpawnChildren after each successful
// flag PATCH. Falsy entries are simple flag-only flips with no spawn
// effect.
const SPAWN_MAP: Record<string, Record<string, string>> = {
// Parent proceeding code → flag_key → child proceeding code.
"upc.inf.cfi": { with_ccr: "upc.ccr.cfi" },
};
// ────────────────────────────────────────────────────────────────────────────
// Wire types — mirror internal/services/scenario_builder_service.go
// ────────────────────────────────────────────────────────────────────────────
export interface BuilderScenario {
id: string;
owner_id?: string;
name: string;
status: "active" | "archived" | "promoted";
origin_project_id?: string;
promoted_project_id?: string;
stichtag?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface BuilderProceeding {
id: string;
scenario_id: string;
proceeding_type_id: number;
primary_party?: "claimant" | "defendant";
scenario_flags: Record<string, unknown>;
parent_scenario_proceeding_id?: string;
spawn_anchor_event_id?: string;
ordinal: number;
stichtag?: string;
detailgrad: "selected" | "all_options";
appeal_target?: string;
collapsed: boolean;
created_at: string;
updated_at: string;
}
export interface BuilderEvent {
id: string;
scenario_proceeding_id: string;
sequencing_rule_id?: string;
procedural_event_id?: string;
custom_label?: string;
state: "planned" | "filed" | "skipped";
actual_date?: string;
skip_reason?: string;
notes?: string;
horizon_optional: number;
created_at: string;
updated_at: string;
}
export interface BuilderScenarioDeep extends BuilderScenario {
proceedings: BuilderProceeding[];
events: BuilderEvent[];
shares: unknown[];
}
// ────────────────────────────────────────────────────────────────────────────
// Module state — single active scenario per tab.
// ────────────────────────────────────────────────────────────────────────────
interface State {
active: BuilderScenarioDeep | null;
list: BuilderScenario[];
procTypes: ProceedingTypeMeta[];
procTypesById: Map<number, ProceedingTypeMeta>;
procTypesByCode: Map<string, ProceedingTypeMeta>;
flagCatalog: ScenarioFlagCatalogEntry[];
saveTimer: number | null;
// Pending field-level deltas merged before each PATCH flush. Avoids
// racing PATCHes overwriting each other when the user changes more
// than one field inside a 500ms window.
pending: { name?: string; stichtag?: string; notes?: string };
}
const state: State = {
active: null,
list: [],
procTypes: [],
procTypesById: new Map(),
procTypesByCode: new Map(),
flagCatalog: [],
saveTimer: null,
pending: {},
};
// ────────────────────────────────────────────────────────────────────────────
// Fetch helpers
// ────────────────────────────────────────────────────────────────────────────
async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T | null> {
try {
const resp = await fetch(input, init);
if (!resp.ok) {
const body = await resp.text().catch(() => "");
console.error("builder fetch error:", resp.status, input, body);
return null;
}
if (resp.status === 204) return null;
return (await resp.json()) as T;
} catch (err) {
console.error("builder network error:", input, err);
return null;
}
}
async function fetchScenarios(): Promise<BuilderScenario[]> {
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=active");
return Array.isArray(out) ? out : [];
}
async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
}
async function fetchProceedingTypes(): Promise<ProceedingTypeMeta[]> {
// PRD v1 is UPC-only; later jurisdictions plug into the same picker
// shape (Forum chip row gates the Verfahren chip row).
const out = await fetchJSON<ProceedingTypeMeta[]>(
"/api/tools/proceeding-types?jurisdiction=UPC&kind=proceeding",
);
return Array.isArray(out) ? out : [];
}
async function fetchFlagCatalog(): Promise<ScenarioFlagCatalogEntry[]> {
const out = await fetchJSON<ScenarioFlagCatalogEntry[]>("/api/builder/scenario-flag-catalog");
return Array.isArray(out) ? out : [];
}
async function createScenario(name?: string): Promise<BuilderScenario | null> {
const body: Record<string, unknown> = {};
if (name) body.name = name;
return await fetchJSON<BuilderScenario>("/api/builder/scenarios", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
async function patchScenario(id: string, body: Record<string, unknown>): Promise<BuilderScenario | null> {
return await fetchJSON<BuilderScenario>("/api/builder/scenarios/" + encodeURIComponent(id), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
async function addProceeding(
scenarioID: string,
body: {
proceeding_type_id: number;
primary_party?: string;
parent_scenario_proceeding_id?: string;
spawn_anchor_event_id?: string;
},
): Promise<BuilderProceeding | null> {
return await fetchJSON<BuilderProceeding>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
async function patchProceeding(
scenarioID: string,
proceedingID: string,
body: Record<string, unknown>,
): Promise<BuilderProceeding | null> {
return await fetchJSON<BuilderProceeding>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
"/proceedings/" + encodeURIComponent(proceedingID),
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
async function deleteProceeding(scenarioID: string, proceedingID: string): Promise<boolean> {
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
"/proceedings/" + encodeURIComponent(proceedingID),
{ method: "DELETE" },
);
return resp.ok;
} catch (err) {
console.error("builder delete proceeding error:", err);
return false;
}
}
async function addEvent(
scenarioID: string,
proceedingID: string,
body: {
sequencing_rule_id?: string;
state?: string;
actual_date?: string;
skip_reason?: string;
horizon_optional?: number;
},
): Promise<BuilderEvent | null> {
return await fetchJSON<BuilderEvent>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
"/proceedings/" + encodeURIComponent(proceedingID) + "/events",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
async function patchEvent(
scenarioID: string,
eventID: string,
body: Record<string, unknown>,
): Promise<BuilderEvent | null> {
return await fetchJSON<BuilderEvent>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
"/events/" + encodeURIComponent(eventID),
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
// ────────────────────────────────────────────────────────────────────────────
// URL state
// ────────────────────────────────────────────────────────────────────────────
function readScenarioFromUrl(): string | null {
return new URLSearchParams(window.location.search).get("scenario");
}
function writeScenarioToUrl(id: string | null): void {
const url = new URL(window.location.href);
if (id) url.searchParams.set("scenario", id);
else url.searchParams.delete("scenario");
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
// ────────────────────────────────────────────────────────────────────────────
// Save indicator
// ────────────────────────────────────────────────────────────────────────────
type SaveState = "idle" | "saving" | "saved" | "error";
function setSaveState(s: SaveState): void {
const el = document.getElementById("builder-save-status");
if (!el) return;
el.setAttribute("data-state", s);
const span = el.querySelector("span");
if (!span) return;
const text =
s === "saving" ? t("builder.save.saving") :
s === "saved" ? t("builder.save.saved") :
s === "error" ? t("builder.save.error") :
t("builder.save.idle");
const key =
s === "saving" ? "builder.save.saving" :
s === "saved" ? "builder.save.saved" :
s === "error" ? "builder.save.error" :
"builder.save.idle";
span.setAttribute("data-i18n", key);
span.textContent = text;
}
// ────────────────────────────────────────────────────────────────────────────
// Auto-save (500ms debounce per PRD §4.2 + §10).
// ────────────────────────────────────────────────────────────────────────────
function scheduleAutoSave(): void {
if (!state.active) return;
setSaveState("saving");
if (state.saveTimer !== null) {
window.clearTimeout(state.saveTimer);
}
state.saveTimer = window.setTimeout(() => {
void flushAutoSave();
}, 500);
}
async function flushAutoSave(): Promise<void> {
state.saveTimer = null;
if (!state.active) return;
const body = { ...state.pending };
state.pending = {};
if (Object.keys(body).length === 0) {
setSaveState("saved");
return;
}
const updated = await patchScenario(state.active.id, body);
if (!updated) {
setSaveState("error");
return;
}
state.active.name = updated.name;
state.active.status = updated.status;
state.active.stichtag = updated.stichtag;
state.active.notes = updated.notes;
state.active.updated_at = updated.updated_at;
setSaveState("saved");
// Refresh the side panel so the just-saved scenario floats to top.
await refreshScenarioList();
}
// ────────────────────────────────────────────────────────────────────────────
// Side panel + dropdown
// ────────────────────────────────────────────────────────────────────────────
async function refreshScenarioList(): Promise<void> {
state.list = await fetchScenarios();
renderScenarioList();
renderScenarioPicker();
}
function renderScenarioList(): void {
const ul = document.getElementById("builder-scenario-list-active");
if (!ul) return;
if (state.list.length === 0) {
ul.innerHTML = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
return;
}
const activeId = state.active?.id;
ul.innerHTML = state.list.map((sc) => {
const isActive = sc.id === activeId;
return (
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
` data-scenario-id="${escAttr(sc.id)}">` +
`<button type="button" class="builder-scenario-list-link">` +
`<span class="builder-scenario-list-name">${escHtml(sc.name)}</span>` +
`</button></li>`
);
}).join("");
ul.querySelectorAll<HTMLElement>(".builder-scenario-list-item").forEach((li) => {
const id = li.getAttribute("data-scenario-id");
if (!id) return;
li.addEventListener("click", () => {
void loadScenario(id);
});
});
}
function renderScenarioPicker(): void {
const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
if (!sel) return;
const placeholderText = t("builder.picker.placeholder");
const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
for (const sc of state.list) {
const selected = sc.id === state.active?.id ? " selected" : "";
opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
}
sel.innerHTML = opts.join("");
}
// ────────────────────────────────────────────────────────────────────────────
// Canvas rendering
// ────────────────────────────────────────────────────────────────────────────
function showEmpty(): void {
const canvas = document.getElementById("builder-canvas");
if (!canvas) return;
canvas.innerHTML = "";
const empty = document.createElement("div");
empty.id = "builder-empty";
empty.className = "builder-empty";
empty.innerHTML = `
<p class="builder-empty-headline">${escHtml(t("builder.empty.headline"))}</p>
<p class="builder-empty-hint">${escHtml(t("builder.empty.hint"))}</p>
<button type="button" id="builder-cta-new" class="builder-cta-new">
${escHtml(t("builder.empty.cta"))}
</button>
${renderRecentList()}
`;
canvas.appendChild(empty);
document.getElementById("builder-cta-new")?.addEventListener("click", () => {
void onNewScenarioClick();
});
empty.querySelectorAll<HTMLElement>(".builder-recent-item").forEach((li) => {
const id = li.getAttribute("data-scenario-id");
if (!id) return;
li.addEventListener("click", () => {
void loadScenario(id);
});
});
}
function renderRecentList(): string {
if (state.list.length === 0) return "";
const recent = state.list.slice(0, 5);
const items = recent.map((sc) => (
`<li class="builder-recent-item" data-scenario-id="${escAttr(sc.id)}">` +
`<span class="builder-recent-name">${escHtml(sc.name)}</span>` +
`</li>`
)).join("");
return (
`<div class="builder-recent">` +
`<h3 class="builder-recent-title">${escHtml(t("builder.empty.recent"))}</h3>` +
`<ul class="builder-recent-list">${items}</ul>` +
`</div>`
);
}
function renderCanvas(): void {
if (!state.active) {
showEmpty();
return;
}
const canvas = document.getElementById("builder-canvas");
if (!canvas) return;
canvas.innerHTML = "";
// Top-level proceedings sorted by ordinal (parent_scenario_proceeding_id IS NULL).
const topLevel = state.active.proceedings
.filter((p) => !p.parent_scenario_proceeding_id)
.sort((a, b) => a.ordinal - b.ordinal);
for (const proc of topLevel) {
renderProceedingTripletInto(canvas, proc, /*isChild*/ false);
// Inline child triplets (spawn nesting). PRD §3.6.
const children = state.active.proceedings
.filter((p) => p.parent_scenario_proceeding_id === proc.id)
.sort((a, b) => a.ordinal - b.ordinal);
for (const child of children) {
renderProceedingTripletInto(canvas, child, /*isChild*/ true);
}
}
// Add-proceeding affordance always at the bottom of the stack.
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "builder-add-proceeding-btn";
addBtn.id = "builder-add-proceeding-btn";
addBtn.textContent = t("builder.canvas.add_proceeding");
addBtn.addEventListener("click", () => {
openAddProceedingPicker(addBtn);
});
canvas.appendChild(addBtn);
}
function renderProceedingTripletInto(
canvas: HTMLElement,
proc: BuilderProceeding,
isChild: boolean,
): void {
const host = document.createElement("article");
host.className = "builder-triplet-host";
host.setAttribute("data-proceeding-id", proc.id);
if (isChild) host.setAttribute("data-child", "true");
canvas.appendChild(host);
void hydrateTriplet(proc, host, isChild);
}
async function hydrateTriplet(
proc: BuilderProceeding,
host: HTMLElement,
isChild: boolean,
): Promise<void> {
const meta = state.procTypesById.get(proc.proceeding_type_id);
if (!meta) {
host.innerHTML = `<div class="builder-triplet-error">${escHtml(
t("builder.triplet.unknown_proceeding"),
)}</div>`;
return;
}
const stichtag = proc.stichtag || state.active?.stichtag || todayISO();
const data: DeadlineResponse | null = await calculateDeadlines({
proceedingType: meta.code,
triggerDate: stichtag,
flags: scenarioFlagsToArray(proc.scenario_flags),
});
const side: Side = (proc.primary_party as Side) || null;
const eventsByRule = buildEventsByRule(proc.id);
const columnsHtml = data
? renderColumnsBody(data, { editable: false, side, showDurations: false })
: "";
host.innerHTML = renderTriplet({
proceeding: proc,
meta,
data,
side,
flagCatalog: state.flagCatalog,
eventsByRule,
columnsHtml,
isChild,
});
wireTripletInteractions(host, proc, meta);
overlayEventStates(host, eventsByRule, {
onAction: (ruleId, action, payload) => {
void onEventAction(proc, ruleId, action, payload);
},
onHorizon: (ruleId, delta) => {
void onEventHorizon(proc, ruleId, delta);
},
});
}
function buildEventsByRule(proceedingID: string): Map<string, BuilderEvent> {
const out = new Map<string, BuilderEvent>();
if (!state.active) return out;
for (const ev of state.active.events) {
if (ev.scenario_proceeding_id !== proceedingID) continue;
if (!ev.sequencing_rule_id) continue;
out.set(ev.sequencing_rule_id.toLowerCase(), ev);
}
return out;
}
function wireTripletInteractions(
host: HTMLElement,
proc: BuilderProceeding,
meta: ProceedingTypeMeta,
): void {
// Perspective radio
host.querySelectorAll<HTMLElement>("[data-action='perspective']").forEach((btn) => {
btn.addEventListener("click", () => {
const value = btn.getAttribute("data-value") || "";
void onPerspectiveChange(proc, value);
});
});
// Detailgrad toggle
host.querySelectorAll<HTMLElement>("[data-action='detailgrad']").forEach((btn) => {
btn.addEventListener("click", () => {
const value = btn.getAttribute("data-value") || "selected";
void onDetailgradChange(proc, value);
});
});
// Remove
host.querySelectorAll<HTMLElement>("[data-action='remove']").forEach((btn) => {
btn.addEventListener("click", () => {
void onRemoveProceeding(proc);
});
});
// Flag checkboxes
host.querySelectorAll<HTMLInputElement>("[data-action='flag']").forEach((box) => {
box.addEventListener("change", () => {
const key = box.getAttribute("data-flag-key");
if (!key) return;
void onFlagChange(proc, meta, key, box.checked);
});
});
}
async function onPerspectiveChange(proc: BuilderProceeding, value: string): Promise<void> {
if (!state.active) return;
const updated = await patchProceeding(state.active.id, proc.id, {
primary_party: value,
});
if (!updated) {
setSaveState("error");
return;
}
applyProceedingPatch(updated);
setSaveState("saved");
renderCanvas();
}
async function onDetailgradChange(proc: BuilderProceeding, value: string): Promise<void> {
if (!state.active) return;
const updated = await patchProceeding(state.active.id, proc.id, {
detailgrad: value,
});
if (!updated) {
setSaveState("error");
return;
}
applyProceedingPatch(updated);
setSaveState("saved");
renderCanvas();
}
async function onRemoveProceeding(proc: BuilderProceeding): Promise<void> {
if (!state.active) return;
const confirmed = window.confirm(t("builder.triplet.remove") + " — " + state.procTypesById.get(proc.proceeding_type_id)?.code);
if (!confirmed) return;
const ok = await deleteProceeding(state.active.id, proc.id);
if (!ok) {
setSaveState("error");
return;
}
// Cascade in-memory: drop the proceeding + its child proceedings + events.
state.active.proceedings = state.active.proceedings.filter(
(p) => p.id !== proc.id && p.parent_scenario_proceeding_id !== proc.id,
);
state.active.events = state.active.events.filter(
(e) => state.active!.proceedings.some((p) => p.id === e.scenario_proceeding_id),
);
setSaveState("saved");
renderCanvas();
}
async function onFlagChange(
proc: BuilderProceeding,
meta: ProceedingTypeMeta,
flagKey: string,
enabled: boolean,
): Promise<void> {
if (!state.active) return;
const newFlags: Record<string, unknown> = { ...proc.scenario_flags, [flagKey]: enabled };
const updated = await patchProceeding(state.active.id, proc.id, {
scenario_flags: newFlags,
});
if (!updated) {
setSaveState("error");
return;
}
applyProceedingPatch(updated);
// Spawn handling — flip the child triplet in or out based on the
// SPAWN_MAP entry for this parent.
const spawnChildCode = SPAWN_MAP[meta.code]?.[flagKey];
if (spawnChildCode) {
await syncSpawnChild(updated, spawnChildCode, enabled);
}
setSaveState("saved");
renderCanvas();
}
async function syncSpawnChild(
parent: BuilderProceeding,
childCode: string,
enabled: boolean,
): Promise<void> {
if (!state.active) return;
const existing = state.active.proceedings.find(
(p) => p.parent_scenario_proceeding_id === parent.id,
);
if (enabled && !existing) {
const childMeta = state.procTypesByCode.get(childCode);
if (!childMeta) return;
const child = await addProceeding(state.active.id, {
proceeding_type_id: childMeta.id,
parent_scenario_proceeding_id: parent.id,
});
if (child) state.active.proceedings.push(child);
} else if (!enabled && existing) {
const ok = await deleteProceeding(state.active.id, existing.id);
if (ok) {
state.active.proceedings = state.active.proceedings.filter((p) => p.id !== existing.id);
state.active.events = state.active.events.filter(
(e) => e.scenario_proceeding_id !== existing.id,
);
}
}
}
function applyProceedingPatch(updated: BuilderProceeding): void {
if (!state.active) return;
const idx = state.active.proceedings.findIndex((p) => p.id === updated.id);
if (idx >= 0) state.active.proceedings[idx] = updated;
}
async function onEventAction(
proc: BuilderProceeding,
ruleId: string,
action: "file" | "skip" | "reset",
payload?: { date?: string; reason?: string },
): Promise<void> {
if (!state.active) return;
const ruleKey = ruleId.toLowerCase();
const existing = state.active.events.find(
(e) => e.scenario_proceeding_id === proc.id &&
e.sequencing_rule_id?.toLowerCase() === ruleKey,
);
if (action === "file") {
const date = payload?.date || todayISO();
if (existing) {
const upd = await patchEvent(state.active.id, existing.id, {
state: "filed",
actual_date: date,
});
if (upd) replaceEvent(upd);
} else {
const ev = await addEvent(state.active.id, proc.id, {
sequencing_rule_id: ruleId,
state: "filed",
actual_date: date,
});
if (ev) state.active.events.push(ev);
}
} else if (action === "skip") {
if (existing) {
const upd = await patchEvent(state.active.id, existing.id, {
state: "skipped",
skip_reason: payload?.reason ?? "",
});
if (upd) replaceEvent(upd);
} else {
const ev = await addEvent(state.active.id, proc.id, {
sequencing_rule_id: ruleId,
state: "skipped",
skip_reason: payload?.reason ?? "",
});
if (ev) state.active.events.push(ev);
}
} else {
// reset → either patch back to planned or delete the row outright.
// PATCH is simpler and keeps any horizon_optional the user had set;
// a separate "clear horizon" affordance handles full removal.
if (existing) {
const upd = await patchEvent(state.active.id, existing.id, { state: "planned" });
if (upd) replaceEvent(upd);
}
}
setSaveState("saved");
renderCanvas();
}
async function onEventHorizon(
proc: BuilderProceeding,
ruleId: string,
delta: 1 | -1,
): Promise<void> {
if (!state.active) return;
const ruleKey = ruleId.toLowerCase();
const existing = state.active.events.find(
(e) => e.scenario_proceeding_id === proc.id &&
e.sequencing_rule_id?.toLowerCase() === ruleKey,
);
if (existing) {
const newHorizon = Math.max(0, existing.horizon_optional + delta);
const upd = await patchEvent(state.active.id, existing.id, {
horizon_optional: newHorizon,
});
if (upd) replaceEvent(upd);
} else if (delta > 0) {
const ev = await addEvent(state.active.id, proc.id, {
sequencing_rule_id: ruleId,
horizon_optional: 1,
state: "planned",
});
if (ev) state.active.events.push(ev);
}
setSaveState("saved");
renderCanvas();
}
function replaceEvent(updated: BuilderEvent): void {
if (!state.active) return;
const idx = state.active.events.findIndex((e) => e.id === updated.id);
if (idx >= 0) state.active.events[idx] = updated;
else state.active.events.push(updated);
}
function scenarioFlagsToArray(flags: Record<string, unknown>): string[] {
// The calc API still consumes the historical flat-flag array form
// (string slug per active flag). Builder scenario_flags is the
// jsonb {flag_name: true|false|null} shape — translate by picking
// every truthy key.
const out: string[] = [];
for (const [k, v] of Object.entries(flags)) {
if (v === true) out.push(k);
}
return out;
}
// ────────────────────────────────────────────────────────────────────────────
// Actions
// ────────────────────────────────────────────────────────────────────────────
async function loadScenario(id: string): Promise<void> {
const deep = await fetchScenarioDeep(id);
if (!deep) {
setSaveState("error");
return;
}
// Defensive: Go's encoding/json serialises a nil slice as `null`, not
// `[]`. The server initialises these arrays today, but normalising on
// the client too means a future regression (or an older deployed
// build) can't crash renderCanvas with `null.filter(...)`.
if (!Array.isArray(deep.proceedings)) deep.proceedings = [];
if (!Array.isArray(deep.events)) deep.events = [];
if (!Array.isArray(deep.shares)) deep.shares = [];
state.active = deep;
state.pending = {};
writeScenarioToUrl(id);
setSaveState("saved");
// Sync header inputs to scenario state.
const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
if (rename) rename.disabled = false;
renderScenarioPicker();
renderScenarioList();
renderCanvas();
}
async function onNewScenarioClick(): Promise<void> {
// Scratch scenario per PRD §2.1 — anonymous until "Benennen". Server
// applies the default name "Unbenanntes Szenario".
const sc = await createScenario();
if (!sc) {
setSaveState("error");
return;
}
state.list.unshift(sc);
await loadScenario(sc.id);
// Open the add-proceeding picker so the user lands on the next action.
const btn = document.getElementById("builder-add-proceeding-btn") as HTMLElement | null;
if (btn) openAddProceedingPicker(btn);
}
function openAddProceedingPicker(anchor: HTMLElement): void {
if (!state.active) return;
mountAddProceedingPicker(anchor, state.procTypes, async (meta) => {
if (!state.active) return;
// Guard against a wire-shape regression: if the proceeding-types
// endpoint stops returning `id`, `meta.id` is undefined and
// JSON.stringify silently drops the field, the server rejects the
// POST with a 400, and fetchJSON swallows the error — the user
// sees "nothing happens" (t-paliad-345). Fail loud instead.
if (typeof meta.id !== "number" || meta.id <= 0) {
console.error("builder: missing proceeding_type id in picker meta", meta);
setSaveState("error");
return;
}
const proc = await addProceeding(state.active.id, {
proceeding_type_id: meta.id,
});
if (!proc) {
setSaveState("error");
return;
}
state.active.proceedings.push(proc);
setSaveState("saved");
renderCanvas();
});
}
async function onRenameClick(): Promise<void> {
if (!state.active) return;
const current = state.active.name;
const next = window.prompt(t("builder.action.rename.prompt"), current);
if (next === null) return;
const trimmed = next.trim();
if (!trimmed || trimmed === current) return;
state.pending.name = trimmed;
scheduleAutoSave();
state.active.name = trimmed;
renderScenarioPicker();
renderScenarioList();
}
function onStichtagChange(value: string): void {
if (!state.active) return;
state.active.stichtag = value;
state.pending.stichtag = value;
scheduleAutoSave();
// Re-render: the triplet's calc result depends on stichtag.
renderCanvas();
}
// ────────────────────────────────────────────────────────────────────────────
// Wiring
// ────────────────────────────────────────────────────────────────────────────
function wirePageHeader(): void {
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
void onRenameClick();
});
document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
void onNewScenarioClick();
});
document.getElementById("builder-cta-new")?.addEventListener("click", () => {
void onNewScenarioClick();
});
const picker = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
picker?.addEventListener("change", () => {
const id = picker.value;
if (id) void loadScenario(id);
else {
state.active = null;
writeScenarioToUrl(null);
renderCanvas();
}
});
const stichtag = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
stichtag?.addEventListener("change", () => {
onStichtagChange(stichtag.value);
});
}
export async function mountBuilder(): Promise<void> {
wirePageHeader();
// Parallel boot — proceeding type catalog (Forum=UPC, Kind=proceeding)
// for the add-proceeding picker + scenario_flag_catalog for the
// per-triplet flag strip. PRD §0.4 — UPC v1.
const [procTypes, flagCatalog] = await Promise.all([
fetchProceedingTypes(),
fetchFlagCatalog(),
]);
state.procTypes = procTypes;
state.procTypesById = new Map(state.procTypes.map((p) => [p.id, p]));
state.procTypesByCode = new Map(state.procTypes.map((p) => [p.code, p]));
state.flagCatalog = flagCatalog;
await refreshScenarioList();
const requested = readScenarioFromUrl();
if (requested && state.list.some((s) => s.id === requested)) {
await loadScenario(requested);
} else {
renderCanvas();
}
setSaveState("idle");
}
// ────────────────────────────────────────────────────────────────────────────
// helpers
// ────────────────────────────────────────────────────────────────────────────
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
export function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Re-export getLang so the per-page bundle pulls i18n into the dep
// graph (the i18n module's side-effect-free initialiser otherwise
// gets tree-shaken when only string keys are referenced).
export { getLang };

View File

@@ -214,6 +214,69 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
"nav.procedures": "Verfahren & Fristen",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
"builder.header.scenario": "Szenario:",
"builder.header.akte": "Akte:",
"builder.header.stichtag": "Stichtag:",
"builder.header.search": "Suche:",
"builder.akte.none": "\u2014 ohne \u2014",
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
"builder.action.rename": "Benennen",
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
"builder.action.share": "Teilen",
"builder.action.promote": "Als Projekt anlegen",
"builder.mode.cold": "\u00dcbersicht",
"builder.mode.event": "Ereignis",
"builder.mode.akte": "Aus Akte",
"builder.panel.title": "Meine Szenarien",
"builder.panel.new": "+ Neues Szenario",
"builder.panel.empty": "Noch keine Szenarien.",
"builder.bucket.active": "Aktiv",
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
"builder.empty.cta": "Neues Szenario starten",
"builder.empty.recent": "Zuletzt bearbeitet",
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
"builder.picker.close": "Schlie\u00dfen",
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Verfahren:",
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
"builder.triplet.loading": "Berechne Fristen \u2026",
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
"builder.triplet.side.defendant": "Beklagten-Sicht",
"builder.triplet.flags.label": "Optionen:",
"builder.triplet.perspective.label": "Perspektive:",
"builder.triplet.perspective.none": "keine",
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
"builder.triplet.perspective.defendant": "Beklagter",
"builder.triplet.detailgrad.label": "Detailgrad:",
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
"builder.triplet.detailgrad.all_options": "Alle Optionen",
"builder.triplet.remove": "Entfernen",
"builder.triplet.collapse": "Einklappen",
"builder.triplet.expand": "Ausklappen",
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
"builder.event.state.planned": "geplant",
"builder.event.state.filed": "eingereicht",
"builder.event.state.skipped": "ausgelassen",
"builder.event.action.file": "Einreichen",
"builder.event.action.skip": "Auslassen",
"builder.event.action.reset": "Zur\u00fcck zu geplant",
"builder.event.actual_date.prompt": "Datum der Einreichung:",
"builder.event.skip_reason.prompt": "Grund (optional):",
"builder.event.horizon.label": "+{n} Optionen \u25be",
"builder.event.horizon.hide": "Optionen ausblenden",
"builder.save.idle": "\u00a0",
"builder.save.saving": "Speichert \u2026",
"builder.save.saved": "Gespeichert \u2713",
"builder.save.error": "Speichern fehlgeschlagen",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step2.perspective": "Perspektive und Datum",
@@ -3418,6 +3481,69 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
"nav.procedures": "Procedures & Deadlines",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
"builder.header.scenario": "Scenario:",
"builder.header.akte": "Matter:",
"builder.header.stichtag": "Anchor:",
"builder.header.search": "Search:",
"builder.akte.none": "— none —",
"builder.search.placeholder": "Event, scenario, matter …",
"builder.action.rename": "Name it",
"builder.action.rename.prompt": "Name for this scenario:",
"builder.action.share": "Share",
"builder.action.promote": "Create as project",
"builder.mode.cold": "Overview",
"builder.mode.event": "Event",
"builder.mode.akte": "From matter",
"builder.panel.title": "My scenarios",
"builder.panel.new": "+ New scenario",
"builder.panel.empty": "No scenarios yet.",
"builder.bucket.active": "Active",
"builder.empty.headline": "No scenario open.",
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
"builder.empty.cta": "Start a new scenario",
"builder.empty.recent": "Recent",
"builder.picker.placeholder": "— pick a scenario —",
"builder.picker.title": "Add proceeding",
"builder.picker.close": "Close",
"builder.picker.aria": "Pick a proceeding",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Proceeding:",
"builder.picker.empty": "No proceedings available.",
"builder.picker.future_jurisdiction": "Other forums coming later.",
"builder.canvas.add_proceeding": "+ Add proceeding",
"builder.triplet.loading": "Calculating deadlines …",
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
"builder.triplet.side.claimant": "Claimant view",
"builder.triplet.side.defendant": "Defendant view",
"builder.triplet.flags.label": "Options:",
"builder.triplet.perspective.label": "Perspective:",
"builder.triplet.perspective.none": "none",
"builder.triplet.perspective.claimant": "Claimant",
"builder.triplet.perspective.defendant": "Defendant",
"builder.triplet.detailgrad.label": "Detail:",
"builder.triplet.detailgrad.selected": "Selected",
"builder.triplet.detailgrad.all_options": "All options",
"builder.triplet.remove": "Remove",
"builder.triplet.collapse": "Collapse",
"builder.triplet.expand": "Expand",
"builder.triplet.no_flags": "(no flags for this proceeding type)",
"builder.event.state.planned": "planned",
"builder.event.state.filed": "filed",
"builder.event.state.skipped": "skipped",
"builder.event.action.file": "File",
"builder.event.action.skip": "Skip",
"builder.event.action.reset": "Reset to planned",
"builder.event.actual_date.prompt": "Date of filing:",
"builder.event.skip_reason.prompt": "Reason (optional):",
"builder.event.horizon.label": "+{n} optional ▾",
"builder.event.horizon.hide": "Hide optional",
"builder.save.idle": " ",
"builder.save.saving": "Saving …",
"builder.save.saved": "Saved ✓",
"builder.save.error": "Save failed",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step2.perspective": "Perspective and Date",

View File

@@ -1,150 +1,15 @@
// /tools/procedures client (m/paliad#151,
// docs/design-unified-procedural-events-tool-2026-05-27.md).
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
//
// Boot logic + tab switching for the unified procedural-events tool.
// Each entry tab mounts its own module; the search box and chip
// filters in the top filter strip are wired in U1+ as each slice adds
// its dimension-aware behaviour.
//
// U0 — Skeleton + tab toggling.
// U1 — Direkt suchen mounts Mode A.
// U2 — Geführt mounts Mode B wizard.
// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle.
//
// Mode A renders its shell into #fristen-overhaul-root (replacing
// children); Mode B renders into #fristen-overhaul-mode-host; the
// result view (post-commit) writes into #fristen-overhaul-root. To
// keep those IDs unique in the DOM, only the active tab's panel ever
// hosts the overhaul scaffold — installOverhaulHost() tears down any
// existing host and installs a fresh one inside the target panel
// before handing off to the per-mode module.
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
// emitted by procedures.tsx; this file boots the i18n + sidebar
// runtime and hands off to builder.ts.
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import { mountModeA } from "./fristenrechner-mode-a";
import { mountResultView } from "./fristenrechner-result";
import { mountWizard } from "./fristenrechner-wizard";
import { initVerfahrensablauf } from "./verfahrensablauf";
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
function readTabFromUrl(): ProceduresTab {
const params = new URLSearchParams(window.location.search);
const raw = params.get("mode");
if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab;
return "proceeding";
}
function writeTabToUrl(tab: ProceduresTab): void {
const url = new URL(window.location.href);
if (tab === "proceeding") {
url.searchParams.delete("mode");
} else {
url.searchParams.set("mode", tab);
}
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
// any existing host first, so the IDs stay unique across the page even
// when the user toggles between Direkt-suchen and Geführt — both Mode
// A and the wizard read these IDs from document.getElementById which
// returns the first match in DOM order, so two parallel hosts would
// cross-wire.
function installOverhaulHost(panelId: string): HTMLElement | null {
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
const panel = document.getElementById(panelId);
if (!panel) return null;
panel.innerHTML = `
<div class="procedures-overhaul-host">
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
<div id="fristen-overhaul-mode-host"></div>
</div>
</div>
`;
return panel;
}
function setActiveTabUI(tab: ProceduresTab): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
const panel = document.getElementById(`procedures-panel-${t}`);
const active = t === tab;
if (btn) {
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
}
if (panel) panel.hidden = !active;
}
}
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
// selectedType + lastResponse + listeners that re-bind on every
// proceeding click). Wire it exactly once per page load; on subsequent
// activations the existing DOM + listeners are reused so picked
// proceeding / dates / flags persist across tab switches.
let verfahrensablaufWired = false;
async function activateTab(tab: ProceduresTab): Promise<void> {
setActiveTabUI(tab);
if (tab === "search") {
installOverhaulHost("procedures-panel-search");
await mountModeA();
return;
}
if (tab === "wizard") {
installOverhaulHost("procedures-panel-wizard");
await mountWizard();
return;
}
if (tab === "proceeding") {
if (!verfahrensablaufWired) {
initVerfahrensablauf();
verfahrensablaufWired = true;
}
}
}
function wireTabs(): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
if (!btn) continue;
btn.addEventListener("click", () => {
void activateTab(t);
writeTabToUrl(t);
});
}
}
// boot dispatches on the URL: a deep link with `?event=` jumps straight
// to the linear result view (the Direkt-suchen tab stays as the visible
// context). Otherwise the requested tab — defaulting to "proceeding" —
// activates per readTabFromUrl().
async function boot(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const eventRef = params.get("event") || "";
if (eventRef) {
setActiveTabUI("search");
installOverhaulHost("procedures-panel-search");
await mountResultView({
eventRef,
triggerDate: params.get("trigger_date") || undefined,
party: params.get("party") || undefined,
courtId: params.get("court_id") || undefined,
});
return;
}
await activateTab(readTabFromUrl());
}
import { mountBuilder } from "./builder";
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
wireTabs();
void boot();
void mountBuilder();
});

View File

@@ -1042,7 +1042,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
// timeline-item — dotted border + faded styling.
dl.isConditional ? "fr-col-item--conditional" : "",
].filter(Boolean).join(" ");
return `<div class="${itemClasses}">
// data-rule-id on the card root lets the Litigation Builder
// overlay per-card state (planned/filed/skipped) + action
// affordances onto cards rendered through this shared body
// without re-implementing the columns renderer. Empty on
// synthetic rows (appeal trigger marker etc.); the Builder
// skips state lookup when missing.
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;

View File

@@ -728,6 +728,67 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "builder.action.promote"
| "builder.action.rename"
| "builder.action.rename.prompt"
| "builder.action.share"
| "builder.akte.none"
| "builder.bucket.active"
| "builder.canvas.add_proceeding"
| "builder.empty.cta"
| "builder.empty.headline"
| "builder.empty.hint"
| "builder.empty.recent"
| "builder.event.action.file"
| "builder.event.action.reset"
| "builder.event.action.skip"
| "builder.event.actual_date.prompt"
| "builder.event.horizon.hide"
| "builder.event.horizon.label"
| "builder.event.skip_reason.prompt"
| "builder.event.state.filed"
| "builder.event.state.planned"
| "builder.event.state.skipped"
| "builder.header.akte"
| "builder.header.scenario"
| "builder.header.search"
| "builder.header.stichtag"
| "builder.mode.akte"
| "builder.mode.cold"
| "builder.mode.event"
| "builder.panel.empty"
| "builder.panel.new"
| "builder.panel.title"
| "builder.picker.aria"
| "builder.picker.axis.forum"
| "builder.picker.axis.proc"
| "builder.picker.close"
| "builder.picker.empty"
| "builder.picker.future_jurisdiction"
| "builder.picker.placeholder"
| "builder.picker.title"
| "builder.save.error"
| "builder.save.idle"
| "builder.save.saved"
| "builder.save.saving"
| "builder.search.placeholder"
| "builder.subtitle"
| "builder.triplet.collapse"
| "builder.triplet.detailgrad.all_options"
| "builder.triplet.detailgrad.label"
| "builder.triplet.detailgrad.selected"
| "builder.triplet.expand"
| "builder.triplet.flags.label"
| "builder.triplet.loading"
| "builder.triplet.no_flags"
| "builder.triplet.perspective.claimant"
| "builder.triplet.perspective.defendant"
| "builder.triplet.perspective.label"
| "builder.triplet.perspective.none"
| "builder.triplet.remove"
| "builder.triplet.side.claimant"
| "builder.triplet.side.defendant"
| "builder.triplet.unknown_proceeding"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"

View File

@@ -4,23 +4,19 @@ import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody";
// U0 — Skeleton for the unified procedural-events tool
// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md).
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
//
// Folds /tools/fristenrechner (Mode A + Mode B + result) and
// /tools/verfahrensablauf into a single page at /tools/procedures. Each
// later slice fills one of the four entry tabs:
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
// builder shell. Server-rendered chrome is minimal — the page-header
// scenario picker, side panel, and canvas are all hydrated by
// `builder.ts` at boot. The builder loads scenarios from
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
// per-proceeding triplets with the existing verfahrensablauf-core calc.
//
// U1 — Direkt suchen (Mode A search)
// U2 — Geführt (Mode B wizard)
// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter)
// U4 — Hard-cut 301 (drop legacy pages, redirect URLs)
//
// This file ships only the page chrome — sidebar, header, filter strip
// with search box, four entry-mode tabs, and the host containers the
// later slices mount their UI into. No data wiring.
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
export function renderProcedures(): string {
const today = new Date().toISOString().split("T")[0];
@@ -36,151 +32,142 @@ export function renderProcedures(): string {
<title data-i18n="procedures.title">Verfahren &amp; Fristen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-procedures">
<body className="has-sidebar page-procedures page-builder">
<Sidebar currentPath="/tools/procedures" />
<BottomNav currentPath="/tools/procedures" />
<main>
<section className="tool-page">
<section className="tool-page builder-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="procedures.heading">Verfahren &amp; Fristen</h1>
<p className="tool-subtitle" data-i18n="procedures.subtitle">
Verfahrensablauf, Fristenrechner und ger&uuml;hrte Suche in einem Tool.
<p className="tool-subtitle" data-i18n="builder.subtitle">
Litigation Builder &mdash; Szenarien bauen, Verfahren stapeln, Fristen behalten.
</p>
</div>
{/* Shared filter strip — search box + four chip groups
(forum / proceeding / event_kind / party). Lives at the
top of the page so every entry tab and output mode reads
the same active filter set (design §4 + m's Q3
divergence: search composes with chip filters). U0
ships the markup only; chip hydration + search wiring
arrive with U1-U3. */}
<section className="procedures-filter-strip" aria-label="Filter">
<div className="procedures-filter-search">
<svg className="procedures-filter-search-icon" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
id="procedures-search-input"
className="procedures-filter-search-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="procedures.filter.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing&hellip;"
/>
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
· Akte picker · Stichtag input. B1 wires the scenario picker
+ name action + Stichtag + save indicator. Akte / share /
promote land at B4 / B5; the affordances render disabled in
B1 so the layout is stable across slices. */}
<section className="builder-pageheader" aria-label="Builder-Steuerung">
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario w&auml;hlen"></select>
</label>
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
<span data-i18n="builder.save.idle">&nbsp;</span>
</span>
<span className="builder-pageheader-spacer"></span>
<button type="button" id="builder-rename-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
data-i18n="builder.action.rename">Benennen</button>
<button type="button" id="builder-share-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.share">Teilen</button>
<button type="button" id="builder-promote-btn"
className="builder-action-btn builder-action-btn--primary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.promote">Als Projekt anlegen</button>
</div>
<div className="procedures-filter-chips" id="procedures-filter-chips">
<div className="procedures-filter-chip-row" data-axis="forum">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="proc">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="kind">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="party">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
</div>
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte w&auml;hlen">
<option value="" data-i18n="builder.akte.none">&mdash; ohne &mdash;</option>
</select>
</label>
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
defaultValue={today} aria-label="Stichtag" />
</label>
<label className="builder-pageheader-field builder-pageheader-field--grow">
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
<input type="search" id="builder-search-input" className="builder-search-input"
data-i18n-placeholder="builder.search.placeholder"
placeholder="Ereignis, Szenario, Akte &hellip;"
autocomplete="off" spellcheck="false" disabled
title="Universelle Suche kommt in B3" />
</label>
</div>
</section>
{/* Entry-mode tab strip — all four tabs visible from boot
(m's Q3 divergence). The active tab is URL-driven
(?mode=proceeding|search|wizard|akte); cold open lands
on "proceeding" per design §11.5.Q3. */}
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
event-triggered + akte ship at B3 / B4 and are disabled
here so the layout stays stable across slices. */}
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
<button type="button"
className="procedures-tab is-active"
className="builder-mode is-active"
role="tab"
aria-selected="true"
data-tab="proceeding"
id="procedures-tab-proceeding">
<span className="procedures-tab-icon" aria-hidden="true">&#128218;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren w&auml;hlen</span>
data-mode="cold"
id="builder-mode-cold">
<span className="builder-mode-label" data-i18n="builder.mode.cold">&Uuml;bersicht</span>
</button>
<button type="button"
className="procedures-tab"
className="builder-mode"
role="tab"
aria-selected="false"
data-tab="search"
id="procedures-tab-search">
<span className="procedures-tab-icon" aria-hidden="true">&#9889;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
data-mode="event"
id="builder-mode-event"
disabled
title="In B3 verf&uuml;gbar">
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
</button>
<button type="button"
className="procedures-tab"
className="builder-mode"
role="tab"
aria-selected="false"
data-tab="wizard"
id="procedures-tab-wizard">
<span className="procedures-tab-icon" aria-hidden="true">&#129517;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Gef&uuml;hrt</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="akte"
id="procedures-tab-akte">
<span className="procedures-tab-icon" aria-hidden="true">&#128193;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
data-mode="akte"
id="builder-mode-akte"
disabled
title="In B4 verf&uuml;gbar">
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
</button>
</nav>
{/* Per-tab content hosts. Only one is visible at a time —
procedures.ts toggles `hidden` on the inactive ones.
Each later slice fills the corresponding host. */}
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
aria-labelledby="procedures-tab-proceeding">
{/* Verfahrensablauf wizard body — shared TSX component
used by /tools/verfahrensablauf (legacy) and the
unified /tools/procedures page. procedures.ts calls
initVerfahrensablauf() on the first activation of
this tab, which wires the .proceeding-btn clicks,
timeline-container, detail-mode toggle, etc. against
the markup. The legacy page's auto-boot is guarded
against the procedures-only #procedures-panel-proceeding
element so it doesn't fire twice. */}
<VerfahrensablaufBody todayIso={today} />
</section>
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
<div className="builder-body">
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
<header className="builder-sidepanel-header">
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
<button type="button" id="builder-new-scenario-btn"
className="builder-sidepanel-newbtn"
data-i18n="builder.panel.new">+ Neues Szenario</button>
</header>
<div className="builder-sidepanel-bucket" data-bucket="active">
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
</div>
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
</aside>
<section className="procedures-panel" id="procedures-panel-search" role="tabpanel"
aria-labelledby="procedures-tab-search" hidden></section>
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
aria-labelledby="procedures-tab-wizard" hidden></section>
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
aria-labelledby="procedures-tab-akte" hidden>
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
Akten-Einstieg folgt in einem sp&auml;teren Slice.
</div>
</section>
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
tree here; U0 leaves it empty + hidden so the
tab placeholders are the only thing visible. */}
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
aria-label="Tree output" hidden></section>
{/* Linear-drawer host. Inline drawer expanding beneath a
tree card (design §8 — desktop) AND the standalone
linear follow-up view that Mode A / Mode B land on
after locking a trigger event (design §3.2). U1
switches it on. */}
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
aria-label="Linear output" hidden></section>
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
<div id="builder-canvas" className="builder-canvas">
{/* Cold-open placeholder — replaced by triplet stack once a
scenario is loaded. */}
<div className="builder-empty" id="builder-empty">
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
Noch kein Szenario ge&ouml;ffnet.
</p>
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
Starte ein neues Szenario, w&auml;hle aus deiner Liste oder &uuml;bernimm eine Akte (B4).
</p>
<button type="button" id="builder-cta-new" className="builder-cta-new"
data-i18n="builder.empty.cta">
Neues Szenario starten
</button>
</div>
</div>
</section>
</div>
</div>
</section>
</main>

View File

@@ -19811,3 +19811,694 @@ a.fristen-overhaul-rule-source {
width: 100%;
}
}
/* --- Litigation Builder (m/paliad#153 B1+B2) --- */
.builder-page .tool-header {
margin-bottom: 0.75rem;
}
.builder-pageheader {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
margin-bottom: 0.6rem;
}
.builder-pageheader-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.builder-pageheader-field {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.builder-pageheader-field--grow {
flex: 1 1 220px;
}
.builder-pageheader-label {
color: var(--color-text-subtle);
font-weight: 500;
white-space: nowrap;
}
.builder-pageheader-spacer {
flex: 1 1 auto;
}
.builder-scenario-picker,
.builder-akte-picker,
.builder-stichtag-input,
.builder-search-input {
font: inherit;
padding: 0.3rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: 0.3rem;
background: var(--color-surface-2);
color: var(--color-text);
min-width: 200px;
}
.builder-search-input {
min-width: 260px;
}
.builder-scenario-picker:disabled,
.builder-akte-picker:disabled,
.builder-search-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.builder-save-status {
font-size: 0.85rem;
color: var(--color-text-subtle);
min-width: 8rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.builder-save-status[data-state="saving"] { color: var(--color-text-subtle); }
.builder-save-status[data-state="saved"] { color: var(--color-accent-strong-fg); }
.builder-save-status[data-state="error"] { color: var(--status-red-fg, #c5503a); }
.builder-action-btn {
font: inherit;
padding: 0.35rem 0.85rem;
border-radius: 0.3rem;
cursor: pointer;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.builder-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.builder-action-btn--primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-action-btn--primary:hover:not(:disabled) {
background: var(--color-accent-light);
}
.builder-action-btn--secondary:hover:not(:disabled) {
background: var(--color-surface-muted);
}
.builder-modebar {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
padding: 0.15rem;
margin-bottom: 0.75rem;
}
.builder-mode {
font: inherit;
background: transparent;
border: 0;
padding: 0.3rem 0.9rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-mode.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
}
.builder-mode:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.builder-body {
display: grid;
grid-template-columns: 240px 1fr;
gap: 1rem;
align-items: start;
}
.builder-sidepanel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.75rem;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow: auto;
}
.builder-sidepanel-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.builder-sidepanel-title {
font-size: 0.95rem;
margin: 0;
}
.builder-sidepanel-newbtn {
font: inherit;
font-size: 0.8rem;
background: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 0.3rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
color: var(--color-accent-dark);
}
.builder-sidepanel-newbtn:hover {
background: var(--color-accent-light);
}
.builder-bucket-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-subtle);
margin: 0.5rem 0 0.3rem;
}
.builder-scenario-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.builder-scenario-list-empty {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
}
.builder-scenario-list-item {
cursor: pointer;
border-radius: 0.3rem;
}
.builder-scenario-list-item.is-active {
background: var(--color-accent-soft-bg);
}
.builder-scenario-list-link {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: 0;
padding: 0.4rem 0.5rem;
font: inherit;
color: inherit;
cursor: pointer;
}
.builder-scenario-list-item:hover {
background: var(--color-surface-muted);
}
.builder-canvas-wrap {
min-height: 320px;
}
.builder-canvas {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.builder-empty {
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
}
.builder-empty-headline {
font-size: 1.05rem;
margin: 0 0 0.4rem;
}
.builder-empty-hint {
color: var(--color-text-subtle);
margin: 0 0 1rem;
}
.builder-cta-new {
font: inherit;
background: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 0.3rem;
padding: 0.55rem 1.2rem;
cursor: pointer;
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-cta-new:hover {
background: var(--color-accent-light);
}
.builder-recent {
margin-top: 1.5rem;
text-align: left;
}
.builder-recent-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-subtle);
margin: 0 0 0.5rem;
}
.builder-recent-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.builder-recent-item {
padding: 0.4rem 0.6rem;
background: var(--color-surface-2);
border-radius: 0.3rem;
cursor: pointer;
}
.builder-recent-item:hover {
background: var(--color-surface-muted);
}
.builder-triplet-host {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
overflow: hidden;
}
.builder-triplet-host[data-child="true"] {
margin-left: 1.5rem;
border-left: 3px solid var(--color-accent);
}
.builder-triplet-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
background: var(--color-surface-2);
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
flex-wrap: wrap;
}
.builder-triplet-jurisdiction {
background: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 600;
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
letter-spacing: 0.05em;
}
.builder-triplet-code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.78rem;
color: var(--color-text-subtle);
}
.builder-triplet-name {
font-weight: 500;
margin-right: auto;
}
.builder-triplet-side {
background: var(--color-accent-soft-bg);
color: var(--color-accent-soft-fg);
border: 1px solid var(--color-accent-soft-border);
padding: 0.1rem 0.45rem;
border-radius: 0.25rem;
font-size: 0.75rem;
}
.builder-triplet-flags {
font-size: 0.78rem;
color: var(--color-text-subtle);
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.builder-triplet-flag-chip {
background: var(--color-accent-soft-bg);
border: 1px solid var(--color-accent-soft-border);
color: var(--color-accent-soft-fg);
padding: 0.05rem 0.4rem;
border-radius: 0.25rem;
font-family: ui-monospace, Menlo, monospace;
}
.builder-triplet-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 0.85rem;
}
.builder-triplet-controls-label {
color: var(--color-text-subtle);
}
.builder-triplet-perspective,
.builder-triplet-detailgrad {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface-2);
padding: 0.1rem;
}
.builder-triplet-perspective button,
.builder-triplet-detailgrad button {
font: inherit;
font-size: 0.78rem;
border: 0;
background: transparent;
padding: 0.2rem 0.6rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-triplet-perspective button.is-active,
.builder-triplet-detailgrad button.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
}
.builder-triplet-remove {
margin-left: auto;
font: inherit;
font-size: 0.78rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.15rem 0.55rem;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-triplet-remove:hover {
border-color: var(--status-red-border, #d08070);
color: var(--status-red-fg, #c5503a);
}
.builder-triplet-flagstrip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 0.85rem;
}
.builder-triplet-flag-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.builder-triplet-flag-empty {
font-style: italic;
color: var(--color-text-subtle);
}
.builder-triplet-body {
padding: 0.85rem;
}
.builder-triplet-loading,
.builder-triplet-error {
padding: 1rem;
text-align: center;
color: var(--color-text-subtle);
font-style: italic;
}
.builder-add-proceeding-btn {
font: inherit;
background: var(--color-surface-2);
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
padding: 0.7rem;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-add-proceeding-btn:hover {
background: var(--color-accent-soft-bg);
border-color: var(--color-accent-soft-border);
color: var(--color-accent-soft-fg);
}
/* Add-proceeding popover */
.builder-picker-popover {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
min-width: 380px;
}
.builder-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.builder-picker-title {
font-size: 0.95rem;
}
.builder-picker-close {
font: inherit;
font-size: 1.2rem;
background: transparent;
border: 0;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-picker-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.builder-picker-axis-label {
flex: 0 0 6rem;
font-size: 0.85rem;
color: var(--color-text-subtle);
padding-top: 0.25rem;
}
.builder-picker-chips {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.builder-picker-chips--wrap {
flex: 1;
}
.builder-picker-chip {
font: inherit;
font-size: 0.85rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
padding: 0.25rem 0.55rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text);
}
.builder-picker-chip.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
border-color: var(--color-segment-active-border);
}
.builder-picker-chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.builder-picker-chip:hover:not(:disabled) {
background: var(--color-accent-soft-bg);
}
.builder-picker-chip--proc {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
text-align: left;
}
.builder-picker-chip-code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.72rem;
color: var(--color-text-subtle);
}
.builder-picker-empty {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
}
/* Event-card state overrides (B2). The 3-state machine sits on top of
the existing .fr-col-item card. The Builder render passes editable=false
to renderColumnsBody and overlays its own per-card state attributes
on top of the card root via data-builder-state. */
.fr-col-item[data-builder-state="filed"] {
background: var(--color-accent-soft-bg);
border-left: 3px solid var(--color-accent);
}
.fr-col-item[data-builder-state="filed"] .timeline-name::before {
content: "✓ ";
color: var(--color-accent-soft-fg);
font-weight: 600;
}
.fr-col-item[data-builder-state="skipped"] {
opacity: 0.55;
}
.fr-col-item[data-builder-state="skipped"] .timeline-name {
text-decoration: line-through;
}
.builder-event-actions {
display: flex;
gap: 0.3rem;
margin-top: 0.4rem;
}
.builder-event-action {
font: inherit;
font-size: 0.72rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.15rem 0.45rem;
cursor: pointer;
color: var(--color-text);
}
.builder-event-action:hover {
background: var(--color-accent-soft-bg);
}
.builder-event-action[data-action="file"] {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
.builder-event-action[data-action="file"]:hover {
background: var(--color-accent-light);
}
.builder-event-horizon-chip {
display: inline-block;
font-size: 0.72rem;
color: var(--color-accent-soft-fg);
background: var(--color-accent-soft-bg);
border: 1px solid var(--color-accent-soft-border);
border-radius: 0.25rem;
padding: 0.1rem 0.45rem;
margin-top: 0.3rem;
cursor: pointer;
}
.builder-event-horizon-chip:hover {
background: var(--color-accent-strong-bg);
}
/* Responsive: collapse side panel into stacked block on narrow viewports. */
@media (max-width: 900px) {
.builder-body {
grid-template-columns: 1fr;
}
.builder-sidepanel {
position: static;
max-height: none;
}
}
@media (max-width: 640px) {
.builder-pageheader-row {
flex-direction: column;
align-items: stretch;
gap: 0.4rem;
}
.builder-pageheader-field {
flex-wrap: wrap;
}
.builder-scenario-picker,
.builder-akte-picker,
.builder-search-input {
min-width: 0;
width: 100%;
}
}

View File

@@ -0,0 +1,94 @@
-- 157_scenario_builder_foundation — down
--
-- Rolls back mig 157 in reverse order. Down files are reference material
-- (not auto-applied); operator recovery path is:
--
-- psql ... < 157_scenario_builder_foundation.down.sql
-- DELETE FROM paliad.applied_migrations WHERE version = 157;
--
-- This restores the legacy paliad.scenarios shape from mig 145 — the
-- builder columns and the three sibling tables are dropped wholesale.
-- Any builder data in the dropped tables is lost (the tables CASCADE to
-- their children, and DROP TABLE doesn't keep a backup).
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 157 rollback: tear down Scenario builder foundation (t-paliad-340)',
true
);
-- 8. updated_at triggers
DROP TRIGGER IF EXISTS scenario_events_touch_updated_at_trg ON paliad.scenario_events;
DROP TRIGGER IF EXISTS scenario_proceedings_touch_updated_at_trg ON paliad.scenario_proceedings;
-- 7. RLS — drop new policies + restore legacy four
DROP POLICY IF EXISTS scenario_shares_mutate ON paliad.scenario_shares;
DROP POLICY IF EXISTS scenario_shares_select ON paliad.scenario_shares;
DROP POLICY IF EXISTS scenario_events_mutate ON paliad.scenario_events;
DROP POLICY IF EXISTS scenario_events_select ON paliad.scenario_events;
DROP POLICY IF EXISTS scenario_proceedings_mutate ON paliad.scenario_proceedings;
DROP POLICY IF EXISTS scenario_proceedings_select ON paliad.scenario_proceedings;
DROP POLICY IF EXISTS scenarios_owner_mutate ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_select ON paliad.scenarios;
-- Restore the four mig-145 policies verbatim.
CREATE POLICY scenarios_project_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NULL AND created_by = auth.uid());
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NULL AND created_by = auth.uid())
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
-- 6. helper function
DROP FUNCTION IF EXISTS paliad.can_see_scenario(uuid);
-- 5. paliad.projects.origin_scenario_id
DROP INDEX IF EXISTS paliad.projects_origin_scenario_idx;
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS origin_scenario_id;
-- 4. paliad.scenario_shares
DROP TABLE IF EXISTS paliad.scenario_shares;
-- 3. paliad.scenario_events
DROP TABLE IF EXISTS paliad.scenario_events;
-- 2. paliad.scenario_proceedings
DROP TABLE IF EXISTS paliad.scenario_proceedings;
-- 1. paliad.scenarios — restore mig-145 shape
DROP INDEX IF EXISTS paliad.scenarios_updated_idx;
DROP INDEX IF EXISTS paliad.scenarios_owner_status_idx;
-- Restore the unique constraint mig 145 had.
ALTER TABLE paliad.scenarios
ADD CONSTRAINT scenarios_unique_per_scope
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name);
-- spec was NOT NULL in mig 145. Restore that — but only after backfilling
-- any NULL specs the builder might have created (none in legacy paths;
-- only builder rows have NULL spec, and those are dropped together with
-- the builder schema if a real rollback is needed).
UPDATE paliad.scenarios SET spec = '{}'::jsonb WHERE spec IS NULL;
ALTER TABLE paliad.scenarios ALTER COLUMN spec SET NOT NULL;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS notes;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS stichtag;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS promoted_project_id;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS origin_project_id;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS status;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS owner_id;
COMMIT;

View File

@@ -0,0 +1,500 @@
-- 157_scenario_builder_foundation — t-paliad-340 / m/paliad#153 B0
--
-- Schema foundation for the Litigation Builder (PRD
-- docs/plans/prd-procedures-litigation-planner-2026-05-27.md §5.1 + §5.2).
-- Phase B0 of the 7-slice train described in PRD §7.1. DB-only — no UI
-- depends on these tables yet; B1 wires the builder shell on top.
--
-- What this migration adds:
--
-- 1. Six new columns on paliad.scenarios for the builder shape:
-- owner_id, status, origin_project_id, promoted_project_id,
-- stichtag, notes.
-- Two relaxations on existing columns:
-- - spec NOT NULL → NULL (the builder normalises spec contents
-- into scenario_proceedings / scenario_events; new rows skip
-- spec entirely. Legacy callers from mig 145 still provide it
-- explicitly, so they keep inserting valid rows.)
-- - DROP CONSTRAINT scenarios_unique_per_scope (the builder
-- allows multiple "Unbenanntes Szenario" + multiple scratch
-- scenarios per user — uniqueness on (project_id, created_by,
-- name) blocks that. The legacy service treated the constraint
-- as UX collision avoidance, not correctness.)
--
-- 2. Three new tables for the normalised builder shape:
-- - paliad.scenario_proceedings (one row per proceeding in a
-- scenario; multi-proceeding constellations + spawned children)
-- - paliad.scenario_events (one row per event card on the
-- canvas; planned / filed / skipped state + actual_date + notes
-- + per-card optional horizon)
-- - paliad.scenario_shares (read-only team shares; owner is
-- the sole editor)
--
-- 3. One new column on paliad.projects:
-- - origin_scenario_id — audit trail for promote-to-project
-- (B5; the column lands now so the FK is in place when the
-- wizard arrives).
--
-- 4. New helper function paliad.can_see_scenario(_scenario_id) that
-- mirrors paliad.can_see_project's STABLE SECURITY DEFINER shape.
-- Visibility logic:
-- - global_admin sees everything,
-- - owner_id = auth.uid() (builder-owned scenarios),
-- - scenario_shares.shared_with_user_id = auth.uid()
-- (read-only shared scenarios),
-- - legacy project-scoped scenarios (owner_id IS NULL AND
-- project_id IS NOT NULL) follow can_see_project(project_id),
-- - legacy abstract scenarios (owner_id IS NULL AND project_id
-- IS NULL) follow created_by = auth.uid().
--
-- 5. Replacement RLS policies on paliad.scenarios that fold builder
-- visibility together with the legacy shape. The legacy
-- project_* / abstract_* policies are dropped (they covered only
-- legacy paths) and rewritten as a single pair of policies that
-- treats owner_id, scenario_shares, and the legacy paths uniformly.
--
-- Builder-only RLS for the three new tables: read = scenario
-- visibility; write = scenario owner (or legacy editor) only.
--
-- PRD §5.1 deviations called out for the reader:
--
-- - PRD specs `proceeding_type_id uuid REFERENCES paliad.proceeding_types(id)`.
-- The live column is `integer` (see paliad.proceeding_types.id);
-- scenario_proceedings.proceeding_type_id is integer here to match
-- the real FK target. PRD authors did not check the column type;
-- this migration uses the truth on disk.
--
-- - PRD references `auth.users(id)` for owner_id and share columns;
-- the established paliad convention (see paliad.projects.created_by,
-- paliad.scenarios.created_by) uses `paliad.users(id)`. Same UUIDs
-- either way (paliad.users.id == auth.users.id), but the FK targets
-- paliad.users to stay consistent with project tables.
--
-- Audit-first: all DDL ran clean against a BEGIN/ROLLBACK probe on the
-- live DB before this file was committed. paliad.scenarios has 0 rows
-- (verified pre-mig), so the column additions and constraint relaxations
-- have no data impact.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 157: Scenario builder foundation (t-paliad-340 / m/paliad#153 B0)',
true
);
-- ----------------------------------------------------------------
-- 1. paliad.scenarios — additive columns + constraint relaxations
-- ----------------------------------------------------------------
ALTER TABLE paliad.scenarios
ADD COLUMN owner_id uuid NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
ADD COLUMN status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','promoted')),
ADD COLUMN origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
ADD COLUMN promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
ADD COLUMN stichtag date NULL,
ADD COLUMN notes text NULL;
ALTER TABLE paliad.scenarios ALTER COLUMN spec DROP NOT NULL;
ALTER TABLE paliad.scenarios DROP CONSTRAINT IF EXISTS scenarios_unique_per_scope;
CREATE INDEX scenarios_owner_status_idx
ON paliad.scenarios(owner_id, status)
WHERE owner_id IS NOT NULL;
CREATE INDEX scenarios_updated_idx
ON paliad.scenarios(owner_id, updated_at DESC)
WHERE owner_id IS NOT NULL;
COMMENT ON COLUMN paliad.scenarios.owner_id IS
'Litigation Builder owner (PRD §5.1). NULL = legacy composition-spec '
'scenario from m/paliad#124 Slice D (mig 145). Builder rows MUST have '
'owner_id set; the application enforces it via ScenarioBuilderService.';
COMMENT ON COLUMN paliad.scenarios.status IS
'Lifecycle: active (default; user-editable) / archived (soft-deleted, '
'still visible in side panel) / promoted (converted to project via '
'B5 wizard; read-only). Legacy mig-145 rows default to active.';
COMMENT ON COLUMN paliad.scenarios.origin_project_id IS
'Set when the scenario was exported from an existing project '
'("Im Builder öffnen" — Akte mode, PRD §2.3).';
COMMENT ON COLUMN paliad.scenarios.promoted_project_id IS
'Set after the scenario was promoted to a real project via the 3-step '
'wizard (PRD §5.4). Together with paliad.projects.origin_scenario_id, '
'forms the bidirectional audit link.';
COMMENT ON COLUMN paliad.scenarios.stichtag IS
'Scenario-level default Stichtag; per-proceeding overrides in '
'paliad.scenario_proceedings.stichtag take precedence.';
-- ----------------------------------------------------------------
-- 2. paliad.scenario_proceedings — one proceeding per scenario row
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_proceedings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
proceeding_type_id integer NOT NULL
REFERENCES paliad.proceeding_types(id),
primary_party text NULL
CHECK (primary_party IN ('claimant','defendant')),
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(scenario_flags) = 'object'),
parent_scenario_proceeding_id uuid NULL
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
spawn_anchor_event_id uuid NULL
REFERENCES paliad.sequencing_rules(id),
ordinal int NOT NULL DEFAULT 0,
stichtag date NULL,
detailgrad text NOT NULL DEFAULT 'selected'
CHECK (detailgrad IN ('selected','all_options')),
appeal_target text NULL,
collapsed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenario_proceedings_scenario_idx
ON paliad.scenario_proceedings(scenario_id, ordinal);
CREATE INDEX scenario_proceedings_parent_idx
ON paliad.scenario_proceedings(parent_scenario_proceeding_id)
WHERE parent_scenario_proceeding_id IS NOT NULL;
COMMENT ON TABLE paliad.scenario_proceedings IS
'One proceeding inside a Litigation Builder scenario. Multiple rows '
'per scenario for multi-proceeding constellations. '
'parent_scenario_proceeding_id self-refs for spawned children '
'(e.g. upc.ccr.cfi spawned by with_ccr on upc.inf.cfi). '
'PRD §5.1, m/paliad#153 B0.';
COMMENT ON COLUMN paliad.scenario_proceedings.primary_party IS
'Per-proceeding perspective ("our side"). NULL = no perspective '
'picked yet (both party columns render with natural labels). '
'Per-proceeding so multi-jurisdiction constellations can flip side '
'independently (PRD §3.3).';
COMMENT ON COLUMN paliad.scenario_proceedings.scenario_flags IS
'Per-proceeding flags (e.g. {"with_ccr": true, "with_amend": false}). '
'Mirrors paliad.projects.scenario_flags shape but lives per-proceeding-'
'per-scenario. Validated by the application against '
'paliad.scenario_flag_catalog at write time.';
COMMENT ON COLUMN paliad.scenario_proceedings.spawn_anchor_event_id IS
'Which sequencing_rule of the parent proceeding caused this spawn. '
'NULL for root proceedings. Used by the UI to place the spawned child '
'triplet directly below the parent at the spawn node (PRD §3.6).';
COMMENT ON COLUMN paliad.scenario_proceedings.ordinal IS
'Stack order on canvas (top to bottom). Siblings under the same '
'parent (or top-level) are ordered by ordinal asc, then created_at.';
COMMENT ON COLUMN paliad.scenario_proceedings.detailgrad IS
'Per-proceeding optional-detail toggle: selected (only explicitly '
'chosen optionals + mandatories) or all_options (every optional '
'sequencing_rule surfaces). Matches today''s Verfahrensablauf pattern.';
-- ----------------------------------------------------------------
-- 3. paliad.scenario_events — one event card on the canvas
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_proceeding_id uuid NOT NULL
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
sequencing_rule_id uuid NULL
REFERENCES paliad.sequencing_rules(id),
procedural_event_id uuid NULL
REFERENCES paliad.procedural_events(id),
custom_label text NULL,
state text NOT NULL DEFAULT 'planned'
CHECK (state IN ('planned','filed','skipped')),
actual_date date NULL,
skip_reason text NULL,
notes text NULL,
horizon_optional int NOT NULL DEFAULT 0
CHECK (horizon_optional >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT scenario_events_one_anchor CHECK (
(sequencing_rule_id IS NOT NULL)::int +
(procedural_event_id IS NOT NULL)::int +
(custom_label IS NOT NULL)::int >= 1
)
);
CREATE INDEX scenario_events_proceeding_idx
ON paliad.scenario_events(scenario_proceeding_id);
-- A single proceeding can't carry two cards for the same sequencing rule
-- (each rule maps to one card). Free-form / procedural_event-only cards
-- skip this uniqueness — multiple custom cards per proceeding are OK.
CREATE UNIQUE INDEX scenario_events_rule_uniq_idx
ON paliad.scenario_events(scenario_proceeding_id, sequencing_rule_id)
WHERE sequencing_rule_id IS NOT NULL;
COMMENT ON TABLE paliad.scenario_events IS
'One event card on the Litigation Builder canvas. Captures state '
'(planned/filed/skipped), actual_date, notes, skip_reason, and the '
'per-card optional-horizon setting. At least one of '
'(sequencing_rule_id, procedural_event_id, custom_label) must be '
'set — sequencing-rule-backed cards are the common case; free-form '
'cards exist for events the catalog doesn''t cover yet. '
'PRD §3.4 / §5.1.';
COMMENT ON COLUMN paliad.scenario_events.state IS
'3-state machine: planned (default, future event with computed date) '
'/ filed (past event, actual_date set) / skipped (user chose not to '
'file; optional skip_reason). No "overdue" enum — that''s derived '
'(date < today AND state=planned), not stored. PRD Q10 / §3.4.';
COMMENT ON COLUMN paliad.scenario_events.actual_date IS
'Set when state=filed (real-world filing date) OR when state=planned '
'and the user overrode the computed date (court-set events, manual '
'tweaks). NULL when the computed date is canonical.';
COMMENT ON COLUMN paliad.scenario_events.horizon_optional IS
'Per-card "show N more optional follow-ups" affordance. Default 0 '
'(hidden). PRD Q4 / §3.4.';
-- ----------------------------------------------------------------
-- 4. paliad.scenario_shares — read-only team shares
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
shared_with_user_id uuid NOT NULL
REFERENCES paliad.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES paliad.users(id),
UNIQUE (scenario_id, shared_with_user_id)
);
CREATE INDEX scenario_shares_user_idx
ON paliad.scenario_shares(shared_with_user_id);
COMMENT ON TABLE paliad.scenario_shares IS
'Read-only team shares for Litigation Builder scenarios. Owner '
'(paliad.scenarios.owner_id) is the sole editor; rows here grant '
'view-only access to other paliad users. PRD Q12 / §5.1.';
-- ----------------------------------------------------------------
-- 5. paliad.projects.origin_scenario_id — promote-to-project trail
-- ----------------------------------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN origin_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
CREATE INDEX projects_origin_scenario_idx
ON paliad.projects(origin_scenario_id)
WHERE origin_scenario_id IS NOT NULL;
COMMENT ON COLUMN paliad.projects.origin_scenario_id IS
'FK to the scenario this project was promoted from (B5 wizard). '
'NULL = project was created directly, not via Builder. Together with '
'paliad.scenarios.promoted_project_id, forms the bidirectional audit '
'link. PRD §5.2.';
-- ----------------------------------------------------------------
-- 6. paliad.can_see_scenario — visibility helper
-- ----------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.can_see_scenario(_scenario_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $func$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id AND s.owner_id = auth.uid()
)
OR EXISTS (
SELECT 1 FROM paliad.scenario_shares sh
WHERE sh.scenario_id = _scenario_id
AND sh.shared_with_user_id = auth.uid()
)
-- Legacy project-scoped scenarios (mig 145) — visible via project
-- team membership.
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id
AND s.owner_id IS NULL
AND s.project_id IS NOT NULL
AND paliad.can_see_project(s.project_id)
)
-- Legacy abstract scenarios (mig 145) — owner-only via created_by.
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id
AND s.owner_id IS NULL
AND s.project_id IS NULL
AND s.created_by = auth.uid()
);
$func$;
COMMENT ON FUNCTION paliad.can_see_scenario(uuid) IS
'Returns true if the caller (auth.uid()) can see the given scenario. '
'Mirrors paliad.can_see_project. Covers builder-owned scenarios '
'(owner_id), read-only shares (scenario_shares), and the two legacy '
'paths from mig 145 (project-scoped via can_see_project, abstract '
'via created_by). Used by RLS on all four scenario_* tables.';
-- ----------------------------------------------------------------
-- 7. RLS — replace legacy scenarios policies + new tables
-- ----------------------------------------------------------------
-- Replace mig-145's four policies with a single pair that handles
-- builder + legacy shapes together.
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
CREATE POLICY scenarios_select ON paliad.scenarios
FOR SELECT USING (paliad.can_see_scenario(id));
-- Write rule: builder owner, legacy project team member (if no owner),
-- or legacy abstract creator (if no owner + no project). Shares are
-- read-only — they don't grant mutate.
CREATE POLICY scenarios_owner_mutate ON paliad.scenarios
FOR ALL
USING (
owner_id = auth.uid()
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
)
WITH CHECK (
owner_id = auth.uid()
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
);
-- scenario_proceedings — visibility piggybacks on the parent scenario.
ALTER TABLE paliad.scenario_proceedings ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_proceedings_select ON paliad.scenario_proceedings
FOR SELECT USING (paliad.can_see_scenario(scenario_id));
CREATE POLICY scenario_proceedings_mutate ON paliad.scenario_proceedings
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- scenario_events — visibility piggybacks on the parent scenario via
-- the proceeding row.
ALTER TABLE paliad.scenario_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_events_select ON paliad.scenario_events
FOR SELECT
USING (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
WHERE sp.id = scenario_proceeding_id
AND paliad.can_see_scenario(sp.scenario_id)
));
CREATE POLICY scenario_events_mutate ON paliad.scenario_events
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
JOIN paliad.scenarios s ON s.id = sp.scenario_id
WHERE sp.id = scenario_proceeding_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
JOIN paliad.scenarios s ON s.id = sp.scenario_id
WHERE sp.id = scenario_proceeding_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- scenario_shares — recipient can see their share rows; the scenario
-- owner (or legacy editor) can manage them.
ALTER TABLE paliad.scenario_shares ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_shares_select ON paliad.scenario_shares
FOR SELECT
USING (
shared_with_user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
)
);
CREATE POLICY scenario_shares_mutate ON paliad.scenario_shares
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- ----------------------------------------------------------------
-- 8. updated_at triggers on the new tables (reuse the function mig 145
-- already created for paliad.scenarios).
-- ----------------------------------------------------------------
CREATE TRIGGER scenario_proceedings_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenario_proceedings
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
CREATE TRIGGER scenario_events_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenario_events
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
-- ----------------------------------------------------------------
-- 9. Informational NOTICE.
-- ----------------------------------------------------------------
DO $$
BEGIN
RAISE NOTICE '[mig 157] paliad.scenarios extended with builder columns (0 legacy rows affected)';
RAISE NOTICE '[mig 157] paliad.scenario_proceedings created';
RAISE NOTICE '[mig 157] paliad.scenario_events created';
RAISE NOTICE '[mig 157] paliad.scenario_shares created';
RAISE NOTICE '[mig 157] paliad.projects.origin_scenario_id added';
RAISE NOTICE '[mig 157] paliad.can_see_scenario(uuid) created';
END $$;
COMMIT;

View File

@@ -142,6 +142,12 @@ type Services struct {
// and per-rule selection state (`rule:<uuid>` keys).
ScenarioFlags *services.ScenarioFlagsService
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder. CRUD over the
// new normalised scenario shape (paliad.scenarios with owner_id +
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
// Nil when DATABASE_URL is unset — /api/builder/scenarios* routes 503.
ScenarioBuilder *services.ScenarioBuilderService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -212,6 +218,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
scenarioFlags: svc.ScenarioFlags,
scenarioBuilder: svc.ScenarioBuilder,
}
}
@@ -514,6 +521,28 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder API over the
// new normalised scenario shape (mig 157). Coexists with the legacy
// /api/scenarios surface during the B0→B6 migration; B6 cleanup
// retires the legacy routes.
protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList)
protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate)
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingPatch)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings/{pid}/events", handleBuilderEventCreate)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventPatch)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
// m/paliad#153 B2 — read-only passthrough so the builder can render
// per-triplet flag toggles without a per-project round-trip.
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
// Dev-only test route — gated to PaliadinOwnerEmail (m).
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)

View File

@@ -85,6 +85,11 @@ type dbServices struct {
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
scenarioFlags *services.ScenarioFlagsService
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder over the new
// normalised scenario shape (paliad.scenarios with owner_id +
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
scenarioBuilder *services.ScenarioBuilderService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,627 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-340 / m/paliad#153 B0 — REST endpoints over the new normalised
// scenario builder shape (paliad.scenarios with owner_id, +
// paliad.scenario_proceedings / scenario_events / scenario_shares).
//
// Endpoints live under /api/builder/scenarios/* to avoid clashing with
// the legacy /api/scenarios/* endpoints from m/paliad#124 Slice D. The
// B6 cleanup slice retires the legacy surface; until then both shapes
// coexist on the same paliad.scenarios table (the legacy paths require
// project_id IS NOT NULL OR an abstract created_by = caller; the builder
// paths require owner_id = caller).
//
// All handlers gate by requireScenarioBuilderService — 503 when the
// service is nil (DATABASE_URL unset). Auth is checked via requireUser;
// per-row visibility is enforced inside the service.
func requireScenarioBuilderService(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.scenarioBuilder == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Litigation-Builder ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return false
}
return true
}
// scenarioBuilderErrorToStatus maps service errors to HTTP statuses.
func scenarioBuilderErrorToStatus(err error) (int, string) {
switch {
case errors.Is(err, services.ErrScenarioBuilderNotVisible),
errors.Is(err, services.ErrNotVisible):
return http.StatusNotFound, "Szenario nicht gefunden"
case errors.Is(err, services.ErrInvalidInput):
return http.StatusBadRequest, err.Error()
}
return http.StatusInternalServerError, err.Error()
}
func writeBuilderError(w http.ResponseWriter, err error) {
status, msg := scenarioBuilderErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------------------------------------------------------------------------
// Scenario CRUD
// ---------------------------------------------------------------------------
// handleBuilderScenariosList — GET /api/builder/scenarios?status=<active|archived|promoted|all>
func handleBuilderScenariosList(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
status := r.URL.Query().Get("status")
out, err := dbSvc.scenarioBuilder.ListMyScenarios(r.Context(), uid, status)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioCreate — POST /api/builder/scenarios
func handleBuilderScenarioCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateBuilderScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.CreateScenario(r.Context(), uid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderScenarioGet — GET /api/builder/scenarios/{id}
func handleBuilderScenarioGet(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
out, err := dbSvc.scenarioBuilder.GetScenarioDeep(r.Context(), uid, id)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioPatch — PATCH /api/builder/scenarios/{id}
func handleBuilderScenarioPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
var input services.PatchBuilderScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchScenario(r.Context(), uid, id, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Proceedings
// ---------------------------------------------------------------------------
// handleBuilderProceedingCreate — POST /api/builder/scenarios/{id}/proceedings
func handleBuilderProceedingCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var input services.AddProceedingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddProceeding(r.Context(), uid, sid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderProceedingPatch — PATCH /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.PatchProceedingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchProceeding(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderProceedingDelete — DELETE /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteProceeding(r.Context(), uid, sid, pid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
// handleBuilderEventCreate — POST /api/builder/scenarios/{id}/proceedings/{pid}/events
func handleBuilderEventCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.AddEventInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddEvent(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderEventPatch — PATCH /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
var input services.PatchEventInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchEvent(r.Context(), uid, sid, eid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderEventDelete — DELETE /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteEvent(r.Context(), uid, sid, eid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Shares
// ---------------------------------------------------------------------------
// handleBuilderShareCreate — POST /api/builder/scenarios/{id}/shares
// Body: {"shared_with_user_id": "<uuid>"}
func handleBuilderShareCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var body struct {
SharedWithUserID uuid.UUID `json:"shared_with_user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddShare(r.Context(), uid, sid, body.SharedWithUserID)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderShareDelete — DELETE /api/builder/scenarios/{id}/shares/{sid}
func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
scid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
shid, err := uuid.Parse(r.PathValue("sid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Share-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteShare(r.Context(), uid, scid, shid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Scenario flag catalog passthrough (m/paliad#153 B2)
// ---------------------------------------------------------------------------
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
//
// Returns every row of paliad.scenario_flag_catalog so the Litigation
// Builder can render per-triplet flag toggles without a per-project
// round-trip. The catalog itself is global (no jurisdiction or
// proceeding scope baked into the table); which flags actually apply
// to a given proceeding type is decided by the calc engine via
// condition_expr at calculation time. The client renders every catalog
// flag and lets the user toggle them — flags with no effect on the
// active proceeding's rules simply have no condition_expr referencing
// them, so toggling is a no-op.
//
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
// visibility checks aren't needed because the catalog is global.
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.scenarioFlags == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
if _, ok := requireUser(w, r); !ok {
return
}
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Flag-Katalog konnte nicht geladen werden",
})
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Dev-only test route
// ---------------------------------------------------------------------------
// handleBuilderDevTestPage — GET /dev/scenario-builder
//
// Gated to services.PaliadinOwnerEmail (the same single-owner gate the
// /paliadin route uses). Every other authenticated user gets 404. Pure
// HTML — no JS bundle — so the page works even before B1 wires the real
// builder shell. Renders curl-equivalent forms for the B0 surface so the
// schema can be exercised end-to-end without Postman / shell scripts.
//
// This is the "dev-only test route" the head's task spec asked for. It
// disappears in B6 cleanup once the production builder UI ships at
// /tools/procedures.
func handleBuilderDevTestPage(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(builderDevTestHTML))
}
const builderDevTestHTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Scenario Builder — Dev Test (B0)</title>
<style>
body { font-family: ui-monospace, Menlo, monospace; max-width: 880px; margin: 2em auto;
padding: 0 1em; color: #222; background: #fafaf7; }
h1, h2 { font-family: ui-sans-serif, system-ui, sans-serif; }
h1 { border-bottom: 4px solid #c6f41c; padding-bottom: .2em; }
section { background: #fff; border: 1px solid #ddd; border-radius: 4px;
padding: 1em 1.2em; margin: 1em 0; }
label { display: block; margin: .4em 0 .15em; font-size: .85em; color: #555; }
input, textarea, select, button { font: inherit; padding: .35em .5em; box-sizing: border-box; }
input[type="text"], input[type="number"], textarea { width: 100%; }
button { background: #c6f41c; border: 1px solid #9ec61f; cursor: pointer;
padding: .4em 1em; border-radius: 3px; margin: .2em 0; }
button.secondary { background: #eee; border-color: #ccc; }
pre.out { background: #1e1e1e; color: #e6e6e6; padding: .8em 1em; border-radius: 4px;
overflow: auto; max-height: 30em; font-size: .85em; }
.note { color: #777; font-size: .9em; }
.row { display: flex; gap: .5em; }
.row > * { flex: 1; }
</style>
</head>
<body>
<h1>Scenario Builder — Dev Test (B0)</h1>
<p class="note">t-paliad-340 / m/paliad#153 — DB-only slice. Exercises
paliad.scenarios (builder rows), scenario_proceedings, scenario_events,
scenario_shares via /api/builder/scenarios/*. Gated to PaliadinOwnerEmail.</p>
<section>
<h2>1. Liste meine Szenarien</h2>
<label>Status filter</label>
<select id="list-status">
<option value="">(default: alle)</option>
<option value="active">active</option>
<option value="archived">archived</option>
<option value="promoted">promoted</option>
<option value="all">all (explicit)</option>
</select>
<button onclick="listScenarios()">GET /api/builder/scenarios</button>
<pre class="out" id="list-out"></pre>
</section>
<section>
<h2>2. Szenario anlegen</h2>
<label>Name</label>
<input type="text" id="create-name" placeholder="(leer = Unbenanntes Szenario)">
<label>Notes (optional)</label>
<textarea id="create-notes" rows="2"></textarea>
<button onclick="createScenario()">POST /api/builder/scenarios</button>
<pre class="out" id="create-out"></pre>
</section>
<section>
<h2>3. Szenario abrufen (deep)</h2>
<label>Scenario ID</label>
<input type="text" id="get-id">
<button onclick="getScenario()">GET /api/builder/scenarios/{id}</button>
<pre class="out" id="get-out"></pre>
</section>
<section>
<h2>4. Verfahren hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="proc-sid">
<label>proceeding_type_id (integer)</label>
<input type="number" id="proc-pt-id" placeholder="z.B. 7 für upc.inf.cfi">
<label>primary_party</label>
<select id="proc-party">
<option value="">(none)</option>
<option value="claimant">claimant</option>
<option value="defendant">defendant</option>
</select>
<button onclick="addProceeding()">POST .../proceedings</button>
<pre class="out" id="proc-out"></pre>
</section>
<section>
<h2>5. Event-Karte hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="ev-sid">
<label>Proceeding ID</label>
<input type="text" id="ev-pid">
<label>custom_label (oder sequencing_rule_id / procedural_event_id)</label>
<input type="text" id="ev-label" placeholder="freitext-Karte">
<label>state</label>
<select id="ev-state">
<option value="planned">planned</option>
<option value="filed">filed</option>
<option value="skipped">skipped</option>
</select>
<button onclick="addEvent()">POST .../proceedings/{pid}/events</button>
<pre class="out" id="ev-out"></pre>
</section>
<section>
<h2>6. Status patchen (archive / restore)</h2>
<label>Scenario ID</label>
<input type="text" id="patch-sid">
<label>new status</label>
<select id="patch-status">
<option value="active">active</option>
<option value="archived">archived</option>
</select>
<button onclick="patchStatus()">PATCH /api/builder/scenarios/{id}</button>
<pre class="out" id="patch-out"></pre>
</section>
<script>
const j = (id, payload) =>
document.getElementById(id).textContent = JSON.stringify(payload, null, 2);
async function call(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
const text = await r.text();
let parsed = text;
try { parsed = JSON.parse(text); } catch (_) {}
return { status: r.status, body: parsed };
}
async function listScenarios() {
const status = document.getElementById('list-status').value;
const q = status ? '?status=' + encodeURIComponent(status) : '';
j('list-out', await call('GET', '/api/builder/scenarios' + q));
}
async function createScenario() {
const name = document.getElementById('create-name').value;
const notes = document.getElementById('create-notes').value;
const body = {};
if (name) body.name = name;
if (notes) body.notes = notes;
j('create-out', await call('POST', '/api/builder/scenarios', body));
}
async function getScenario() {
const id = document.getElementById('get-id').value.trim();
if (!id) return j('get-out', { error: 'ID erforderlich' });
j('get-out', await call('GET', '/api/builder/scenarios/' + id));
}
async function addProceeding() {
const sid = document.getElementById('proc-sid').value.trim();
const ptID = parseInt(document.getElementById('proc-pt-id').value, 10);
const party = document.getElementById('proc-party').value;
if (!sid || !ptID) return j('proc-out', { error: 'sid + proceeding_type_id erforderlich' });
const body = { proceeding_type_id: ptID };
if (party) body.primary_party = party;
j('proc-out', await call('POST', '/api/builder/scenarios/' + sid + '/proceedings', body));
}
async function addEvent() {
const sid = document.getElementById('ev-sid').value.trim();
const pid = document.getElementById('ev-pid').value.trim();
const label = document.getElementById('ev-label').value.trim();
const state = document.getElementById('ev-state').value;
if (!sid || !pid || !label) return j('ev-out', { error: 'sid + pid + custom_label erforderlich' });
j('ev-out', await call('POST',
'/api/builder/scenarios/' + sid + '/proceedings/' + pid + '/events',
{ custom_label: label, state }));
}
async function patchStatus() {
const sid = document.getElementById('patch-sid').value.trim();
const status = document.getElementById('patch-status').value;
if (!sid) return j('patch-out', { error: 'sid erforderlich' });
j('patch-out', await call('PATCH', '/api/builder/scenarios/' + sid, { status }));
}
</script>
</body>
</html>`

View File

@@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
query := `
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,

View File

@@ -145,7 +145,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
AND pe.event_kind = $%d
)`, opts.EventKind)
}
query := `SELECT code, name, name_en, jurisdiction
query := `SELECT id, code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY sort_order`
@@ -160,7 +160,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
for rows.Next() {
var t lp.FristenrechnerType
var juris sql.NullString
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
return nil, err
}
if juris.Valid {

View File

@@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides(
}
var dRows []drow
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
FROM paliad.deadlines d
WHERE ` + scopeFilter
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {

View File

@@ -0,0 +1,944 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ScenarioBuilderService owns the t-paliad-340 / m/paliad#153 B0 surface
// — CRUD over the new normalised builder shape (paliad.scenarios with
// owner_id + status, paliad.scenario_proceedings, paliad.scenario_events,
// paliad.scenario_shares). The legacy spec-jsonb service
// (ScenarioService) keeps serving m/paliad#124 Slice D callers; this
// service strictly handles builder-owned rows (owner_id IS NOT NULL).
//
// Visibility is enforced both in code (the owner / share / can_see_project
// fall-through) and at the row level via the migration-157 RLS policies.
// The application-level check is the load-bearing one — the service
// connects with the service-role credential, which bypasses RLS.
type ScenarioBuilderService struct {
db *sqlx.DB
}
// NewScenarioBuilderService wires the service to the shared pool.
func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db}
}
// ErrScenarioBuilderNotVisible is returned when the caller is neither
// owner, an accepted share recipient, nor a global_admin / legacy
// editor for the scenario.
var ErrScenarioBuilderNotVisible = errors.New("scenario not visible to caller")
// -----------------------------------------------------------------------------
// Row types — flat shapes matching the table columns. Deep tree (scenario +
// proceedings + events) is composed at the GET-by-id endpoint.
// -----------------------------------------------------------------------------
// BuilderScenario is one paliad.scenarios row from the builder's perspective.
// Legacy columns (project_id, description, spec, created_by) are still
// returned so a UI can detect a legacy row and refuse to mutate it.
type BuilderScenario struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID *uuid.UUID `db:"owner_id" json:"owner_id,omitempty"`
Name string `db:"name" json:"name"`
Status string `db:"status" json:"status"`
OriginProjectID *uuid.UUID `db:"origin_project_id" json:"origin_project_id,omitempty"`
PromotedProjectID *uuid.UUID `db:"promoted_project_id" json:"promoted_project_id,omitempty"`
Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
LegacyProjectID *uuid.UUID `db:"project_id" json:"legacy_project_id,omitempty"`
LegacyDescription *string `db:"description" json:"legacy_description,omitempty"`
LegacyCreatedBy *uuid.UUID `db:"created_by" json:"legacy_created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// BuilderProceeding is one paliad.scenario_proceedings row.
type BuilderProceeding struct {
ID uuid.UUID `db:"id" json:"id"`
ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"`
ProceedingTypeID int `db:"proceeding_type_id" json:"proceeding_type_id"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
ScenarioFlags json.RawMessage `db:"scenario_flags" json:"scenario_flags"`
ParentScenarioProceedingID *uuid.UUID `db:"parent_scenario_proceeding_id" json:"parent_scenario_proceeding_id,omitempty"`
SpawnAnchorEventID *uuid.UUID `db:"spawn_anchor_event_id" json:"spawn_anchor_event_id,omitempty"`
Ordinal int `db:"ordinal" json:"ordinal"`
Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"`
Detailgrad string `db:"detailgrad" json:"detailgrad"`
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
Collapsed bool `db:"collapsed" json:"collapsed"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// BuilderEvent is one paliad.scenario_events row.
type BuilderEvent struct {
ID uuid.UUID `db:"id" json:"id"`
ScenarioProceedingID uuid.UUID `db:"scenario_proceeding_id" json:"scenario_proceeding_id"`
SequencingRuleID *uuid.UUID `db:"sequencing_rule_id" json:"sequencing_rule_id,omitempty"`
ProceduralEventID *uuid.UUID `db:"procedural_event_id" json:"procedural_event_id,omitempty"`
CustomLabel *string `db:"custom_label" json:"custom_label,omitempty"`
State string `db:"state" json:"state"`
ActualDate *time.Time `db:"actual_date" json:"actual_date,omitempty"`
SkipReason *string `db:"skip_reason" json:"skip_reason,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
HorizonOptional int `db:"horizon_optional" json:"horizon_optional"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// BuilderShare is one paliad.scenario_shares row.
type BuilderShare struct {
ID uuid.UUID `db:"id" json:"id"`
ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"`
SharedWithUserID uuid.UUID `db:"shared_with_user_id" json:"shared_with_user_id"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// BuilderScenarioDeep bundles a scenario with its proceedings + events
// for the GET /api/builder/scenarios/{id} response. Proceedings sort by
// ordinal asc; events sort by created_at asc within a proceeding.
type BuilderScenarioDeep struct {
BuilderScenario
Proceedings []BuilderProceeding `json:"proceedings"`
Events []BuilderEvent `json:"events"`
Shares []BuilderShare `json:"shares"`
}
// -----------------------------------------------------------------------------
// Scenario CRUD
// -----------------------------------------------------------------------------
// CreateBuilderScenarioInput is the POST /api/builder/scenarios body.
// Name defaults to "Unbenanntes Szenario" when blank (PRD §5.1).
type CreateBuilderScenarioInput struct {
Name string `json:"name,omitempty"`
Stichtag *time.Time `json:"stichtag,omitempty"`
Notes *string `json:"notes,omitempty"`
OriginProjectID *uuid.UUID `json:"origin_project_id,omitempty"`
}
// CreateScenario inserts a new builder-owned scenario. owner_id is set to
// the caller; status defaults to 'active'. Audit reason is set inside the
// write tx so any future audit trigger picks it up.
func (s *ScenarioBuilderService) CreateScenario(ctx context.Context, userID uuid.UUID, input CreateBuilderScenarioInput) (*BuilderScenario, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
name = "Unbenanntes Szenario"
}
var out BuilderScenario
err := s.withAuditTx(ctx, "scenario_builder: create scenario", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out,
`INSERT INTO paliad.scenarios
(owner_id, name, status, stichtag, notes, origin_project_id)
VALUES ($1, $2, 'active', $3, $4, $5)
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at`,
userID, name, input.Stichtag, input.Notes, input.OriginProjectID)
})
if err != nil {
return nil, fmt.Errorf("create builder scenario: %w", err)
}
return &out, nil
}
// ListMyScenarios returns the caller's owned scenarios filtered by status.
// Status "" (or "all") returns every status; otherwise filters by the
// given enum value. Sorted by updated_at desc.
func (s *ScenarioBuilderService) ListMyScenarios(ctx context.Context, userID uuid.UUID, status string) ([]BuilderScenario, error) {
switch status {
case "", "all":
// no filter
case "active", "archived", "promoted":
// ok
default:
return nil, fmt.Errorf("%w: status %q must be one of {active,archived,promoted,all}",
ErrInvalidInput, status)
}
q := `SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE owner_id = $1`
args := []any{userID}
if status != "" && status != "all" {
q += ` AND status = $2`
args = append(args, status)
}
q += ` ORDER BY updated_at DESC`
out := []BuilderScenario{}
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list builder scenarios: %w", err)
}
return out, nil
}
// GetScenarioDeep returns the scenario + proceedings + events + shares.
// Visibility: owner, share recipient, global_admin, or legacy editor.
func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenarioDeep, error) {
sc, err := s.getScenarioRow(ctx, scenarioID)
if err != nil {
return nil, err
}
visible, err := s.canSeeScenario(ctx, userID, sc)
if err != nil {
return nil, err
}
if !visible {
return nil, ErrScenarioBuilderNotVisible
}
deep := &BuilderScenarioDeep{
BuilderScenario: *sc,
// 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.
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf(clause, len(args)))
}
if input.PrimaryParty != nil {
switch *input.PrimaryParty {
case "claimant", "defendant", "":
default:
return nil, fmt.Errorf("%w: primary_party %q invalid", ErrInvalidInput, *input.PrimaryParty)
}
if *input.PrimaryParty == "" {
add("primary_party = $%d", nil)
} else {
add("primary_party = $%d", *input.PrimaryParty)
}
}
if len(input.ScenarioFlags) > 0 {
add("scenario_flags = $%d", []byte(input.ScenarioFlags))
}
if input.Ordinal != nil {
add("ordinal = $%d", *input.Ordinal)
}
if input.Stichtag != nil {
add("stichtag = $%d", *input.Stichtag)
}
if input.Detailgrad != nil {
switch *input.Detailgrad {
case "selected", "all_options":
default:
return nil, fmt.Errorf("%w: detailgrad %q invalid", ErrInvalidInput, *input.Detailgrad)
}
add("detailgrad = $%d", *input.Detailgrad)
}
if input.AppealTarget != nil {
if *input.AppealTarget == "" {
add("appeal_target = $%d", nil)
} else {
add("appeal_target = $%d", *input.AppealTarget)
}
}
if input.Collapsed != nil {
add("collapsed = $%d", *input.Collapsed)
}
if len(sets) == 0 {
// nothing to do — re-fetch and return.
return s.getProceedingRow(ctx, scenarioID, proceedingID)
}
args = append(args, proceedingID, scenarioID)
q := fmt.Sprintf(`UPDATE paliad.scenario_proceedings SET %s
WHERE id = $%d AND scenario_id = $%d
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed,
created_at, updated_at`,
strings.Join(sets, ", "), len(args)-1, len(args))
var out BuilderProceeding
err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
ErrNotVisible, proceedingID, scenarioID)
}
return nil, fmt.Errorf("patch proceeding: %w", err)
}
return &out, nil
}
// DeleteProceeding removes a proceeding (and cascades to events + children).
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return err
}
var n int64
err := s.withAuditTx(ctx, "scenario_builder: delete proceeding", func(tx *sqlx.Tx) error {
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.scenario_proceedings
WHERE id = $1 AND scenario_id = $2`,
proceedingID, scenarioID)
if err != nil {
return err
}
n, _ = res.RowsAffected()
return nil
})
if err != nil {
return fmt.Errorf("delete proceeding: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: proceeding %s not in scenario %s",
ErrNotVisible, proceedingID, scenarioID)
}
return nil
}
// -----------------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------------
// AddEventInput is the POST .../proceedings/{pid}/events body. At least
// one of {SequencingRuleID, ProceduralEventID, CustomLabel} must be set,
// matching the scenario_events_one_anchor CHECK constraint.
type AddEventInput struct {
SequencingRuleID *uuid.UUID `json:"sequencing_rule_id,omitempty"`
ProceduralEventID *uuid.UUID `json:"procedural_event_id,omitempty"`
CustomLabel *string `json:"custom_label,omitempty"`
State *string `json:"state,omitempty"`
ActualDate *time.Time `json:"actual_date,omitempty"`
SkipReason *string `json:"skip_reason,omitempty"`
Notes *string `json:"notes,omitempty"`
HorizonOptional *int `json:"horizon_optional,omitempty"`
}
// AddEvent inserts an event card under the given proceeding. The
// proceeding must belong to the addressed scenario.
func (s *ScenarioBuilderService) AddEvent(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input AddEventInput) (*BuilderEvent, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
if input.SequencingRuleID == nil && input.ProceduralEventID == nil &&
(input.CustomLabel == nil || strings.TrimSpace(*input.CustomLabel) == "") {
return nil, fmt.Errorf("%w: at least one of sequencing_rule_id, procedural_event_id, custom_label must be set",
ErrInvalidInput)
}
if err := s.assertProceedingInScenario(ctx, scenarioID, proceedingID); err != nil {
return nil, err
}
state := "planned"
if input.State != nil {
switch *input.State {
case "planned", "filed", "skipped":
state = *input.State
default:
return nil, fmt.Errorf("%w: state %q must be one of {planned,filed,skipped}",
ErrInvalidInput, *input.State)
}
}
horizon := 0
if input.HorizonOptional != nil {
if *input.HorizonOptional < 0 {
return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
}
horizon = *input.HorizonOptional
}
var out BuilderEvent
err := s.withAuditTx(ctx, "scenario_builder: add event", func(tx *sqlx.Tx) error {
if err := tx.GetContext(ctx, &out,
`INSERT INTO paliad.scenario_events
(scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes, horizon_optional)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes,
horizon_optional, created_at, updated_at`,
proceedingID, input.SequencingRuleID, input.ProceduralEventID,
input.CustomLabel, state, input.ActualDate, input.SkipReason,
input.Notes, horizon); err != nil {
return err
}
_, err := tx.ExecContext(ctx,
`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
return err
})
if err != nil {
return nil, fmt.Errorf("add event: %w", err)
}
return &out, nil
}
// PatchEventInput is the PATCH body for an event card.
type PatchEventInput struct {
State *string `json:"state,omitempty"`
ActualDate *time.Time `json:"actual_date,omitempty"`
SkipReason *string `json:"skip_reason,omitempty"`
Notes *string `json:"notes,omitempty"`
HorizonOptional *int `json:"horizon_optional,omitempty"`
}
// PatchEvent updates fields on one event card. The card's parent
// proceeding must belong to the addressed scenario.
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf(clause, len(args)))
}
if input.State != nil {
switch *input.State {
case "planned", "filed", "skipped":
default:
return nil, fmt.Errorf("%w: state %q invalid", ErrInvalidInput, *input.State)
}
add("state = $%d", *input.State)
}
if input.ActualDate != nil {
add("actual_date = $%d", *input.ActualDate)
}
if input.SkipReason != nil {
add("skip_reason = $%d", *input.SkipReason)
}
if input.Notes != nil {
add("notes = $%d", *input.Notes)
}
if input.HorizonOptional != nil {
if *input.HorizonOptional < 0 {
return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
}
add("horizon_optional = $%d", *input.HorizonOptional)
}
if len(sets) == 0 {
return s.getEventRow(ctx, eventID)
}
args = append(args, eventID)
q := fmt.Sprintf(`UPDATE paliad.scenario_events SET %s
WHERE id = $%d
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes,
horizon_optional, created_at, updated_at`,
strings.Join(sets, ", "), len(args))
var out BuilderEvent
err := s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
})
if err != nil {
return nil, fmt.Errorf("patch event: %w", err)
}
return &out, nil
}
// DeleteEvent removes one event card.
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return err
}
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
return err
}
err := s.withAuditTx(ctx, "scenario_builder: delete event", func(tx *sqlx.Tx) error {
_, err := tx.ExecContext(ctx,
`DELETE FROM paliad.scenario_events WHERE id = $1`, eventID)
return err
})
if err != nil {
return fmt.Errorf("delete event: %w", err)
}
return nil
}
// -----------------------------------------------------------------------------
// Shares
// -----------------------------------------------------------------------------
// AddShare grants read-only access to another paliad user.
func (s *ScenarioBuilderService) AddShare(ctx context.Context, userID, scenarioID, recipientID uuid.UUID) (*BuilderShare, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
if recipientID == uuid.Nil {
return nil, fmt.Errorf("%w: shared_with_user_id is required", ErrInvalidInput)
}
if recipientID == userID {
return nil, fmt.Errorf("%w: cannot share a scenario with yourself", ErrInvalidInput)
}
var out BuilderShare
err := s.withAuditTx(ctx, "scenario_builder: add share", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out,
`INSERT INTO paliad.scenario_shares (scenario_id, shared_with_user_id, created_by)
VALUES ($1, $2, $3)
ON CONFLICT (scenario_id, shared_with_user_id) DO UPDATE
SET created_at = paliad.scenario_shares.created_at
RETURNING id, scenario_id, shared_with_user_id, created_by, created_at`,
scenarioID, recipientID, userID)
})
if err != nil {
return nil, fmt.Errorf("add share: %w", err)
}
return &out, nil
}
// DeleteShare revokes a share row.
func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenarioID, shareID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return err
}
var n int64
err := s.withAuditTx(ctx, "scenario_builder: delete share", func(tx *sqlx.Tx) error {
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.scenario_shares
WHERE id = $1 AND scenario_id = $2`, shareID, scenarioID)
if err != nil {
return err
}
n, _ = res.RowsAffected()
return nil
})
if err != nil {
return fmt.Errorf("delete share: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: share %s not in scenario %s", ErrNotVisible, shareID, scenarioID)
}
return nil
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------
func (s *ScenarioBuilderService) getScenarioRow(ctx context.Context, scenarioID uuid.UUID) (*BuilderScenario, error) {
var out BuilderScenario
err := s.db.GetContext(ctx, &out,
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE id = $1`, scenarioID)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: scenario %s not found", ErrNotVisible, scenarioID)
}
if err != nil {
return nil, fmt.Errorf("get scenario: %w", err)
}
return &out, nil
}
func (s *ScenarioBuilderService) getProceedingRow(ctx context.Context, scenarioID, proceedingID uuid.UUID) (*BuilderProceeding, error) {
var out BuilderProceeding
err := s.db.GetContext(ctx, &out,
`SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed,
created_at, updated_at
FROM paliad.scenario_proceedings
WHERE id = $1 AND scenario_id = $2`, proceedingID, scenarioID)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
ErrNotVisible, proceedingID, scenarioID)
}
if err != nil {
return nil, fmt.Errorf("get proceeding: %w", err)
}
return &out, nil
}
func (s *ScenarioBuilderService) getEventRow(ctx context.Context, eventID uuid.UUID) (*BuilderEvent, error) {
var out BuilderEvent
err := s.db.GetContext(ctx, &out,
`SELECT id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes,
horizon_optional, created_at, updated_at
FROM paliad.scenario_events
WHERE id = $1`, eventID)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: event %s not found", ErrNotVisible, eventID)
}
if err != nil {
return nil, fmt.Errorf("get event: %w", err)
}
return &out, nil
}
func (s *ScenarioBuilderService) assertProceedingInScenario(ctx context.Context, scenarioID, proceedingID uuid.UUID) error {
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS (SELECT 1 FROM paliad.scenario_proceedings
WHERE id = $1 AND scenario_id = $2)`,
proceedingID, scenarioID); err != nil {
return fmt.Errorf("check proceeding membership: %w", err)
}
if !exists {
return fmt.Errorf("%w: proceeding %s not in scenario %s",
ErrNotVisible, proceedingID, scenarioID)
}
return nil
}
func (s *ScenarioBuilderService) assertEventInScenario(ctx context.Context, scenarioID, eventID uuid.UUID) error {
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS (
SELECT 1 FROM paliad.scenario_events e
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
WHERE e.id = $1 AND sp.scenario_id = $2
)`,
eventID, scenarioID); err != nil {
return fmt.Errorf("check event membership: %w", err)
}
if !exists {
return fmt.Errorf("%w: event %s not in scenario %s",
ErrNotVisible, eventID, scenarioID)
}
return nil
}
// canSeeScenario mirrors the SQL paliad.can_see_scenario(...) function in
// Go. The service connection bypasses RLS, so this check is the
// authoritative gate.
func (s *ScenarioBuilderService) canSeeScenario(ctx context.Context, userID uuid.UUID, sc *BuilderScenario) (bool, error) {
// owner — fast path
if sc.OwnerID != nil && *sc.OwnerID == userID {
return true, nil
}
// global_admin
var isAdmin bool
if err := s.db.GetContext(ctx, &isAdmin,
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1 AND global_role = 'global_admin')`,
userID); err != nil {
return false, fmt.Errorf("check global_admin: %w", err)
}
if isAdmin {
return true, nil
}
// share recipient
var shared bool
if err := s.db.GetContext(ctx, &shared,
`SELECT EXISTS (SELECT 1 FROM paliad.scenario_shares
WHERE scenario_id = $1 AND shared_with_user_id = $2)`,
sc.ID, userID); err != nil {
return false, fmt.Errorf("check share: %w", err)
}
if shared {
return true, nil
}
// legacy project-scoped — visible via project team membership
if sc.OwnerID == nil && sc.LegacyProjectID != nil {
var ok bool
if err := s.db.GetContext(ctx, &ok,
`SELECT paliad.can_see_project($1::uuid)`,
*sc.LegacyProjectID); err == nil && ok {
return true, nil
}
}
// legacy abstract — owner-only via created_by
if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
*sc.LegacyCreatedBy == userID {
return true, nil
}
return false, nil
}
// requireOwnerOrLegacyEditor fetches the scenario and validates that the
// caller has write rights. Returns the loaded row for downstream use.
func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenario, error) {
sc, err := s.getScenarioRow(ctx, scenarioID)
if err != nil {
return nil, err
}
// owner
if sc.OwnerID != nil && *sc.OwnerID == userID {
return sc, nil
}
// legacy project-scoped editor
if sc.OwnerID == nil && sc.LegacyProjectID != nil {
var ok bool
if err := s.db.GetContext(ctx, &ok,
`SELECT paliad.can_see_project($1::uuid)`,
*sc.LegacyProjectID); err == nil && ok {
return sc, nil
}
}
// legacy abstract creator
if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
*sc.LegacyCreatedBy == userID {
return sc, nil
}
return nil, ErrScenarioBuilderNotVisible
}
// withAuditTx opens a transaction, stamps paliad.audit_reason via
// set_config(..., true) so the reason persists for the duration of the
// tx (matching the mig-079 audit-trigger pattern used by event_choice_
// service.go), invokes fn, and commits. Any error returned by fn rolls
// back. The audit reason is appended with the task slug so audit-log
// readers can trace writes back to t-paliad-340.
func (s *ScenarioBuilderService) withAuditTx(ctx context.Context, reason string, fn func(tx *sqlx.Tx) error) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason', $1, true)`,
fmt.Sprintf("%s (t-paliad-340)", reason)); err != nil {
return fmt.Errorf("set audit_reason: %w", err)
}
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}

View File

@@ -0,0 +1,220 @@
package services
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestScenarioBuilderService exercises the t-paliad-340 / m/paliad#153 B0
// surface end-to-end against a live DB: create + list + deep-get + patch
// + add-proceeding + add-event + add/delete-share, plus the visibility
// negative case (a non-owner can't see the scenario unless shared).
//
// Skipped without TEST_DATABASE_URL — matches the pattern in
// project_service_test.go / event_choice_service_test.go.
func TestScenarioBuilderService(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
owner := uuid.New()
other := uuid.New()
cleanup := func() {
// Cascade order: delete from scenarios → CASCADE clears
// proceedings, events, shares. Then the two users.
pool.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE owner_id IN ($1, $2)`, owner, other)
pool.ExecContext(ctx,
`DELETE FROM paliad.users WHERE id IN ($1, $2)`, owner, other)
pool.ExecContext(ctx,
`DELETE FROM auth.users WHERE id IN ($1, $2)`, owner, other)
}
cleanup()
defer cleanup()
for _, seed := range []struct {
id uuid.UUID
email string
name string
}{
{owner, "builder-owner-test@hlc.com", "Builder Owner"},
{other, "builder-other-test@hlc.com", "Builder Other"},
} {
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
seed.id, seed.email); err != nil {
t.Fatalf("seed auth.users %s: %v", seed.email, err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, lang)
VALUES ($1, $2, $3, 'munich', 'de')`,
seed.id, seed.email, seed.name); err != nil {
t.Fatalf("seed paliad.users %s: %v", seed.email, err)
}
}
// Pick a real proceeding_type_id so the FK insert succeeds.
var ptID int
if err := pool.GetContext(ctx, &ptID,
`SELECT id FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true
LIMIT 1`, CodeUPCInfringement); err != nil {
t.Fatalf("look up upc.inf id: %v", err)
}
svc := NewScenarioBuilderService(pool)
// 1. Create a scenario for the owner. Empty name should default.
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
if err != nil {
t.Fatalf("CreateScenario: %v", err)
}
if sc.Name != "Unbenanntes Szenario" {
t.Errorf("default name = %q, want %q", sc.Name, "Unbenanntes Szenario")
}
if sc.Status != "active" {
t.Errorf("default status = %q, want active", sc.Status)
}
if sc.OwnerID == nil || *sc.OwnerID != owner {
t.Errorf("owner_id = %v, want %v", sc.OwnerID, owner)
}
// 2. List — should return the one row.
list, err := svc.ListMyScenarios(ctx, owner, "active")
if err != nil {
t.Fatalf("ListMyScenarios: %v", err)
}
if len(list) != 1 || list[0].ID != sc.ID {
t.Errorf("ListMyScenarios returned %d rows; want 1 with id %s", len(list), sc.ID)
}
// 3. Other user can NOT see the scenario.
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("GetScenarioDeep by non-owner = %v, want ErrScenarioBuilderNotVisible", err)
}
// 4. Add a proceeding.
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
ProceedingTypeID: ptID,
PrimaryParty: ptrString("defendant"),
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
})
if err != nil {
t.Fatalf("AddProceeding: %v", err)
}
if pr.ProceedingTypeID != ptID {
t.Errorf("ProceedingTypeID = %d, want %d", pr.ProceedingTypeID, ptID)
}
if pr.PrimaryParty == nil || *pr.PrimaryParty != "defendant" {
t.Errorf("PrimaryParty = %v, want defendant", pr.PrimaryParty)
}
// 5. Add a custom-label event card.
ev, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
CustomLabel: ptrString("Klageerwiderung"),
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent: %v", err)
}
if ev.State != "planned" {
t.Errorf("event state = %q, want planned", ev.State)
}
// 5b. Add-event with NO anchor fields fails.
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("AddEvent without anchor = %v, want ErrInvalidInput", err)
}
// 6. Deep get — should bundle the scenario + 1 proceeding + 1 event + 0 shares.
deep, err := svc.GetScenarioDeep(ctx, owner, sc.ID)
if err != nil {
t.Fatalf("GetScenarioDeep: %v", err)
}
if len(deep.Proceedings) != 1 || deep.Proceedings[0].ID != pr.ID {
t.Errorf("deep proceedings count=%d want 1; ids: %+v", len(deep.Proceedings), deep.Proceedings)
}
if len(deep.Events) != 1 || deep.Events[0].ID != ev.ID {
t.Errorf("deep events count=%d want 1; ids: %+v", len(deep.Events), deep.Events)
}
if len(deep.Shares) != 0 {
t.Errorf("deep shares count=%d want 0", len(deep.Shares))
}
// 7. Share with `other`. Recipient should now see the scenario.
sh, err := svc.AddShare(ctx, owner, sc.ID, other)
if err != nil {
t.Fatalf("AddShare: %v", err)
}
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); err != nil {
t.Errorf("GetScenarioDeep by share recipient: %v", err)
}
// But the recipient can NOT add proceedings.
if _, err := svc.AddProceeding(ctx, other, sc.ID, AddProceedingInput{
ProceedingTypeID: ptID,
}); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("AddProceeding by share recipient = %v, want ErrScenarioBuilderNotVisible", err)
}
// 7b. Self-share should be rejected.
if _, err := svc.AddShare(ctx, owner, sc.ID, owner); !errors.Is(err, ErrInvalidInput) {
t.Errorf("self-share = %v, want ErrInvalidInput", err)
}
// 8. Patch — archive then re-activate.
patched, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("archived"),
})
if err != nil {
t.Fatalf("PatchScenario archive: %v", err)
}
if patched.Status != "archived" {
t.Errorf("after archive, status = %q, want archived", patched.Status)
}
// PATCH to 'promoted' is rejected — that's the wizard's job.
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("promoted"),
}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("PATCH status=promoted = %v, want ErrInvalidInput", err)
}
patched, err = svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("active"),
})
if err != nil {
t.Fatalf("PatchScenario re-activate: %v", err)
}
if patched.Status != "active" {
t.Errorf("after re-activate, status = %q, want active", patched.Status)
}
// 9. Revoke the share. Recipient loses visibility.
if err := svc.DeleteShare(ctx, owner, sc.ID, sh.ID); err != nil {
t.Fatalf("DeleteShare: %v", err)
}
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("after revoke, recipient GetScenarioDeep = %v, want ErrScenarioBuilderNotVisible", err)
}
}
// (Note: ptrString lives in rule_editor_service_test.go in this package
// and is reused here. No second declaration needed.)

View File

@@ -405,6 +405,23 @@ func parseInlineSpans(text string) []inlineSpan {
i := 0
n := len(text)
for i < n {
// Preserve {{...}} placeholders verbatim. Underscores and
// other Markdown-significant chars inside a placeholder key
// (e.g. {{project.case_number}}) must not be interpreted as
// bold/italic delimiters — otherwise the key gets stripped of
// its underscores and the v1 placeholder pass looks up the
// wrong key, surfacing [KEIN WERT: project.casenumber] in the
// preview.
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
rel := strings.Index(text[i+2:], "}}")
if rel >= 0 {
end := i + 2 + rel + 2
cur.WriteString(text[i:end])
i = end
continue
}
// Unmatched {{ — fall through to plain character handling.
}
// Bold delimiters first (longer match wins over italic).
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
flush()

View File

@@ -86,6 +86,90 @@ func TestRenderMarkdownToOOXML_PlaceholdersPassThrough(t *testing.T) {
}
}
func TestRenderMarkdownToOOXML_PlaceholderUnderscoresPreserved(t *testing.T) {
// Regression: a placeholder key containing underscores (project.case_number,
// user.display_name, project.patent_number_upc) used to get its underscores
// consumed by the italic/bold inline scanner — the OOXML stored
// {{project.casenumber}} and the preview surfaced
// [KEIN WERT: project.casenumber] instead of the real value.
cases := []string{
"{{project.case_number}}",
"{{user.display_name}}",
"{{project.patent_number_upc}}",
"prefix {{project.case_number}} suffix",
"two: {{a.b_c}} and {{d.e_f}}",
"mixed: _italic_ then {{project.case_number}} then __bold__",
}
for _, in := range cases {
out := RenderMarkdownToOOXML(in, "Normal")
// Every placeholder substring in the input must appear verbatim
// in the output (XML escaping is irrelevant for {} and _).
for _, ph := range extractPlaceholders(in) {
if !strings.Contains(out, ph) {
t.Errorf("input %q: placeholder %q lost; got %q", in, ph, out)
}
}
}
}
func TestParseInlineSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
// Direct guard on the inline scanner. {{project.case_number}} must
// emit as a single non-italic span containing the full placeholder.
spans := parseInlineSpans("{{project.case_number}}")
if len(spans) != 1 {
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
}
if spans[0].Italic || spans[0].Bold {
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
}
if spans[0].Text != "{{project.case_number}}" {
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
}
}
func TestParseInlineSpans_ItalicAroundPlaceholder(t *testing.T) {
// Italic delimiters outside a placeholder still work; the placeholder
// itself stays literal even when it sits between italics.
spans := parseInlineSpans("_before_ {{x.y_z}} _after_")
var saw struct {
italicBefore bool
placeholder bool
italicAfter bool
}
for _, s := range spans {
if s.Italic && s.Text == "before" {
saw.italicBefore = true
}
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
saw.placeholder = true
}
if s.Italic && s.Text == "after" {
saw.italicAfter = true
}
}
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
}
}
// extractPlaceholders pulls every {{...}} occurrence out of a Markdown
// source. Tiny helper, only used by the regression test above.
func extractPlaceholders(s string) []string {
var out []string
for {
start := strings.Index(s, "{{")
if start < 0 {
return out
}
end := strings.Index(s[start+2:], "}}")
if end < 0 {
return out
}
out = append(out, s[start:start+2+end+2])
s = s[start+2+end+2:]
}
}
func TestRenderMarkdownToOOXML_XMLEscape(t *testing.T) {
out := RenderMarkdownToOOXML("a & b < c > d", "")
if strings.Contains(out, " & ") {

View File

@@ -205,7 +205,11 @@ func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T)
cat := &stubCatalog{pt: pt, rules: rules}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
// IncludeOptional=true because translation_request carries
// priority='optional'; the test exercises the before-child-of-
// court-set-parent flow, which is orthogonal to the optional-rule
// suppression added in t-paliad-342.
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
@@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
cat := &stubCatalog{pt: pt, rules: rules}
// User pins the oral hearing to 2026-10-15.
// User pins the oral hearing to 2026-10-15. IncludeOptional=true
// because translation_request is priority='optional' (t-paliad-342).
opts := CalcOptions{
IncludeOptional: true,
AnchorOverrides: map[string]string{
oralCode: "2026-10-15",
},

View File

@@ -80,6 +80,21 @@ func Calculate(
overrideDates[code] = od
}
// Trigger-event anchors keyed by paliad.trigger_events.code
// (t-paliad-342). Parsed up-front so malformed dates error before
// the rule walk. When a rule has trigger_event_id set, the engine
// looks up triggerAnchorByCode[trigger_event.code] for the
// semantic anchor instead of falling back to the proceeding's
// trigger date.
triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors))
for code, dateStr := range opts.TriggerEventAnchors {
td, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err)
}
triggerAnchorByCode[code] = td
}
// Look up proceeding type metadata.
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
if err != nil {
@@ -213,6 +228,7 @@ func Calculate(
perCardAppellant := opts.PerCardAppellant
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
hiddenCount := 0
rulesAwaitingAnchor := 0
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range walkRules {
@@ -227,6 +243,17 @@ func Calculate(
continue
}
// Optional-rule suppression (t-paliad-342 / youpcorg#2570).
// Rules tagged priority='optional' don't auto-fire in the
// default timeline; the caller opts in via
// CalcOptions.IncludeOptional. Cascade through skippedIDs so
// children chaining off the suppressed rule also drop — they
// can't compute a date against a missing parent.
if r.Priority == "optional" && !opts.IncludeOptional {
skippedIDs[r.ID] = struct{}{}
continue
}
// SkipRules suppression (t-paliad-265).
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
// we re-surface the directly-skipped row (faded via IsHidden)
@@ -327,15 +354,43 @@ func Calculate(
// (m/paliad#126 / t-paliad-294). When a rule has a real
// trigger_event_id, that catalog event is the actual semantic
// anchor — not the parent_id node, which is only the calc-time
// arithmetic anchor. Only the user-facing wire fields shift;
// parentRule (and the parent_id chain feeding parentIsCourtSet
// and the calc-time arithmetic below) stays anchored on the
// rule tree.
// arithmetic anchor. Only the user-facing wire fields shift
// here; the calc-time anchor logic for trigger_event_id rules
// lives just below.
var triggerEventAnchor time.Time
var hasTriggerEventAnchor bool
if r.TriggerEventID != nil {
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
d.ParentRuleCode = te.Code
d.ParentRuleName = te.NameDE
d.ParentRuleNameEN = te.Name
if td, ok := triggerAnchorByCode[te.Code]; ok {
triggerEventAnchor = td
hasTriggerEventAnchor = true
}
}
// Trigger-event semantic-anchor suppression (t-paliad-342 /
// youpcorg#2568). When a rule has an explicit trigger_event_id
// but the caller hasn't supplied a date for that event via
// CalcOptions.TriggerEventAnchors, the engine refuses to
// fabricate a date off the proceeding's trigger date — the
// rule's semantic anchor is the event itself, not the SoC.
// Render IsConditional with empty dates and propagate via
// courtSet so descendants chaining off this rule also surface
// as conditional rather than projecting fictional dates.
if !hasTriggerEventAnchor {
d.IsConditional = true
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
rulesAwaitingAnchor++
if r.SubmissionCode != nil {
skippedIDs[r.ID] = struct{}{}
}
deadlines = append(deadlines, d)
continue
}
}
@@ -379,6 +434,20 @@ func Calculate(
}
}
// Trigger-event anchor wins over the bucket logic below: a
// zero-duration rule with trigger_event_id is "occurs on the
// trigger event's date". Anchor missing was already caught
// above (suppression branch).
if hasTriggerEventAnchor {
d.DueDate = triggerEventAnchor.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = triggerEventAnchor
}
deadlines = append(deadlines, d)
continue
}
if r.ParentID == nil && !r.IsCourtSet {
// Bucket 1: timeline anchor.
d.IsRootEvent = true
@@ -457,11 +526,19 @@ func Calculate(
continue
}
// Anchor: prefer alt-anchor (e.g. priority_date for
// epa.grant.exa publish) when supplied, then parent's computed
// date (or user override), then trigger date.
// Anchor priority:
// 1. trigger_event_id semantic anchor (t-paliad-342) — when
// the rule has trigger_event_id and the caller supplied a
// date in TriggerEventAnchors, that date wins over the
// parent chain AND the priority_date alt-anchor. The
// missing-anchor case was already short-circuited above.
// 2. priority_date alt-anchor (epa.grant.exa publish).
// 3. parent's computed date (or user override).
// 4. proceeding trigger date (default fallback).
baseDate := triggerDate
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
if hasTriggerEventAnchor {
baseDate = triggerEventAnchor
} else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
baseDate = *priorityDate
} else if r.ParentID != nil {
for _, prev := range rules {
@@ -635,12 +712,13 @@ func Calculate(
}
resp := &Timeline{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
RulesAwaitingAnchor: rulesAwaitingAnchor,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding`.

View File

@@ -0,0 +1,379 @@
package litigationplanner
import (
"context"
"testing"
"github.com/google/uuid"
)
// Tests for t-paliad-342 / youpcorg#2568 + #2570.
//
// Two paired engine semantics:
//
// - Optional rules (priority='optional') don't auto-fire in the
// default timeline; the caller opts in via
// CalcOptions.IncludeOptional.
// - Rules with explicit trigger_event_id anchor on the trigger
// event's date (CalcOptions.TriggerEventAnchors keyed by
// trigger_events.code). Missing anchor = render conditional
// instead of fabricating a date off the proceeding's trigger date.
// stubCatalogWithTriggers extends stubCatalog with a trigger-events
// map so the engine can resolve TriggerEventID → code for the
// trigger-anchor branch. stubCatalog from before_court_set_anchor_test.go
// returns an empty map, which suffices for tests that don't exercise
// trigger_event_id; here we need real entries.
type stubCatalogWithTriggers struct {
stubCatalog
triggerEvents map[int64]TriggerEvent
}
func (s *stubCatalogWithTriggers) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]TriggerEvent, error) {
out := make(map[int64]TriggerEvent, len(ids))
for _, id := range ids {
if te, ok := s.triggerEvents[id]; ok {
out[id] = te
}
}
return out, nil
}
// mandatory_socRule builds a minimal SoC root rule + the proceeding
// type wrapper that nearly every test below needs.
func mandatorySocFixture(t *testing.T) (ProceedingType, Rule, uuid.UUID) {
t.Helper()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
Jurisdiction: &jurisdiction,
IsActive: true,
}
socID, _ := uuid.NewRandom()
socCode := "upc.inf.cfi.soc"
procIDPtr := &procID
str := func(s string) *string { return &s }
soc := Rule{
ID: socID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &socCode,
Name: "Klageerhebung",
NameEN: "SoC",
PrimaryParty: str("claimant"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
}
return pt, soc, socID
}
// TestCalculate_TriggerEventIDWithoutAnchor_Suppressed verifies the
// RoP.109.5 bug from youpcorg#2568: a rule with trigger_event_id and
// no parent_id must NOT fall back to the proceeding's trigger date.
// The buggy behaviour rendered the rule with a fabricated date 2 weeks
// before the user's SoC date.
func TestCalculate_TriggerEventIDWithoutAnchor_Suppressed(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
ruleID, _ := uuid.NewRandom()
ruleCode := "upc.inf.cfi.rop_109_5"
rop109_5Trigger := int64(49)
rop109_5 := Rule{
ID: ruleID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &ruleCode,
Name: "Vorbereitung mündliche Verhandlung",
NameEN: "Oral hearing preparation",
PrimaryParty: str("both"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 100,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
TriggerEventID: &rop109_5Trigger,
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
triggerEvents: map[int64]TriggerEvent{
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
rop, ok := byCode[ruleCode]
if !ok {
t.Fatalf("RoP.109.5 missing from timeline; want present with conditional-no-date")
}
if rop.DueDate != "" {
t.Errorf("RoP.109.5: DueDate=%q, want empty (no oral_hearing anchor supplied)", rop.DueDate)
}
if !rop.IsConditional {
t.Errorf("RoP.109.5: IsConditional=%v, want true", rop.IsConditional)
}
if timeline.RulesAwaitingAnchor != 1 {
t.Errorf("RulesAwaitingAnchor=%d, want 1", timeline.RulesAwaitingAnchor)
}
}
// TestCalculate_TriggerEventIDWithAnchor_Renders verifies the
// caller-supplied trigger-event anchor produces correct arithmetic.
// 2 weeks before 2026-10-15 = 2026-10-01.
func TestCalculate_TriggerEventIDWithAnchor_Renders(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
ruleID, _ := uuid.NewRandom()
ruleCode := "upc.inf.cfi.rop_109_5"
rop109_5Trigger := int64(49)
rop109_5 := Rule{
ID: ruleID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &ruleCode,
Name: "Vorbereitung mündliche Verhandlung",
NameEN: "Oral hearing preparation",
PrimaryParty: str("both"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 100,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
TriggerEventID: &rop109_5Trigger,
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
triggerEvents: map[int64]TriggerEvent{
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
},
}
opts := CalcOptions{
TriggerEventAnchors: map[string]string{
"oral_hearing": "2026-10-15",
},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
rop := byCode[ruleCode]
if rop.DueDate != "2026-10-01" {
t.Errorf("RoP.109.5: DueDate=%q, want 2026-10-01 (2 weeks before oral_hearing 2026-10-15)", rop.DueDate)
}
if rop.IsConditional {
t.Errorf("RoP.109.5: IsConditional=%v, want false (anchor supplied)", rop.IsConditional)
}
if timeline.RulesAwaitingAnchor != 0 {
t.Errorf("RulesAwaitingAnchor=%d, want 0", timeline.RulesAwaitingAnchor)
}
}
// TestCalculate_MandatoryRule_RendersByDefault is the control case for
// the optional-suppression fix: mandatory rules render with their
// computed dates by default. Prevents regression where the optional
// filter accidentally drops mandatory rules too.
func TestCalculate_MandatoryRule_RendersByDefault(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
replyID, _ := uuid.NewRandom()
replyCode := "upc.inf.cfi.reply"
reply := Rule{
ID: replyID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &replyCode,
Name: "Klageerwiderung",
NameEN: "Reply to SoC",
PrimaryParty: str("defendant"),
DurationValue: 3,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 10,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, reply}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
got, ok := byCode[replyCode]
if !ok {
t.Fatalf("mandatory reply rule missing from default timeline")
}
if got.DueDate != "2026-08-26" {
t.Errorf("reply: DueDate=%q, want 2026-08-26 (3 months after SoC)", got.DueDate)
}
}
// TestCalculate_OptionalRule_SuppressedByDefault pins the
// youpcorg#2570 fix: priority='optional' rules don't render in the
// default timeline. The caller opts in via IncludeOptional=true.
func TestCalculate_OptionalRule_SuppressedByDefault(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
confID, _ := uuid.NewRandom()
confCode := "upc.inf.cfi.rop_262_2"
conf := Rule{
ID: confID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &confCode,
Name: "Erwiderung Vertraulichkeitsantrag",
NameEN: "Reply to confidentiality motion",
PrimaryParty: str("both"),
DurationValue: 14,
DurationUnit: "days",
Timing: str("after"),
SequenceOrder: 20,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
for _, d := range timeline.Deadlines {
if d.Code == confCode {
t.Errorf("optional R.262(2) rule rendered in default timeline (DueDate=%q); want suppressed", d.DueDate)
}
}
}
// TestCalculate_OptionalRule_RendersWithIncludeOptional verifies the
// opt-in path: when the caller passes IncludeOptional=true, optional
// rules show up in the timeline with their computed dates.
func TestCalculate_OptionalRule_RendersWithIncludeOptional(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
confID, _ := uuid.NewRandom()
confCode := "upc.inf.cfi.rop_262_2"
conf := Rule{
ID: confID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &confCode,
Name: "Erwiderung Vertraulichkeitsantrag",
NameEN: "Reply to confidentiality motion",
PrimaryParty: str("both"),
DurationValue: 14,
DurationUnit: "days",
Timing: str("after"),
SequenceOrder: 20,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
got, ok := byCode[confCode]
if !ok {
t.Fatalf("optional R.262(2) missing from timeline despite IncludeOptional=true")
}
// R.262(2) is the "optional opposing-side" pattern (priority=optional,
// primary_party=both, parent=SoC root) — the engine renders this as
// IsConditional (no concrete date) per the t-paliad-289 logic
// preserved in the walk. The point of this test is that the rule
// is no longer suppressed wholesale by the t-paliad-342 default —
// it surfaces, just with the conditional-render UX.
if !got.IsConditional {
t.Errorf("R.262(2): IsConditional=%v, want true (optional opposing-side, no real trigger recorded)", got.IsConditional)
}
}
// TestCalculate_TriggerEventID_MalformedAnchor_Errors ensures
// malformed dates in TriggerEventAnchors fail fast at the top of the
// engine, before any rule walking — same protocol as AnchorOverrides.
func TestCalculate_TriggerEventID_MalformedAnchor_Errors(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc}},
}
opts := CalcOptions{
TriggerEventAnchors: map[string]string{
"oral_hearing": "15-10-2026", // wrong format
},
}
_, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err == nil {
t.Fatalf("Calculate: want error on malformed TriggerEventAnchors date, got nil")
}
}

View File

@@ -334,6 +334,25 @@ type CalcOptions struct {
// filter applied) so a stale frontend chip doesn't break the
// timeline render — see IsValidAppealTarget.
AppealTarget string
// IncludeOptional surfaces rules with priority='optional' in the
// default timeline (t-paliad-342 / youpcorg#2570). Default false:
// optional rules don't auto-fire alongside mandatory ones. The
// caller (paliad /tools/procedures, youpc.org/deadlines) wires this
// to a user-facing "show optional applications" toggle.
IncludeOptional bool
// TriggerEventAnchors supplies concrete dates for procedural events
// referenced by rules' trigger_event_id (t-paliad-342 / youpcorg#2568).
// Key = paliad.trigger_events.code (e.g. "oral_hearing"), value =
// YYYY-MM-DD. When a rule carries an explicit trigger_event_id, that
// catalog event is the authoritative semantic anchor: arithmetic
// resolves against TriggerEventAnchors[code] if set, otherwise the
// rule is suppressed as IsConditional (no fabricated date off the
// user's trigger date). Empty map = engine never anchors on a
// trigger event, so every rule with trigger_event_id surfaces as
// conditional.
TriggerEventAnchors map[string]string
}
// ProjectHint scopes a Catalog call to a specific project. Paliad's
@@ -375,6 +394,13 @@ type Timeline struct {
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
HiddenCount int `json:"hiddenCount"`
// RulesAwaitingAnchor counts rules suppressed because their
// trigger_event_id anchor date wasn't supplied via
// CalcOptions.TriggerEventAnchors (t-paliad-342). Such rules still
// render in the timeline as IsConditional (no date) — the field
// gives the caller a single integer for "N rules waiting on an
// anchor" UI affordances + telemetry.
RulesAwaitingAnchor int `json:"rulesAwaitingAnchor,omitempty"`
}
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
@@ -505,7 +531,17 @@ type RuleCalculationProceeding struct {
// FristenrechnerType mirrors the /api/tools/proceeding-types response
// metadata.
//
// ID is the paliad.proceeding_types primary key. Surfaces so frontend
// pickers (Litigation Builder add-proceeding, fristenrechner-wizard
// project prefill) can POST the FK directly without a code→id round
// trip. Historically code-keyed; the Litigation Builder POSTing
// proceeding_type_id (int) to /api/builder/scenarios/{id}/proceedings
// forced surfacing the id (t-paliad-345 — the missing id meant the
// POST silently sent body={} and the "+ Verfahren hinzufügen" button
// did nothing).
type FristenrechnerType struct {
ID int `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`

View File

@@ -0,0 +1,50 @@
package litigationplanner
import (
"encoding/json"
"strings"
"testing"
)
// TestFristenrechnerType_WireShapeIncludesID is the regression test for
// t-paliad-345: the /api/tools/proceeding-types JSON response must
// include `id` so frontend pickers (Litigation Builder add-proceeding,
// fristenrechner-wizard project prefill) can POST proceeding_type_id
// directly without a code→id round trip. When the id was missing the
// Litigation Builder "+ Verfahren hinzufügen" button silently dropped
// the proceeding_type_id from the POST body (JSON.stringify omits
// undefined keys), the server rejected with 400, and the client
// swallowed the error — user-visible symptom was "nothing happens".
func TestFristenrechnerType_WireShapeIncludesID(t *testing.T) {
in := FristenrechnerType{
ID: 42,
Code: "upc.inf.cfi",
Name: "UPC Verletzungsverfahren",
NameEN: "UPC Infringement Action",
Group: "UPC",
}
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
got := string(b)
if !strings.Contains(got, `"id":42`) {
t.Errorf("missing id in wire shape: %s", got)
}
for _, want := range []string{`"code":"upc.inf.cfi"`, `"nameEN":"UPC Infringement Action"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in wire shape: %s", want, got)
}
}
// Round-trip — a client that posts the id back to /api/builder/
// scenarios/{id}/proceedings should see it preserved as an integer
// (paliad.scenario_proceedings.proceeding_type_id is INT, not UUID).
var out FristenrechnerType
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.ID != 42 {
t.Errorf("id lost on round-trip: got %d want 42", out.ID)
}
}