diff --git a/cmd/server/main.go b/cmd/server/main.go index f3ab355..84434ca 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -256,7 +256,7 @@ func main() { // Akte-mode dual-write: project-backed scenarios write through // to paliad.projects.scenario_flags + paliad.deadlines via the // injected project + scenarioFlags services. - ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc)), + ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)), } // t-paliad-246 Slice A — Backup Mode runner. Wired only when diff --git a/frontend/src/client/builder-promote.ts b/frontend/src/client/builder-promote.ts new file mode 100644 index 0000000..057d838 --- /dev/null +++ b/frontend/src/client/builder-promote.ts @@ -0,0 +1,370 @@ +// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4 +// + §5.4, B5). +// +// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party +// names) → Akte-Metadaten (title, reference, case number, our_side, +// litigation parent, team). Commit POSTs the merged payload to +// /api/builder/scenarios/{id}/promote — a single server-side transaction +// (no partial promotions) that creates the paliad.projects 'case' row, +// cascades deadlines, and flips the scenario to 'promoted'. On success +// the wizard navigates to /projects/{new-id}. + +import { t } from "./i18n"; + +interface ProjectOption { + id: string; + title: string; + type: string; + reference?: string; +} + +interface UserOption { + id: string; + email: string; + display_name?: string; + office?: string; +} + +interface PartyRow { + name: string; + role: string; + representative: string; +} + +export interface PromoteContext { + scenarioId: string; + ownerId?: string; + proceedingLabel: string; + filedCount: number; + plannedCount: number; + flagCount: number; + extraTopLevel: number; + defaultOurSide: "claimant" | "defendant" | null; + defaultTitle: string; + onSuccess: (projectId: string) => void; +} + +export async function openPromoteWizard(ctx: PromoteContext): Promise { + // Parallel fetch: litigation parents + HLC users (both optional pickers). + const [parents, users] = await Promise.all([ + fetchProjects("litigation"), + fetchUsers(), + ]); + + let step = 1; + const parties: PartyRow[] = []; + const meta = { + title: ctx.defaultTitle || "", + reference: "", + caseNumber: "", + clientNumber: "", + ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant", + parentId: "", + teamIds: new Set(), + }; + + const backdrop = document.createElement("div"); + backdrop.className = "builder-modal-backdrop"; + const modal = document.createElement("div"); + modal.className = "builder-modal builder-promote-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-label", t("builder.promote.title")); + backdrop.appendChild(modal); + + const close = () => { + document.removeEventListener("keydown", onEsc, true); + backdrop.remove(); + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(); + }); + document.addEventListener("keydown", onEsc, true); + + function stepHeader(): string { + const steps = [ + t("builder.promote.step1"), + t("builder.promote.step2"), + t("builder.promote.step3"), + ]; + const dots = steps.map((label, i) => { + const n = i + 1; + const cls = n === step ? " is-active" : n < step ? " is-done" : ""; + return `
  • ${n}` + + `${escHtml(label)}
  • `; + }).join(""); + return `
      ${dots}
    `; + } + + function renderStep1(): string { + const rows = [ + `
  • ${escHtml(t("builder.promote.summary.proceeding"))}${escHtml(ctx.proceedingLabel)}
  • `, + `
  • ${escHtml(t("builder.promote.summary.events_filed"))}${ctx.filedCount}
  • `, + `
  • ${escHtml(t("builder.promote.summary.events_planned"))}${ctx.plannedCount}
  • `, + `
  • ${escHtml(t("builder.promote.summary.flags"))}${ctx.flagCount}
  • `, + ].join(""); + const extra = ctx.extraTopLevel > 0 + ? `

    ${escHtml( + t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)), + )}

    ` + : ""; + return ( + `

    ${escHtml(t("builder.promote.summary.heading"))}

    ` + + `${extra}` + ); + } + + function renderStep2(): string { + const list = parties.length === 0 + ? `

    ${escHtml(t("builder.promote.parties.empty"))}

    ` + : parties.map((p, i) => ( + `
    ` + + `` + + `` + + `` + + `` + + `
    ` + )).join(""); + return ( + `

    ${escHtml(t("builder.promote.parties.hint"))}

    ` + + `
    ${list}
    ` + + `` + ); + } + + function renderStep3(): string { + const parentOpts = [``] + .concat(parents.map((p) => { + const sel = p.id === meta.parentId ? " selected" : ""; + const label = p.reference ? `${p.title} (${p.reference})` : p.title; + return ``; + })).join(""); + const sideSel = (v: string) => (meta.ourSide === v ? " selected" : ""); + const team = users + .filter((u) => u.id !== ctx.ownerId) + .slice(0, 40) + .map((u) => { + const checked = meta.teamIds.has(u.id) ? " checked" : ""; + const label = (u.display_name || "").trim() + ? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string) + : u.email; + return ( + `` + ); + }).join(""); + return ( + `` + + `
    ` + + `` + + `` + + `
    ` + + `
    ` + + `` + + `` + + `
    ` + + `` + + `
    ${escHtml(t("builder.promote.meta.team"))}` + + `

    ${escHtml(t("builder.promote.meta.team.hint"))}

    ` + + `
    ${team}
    ` + + `` + ); + } + + function render(): void { + let body = ""; + if (step === 1) body = renderStep1(); + else if (step === 2) body = renderStep2(); + else body = renderStep3(); + + const backLabel = t("builder.promote.back"); + const cancelLabel = t("builder.promote.cancel"); + const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit"); + + modal.innerHTML = ` +
    +

    ${escHtml(t("builder.promote.title"))}

    + +
    + ${stepHeader()} +
    ${body}
    +
    + + + ${step > 1 ? `` : ""} + +
    `; + wire(); + } + + function captureStep2(): void { + modal.querySelectorAll(".builder-promote-party").forEach((row) => { + const idx = Number(row.getAttribute("data-idx")); + if (Number.isNaN(idx) || !parties[idx]) return; + parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value; + parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value; + parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value; + }); + } + + function captureStep3(): void { + const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? ""; + meta.title = get(".builder-promote-title"); + meta.reference = get(".builder-promote-reference"); + meta.caseNumber = get(".builder-promote-casenumber"); + meta.clientNumber = get(".builder-promote-clientnumber"); + meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide; + meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || ""; + meta.teamIds = new Set( + Array.from(modal.querySelectorAll(".builder-promote-team-cb:checked")) + .map((cb) => cb.getAttribute("data-user-id") || "") + .filter(Boolean), + ); + } + + function wire(): void { + modal.querySelector(".builder-modal-close")?.addEventListener("click", close); + modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close); + modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => { + if (step === 2) captureStep2(); + if (step === 3) captureStep3(); + step = Math.max(1, step - 1); + render(); + }); + modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => { + if (step === 2) captureStep2(); + if (step < 3) { + step += 1; + render(); + return; + } + captureStep3(); + void commit(); + }); + if (step === 2) { + modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => { + captureStep2(); + parties.push({ name: "", role: "", representative: "" }); + render(); + }); + modal.querySelectorAll(".builder-promote-party-remove").forEach((btn) => { + btn.addEventListener("click", () => { + captureStep2(); + const row = btn.closest(".builder-promote-party") as HTMLElement; + const idx = Number(row?.getAttribute("data-idx")); + if (!Number.isNaN(idx)) parties.splice(idx, 1); + render(); + }); + }); + } + } + + async function commit(): Promise { + const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null; + const showErr = (msg: string) => { + if (errEl) { + errEl.textContent = msg; + errEl.hidden = false; + } + }; + if (!meta.title.trim()) { + showErr(t("builder.promote.error.title_required")); + return; + } + const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null; + if (nextBtn) nextBtn.disabled = true; + + const payload: Record = { + title: meta.title.trim(), + reference: meta.reference.trim() || undefined, + case_number: meta.caseNumber.trim() || undefined, + client_number: meta.clientNumber.trim() || undefined, + our_side: meta.ourSide || undefined, + parent_id: meta.parentId || undefined, + parties: parties + .filter((p) => p.name.trim()) + .map((p) => ({ + name: p.name.trim(), + role: p.role.trim() || undefined, + representative: p.representative.trim() || undefined, + })), + team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })), + }; + + try { + const resp = await fetch( + "/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!resp.ok) { + if (nextBtn) nextBtn.disabled = false; + showErr(t("builder.promote.error.generic")); + return; + } + const out = (await resp.json()) as { project_id: string }; + const body = modal.querySelector(".builder-promote-body") as HTMLElement; + if (body) body.innerHTML = `

    ${escHtml(t("builder.promote.success"))}

    `; + ctx.onSuccess(out.project_id); + } catch { + if (nextBtn) nextBtn.disabled = false; + showErr(t("builder.promote.error.generic")); + } + } + + render(); + document.body.appendChild(backdrop); + (modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus(); +} + +async function fetchProjects(type: string): Promise { + try { + const resp = await fetch("/api/projects?type=" + encodeURIComponent(type)); + if (!resp.ok) return []; + const data = (await resp.json()) as ProjectOption[]; + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +async function fetchUsers(): Promise { + try { + const resp = await fetch("/api/users"); + if (!resp.ok) return []; + const data = (await resp.json()) as UserOption[]; + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +function escHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """); +} diff --git a/frontend/src/client/builder-shares.ts b/frontend/src/client/builder-shares.ts new file mode 100644 index 0000000..1ea2cb0 --- /dev/null +++ b/frontend/src/client/builder-shares.ts @@ -0,0 +1,229 @@ +// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5). +// +// "Teilen" opens a modal with an HLC user picker. Picking a colleague + +// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner +// stays sole editor. Existing shares are listed with a revoke affordance. +// The sharee sees the scenario in their "Geteilt mit mir" bucket (read- +// only) — that side is handled by builder.ts. + +import { t } from "./i18n"; + +export interface ShareUser { + id: string; + email: string; + display_name?: string; + office?: string; +} + +export interface BuilderShareRow { + id: string; + scenario_id: string; + shared_with_user_id: string; + created_by: string; + created_at: string; +} + +interface ShareModalOpts { + scenarioId: string; + ownerId?: string; + currentShares: BuilderShareRow[]; + // Called after a successful add/revoke with the fresh share list so the + // caller can update state.active.shares + re-render side panel buckets. + onChanged: (shares: BuilderShareRow[]) => void; +} + +let allUsers: ShareUser[] | null = null; + +async function fetchUsers(): Promise { + if (allUsers) return allUsers; + try { + const resp = await fetch("/api/users"); + if (!resp.ok) return []; + const data = (await resp.json()) as ShareUser[]; + allUsers = Array.isArray(data) ? data : []; + return allUsers; + } catch { + return []; + } +} + +function userLabel(u: ShareUser): string { + const name = (u.display_name || "").trim(); + if (name) return u.office ? `${name} · ${u.office}` : name; + return u.email; +} + +export async function openShareModal(opts: ShareModalOpts): Promise { + const users = await fetchUsers(); + let shares = [...opts.currentShares]; + + const backdrop = document.createElement("div"); + backdrop.className = "builder-modal-backdrop"; + backdrop.innerHTML = ` + `; + + const close = () => { + document.removeEventListener("keydown", onEsc, true); + backdrop.remove(); + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(); + }); + backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close); + document.addEventListener("keydown", onEsc, true); + + const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement; + const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement; + const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement; + + function renderCurrent(): void { + if (shares.length === 0) { + currentEl.innerHTML = `
  • ${escHtml(t("builder.share.current.empty"))}
  • `; + return; + } + currentEl.innerHTML = shares.map((sh) => { + const u = users.find((x) => x.id === sh.shared_with_user_id); + const label = u ? userLabel(u) : sh.shared_with_user_id; + return ( + `
  • ` + + `${escHtml(label)}` + + `` + + `
  • ` + ); + }).join(""); + currentEl.querySelectorAll(".builder-share-current-item").forEach((li) => { + const id = li.getAttribute("data-share-id"); + if (!id) return; + li.querySelector(".builder-share-revoke")?.addEventListener("click", () => { + void revoke(id); + }); + }); + } + + function renderResults(): void { + const q = searchEl.value.trim().toLowerCase(); + const sharedIds = new Set(shares.map((s) => s.shared_with_user_id)); + const matches = users + .filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id)) + .filter((u) => { + if (!q) return true; + return ( + (u.display_name || "").toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + (u.office || "").toLowerCase().includes(q) + ); + }) + .slice(0, 12); + if (matches.length === 0) { + resultsEl.innerHTML = `
  • ${escHtml(t("builder.share.no_results"))}
  • `; + return; + } + resultsEl.innerHTML = matches.map((u) => ( + `
  • ` + + `${escHtml(userLabel(u))}` + + `` + + `
  • ` + )).join(""); + resultsEl.querySelectorAll(".builder-share-result").forEach((li) => { + const uid = li.getAttribute("data-user-id"); + if (!uid) return; + li.querySelector(".builder-share-add")?.addEventListener("click", () => { + void add(uid); + }); + }); + } + + async function add(userId: string): Promise { + try { + const resp = await fetch( + "/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ shared_with_user_id: userId }), + }, + ); + if (!resp.ok) { + flashError(); + return; + } + const row = (await resp.json()) as BuilderShareRow; + shares = [...shares.filter((s) => s.id !== row.id), row]; + searchEl.value = ""; + renderResults(); + renderCurrent(); + opts.onChanged(shares); + } catch { + flashError(); + } + } + + async function revoke(shareId: string): Promise { + try { + const resp = await fetch( + "/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + + "/shares/" + encodeURIComponent(shareId), + { method: "DELETE" }, + ); + if (!resp.ok && resp.status !== 204) { + flashError(); + return; + } + shares = shares.filter((s) => s.id !== shareId); + renderResults(); + renderCurrent(); + opts.onChanged(shares); + } catch { + flashError(); + } + } + + function flashError(): void { + const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement; + let err = box.querySelector(".builder-share-error") as HTMLElement | null; + if (!err) { + err = document.createElement("p"); + err.className = "builder-share-error"; + box.appendChild(err); + } + err.textContent = t("builder.share.error"); + } + + searchEl.addEventListener("input", renderResults); + renderResults(); + renderCurrent(); + document.body.appendChild(backdrop); + searchEl.focus(); +} + +function escHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """); +} diff --git a/frontend/src/client/builder.ts b/frontend/src/client/builder.ts index 750a911..644cb1d 100644 --- a/frontend/src/client/builder.ts +++ b/frontend/src/client/builder.ts @@ -44,6 +44,8 @@ import { SCENARIO_FLAG_CHANGED_EVENT, type ScenarioFlagChangedDetail, } from "./scenario-flags"; +import { openShareModal, type BuilderShareRow } from "./builder-shares"; +import { openPromoteWizard } from "./builder-promote"; // Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a // parent proceeding, the builder auto-creates a child proceeding row @@ -125,6 +127,14 @@ type EntryMode = "cold" | "event" | "akte"; interface State { active: BuilderScenarioDeep | null; list: BuilderScenario[]; + // B5 — scenarios shared read-only with me (the "Geteilt mit mir" + // bucket). Disjoint from `list` (which is owner-scoped). readonly is + // true when the active scenario is one of these OR is promoted — + // either way every mutating affordance is disabled + a watermark shows. + shared: BuilderScenario[]; + readonly: boolean; + // owner display-name cache for the read-only watermark. + ownerNameById: Map; procTypes: ProceedingTypeMeta[]; procTypesById: Map; procTypesByCode: Map; @@ -151,6 +161,9 @@ interface State { const state: State = { active: null, list: [], + shared: [], + readonly: false, + ownerNameById: new Map(), procTypes: [], procTypesById: new Map(), procTypesByCode: new Map(), @@ -184,10 +197,29 @@ async function fetchJSON(input: RequestInfo, init?: RequestInit): Promise { - const out = await fetchJSON("/api/builder/scenarios?status=active"); + // B5 — pull every status so the side panel can bucket into Aktiv / + // Promoted / Archiviert. The picker + recent list filter to active. + const out = await fetchJSON("/api/builder/scenarios?status=all"); return Array.isArray(out) ? out : []; } +async function fetchSharedScenarios(): Promise { + const out = await fetchJSON("/api/builder/scenarios/shared"); + return Array.isArray(out) ? out : []; +} + +// fetchOwnerNames lazily loads the user directory once so the read-only +// watermark can render "Geteilt von ". Failures degrade to showing +// the owner uuid; the watermark is informational, not load-bearing. +async function ensureOwnerNames(): Promise { + if (state.ownerNameById.size > 0) return; + const users = await fetchJSON>("/api/users"); + if (!Array.isArray(users)) return; + for (const u of users) { + state.ownerNameById.set(u.id, (u.display_name || "").trim() || u.email); + } +} + async function fetchScenarioDeep(id: string): Promise { return await fetchJSON("/api/builder/scenarios/" + encodeURIComponent(id)); } @@ -396,20 +428,32 @@ async function flushAutoSave(): Promise { // ──────────────────────────────────────────────────────────────────────────── async function refreshScenarioList(): Promise { - state.list = await fetchScenarios(); + // Owned (all statuses) + shared-with-me run in parallel. + const [owned, shared] = await Promise.all([fetchScenarios(), fetchSharedScenarios()]); + state.list = owned; + state.shared = shared; renderScenarioList(); renderScenarioPicker(); } -function renderScenarioList(): void { - const ul = document.getElementById("builder-scenario-list-active"); +// renderBucket paints one side-panel bucket UL + toggles its wrapper's +// hidden attribute when empty. The Aktiv bucket always renders (shows the +// empty hint); the others hide when they have no rows. +function renderBucket(listId: string, wrapId: string | null, scenarios: BuilderScenario[], alwaysShow: boolean): void { + const ul = document.getElementById(listId); if (!ul) return; - if (state.list.length === 0) { - ul.innerHTML = `
  • Noch keine Szenarien.
  • `; + if (wrapId) { + const wrap = document.getElementById(wrapId); + if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0; + } + if (scenarios.length === 0) { + ul.innerHTML = alwaysShow + ? `
  • Noch keine Szenarien.
  • ` + : ""; return; } const activeId = state.active?.id; - ul.innerHTML = state.list.map((sc) => { + ul.innerHTML = scenarios.map((sc) => { const isActive = sc.id === activeId; return ( `
  • s.status === "active"), true); + renderBucket("builder-scenario-list-shared", "builder-bucket-shared", state.shared, false); + renderBucket("builder-scenario-list-promoted", "builder-bucket-promoted", + state.list.filter((s) => s.status === "promoted"), false); + renderBucket("builder-scenario-list-archived", "builder-bucket-archived", + state.list.filter((s) => s.status === "archived"), false); +} + 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[] = [``]; - for (const sc of state.list) { + // Picker shows openable scenarios: active owned + shared-with-me. + const pickable = [ + ...state.list.filter((s) => s.status === "active"), + ...state.shared, + ]; + // Ensure the currently-active scenario is selectable even if promoted/ + // archived (so the dropdown reflects reality when one is open). + if (state.active && !pickable.some((s) => s.id === state.active!.id)) { + pickable.unshift(state.active); + } + for (const sc of pickable) { const selected = sc.id === state.active?.id ? " selected" : ""; opts.push(``); } @@ -977,13 +1041,16 @@ async function loadScenario(id: string): Promise { if (!Array.isArray(deep.shares)) deep.shares = []; state.active = deep; state.pending = {}; + // B5 — read-only when the scenario is shared with me (I'm not the + // owner) or already promoted (server blocks mutations either way). + const isShared = state.shared.some((s) => s.id === id); + state.readonly = isShared || deep.status === "promoted"; 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; + await applyScenarioChrome(deep, isShared); // B4 — reflect the scenario's Akte link on the page-header picker // + banner. Project-backed scenarios reveal the source project so // the user knows the builder writes feed into that Akte; non-Akte @@ -1040,6 +1107,114 @@ function openAddProceedingPicker(anchor: HTMLElement): void { }); } +// applyScenarioChrome sets the page-header action buttons + read-only +// watermark + body class for the freshly-loaded scenario. Editable +// scenarios get rename / share / promote enabled; read-only ones (shared +// with me, or promoted) lock all three and show the watermark. The body +// class drives the CSS that neutralises in-canvas mutating affordances. +async function applyScenarioChrome(deep: BuilderScenarioDeep, isShared: boolean): Promise { + document.body.classList.toggle("builder-readonly", state.readonly); + const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null; + const share = document.getElementById("builder-share-btn") as HTMLButtonElement | null; + const promote = document.getElementById("builder-promote-btn") as HTMLButtonElement | null; + if (rename) rename.disabled = state.readonly; + if (share) share.disabled = state.readonly; + if (promote) promote.disabled = state.readonly; + + const wm = document.getElementById("builder-readonly-watermark"); + if (!wm) return; + if (!state.readonly) { + wm.hidden = true; + wm.textContent = ""; + return; + } + if (isShared) { + await ensureOwnerNames(); + const owner = (deep.owner_id && state.ownerNameById.get(deep.owner_id)) || deep.owner_id || "?"; + wm.textContent = t("builder.readonly.watermark").replace("{owner}", owner); + } else { + // Promoted (owned) scenario — read-only reference. + wm.textContent = t("builder.bucket.promoted"); + } + wm.hidden = false; +} + +// resetScenarioChrome clears the page-header action state + watermark +// when no scenario is active (cold-open / picker cleared). +function resetScenarioChrome(): void { + document.body.classList.remove("builder-readonly"); + for (const id of ["builder-rename-btn", "builder-share-btn", "builder-promote-btn"]) { + const b = document.getElementById(id) as HTMLButtonElement | null; + if (b) b.disabled = true; + } + const wm = document.getElementById("builder-readonly-watermark"); + if (wm) { + wm.hidden = true; + wm.textContent = ""; + } +} + +// onShareClick opens the share modal for the active (owned, editable) +// scenario. PRD §2.5. +function onShareClick(): void { + if (!state.active || state.readonly) return; + void openShareModal({ + scenarioId: state.active.id, + ownerId: state.active.owner_id, + currentShares: (state.active.shares as BuilderShareRow[]) ?? [], + onChanged: (shares) => { + if (state.active) state.active.shares = shares; + }, + }); +} + +// onPromoteClick gathers the summary numbers for wizard step 1 and opens +// the promote-to-project wizard. PRD §2.4. The primary proceeding (lowest- +// ordinal top-level) + its spawned descendants are what the server +// promotes into one case file; additional standalone proceedings are +// reported in the summary as staying behind. +function onPromoteClick(): void { + if (!state.active || state.readonly) return; + const sc = state.active; + const topLevel = sc.proceedings + .filter((p) => !p.parent_scenario_proceeding_id) + .sort((a, b) => a.ordinal - b.ordinal); + const primary = topLevel[0]; + if (!primary) { + setSaveState("error"); + return; + } + // Collect primary + descendants to scope the event counts. + const subtree = new Set([primary.id]); + for (let changed = true; changed; ) { + changed = false; + for (const p of sc.proceedings) { + if (p.parent_scenario_proceeding_id && subtree.has(p.parent_scenario_proceeding_id) && !subtree.has(p.id)) { + subtree.add(p.id); + changed = true; + } + } + } + const evs = sc.events.filter((e) => subtree.has(e.scenario_proceeding_id)); + const meta = state.procTypesById.get(primary.proceeding_type_id); + const label = meta ? meta.name || meta.code : "?"; + const defaultParty = (primary.primary_party as "claimant" | "defendant" | undefined) ?? null; + void openPromoteWizard({ + scenarioId: sc.id, + ownerId: sc.owner_id, + proceedingLabel: label, + filedCount: evs.filter((e) => e.state === "filed").length, + plannedCount: evs.filter((e) => e.state === "planned").length, + flagCount: Object.values(primary.scenario_flags).filter((v) => v === true).length, + extraTopLevel: topLevel.length - 1, + defaultOurSide: defaultParty, + defaultTitle: sc.name && sc.name !== "Unbenanntes Szenario" ? sc.name : "", + onSuccess: (projectId) => { + window.location.href = "/projects/" + encodeURIComponent(projectId); + }, + }); +} + async function onRenameClick(): Promise { if (!state.active) return; const current = state.active.name; @@ -1194,6 +1369,12 @@ function wirePageHeader(): void { document.getElementById("builder-rename-btn")?.addEventListener("click", () => { void onRenameClick(); }); + document.getElementById("builder-share-btn")?.addEventListener("click", () => { + onShareClick(); + }); + document.getElementById("builder-promote-btn")?.addEventListener("click", () => { + onPromoteClick(); + }); document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => { void onNewScenarioClick(); }); @@ -1206,7 +1387,9 @@ function wirePageHeader(): void { if (id) void loadScenario(id); else { state.active = null; + state.readonly = false; writeScenarioToUrl(null); + resetScenarioChrome(); renderCanvas(); } }); @@ -1277,8 +1460,15 @@ export async function mountBuilder(): Promise { }); const requested = readScenarioFromUrl(); - if (requested && state.list.some((s) => s.id === requested)) { - await loadScenario(requested); + // Deep-link auto-load covers both owned scenarios and ones shared with + // me (so a "Geteilt mit mir" link opens straight into the read-only + // view, not the cold-open canvas). loadScenario derives read-only from + // state.shared, so the share watermark + locked affordances apply. + const isKnown = + requested != null && + (state.list.some((s) => s.id === requested) || state.shared.some((s) => s.id === requested)); + if (isKnown) { + await loadScenario(requested as string); } else { renderCanvas(); } diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 192a40f..a6ccb2c 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -294,6 +294,62 @@ const translations: Record> = { "builder.search.summary.projects.other": "{n} Akten", "builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501", + // B5 \u2014 side-panel buckets, sharing, promote-to-project wizard. + "builder.bucket.shared": "Geteilt mit mir", + "builder.bucket.promoted": "Als Projekt angelegt", + "builder.bucket.archived": "Archiviert", + "builder.bucket.empty": "\u2014", + "builder.readonly.watermark": "Geteilt von {owner} \u00b7 schreibgesch\u00fctzt", + "builder.readonly.blocked": "Schreibgesch\u00fctzt \u2014 Bearbeiten ist nur f\u00fcr die Eigent\u00fcmer:in m\u00f6glich.", + "builder.share.title": "Szenario teilen", + "builder.share.subtitle": "Schreibgesch\u00fctzt mit HLC-Kolleg:innen teilen. Du bleibst alleinige Bearbeiter:in.", + "builder.share.search.placeholder": "Name oder E-Mail suchen \u2026", + "builder.share.button": "Schreibgesch\u00fctzt teilen", + "builder.share.current.title": "Bereits geteilt mit:", + "builder.share.current.empty": "Noch mit niemandem geteilt.", + "builder.share.revoke": "Entfernen", + "builder.share.close": "Schlie\u00dfen", + "builder.share.no_results": "Keine Nutzer:innen gefunden.", + "builder.share.error": "Teilen fehlgeschlagen. Erneut versuchen.", + "builder.promote.title": "Als Projekt anlegen", + "builder.promote.step1": "Best\u00e4tigen", + "builder.promote.step2": "Parteien erg\u00e4nzen", + "builder.promote.step3": "Akte-Metadaten", + "builder.promote.next": "Weiter", + "builder.promote.back": "Zur\u00fcck", + "builder.promote.commit": "Anlegen", + "builder.promote.cancel": "Abbrechen", + "builder.promote.summary.heading": "Das wird angelegt:", + "builder.promote.summary.proceeding": "Hauptverfahren", + "builder.promote.summary.events_filed": "eingereichte Ereignisse", + "builder.promote.summary.events_planned": "geplante Ereignisse", + "builder.promote.summary.flags": "aktive Optionen", + "builder.promote.summary.note_extra": "{n} weitere(s) eigenst\u00e4ndige(s) Verfahren bleibt im Szenario und wird nicht automatisch \u00fcbernommen.", + "builder.promote.parties.hint": "Trage die echten Parteinamen ein \u2014 oder erg\u00e4nze sie sp\u00e4ter in der Akte.", + "builder.promote.parties.add": "+ Partei hinzuf\u00fcgen", + "builder.promote.parties.name": "Name", + "builder.promote.parties.role": "Rolle (z. B. Kl\u00e4ger)", + "builder.promote.parties.representative": "Vertreter:in", + "builder.promote.parties.remove": "Entfernen", + "builder.promote.parties.empty": "Noch keine Parteien.", + "builder.promote.meta.title": "Aktentitel / Mandat", + "builder.promote.meta.title.placeholder": "z. B. Becker ./. X \u2014 UPC Verletzung", + "builder.promote.meta.reference": "Referenz (optional)", + "builder.promote.meta.case_number": "Aktenzeichen (optional)", + "builder.promote.meta.client_number": "Mandantennummer (optional)", + "builder.promote.meta.our_side": "Unsere Seite", + "builder.promote.meta.our_side.claimant": "Kl\u00e4ger", + "builder.promote.meta.our_side.defendant": "Beklagter", + "builder.promote.meta.our_side.none": "\u2014 offen \u2014", + "builder.promote.meta.parent": "\u00dcbergeordnetes Verfahren (optional)", + "builder.promote.meta.parent.none": "\u2014 keines \u2014", + "builder.promote.meta.team": "Team (optional)", + "builder.promote.meta.team.hint": "Du wirst automatisch als Lead hinzugef\u00fcgt.", + "builder.promote.error.title_required": "Bitte einen Aktentitel eingeben.", + "builder.promote.error.generic": "Anlegen fehlgeschlagen. Erneut versuchen.", + "builder.promote.success": "Akte angelegt \u2014 Weiterleitung \u2026", + "builder.mobile.blocked": "Auf gr\u00f6\u00dferem Bildschirm \u00f6ffnen, um zu bearbeiten.", + "deadlines.step1": "Verfahrensart w\u00e4hlen", "deadlines.step2": "Ausgangsdatum eingeben", "deadlines.step2.perspective": "Perspektive und Datum", @@ -3591,6 +3647,62 @@ const translations: Record> = { "builder.search.summary.projects.other": "{n} matters", "builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━", + // B5 — side-panel buckets, sharing, promote-to-project wizard. + "builder.bucket.shared": "Shared with me", + "builder.bucket.promoted": "Promoted to project", + "builder.bucket.archived": "Archived", + "builder.bucket.empty": "—", + "builder.readonly.watermark": "Shared by {owner} · read-only", + "builder.readonly.blocked": "Read-only — only the owner can edit.", + "builder.share.title": "Share scenario", + "builder.share.subtitle": "Share read-only with HLC colleagues. You remain the sole editor.", + "builder.share.search.placeholder": "Search name or email …", + "builder.share.button": "Share read-only", + "builder.share.current.title": "Already shared with:", + "builder.share.current.empty": "Not shared with anyone yet.", + "builder.share.revoke": "Remove", + "builder.share.close": "Close", + "builder.share.no_results": "No users found.", + "builder.share.error": "Sharing failed. Please try again.", + "builder.promote.title": "Create as project", + "builder.promote.step1": "Confirm", + "builder.promote.step2": "Add parties", + "builder.promote.step3": "Case metadata", + "builder.promote.next": "Next", + "builder.promote.back": "Back", + "builder.promote.commit": "Create", + "builder.promote.cancel": "Cancel", + "builder.promote.summary.heading": "What will be created:", + "builder.promote.summary.proceeding": "Primary proceeding", + "builder.promote.summary.events_filed": "filed events", + "builder.promote.summary.events_planned": "planned events", + "builder.promote.summary.flags": "active options", + "builder.promote.summary.note_extra": "{n} further standalone proceeding(s) stay in the scenario and are not carried over automatically.", + "builder.promote.parties.hint": "Enter the real party names — or add them later in the case file.", + "builder.promote.parties.add": "+ Add party", + "builder.promote.parties.name": "Name", + "builder.promote.parties.role": "Role (e.g. claimant)", + "builder.promote.parties.representative": "Representative", + "builder.promote.parties.remove": "Remove", + "builder.promote.parties.empty": "No parties yet.", + "builder.promote.meta.title": "Case title / matter", + "builder.promote.meta.title.placeholder": "e.g. Becker v. X — UPC infringement", + "builder.promote.meta.reference": "Reference (optional)", + "builder.promote.meta.case_number": "Case number (optional)", + "builder.promote.meta.client_number": "Client number (optional)", + "builder.promote.meta.our_side": "Our side", + "builder.promote.meta.our_side.claimant": "Claimant", + "builder.promote.meta.our_side.defendant": "Defendant", + "builder.promote.meta.our_side.none": "— open —", + "builder.promote.meta.parent": "Parent litigation (optional)", + "builder.promote.meta.parent.none": "— none —", + "builder.promote.meta.team": "Team (optional)", + "builder.promote.meta.team.hint": "You are added as lead automatically.", + "builder.promote.error.title_required": "Please enter a case title.", + "builder.promote.error.generic": "Creation failed. Please try again.", + "builder.promote.success": "Case created — redirecting …", + "builder.mobile.blocked": "Open on a larger screen to edit.", + "deadlines.step1": "Select Proceeding Type", "deadlines.step2": "Enter Trigger Date", "deadlines.step2.perspective": "Perspective and Date", diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 70dee4b..8d7658c 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -735,6 +735,10 @@ export type I18nKey = | "builder.akte.banner.prefix" | "builder.akte.none" | "builder.bucket.active" + | "builder.bucket.archived" + | "builder.bucket.empty" + | "builder.bucket.promoted" + | "builder.bucket.shared" | "builder.canvas.add_proceeding" | "builder.empty.cta" | "builder.empty.headline" @@ -754,6 +758,7 @@ export type I18nKey = | "builder.header.scenario" | "builder.header.search" | "builder.header.stichtag" + | "builder.mobile.blocked" | "builder.mode.akte" | "builder.mode.cold" | "builder.mode.event" @@ -768,6 +773,45 @@ export type I18nKey = | "builder.picker.future_jurisdiction" | "builder.picker.placeholder" | "builder.picker.title" + | "builder.promote.back" + | "builder.promote.cancel" + | "builder.promote.commit" + | "builder.promote.error.generic" + | "builder.promote.error.title_required" + | "builder.promote.meta.case_number" + | "builder.promote.meta.client_number" + | "builder.promote.meta.our_side" + | "builder.promote.meta.our_side.claimant" + | "builder.promote.meta.our_side.defendant" + | "builder.promote.meta.our_side.none" + | "builder.promote.meta.parent" + | "builder.promote.meta.parent.none" + | "builder.promote.meta.reference" + | "builder.promote.meta.team" + | "builder.promote.meta.team.hint" + | "builder.promote.meta.title" + | "builder.promote.meta.title.placeholder" + | "builder.promote.next" + | "builder.promote.parties.add" + | "builder.promote.parties.empty" + | "builder.promote.parties.hint" + | "builder.promote.parties.name" + | "builder.promote.parties.remove" + | "builder.promote.parties.representative" + | "builder.promote.parties.role" + | "builder.promote.step1" + | "builder.promote.step2" + | "builder.promote.step3" + | "builder.promote.success" + | "builder.promote.summary.events_filed" + | "builder.promote.summary.events_planned" + | "builder.promote.summary.flags" + | "builder.promote.summary.heading" + | "builder.promote.summary.note_extra" + | "builder.promote.summary.proceeding" + | "builder.promote.title" + | "builder.readonly.blocked" + | "builder.readonly.watermark" | "builder.save.error" | "builder.save.idle" | "builder.save.saved" @@ -789,6 +833,16 @@ export type I18nKey = | "builder.search.summary.projects.other" | "builder.search.summary.scenarios.one" | "builder.search.summary.scenarios.other" + | "builder.share.button" + | "builder.share.close" + | "builder.share.current.empty" + | "builder.share.current.title" + | "builder.share.error" + | "builder.share.no_results" + | "builder.share.revoke" + | "builder.share.search.placeholder" + | "builder.share.subtitle" + | "builder.share.title" | "builder.subtitle" | "builder.triplet.collapse" | "builder.triplet.detailgrad.all_options" diff --git a/frontend/src/procedures.tsx b/frontend/src/procedures.tsx index 127049a..8548100 100644 --- a/frontend/src/procedures.tsx +++ b/frontend/src/procedures.tsx @@ -68,12 +68,10 @@ export function renderProcedures(): string {
    @@ -141,10 +139,28 @@ export function renderProcedures(): string {

    Aktiv

      - {/* Geteilt / Promoted / Archiviert buckets land in B5+. */} + {/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert. + Each bucket hides itself when empty (builder.ts toggles + the hidden attribute). */} + + +
      + {/* B5 — read-only watermark for shared / promoted scenarios. + builder.ts fills + unhides it when the active scenario + is not editable by the current user. */} +
      {/* Cold-open placeholder — replaced by triplet stack once a scenario is loaded. */} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 8b2d6d2..0721f46 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -20648,3 +20648,383 @@ a.fristen-overhaul-rule-source { width: 100%; } } + +/* =================================================================== + B5 — side-panel buckets, read-only watermark, share modal, + promote-to-project wizard (m/paliad#153 PRD §2.4 + §2.5). + =================================================================== */ + +.builder-sidepanel-bucket + .builder-sidepanel-bucket { + margin-top: 0.85rem; + padding-top: 0.65rem; + border-top: 1px solid var(--color-border); +} +.builder-bucket-label { + margin: 0 0 0.35rem; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); +} + +/* Read-only watermark banner above the canvas. */ +.builder-readonly-watermark { + display: block; + margin-bottom: 0.6rem; + padding: 0.4rem 0.75rem; + border-radius: 0.4rem; + background: repeating-linear-gradient( + 45deg, + var(--color-surface-muted), + var(--color-surface-muted) 10px, + var(--color-surface-2) 10px, + var(--color-surface-2) 20px + ); + border: 1px dashed var(--color-border); + color: var(--color-text-muted); + font-size: 0.85rem; + font-weight: 500; +} + +/* Read-only mode: neutralise every mutating affordance in the canvas + while keeping text selectable + read interactions working. PRD §2.5 / + §10 — pointer-events:none on the controls, not the cards. */ +body.builder-readonly .builder-triplet-host button, +body.builder-readonly .builder-triplet-host input, +body.builder-readonly .builder-triplet-host select, +body.builder-readonly .builder-add-proceeding-btn { + pointer-events: none; + opacity: 0.5; +} + +/* Generic modal scaffold (shared by share modal + promote wizard). */ +.builder-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.4); +} +.builder-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.6rem; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); + width: 100%; + max-width: 540px; + max-height: calc(100vh - 2rem); + overflow: auto; + padding: 1.1rem 1.25rem 1.25rem; +} +.builder-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.4rem; +} +.builder-modal-title { + margin: 0; + font-size: 1.1rem; +} +.builder-modal-close { + font: inherit; + font-size: 1.4rem; + line-height: 1; + background: transparent; + border: 0; + cursor: pointer; + color: var(--color-text-muted); + padding: 0 0.3rem; +} +.builder-modal-subtitle { + margin: 0 0 0.85rem; + font-size: 0.85rem; + color: var(--color-text-muted); +} + +/* Share modal. */ +.builder-share-pickerbox { + position: relative; +} +.builder-share-search { + width: 100%; + font: inherit; + padding: 0.4rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: 0.35rem; + background: var(--color-surface-2); + color: var(--color-text); +} +.builder-share-results { + list-style: none; + margin: 0.4rem 0 0; + padding: 0; + max-height: 220px; + overflow: auto; +} +.builder-share-result, +.builder-share-current-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.35rem 0.4rem; + border-radius: 0.3rem; +} +.builder-share-result:hover { + background: var(--color-surface-muted); +} +.builder-share-result-empty, +.builder-share-current-empty { + padding: 0.4rem; + font-size: 0.85rem; + color: var(--color-text-muted); + list-style: none; +} +.builder-share-add, +.builder-share-revoke { + font: inherit; + font-size: 0.8rem; + padding: 0.2rem 0.6rem; + border-radius: 0.3rem; + cursor: pointer; + border: 1px solid var(--color-border); + background: var(--color-surface-2); + color: var(--color-text); + white-space: nowrap; +} +.builder-share-add { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-dark); + font-weight: 500; +} +.builder-share-current { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border); +} +.builder-share-current-title { + margin: 0 0 0.4rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-muted); +} +.builder-share-current-list { + list-style: none; + margin: 0; + padding: 0; +} +.builder-share-error { + margin: 0.5rem 0 0; + font-size: 0.82rem; + color: #c0392b; +} + +/* Promote wizard. */ +.builder-promote-modal { + max-width: 600px; +} +.builder-promote-steps { + display: flex; + list-style: none; + margin: 0 0 1rem; + padding: 0; + gap: 0.5rem; +} +.builder-promote-step { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.8rem; + color: var(--color-text-muted); + flex: 1; +} +.builder-promote-step-n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4rem; + height: 1.4rem; + border-radius: 50%; + border: 1px solid var(--color-border); + font-size: 0.78rem; + flex: 0 0 auto; +} +.builder-promote-step.is-active { + color: var(--color-text); + font-weight: 600; +} +.builder-promote-step.is-active .builder-promote-step-n { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-dark); +} +.builder-promote-step.is-done .builder-promote-step-n { + background: var(--color-surface-muted); +} +.builder-promote-body { + min-height: 160px; +} +.builder-promote-section-title { + margin: 0 0 0.5rem; + font-size: 0.95rem; +} +.builder-promote-summary { + list-style: none; + margin: 0; + padding: 0; +} +.builder-promote-summary li { + display: flex; + justify-content: space-between; + padding: 0.3rem 0; + border-bottom: 1px solid var(--color-border); + font-size: 0.9rem; +} +.builder-promote-summary li span { + color: var(--color-text-muted); +} +.builder-promote-note { + margin: 0.75rem 0 0; + padding: 0.5rem 0.65rem; + border-radius: 0.35rem; + background: var(--color-surface-muted); + font-size: 0.82rem; + color: var(--color-text-muted); +} +.builder-promote-hint, +.builder-promote-empty, +.builder-promote-team-hint { + font-size: 0.82rem; + color: var(--color-text-muted); + margin: 0 0 0.6rem; +} +.builder-promote-parties { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 0.5rem; +} +.builder-promote-party { + display: grid; + grid-template-columns: 1.3fr 1fr 1fr auto; + gap: 0.35rem; + align-items: center; +} +.builder-promote-party input, +.builder-promote-field input, +.builder-promote-field select { + font: inherit; + padding: 0.35rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 0.3rem; + background: var(--color-surface-2); + color: var(--color-text); + width: 100%; +} +.builder-promote-party-remove { + font: inherit; + font-size: 1.1rem; + line-height: 1; + background: transparent; + border: 0; + cursor: pointer; + color: var(--color-text-muted); +} +.builder-promote-party-add { + font: inherit; + font-size: 0.85rem; + padding: 0.3rem 0.7rem; + border-radius: 0.3rem; + cursor: pointer; + border: 1px dashed var(--color-border); + background: transparent; + color: var(--color-text); +} +.builder-promote-field { + display: block; + margin-bottom: 0.7rem; +} +.builder-promote-field > span { + display: block; + font-size: 0.8rem; + font-weight: 500; + margin-bottom: 0.2rem; +} +.builder-promote-field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.6rem; +} +.builder-promote-team { + display: flex; + flex-direction: column; + gap: 0.15rem; + max-height: 160px; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: 0.35rem; + padding: 0.4rem; +} +.builder-promote-team-item { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; +} +.builder-promote-error { + margin: 0.4rem 0 0; + font-size: 0.85rem; + color: #c0392b; +} +.builder-promote-success { + text-align: center; + padding: 2rem 0; + font-size: 1rem; + color: var(--color-text); +} +.builder-promote-footer { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 0.8rem; + border-top: 1px solid var(--color-border); +} +.builder-promote-footer-spacer { + flex: 1; +} +.builder-promote-cancel, +.builder-promote-backbtn, +.builder-promote-nextbtn { + font: inherit; + padding: 0.4rem 0.95rem; + border-radius: 0.35rem; + cursor: pointer; + border: 1px solid var(--color-border); + background: var(--color-surface-2); + color: var(--color-text); +} +.builder-promote-nextbtn { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-dark); + font-weight: 500; +} +.builder-promote-nextbtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 640px) { + .builder-promote-party, + .builder-promote-field-row { + grid-template-columns: 1fr; + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6727544..4ec66c0 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -542,6 +542,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc // scenario from a paliad.projects row; subsequent edits dual-write // through to paliad.deadlines + paliad.projects.scenario_flags. protected.HandleFunc("POST /api/builder/scenarios/from-project", handleBuilderScenarioFromProject) + // m/paliad#153 B5 — "Geteilt mit mir" bucket. Literal segment wins + // over {id} in Go 1.22+ ServeMux precedence, so this never shadows GET .../{id}. + protected.HandleFunc("GET /api/builder/scenarios/shared", handleBuilderScenariosShared) 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) @@ -552,6 +555,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc 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 B5 — transactional promote-to-project wizard commit. + protected.HandleFunc("POST /api/builder/scenarios/{id}/promote", handleBuilderScenarioPromote) // 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) diff --git a/internal/handlers/scenario_builder.go b/internal/handlers/scenario_builder.go index 61b517b..53f14eb 100644 --- a/internal/handlers/scenario_builder.go +++ b/internal/handlers/scenario_builder.go @@ -433,6 +433,62 @@ func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// --------------------------------------------------------------------------- +// Shared-with-me + Promote (B5, m/paliad#153) +// --------------------------------------------------------------------------- + +// handleBuilderScenariosShared — GET /api/builder/scenarios/shared +// +// Lists scenarios shared read-only with the caller (the "Geteilt mit mir" +// side-panel bucket, PRD §2.5). The caller's own scenarios are excluded. +func handleBuilderScenariosShared(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + out, err := dbSvc.scenarioBuilder.ListSharedWithMe(r.Context(), uid) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleBuilderScenarioPromote — POST /api/builder/scenarios/{id}/promote +// +// Body: PromoteScenarioInput (wizard steps 2 + 3). Promotes the scenario +// into a real paliad.projects 'case' row transactionally (PRD §10 — no +// partial promotions) and returns PromoteResult with the new project id +// the wizard navigates to (/projects/{project_id}). +func handleBuilderScenarioPromote(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.PromoteScenarioInput + 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.PromoteScenario(r.Context(), uid, sid, input) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusCreated, out) +} + // --------------------------------------------------------------------------- // Scenario flag catalog passthrough (m/paliad#153 B2) // --------------------------------------------------------------------------- diff --git a/internal/services/scenario_builder_service.go b/internal/services/scenario_builder_service.go index 44c99a7..8418a15 100644 --- a/internal/services/scenario_builder_service.go +++ b/internal/services/scenario_builder_service.go @@ -37,16 +37,24 @@ type ScenarioBuilderService struct { db *sqlx.DB projects *ProjectService flags *ScenarioFlagsService + // fristenrechner computes planned-deadline due dates during the B5 + // promote-to-project cascade (PRD §5.4 — "due_date=computed"). nil in + // test setups that don't exercise promotion; the promote path then + // skips planned events that have no actual_date (it can't assert a + // date it didn't compute) and reports them via DeadlinesSkipped. + fristenrechner *FristenrechnerService } // NewScenarioBuilderService wires the service to the shared pool plus // the project + scenario-flags services it leans on for the Akte-mode -// dual-write. projects + flags are optional in test setups (nil → the -// dual-write hooks short-circuit), but a production wiring should -// always pass them so Akte-backed scenarios stay in sync with project -// surfaces. -func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService) *ScenarioBuilderService { - return &ScenarioBuilderService{db: db, projects: projects, flags: flags} +// dual-write, and the Fristenrechner calc service the B5 promote path +// uses to compute planned-deadline dates. projects / flags / frist are +// optional in test setups (nil → the dual-write + promote-compute hooks +// short-circuit), but a production wiring should always pass them so +// Akte-backed scenarios stay in sync with project surfaces and +// promotion cascades real dates. +func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService, frist *FristenrechnerService) *ScenarioBuilderService { + return &ScenarioBuilderService{db: db, projects: projects, flags: flags, fristenrechner: frist} } // ErrScenarioBuilderNotVisible is returned when the caller is neither @@ -928,6 +936,438 @@ func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenar return nil } +// ----------------------------------------------------------------------------- +// Shared-with-me listing (B5) +// ----------------------------------------------------------------------------- + +// ListSharedWithMe returns scenarios shared read-only with the caller +// (a paliad.scenario_shares row exists for shared_with_user_id = caller). +// The caller's own scenarios are excluded — they live in ListMyScenarios. +// Sorted by the share's created_at desc so the most-recently-shared sits +// on top. Promoted scenarios stay visible (read-only reference) just like +// in the owner's own list. +func (s *ScenarioBuilderService) ListSharedWithMe(ctx context.Context, userID uuid.UUID) ([]BuilderScenario, error) { + out := []BuilderScenario{} + if err := s.db.SelectContext(ctx, &out, + `SELECT sc.id, sc.owner_id, sc.name, sc.status, sc.origin_project_id, + sc.promoted_project_id, sc.stichtag, sc.notes, + sc.project_id, sc.description, sc.created_by, + sc.created_at, sc.updated_at + FROM paliad.scenarios sc + JOIN paliad.scenario_shares sh ON sh.scenario_id = sc.id + WHERE sh.shared_with_user_id = $1 + AND (sc.owner_id IS NULL OR sc.owner_id <> $1) + ORDER BY sh.created_at DESC`, userID); err != nil { + return nil, fmt.Errorf("list shared scenarios: %w", err) + } + return out, nil +} + +// ----------------------------------------------------------------------------- +// Promote-to-project (B5, PRD §2.4 + §5.4 + §10) +// ----------------------------------------------------------------------------- + +// PromotePartyInput is one party row the wizard's "Parteien ergänzen" +// step contributes. Mirrors CreatePartyInput minus contact_info (the +// wizard collects names + roles; full contact data is filled in the Akte +// later). +type PromotePartyInput struct { + Name string `json:"name"` + Role *string `json:"role,omitempty"` + Representative *string `json:"representative,omitempty"` +} + +// PromoteTeamMemberInput grants a colleague access to the new project at +// promote time. Responsibility defaults to 'member' when blank. +type PromoteTeamMemberInput struct { + UserID uuid.UUID `json:"user_id"` + Responsibility string `json:"responsibility,omitempty"` +} + +// PromoteScenarioInput is the POST /api/builder/scenarios/{id}/promote +// body — the merged payload from wizard steps 2 (Parteien) + 3 +// (Akte-Metadaten). The procedural shape (proceeding type, flags, +// perspective) + event states come from the scenario itself; the wizard +// only supplies the client-bound metadata the scenario can't know. +type PromoteScenarioInput struct { + Title string `json:"title"` + Reference *string `json:"reference,omitempty"` + CaseNumber *string `json:"case_number,omitempty"` + ClientNumber *string `json:"client_number,omitempty"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + OurSide *string `json:"our_side,omitempty"` + Parties []PromotePartyInput `json:"parties,omitempty"` + TeamMembers []PromoteTeamMemberInput `json:"team_members,omitempty"` +} + +// PromoteResult is the outcome the wizard navigates on. +type PromoteResult struct { + ProjectID uuid.UUID `json:"project_id"` + DeadlinesCreated int `json:"deadlines_created"` + DeadlinesSkipped int `json:"deadlines_skipped"` + PartiesCreated int `json:"parties_created"` + ProceedingsSkipped int `json:"proceedings_skipped"` +} + +// PromoteScenario turns a scenario into a real paliad.projects 'case' row +// in a single transaction (PRD §10 — no partial promotions). It promotes +// the scenario's primary proceeding (the lowest-ordinal top-level +// triplet) plus its spawned descendants (the CCR child etc., whose rules +// fold into the primary's timeline under the active flags). Additional +// unrelated top-level proceedings are left in the scenario and reported +// via ProceedingsSkipped — v1 promotes one case file per call, matching +// the singular acceptance criterion (one project, navigate to one id); +// the scenario stays visible as 'promoted' for historical reference and +// can seed a second promotion later. +// +// The cascade, all inside the tx: +// 1. INSERT paliad.projects (type='case', client metadata from the +// wizard, proceeding_type_id + scenario_flags from the primary +// triplet, origin_scenario_id = scenario.id). +// 2. INSERT the creator as team lead + any wizard-selected colleagues. +// 3. INSERT parties from the wizard's step-2 payload. +// 4. For each event under the promoted proceedings: filed → a completed +// deadline (due_date + completed_at = actual_date); planned → an open +// ('pending') deadline with the computed due_date; skipped → no row. +// Planned events with no computable date (court-set / conditional / +// no actual_date) are skipped and counted. +// 5. UPDATE the scenario: status='promoted', promoted_project_id = new. +// +// Any error rolls the whole transaction back. +func (s *ScenarioBuilderService) PromoteScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PromoteScenarioInput) (*PromoteResult, error) { + sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID) + if err != nil { + return nil, err + } + if sc.Status == "promoted" { + return nil, fmt.Errorf("%w: scenario is already promoted", ErrInvalidInput) + } + title := strings.TrimSpace(input.Title) + if title == "" { + return nil, fmt.Errorf("%w: title is required", ErrInvalidInput) + } + if input.OurSide != nil { + if err := validateOurSide(*input.OurSide); err != nil { + return nil, err + } + } + for i := range input.Parties { + if strings.TrimSpace(input.Parties[i].Name) == "" { + return nil, fmt.Errorf("%w: party %d has a blank name", ErrInvalidInput, i+1) + } + } + for _, tm := range input.TeamMembers { + if tm.UserID == uuid.Nil { + return nil, fmt.Errorf("%w: team member has an empty user_id", ErrInvalidInput) + } + if tm.Responsibility != "" && !IsValidResponsibility(tm.Responsibility) { + return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, tm.Responsibility) + } + } + + // Parent visibility (mirrors ProjectService.Create): a litigation + // parent the caller can't see would leak the new sub-tree. + if input.ParentID != nil && s.projects != nil { + if _, perr := s.projects.GetByID(ctx, userID, *input.ParentID); perr != nil { + return nil, fmt.Errorf("%w: litigation parent not visible", ErrForbidden) + } + } + + // Load the proceeding + event tree. + proceedings := []BuilderProceeding{} + if err := s.db.SelectContext(ctx, &proceedings, ` + SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags, + parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal, + stichtag, detailgrad, appeal_target, collapsed, created_at, updated_at + FROM paliad.scenario_proceedings + WHERE scenario_id = $1 + ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil { + return nil, fmt.Errorf("load proceedings: %w", err) + } + if len(proceedings) == 0 { + return nil, fmt.Errorf("%w: scenario has no proceedings to promote", ErrInvalidInput) + } + + // Primary = first top-level proceeding (lowest ordinal). Collect it + + // its spawned descendants; those form the one case file we promote. + var primary *BuilderProceeding + for i := range proceedings { + if proceedings[i].ParentScenarioProceedingID == nil { + primary = &proceedings[i] + break + } + } + if primary == nil { + return nil, fmt.Errorf("%w: scenario has no top-level proceeding", ErrInvalidInput) + } + promoteSet := collectProceedingSubtree(proceedings, primary.ID) + topLevelCount := 0 + for i := range proceedings { + if proceedings[i].ParentScenarioProceedingID == nil { + topLevelCount++ + } + } + + // Resolve the primary proceeding's catalog code (the calc engine keys + // off code, not id). + var primaryCode string + if err := s.db.GetContext(ctx, &primaryCode, + `SELECT code FROM paliad.proceeding_types WHERE id = $1`, primary.ProceedingTypeID); err != nil { + return nil, fmt.Errorf("resolve proceeding code: %w", err) + } + + // Resolve our_side: explicit wizard value wins; otherwise fold the + // primary triplet's perspective down to the project axis. + ourSide := input.OurSide + if ourSide == nil { + ourSide = primary.PrimaryParty + } + + // Compute the primary proceeding's timeline so planned events get real + // dates. The CCR child's rules fold into this timeline under the + // primary's flags (sub-track routing), so one calc covers the whole + // promoted subtree. Keyed by lowercased rule id → display name/code/date. + type computed struct { + name string + code string + dueDate string + } + timelineByRule := map[string]computed{} + if s.fristenrechner != nil { + stichtag := promoteStichtag(primary, sc) + opts := CalcOptions{Flags: scenarioFlagsTruthyKeys(primary.ScenarioFlags)} + tl, cerr := s.fristenrechner.Calculate(ctx, primaryCode, stichtag, opts) + if cerr != nil { + // A calc failure is not fatal — filed events still carry their + // own actual_date. Planned events then fall to DeadlinesSkipped. + tl = nil + } + if tl != nil { + for _, e := range tl.Deadlines { + if e.RuleID == "" { + continue + } + timelineByRule[strings.ToLower(e.RuleID)] = computed{ + name: e.Name, code: e.Code, dueDate: e.DueDate, + } + } + } + } + + // Load events for the promoted proceedings only. + events := []BuilderEvent{} + if err := s.db.SelectContext(ctx, &events, ` + SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id, + e.procedural_event_id, e.custom_label, e.state, e.actual_date, + e.skip_reason, e.notes, e.horizon_optional, e.created_at, e.updated_at + FROM paliad.scenario_events e + JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id + WHERE sp.scenario_id = $1 + ORDER BY e.created_at ASC`, scenarioID); err != nil { + return nil, fmt.Errorf("load events: %w", err) + } + + result := &PromoteResult{ + ProceedingsSkipped: topLevelCount - 1, + } + newProjectID := uuid.New() + + err = s.withAuditTx(ctx, "scenario_builder: promote scenario", func(tx *sqlx.Tx) error { + now := time.Now().UTC() + + // 1. Project row. path is filled by the BEFORE INSERT trigger + // (projects_sync_path); '' satisfies the NOT NULL constraint. + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.projects + (id, type, parent_id, path, title, reference, status, created_by, + case_number, client_number, proceeding_type_id, our_side, + scenario_flags, origin_scenario_id, metadata, created_at, updated_at) + VALUES ($1, 'case', $2, '', $3, $4, 'active', $5, + $6, $7, $8, $9, $10::jsonb, $11, '{}'::jsonb, $12, $12)`, + newProjectID, input.ParentID, title, input.Reference, userID, + nullableTrimmed(stringPtrOrNil(input.CaseNumber)), + nullableTrimmed(input.ClientNumber), + primary.ProceedingTypeID, nullableOurSide(ourSide), + []byte(primary.ScenarioFlags), scenarioID, now); err != nil { + return fmt.Errorf("insert project: %w", err) + } + + // 2a. Creator as team lead (RLS visibility, matches Create). + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by) + VALUES ($1, $2, 'lead', 'lead', false, $2)`, newProjectID, userID); err != nil { + return fmt.Errorf("insert creator team row: %w", err) + } + // 2b. Wizard-selected colleagues. + for _, tm := range input.TeamMembers { + if tm.UserID == userID { + continue // creator already added as lead + } + resp := tm.Responsibility + if resp == "" { + resp = ResponsibilityMember + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by) + VALUES ($1, $2, $3, $4, false, $5) + ON CONFLICT (project_id, user_id) DO UPDATE + SET role = EXCLUDED.role, responsibility = EXCLUDED.responsibility`, + newProjectID, tm.UserID, legacyRoleFromResponsibility(resp), resp, userID); err != nil { + return fmt.Errorf("insert team member: %w", err) + } + } + + // 3. Parties. + for _, p := range input.Parties { + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.parties (project_id, name, role, representative, contact_info) + VALUES ($1, $2, $3, $4, '{}'::jsonb)`, + newProjectID, strings.TrimSpace(p.Name), p.Role, p.Representative); err != nil { + return fmt.Errorf("insert party: %w", err) + } + result.PartiesCreated++ + } + + // 4. Deadlines from the promoted proceedings' events. + for _, ev := range events { + if !promoteSet[ev.ScenarioProceedingID] { + continue + } + if ev.State == "skipped" { + continue + } + if ev.SequencingRuleID == nil { + // Free-form / procedural-event-only cards have no rule to + // anchor a deadline on in v1 — skip (counts as skipped only + // when it was a dated plan; here just leave it out). + continue + } + ruleKey := strings.ToLower(ev.SequencingRuleID.String()) + comp := timelineByRule[ruleKey] + title := comp.name + if strings.TrimSpace(title) == "" { + title = "Litigation-Builder Frist" + } + ruleCode := comp.code + + if ev.State == "filed" && ev.ActualDate != nil { + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.deadlines + (project_id, title, rule_code, due_date, sequencing_rule_id, + status, completed_at, source, approval_status) + VALUES ($1, $2, $3, $4::date, $5, 'completed', $6::timestamptz, 'rule', 'legacy')`, + newProjectID, title, nullableTrimmed(&ruleCode), *ev.ActualDate, + *ev.SequencingRuleID, *ev.ActualDate); err != nil { + return fmt.Errorf("insert filed deadline: %w", err) + } + result.DeadlinesCreated++ + continue + } + + // planned — need a date. Prefer an explicit actual_date + // (court-set override the user pinned), else the computed date. + var dueDate *time.Time + if ev.ActualDate != nil { + dueDate = ev.ActualDate + } else if comp.dueDate != "" { + if d, perr := time.Parse("2006-01-02", comp.dueDate); perr == nil { + dueDate = &d + } + } + if dueDate == nil { + result.DeadlinesSkipped++ + continue + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.deadlines + (project_id, title, rule_code, due_date, sequencing_rule_id, + status, source, approval_status) + VALUES ($1, $2, $3, $4::date, $5, 'pending', 'rule', 'legacy')`, + newProjectID, title, nullableTrimmed(&ruleCode), *dueDate, + *ev.SequencingRuleID); err != nil { + return fmt.Errorf("insert planned deadline: %w", err) + } + result.DeadlinesCreated++ + } + + // 5. Flip the scenario to promoted. + if _, err := tx.ExecContext(ctx, + `UPDATE paliad.scenarios + SET status = 'promoted', promoted_project_id = $1, updated_at = now() + WHERE id = $2`, newProjectID, scenarioID); err != nil { + return fmt.Errorf("mark scenario promoted: %w", err) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("promote scenario: %w", err) + } + result.ProjectID = newProjectID + return result, nil +} + +// collectProceedingSubtree returns the set of proceeding ids rooted at +// rootID (inclusive), walking parent_scenario_proceeding_id downwards. +func collectProceedingSubtree(all []BuilderProceeding, rootID uuid.UUID) map[uuid.UUID]bool { + set := map[uuid.UUID]bool{rootID: true} + // Iterate to a fixpoint; depth is tiny (<=2 today) so a few passes suffice. + for changed := true; changed; { + changed = false + for i := range all { + p := &all[i] + if p.ParentScenarioProceedingID != nil && set[*p.ParentScenarioProceedingID] && !set[p.ID] { + set[p.ID] = true + changed = true + } + } + } + return set +} + +// promoteStichtag picks the calc anchor for the promote timeline: the +// primary proceeding's own stichtag, else the scenario default, else today. +func promoteStichtag(primary *BuilderProceeding, sc *BuilderScenario) string { + if primary.Stichtag != nil { + return primary.Stichtag.Format("2006-01-02") + } + if sc.Stichtag != nil { + return sc.Stichtag.Format("2006-01-02") + } + return time.Now().UTC().Format("2006-01-02") +} + +// scenarioFlagsTruthyKeys returns the flag keys set to boolean true in the +// builder's scenario_flags jsonb — the array shape the calc engine's +// CalcOptions.Flags consumes. +func scenarioFlagsTruthyKeys(raw json.RawMessage) []string { + if len(raw) == 0 { + return nil + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil + } + out := []string{} + for k, v := range m { + if b, ok := v.(bool); ok && b { + out = append(out, k) + } + } + return out +} + +// stringPtrOrNil normalises a *string so an all-whitespace value becomes +// nil before nullableTrimmed sees it (case_number empty → NULL column). +func stringPtrOrNil(p *string) *string { + if p == nil { + return nil + } + if strings.TrimSpace(*p) == "" { + return nil + } + return p +} + // ----------------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------------- diff --git a/internal/services/scenario_builder_service_test.go b/internal/services/scenario_builder_service_test.go index 19d2055..a86e3b9 100644 --- a/internal/services/scenario_builder_service_test.go +++ b/internal/services/scenario_builder_service_test.go @@ -83,7 +83,7 @@ func TestScenarioBuilderService(t *testing.T) { t.Fatalf("look up upc.inf id: %v", err) } - svc := NewScenarioBuilderService(pool, nil, nil) + svc := NewScenarioBuilderService(pool, nil, nil, nil) // 1. Create a scenario for the owner. Empty name should default. sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{}) @@ -318,7 +318,7 @@ func TestScenarioBuilderAkteDualWrite(t *testing.T) { userSvc := NewUserService(pool) projSvc := NewProjectService(pool, userSvc) flagsSvc := NewScenarioFlagsService(pool, projSvc) - svc := NewScenarioBuilderService(pool, projSvc, flagsSvc) + svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil) // ────────────────────────────────────────────────────────────────── // Phase A — Akte-backed scenario writes through to project tables. @@ -459,6 +459,190 @@ func TestScenarioBuilderAkteDualWrite(t *testing.T) { } } +// TestScenarioBuilderPromote pins B5's load-bearing contract +// (m/paliad#153 / t-paliad-350 / PRD §2.4 + §5.4 + §10): PromoteScenario +// creates a paliad.projects 'case' row transactionally, cascades parties +// + deadlines, flips the scenario to 'promoted' with a back-ref, and +// makes the original scenario read-only afterwards. +func TestScenarioBuilderPromote(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() + var createdProjectID uuid.UUID + cleanup := func() { + if createdProjectID != uuid.Nil { + pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, createdProjectID) + } + pool.ExecContext(ctx, `DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner) + pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, owner) + pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, owner) + } + cleanup() + defer cleanup() + + if _, err := pool.ExecContext(ctx, + `INSERT INTO auth.users (id, email) VALUES ($1, 'promote-owner-test@hlc.com')`, owner); err != nil { + t.Fatalf("seed auth.users: %v", err) + } + if _, err := pool.ExecContext(ctx, + `INSERT INTO paliad.users (id, email, display_name, office, lang) + VALUES ($1, 'promote-owner-test@hlc.com', 'Promote Owner', 'munich', 'de')`, owner); err != nil { + t.Fatalf("seed paliad.users: %v", err) + } + + 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) + } + // Two distinct rule ids: one filed, one planned (with an explicit + // actual_date so the planned deadline lands even without a calc engine). + var ruleIDs []uuid.UUID + if err := pool.SelectContext(ctx, &ruleIDs, + `SELECT id FROM paliad.sequencing_rules + WHERE proceeding_type_id = $1 AND is_active = true AND lifecycle_state = 'published' + ORDER BY sequence_order NULLS LAST, id LIMIT 2`, ptID); err != nil { + t.Fatalf("look up sequencing_rules: %v", err) + } + if len(ruleIDs) < 2 { + t.Skipf("need >=2 published rules for upc.inf; got %d", len(ruleIDs)) + } + + userSvc := NewUserService(pool) + projSvc := NewProjectService(pool, userSvc) + flagsSvc := NewScenarioFlagsService(pool, projSvc) + // fristenrechner nil — planned events carry an explicit actual_date in + // this test, so the cascade doesn't need computed dates. + svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil) + + sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{Name: "Promote-Test"}) + if err != nil { + t.Fatalf("CreateScenario: %v", err) + } + 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) + } + filedDate := mustDate(t, "2026-01-15") + if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{ + SequencingRuleID: &ruleIDs[0], State: ptrString("filed"), ActualDate: &filedDate, + }); err != nil { + t.Fatalf("AddEvent filed: %v", err) + } + plannedDate := mustDate(t, "2026-04-01") + if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{ + SequencingRuleID: &ruleIDs[1], State: ptrString("planned"), ActualDate: &plannedDate, + }); err != nil { + t.Fatalf("AddEvent planned: %v", err) + } + + res, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{ + Title: "Becker ./. X — UPC", + CaseNumber: ptrString("UPC_CFI_123/2026"), + OurSide: ptrString("defendant"), + Parties: []PromotePartyInput{ + {Name: "Becker GmbH", Role: ptrString("defendant")}, + {Name: "X Corp", Role: ptrString("claimant")}, + }, + }) + if err != nil { + t.Fatalf("PromoteScenario: %v", err) + } + createdProjectID = res.ProjectID + if res.ProjectID == uuid.Nil { + t.Fatal("PromoteScenario returned nil project id") + } + if res.PartiesCreated != 2 { + t.Errorf("PartiesCreated = %d, want 2", res.PartiesCreated) + } + if res.DeadlinesCreated != 2 { + t.Errorf("DeadlinesCreated = %d, want 2 (1 filed + 1 planned-with-date)", res.DeadlinesCreated) + } + + // Project row exists, is a 'case', carries origin_scenario_id + flags. + var proj struct { + Type string `db:"type"` + OriginScenario *uuid.UUID `db:"origin_scenario_id"` + ProceedingType *int `db:"proceeding_type_id"` + OurSide *string `db:"our_side"` + ScenarioFlags json.RawMessage `db:"scenario_flags"` + CaseNumber *string `db:"case_number"` + } + if err := pool.GetContext(ctx, &proj, + `SELECT type, origin_scenario_id, proceeding_type_id, our_side, scenario_flags, case_number + FROM paliad.projects WHERE id = $1`, res.ProjectID); err != nil { + t.Fatalf("load promoted project: %v", err) + } + if proj.Type != "case" { + t.Errorf("project type = %q, want case", proj.Type) + } + if proj.OriginScenario == nil || *proj.OriginScenario != sc.ID { + t.Errorf("origin_scenario_id = %v, want %v", proj.OriginScenario, sc.ID) + } + if proj.ProceedingType == nil || *proj.ProceedingType != ptID { + t.Errorf("proceeding_type_id = %v, want %d", proj.ProceedingType, ptID) + } + if proj.OurSide == nil || *proj.OurSide != "defendant" { + t.Errorf("our_side = %v, want defendant", proj.OurSide) + } + + // Scenario flipped to promoted with the back-ref. + var after BuilderScenario + if err := pool.GetContext(ctx, &after, + `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`, sc.ID); err != nil { + t.Fatalf("reload scenario: %v", err) + } + if after.Status != "promoted" { + t.Errorf("scenario status = %q, want promoted", after.Status) + } + if after.PromotedProjectID == nil || *after.PromotedProjectID != res.ProjectID { + t.Errorf("promoted_project_id = %v, want %v", after.PromotedProjectID, res.ProjectID) + } + + // Deadlines + parties physically present. + var deadlineCount, partyCount int + pool.GetContext(ctx, &deadlineCount, + `SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, res.ProjectID) + pool.GetContext(ctx, &partyCount, + `SELECT COUNT(*) FROM paliad.parties WHERE project_id = $1`, res.ProjectID) + if deadlineCount != 2 { + t.Errorf("deadlines in DB = %d, want 2", deadlineCount) + } + if partyCount != 2 { + t.Errorf("parties in DB = %d, want 2", partyCount) + } + + // Promoted scenario is now read-only: further PATCH is rejected. + if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{ + Name: ptrString("rename-after-promote"), + }); !errors.Is(err, ErrInvalidInput) { + t.Errorf("PatchScenario after promote = %v, want ErrInvalidInput", err) + } + // Re-promoting is rejected. + if _, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{Title: "again"}); !errors.Is(err, ErrInvalidInput) { + t.Errorf("re-promote = %v, want ErrInvalidInput", err) + } +} + // mustDate parses an ISO date or fails the test. Helper for the // dual-write test above. func mustDate(t *testing.T, s string) time.Time {