feat(builder): B5 — share + promote-to-project wizard (t-paliad-350)

Litigation Builder slice B5 (m/paliad#153 PRD §2.4 + §2.5 + §5.4 + §10).

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

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

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

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

View File

@@ -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

View File

@@ -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<void> {
// 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<string>(),
};
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 `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
}).join("");
return `<ol class="builder-promote-steps">${dots}</ol>`;
}
function renderStep1(): string {
const rows = [
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
].join("");
const extra = ctx.extraTopLevel > 0
? `<p class="builder-promote-note">${escHtml(
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
)}</p>`
: "";
return (
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
);
}
function renderStep2(): string {
const list = parties.length === 0
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
: parties.map((p, i) => (
`<div class="builder-promote-party" data-idx="${i}">` +
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
`</div>`
)).join("");
return (
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
`<div class="builder-promote-parties">${list}</div>` +
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
);
}
function renderStep3(): string {
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
.concat(parents.map((p) => {
const sel = p.id === meta.parentId ? " selected" : "";
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
})).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 (
`<label class="builder-promote-team-item">` +
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
`<span>${escHtml(label)}</span></label>`
);
}).join("");
return (
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
`<div class="builder-promote-field-row">` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
`</div>` +
`<div class="builder-promote-field-row">` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
`<select class="builder-promote-ourside">` +
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
`</select></label>` +
`</div>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
`<div class="builder-promote-team">${team}</div></div>` +
`<p class="builder-promote-error" hidden></p>`
);
}
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 = `
<header class="builder-modal-header">
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
</header>
${stepHeader()}
<div class="builder-promote-body">${body}</div>
<footer class="builder-promote-footer">
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
<span class="builder-promote-footer-spacer"></span>
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
</footer>`;
wire();
}
function captureStep2(): void {
modal.querySelectorAll<HTMLElement>(".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<HTMLInputElement>(".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<HTMLElement>(".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<void> {
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<string, unknown> = {
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 = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
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<ProjectOption[]> {
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<UserOption[]> {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,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<ShareUser[]> {
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<void> {
const users = await fetchUsers();
let shares = [...opts.currentShares];
const backdrop = document.createElement("div");
backdrop.className = "builder-modal-backdrop";
backdrop.innerHTML = `
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
aria-label="${escAttr(t("builder.share.title"))}">
<header class="builder-modal-header">
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
</header>
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
<div class="builder-share-pickerbox">
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
</div>
<div class="builder-share-current">
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
<ul class="builder-share-current-list"></ul>
</div>
</div>`;
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 = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
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 (
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
`</li>`
);
}).join("");
currentEl.querySelectorAll<HTMLElement>(".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 = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
return;
}
resultsEl.innerHTML = matches.map((u) => (
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
`</li>`
)).join("");
resultsEl.querySelectorAll<HTMLElement>(".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<void> {
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<void> {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -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<string, string>;
procTypes: ProceedingTypeMeta[];
procTypesById: Map<number, ProceedingTypeMeta>;
procTypesByCode: Map<string, ProceedingTypeMeta>;
@@ -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<T>(input: RequestInfo, init?: RequestInit): Promise<T |
}
async function fetchScenarios(): Promise<BuilderScenario[]> {
const out = await fetchJSON<BuilderScenario[]>("/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<BuilderScenario[]>("/api/builder/scenarios?status=all");
return Array.isArray(out) ? out : [];
}
async function fetchSharedScenarios(): Promise<BuilderScenario[]> {
const out = await fetchJSON<BuilderScenario[]>("/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 <Name>". Failures degrade to showing
// the owner uuid; the watermark is informational, not load-bearing.
async function ensureOwnerNames(): Promise<void> {
if (state.ownerNameById.size > 0) return;
const users = await fetchJSON<Array<{ id: string; display_name?: string; email: string }>>("/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<BuilderScenarioDeep | null> {
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
}
@@ -396,20 +428,32 @@ async function flushAutoSave(): Promise<void> {
// ────────────────────────────────────────────────────────────────────────────
async function refreshScenarioList(): Promise<void> {
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 = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
if (wrapId) {
const wrap = document.getElementById(wrapId);
if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0;
}
if (scenarios.length === 0) {
ul.innerHTML = alwaysShow
? `<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) => {
ul.innerHTML = scenarios.map((sc) => {
const isActive = sc.id === activeId;
return (
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
@@ -428,12 +472,32 @@ function renderScenarioList(): void {
});
}
function renderScenarioList(): void {
renderBucket("builder-scenario-list-active", null,
state.list.filter((s) => 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[] = [`<option value="">${escHtml(placeholderText)}</option>`];
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(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
}
@@ -977,13 +1041,16 @@ async function loadScenario(id: string): Promise<void> {
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<void> {
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<string>([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<void> {
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<void> {
});
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();
}

View File

@@ -294,6 +294,62 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",

View File

@@ -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"

View File

@@ -68,12 +68,10 @@ export function renderProcedures(): string {
<button type="button" id="builder-share-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.share">Teilen</button>
<button type="button" id="builder-promote-btn"
className="builder-action-btn builder-action-btn--primary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.promote">Als Projekt anlegen</button>
</div>
<div className="builder-pageheader-row">
@@ -141,10 +139,28 @@ export function renderProcedures(): string {
<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+. */}
{/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert.
Each bucket hides itself when empty (builder.ts toggles
the hidden attribute). */}
<div className="builder-sidepanel-bucket" data-bucket="shared" id="builder-bucket-shared" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.shared">Geteilt mit mir</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-shared" aria-label="Mit mir geteilte Szenarien"></ul>
</div>
<div className="builder-sidepanel-bucket" data-bucket="promoted" id="builder-bucket-promoted" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.promoted">Als Projekt angelegt</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-promoted" aria-label="Promotete Szenarien"></ul>
</div>
<div className="builder-sidepanel-bucket" data-bucket="archived" id="builder-bucket-archived" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.archived">Archiviert</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-archived" aria-label="Archivierte Szenarien"></ul>
</div>
</aside>
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
{/* B5 — read-only watermark for shared / promoted scenarios.
builder.ts fills + unhides it when the active scenario
is not editable by the current user. */}
<div id="builder-readonly-watermark" className="builder-readonly-watermark" hidden></div>
<div id="builder-canvas" className="builder-canvas">
{/* Cold-open placeholder — replaced by triplet stack once a
scenario is loaded. */}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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)
// ---------------------------------------------------------------------------

View File

@@ -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
// -----------------------------------------------------------------------------

View File

@@ -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 {