Compare commits
15 Commits
mai/edison
...
mai/euler/
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c77cb6e67 | |||
| 1f6e586c63 | |||
| a4b865d6bd | |||
| a905911cf4 | |||
| 88c03e922f | |||
| 6bcac2dd20 | |||
| 46dc4ec94b | |||
| 6c1d8cc0cf | |||
| 0c857026a2 | |||
| 3c840c0366 | |||
| 1b4b2e4758 | |||
| b78a984a7c | |||
| 1844df3ae6 | |||
| 0f3c30a647 | |||
| 2c2b93bc7c |
@@ -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
|
||||
|
||||
147
frontend/src/client/builder-picker.ts
Normal file
147
frontend/src/client/builder-picker.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
271
frontend/src/client/builder-triplet.ts
Normal file
271
frontend/src/client/builder-triplet.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
981
frontend/src/client/builder.ts
Normal file
981
frontend/src/client/builder.ts
Normal 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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & Fristen — 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 & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="procedures.subtitle">
|
||||
Verfahrensablauf, Fristenrechner und gerührte Suche in einem Tool.
|
||||
<p className="tool-subtitle" data-i18n="builder.subtitle">
|
||||
Litigation Builder — 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…"
|
||||
/>
|
||||
{/* 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ählen"></select>
|
||||
</label>
|
||||
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
|
||||
<span data-i18n="builder.save.idle"> </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ü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ü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ählen">
|
||||
<option value="" data-i18n="builder.akte.none">— ohne —</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 …"
|
||||
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">📚</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren wählen</span>
|
||||
data-mode="cold"
|
||||
id="builder-mode-cold">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.cold">Ü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">⚡</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ü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">🧭</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Gefü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">📁</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ü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ä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öffnet.
|
||||
</p>
|
||||
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
|
||||
Starte ein neues Szenario, wähle aus deiner Liste oder ü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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
500
internal/db/migrations/157_scenario_builder_foundation.up.sql
Normal file
500
internal/db/migrations/157_scenario_builder_foundation.up.sql
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
627
internal/handlers/scenario_builder.go
Normal file
627
internal/handlers/scenario_builder.go
Normal 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>`
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
944
internal/services/scenario_builder_service.go
Normal file
944
internal/services/scenario_builder_service.go
Normal 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
|
||||
}
|
||||
220
internal/services/scenario_builder_service_test.go
Normal file
220
internal/services/scenario_builder_service_test.go
Normal 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.)
|
||||
@@ -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()
|
||||
|
||||
@@ -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, " & ") {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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`.
|
||||
|
||||
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
50
pkg/litigationplanner/types_wire_test.go
Normal file
50
pkg/litigationplanner/types_wire_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user