Compare commits
13 Commits
mai/ritchi
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
| 07acf7b4a2 | |||
| acf5743fa3 | |||
| d1d0cf9c1d | |||
| 5f0a85fa83 | |||
| 6e585951ee | |||
| 8240717b5a | |||
| 593e6243e0 | |||
| 15cc5e418c | |||
| abf0328dcd | |||
| cc13a5b857 | |||
| abef74fe63 | |||
| 1bd2ebb4ae | |||
| f6c8eb5bcf |
@@ -421,7 +421,7 @@ The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depend
|
||||
| `POST /api/admin/rules` | POST | global_admin | Create a new rule from scratch (starts as `lifecycle_state='draft'`). |
|
||||
| `GET /admin/rules/{id}/audit` | GET | global_admin | Audit log for this rule. |
|
||||
| `POST /admin/rules/{id}/preview` | POST | global_admin | Preview-on-trigger-date — runs calculator with this draft replacing its published peer; returns the resulting timeline (no persistence). |
|
||||
| `POST /admin/rules/export-migration` | POST | global_admin | Export pending (draft + audit-since-last-export) rules as a `*.up.sql` blob the human can paste into `internal/db/migrations/`. Sets `migration_exported=true` on the audit rows. |
|
||||
| _(removed t-paliad-297)_ migration-export endpoint | — | — | Was a SQL-export tool generating `*.up.sql` from audit rows. Workflow shifted to hand-written numbered migrations; tool removed in m/paliad#129. |
|
||||
|
||||
### 4.2 Draft → published lifecycle
|
||||
|
||||
|
||||
1452
docs/design-litigation-planner-2026-05-26.md
Normal file
1452
docs/design-litigation-planner-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ A full org export today is **< 600 rows of user content** plus reference data
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
**Existing export precedent.** _(Originally pointed at the admin rule-migration export. That tool was deleted in m/paliad#129 / t-paliad-297. The gating pattern — `adminGate(users, …)` on a download endpoint that streams a generated artifact — still lives on other admin handlers, e.g. `handleAdminDownloadBackup` for `/api/admin/backups/{id}/file`.)_ Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
@@ -591,7 +591,7 @@ No other slice deltas. v1 still ships slices 1+2+3.
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/handlers/backups.go` — `handleAdminDownloadBackup` (precedent for admin-gated artifact download; the older rule-migration export precedent was removed in t-paliad-297).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
@@ -46,7 +46,6 @@ import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderAdminRulesList } from "./src/admin-rules-list";
|
||||
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
@@ -284,7 +283,6 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-list.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-export.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
@@ -416,7 +414,6 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
|
||||
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
|
||||
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
|
||||
// editor can copy or download. Optional ?since=<audit-id> query lets
|
||||
// the editor scope the export to a particular audit window — empty =
|
||||
// every un-exported audit row.
|
||||
export function renderAdminRulesExport(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
|
||||
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Veränderungen.
|
||||
Manuell in <code>internal/db/migrations/</code> einchecken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-export-controls">
|
||||
<div className="form-field">
|
||||
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
|
||||
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
|
||||
</div>
|
||||
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
|
||||
Export generieren
|
||||
</button>
|
||||
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
|
||||
Als Datei herunterladen
|
||||
</button>
|
||||
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="export-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
|
||||
<span id="export-summary-count" />
|
||||
<span id="export-summary-latest" />
|
||||
</div>
|
||||
|
||||
<pre id="export-output" className="admin-rules-export-pre" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-export.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -39,9 +39,6 @@ export function renderAdminRulesList(): string {
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
|
||||
Migrations exportieren
|
||||
</a>
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-export.ts — /admin/rules/export. Calls
|
||||
// GET /admin/api/rules/export-migrations[?since=<uuid>] and renders the
|
||||
// SQL blob server-side. Download builds a Blob URL and triggers a
|
||||
// fake <a> click; copy uses navigator.clipboard.
|
||||
|
||||
interface ExportResult {
|
||||
migration_sql: string;
|
||||
count: number;
|
||||
latest_audit_id: string;
|
||||
}
|
||||
|
||||
let latest: ExportResult | null = null;
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("export-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
|
||||
async function runExport() {
|
||||
const since = (document.getElementById("export-since") as HTMLInputElement).value.trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (since) qs.set("since", since);
|
||||
const url = "/admin/api/rules/export-migrations" + (qs.toString() ? "?" + qs.toString() : "");
|
||||
const out = document.getElementById("export-output") as HTMLElement;
|
||||
const summary = document.getElementById("export-summary") as HTMLElement;
|
||||
const dl = document.getElementById("export-download") as HTMLElement;
|
||||
const cp = document.getElementById("export-copy") as HTMLElement;
|
||||
out.textContent = t("admin.rules.export.running") || "Lade...";
|
||||
summary.style.display = "none";
|
||||
dl.style.display = "none";
|
||||
cp.style.display = "none";
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || (t("admin.rules.export.error") || "Export fehlgeschlagen."), true);
|
||||
out.textContent = "";
|
||||
return;
|
||||
}
|
||||
latest = await resp.json() as ExportResult;
|
||||
out.textContent = latest.migration_sql;
|
||||
summary.style.display = "";
|
||||
const countEl = document.getElementById("export-summary-count") as HTMLElement;
|
||||
const latestEl = document.getElementById("export-summary-latest") as HTMLElement;
|
||||
countEl.textContent = (t("admin.rules.export.count") || "Audit-Zeilen: {n}").replace("{n}", String(latest.count));
|
||||
if (latest.latest_audit_id) {
|
||||
latestEl.textContent = (t("admin.rules.export.latest") || "Letzte Audit-ID: {id}").replace("{id}", latest.latest_audit_id);
|
||||
} else {
|
||||
latestEl.textContent = "";
|
||||
}
|
||||
if (latest.count > 0) {
|
||||
dl.style.display = "";
|
||||
cp.style.display = "";
|
||||
showFeedback((t("admin.rules.export.ok") || "{n} Audit-Zeilen exportiert.").replace("{n}", String(latest.count)), false);
|
||||
} else {
|
||||
showFeedback(t("admin.rules.export.no_pending") || "Keine offenen Audit-Zeilen zum Export.", false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
if (!latest) return;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const name = `rules-export-${ts}.up.sql`;
|
||||
const blob = new Blob([latest.migration_sql], { type: "application/sql" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!latest) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(latest.migration_sql);
|
||||
showFeedback(t("admin.rules.export.copied") || "In Zwischenablage kopiert.", false);
|
||||
} catch (e) {
|
||||
showFeedback(t("admin.rules.export.copy_failed") || "Kopieren fehlgeschlagen.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
(document.getElementById("export-run") as HTMLElement).addEventListener("click", runExport);
|
||||
(document.getElementById("export-download") as HTMLElement).addEventListener("click", downloadFile);
|
||||
(document.getElementById("export-copy") as HTMLElement).addEventListener("click", copyToClipboard);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -237,6 +237,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.disc.cfi": "Bucheinsicht",
|
||||
"deadlines.upc.apl.cost": "Berufung Kosten",
|
||||
"deadlines.upc.apl.order": "Berufung Anordnungen",
|
||||
"deadlines.upc.apl": "Berufung",
|
||||
"deadlines.appeal_target.label": "Worauf richtet sich die Berufung?",
|
||||
"deadlines.appeal_target.endentscheidung": "Endentscheidung",
|
||||
"deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung",
|
||||
"deadlines.appeal_target.anordnung": "Anordnung",
|
||||
"deadlines.appeal_target.schadensbemessung": "Schadensbemessung",
|
||||
"deadlines.appeal_target.bucheinsicht": "Bucheinsicht",
|
||||
"deadlines.de.group.inf": "Verletzungsverfahren",
|
||||
"deadlines.de.group.null": "Nichtigkeitsverfahren",
|
||||
"deadlines.de.inf.lg": "LG (1. Instanz)",
|
||||
@@ -309,6 +316,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
|
||||
"choices.caret.title": "Optionen für dieses Ereignis",
|
||||
"choices.appellant.title": "Berufung durch …",
|
||||
@@ -2892,7 +2901,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// `admin.procedural_events.*` aliases live after the EN block — they
|
||||
// pin the contract for when .tsx files rebind in Slice B (B.5).
|
||||
"nav.admin.rules": "Verfahrensschritte verwalten",
|
||||
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
|
||||
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
||||
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
|
||||
@@ -2900,7 +2908,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.rules.list.export": "Migrations exportieren",
|
||||
"admin.rules.tab.rules": "Regeln",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Lade…",
|
||||
@@ -3062,23 +3069,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.modal.restore.title": "Wiederherstellen",
|
||||
"admin.rules.edit.modal.restore.body": "Regel wird wiederhergestellt (archived → published).",
|
||||
|
||||
"admin.rules.export.title": "Regel-Migrations exportieren — Paliad",
|
||||
"admin.rules.export.heading": "Regel-Migrations exportieren",
|
||||
"admin.rules.export.subtitle": "Generiert ein *.up.sql-Blob mit allen unsynchronisierten Audit-Veränderungen. Manuell in internal/db/migrations/ einchecken.",
|
||||
"admin.rules.export.breadcrumb": "← Regeln verwalten",
|
||||
"admin.rules.export.field.since": "Startend ab Audit-ID (optional)",
|
||||
"admin.rules.export.run": "Export generieren",
|
||||
"admin.rules.export.running": "Lade…",
|
||||
"admin.rules.export.download": "Als Datei herunterladen",
|
||||
"admin.rules.export.copy": "In Zwischenablage kopieren",
|
||||
"admin.rules.export.copied": "In Zwischenablage kopiert.",
|
||||
"admin.rules.export.copy_failed": "Kopieren fehlgeschlagen.",
|
||||
"admin.rules.export.count": "Audit-Zeilen: {n}",
|
||||
"admin.rules.export.latest": "Letzte Audit-ID: {id}",
|
||||
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
|
||||
"admin.rules.export.error": "Export fehlgeschlagen.",
|
||||
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). Symmetric past/future chip fan
|
||||
// around an ALLES centre. Used by the filter-bar 'time' axis from
|
||||
// Slice A onwards; future slices will migrate /agenda and
|
||||
@@ -3344,6 +3334,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.dmgs.cfi": "Damages Determination",
|
||||
"deadlines.upc.disc.cfi": "Lay-open Books",
|
||||
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
|
||||
"deadlines.upc.apl": "Appeal",
|
||||
"deadlines.appeal_target.label": "Appeal against:",
|
||||
"deadlines.appeal_target.endentscheidung": "Final Decision",
|
||||
"deadlines.appeal_target.kostenentscheidung": "Cost Decision",
|
||||
"deadlines.appeal_target.anordnung": "Order",
|
||||
"deadlines.appeal_target.schadensbemessung": "Damages Determination",
|
||||
"deadlines.appeal_target.bucheinsicht": "Lay-open Books",
|
||||
"deadlines.upc.apl.order": "Order Appeal (15-day)",
|
||||
"deadlines.de.group.inf": "Infringement proceedings",
|
||||
"deadlines.de.group.null": "Nullity proceedings",
|
||||
@@ -3417,6 +3414,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
|
||||
"choices.caret.title": "Options for this event",
|
||||
"choices.appellant.title": "Appeal by …",
|
||||
@@ -5966,7 +5965,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
|
||||
"nav.admin.rules": "Manage procedural events",
|
||||
"nav.admin.rules_export": "Procedural-event migrations",
|
||||
"admin.card.rules.title": "Manage procedural events",
|
||||
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
|
||||
|
||||
@@ -5974,7 +5972,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.list.heading": "Manage procedural events",
|
||||
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ New procedural event",
|
||||
"admin.rules.list.export": "Export migrations",
|
||||
"admin.rules.tab.rules": "Rules",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Loading…",
|
||||
@@ -6136,23 +6133,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.modal.restore.title": "Restore",
|
||||
"admin.rules.edit.modal.restore.body": "Rule will be restored (archived → published).",
|
||||
|
||||
"admin.rules.export.title": "Export rule migrations — Paliad",
|
||||
"admin.rules.export.heading": "Export rule migrations",
|
||||
"admin.rules.export.subtitle": "Generates a *.up.sql blob with every un-exported audit change. Commit manually into internal/db/migrations/.",
|
||||
"admin.rules.export.breadcrumb": "← Manage Rules",
|
||||
"admin.rules.export.field.since": "Starting from audit id (optional)",
|
||||
"admin.rules.export.run": "Generate export",
|
||||
"admin.rules.export.running": "Loading…",
|
||||
"admin.rules.export.download": "Download as file",
|
||||
"admin.rules.export.copy": "Copy to clipboard",
|
||||
"admin.rules.export.copied": "Copied to clipboard.",
|
||||
"admin.rules.export.copy_failed": "Copy failed.",
|
||||
"admin.rules.export.count": "Audit rows: {n}",
|
||||
"admin.rules.export.latest": "Latest audit id: {id}",
|
||||
"admin.rules.export.ok": "{n} audit rows exported.",
|
||||
"admin.rules.export.error": "Export failed.",
|
||||
"admin.rules.export.no_pending": "No pending audit rows to export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). See DE block above for details.
|
||||
"date_range.button.label": "Time range",
|
||||
"date_range.button.label.custom_range": "From {from} to {to}",
|
||||
|
||||
@@ -64,9 +64,7 @@ let sidePrefilledFromProject = false;
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"upc.apl",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
@@ -75,6 +73,29 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
// Proceedings that surface the appeal-target chip group. Currently
|
||||
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
|
||||
// can opt in by adding the code here.
|
||||
const APPEAL_TARGET_PROCEEDINGS = new Set([
|
||||
"upc.apl",
|
||||
]);
|
||||
|
||||
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
|
||||
// in sync with pkg/litigationplanner/types.go AppealTargets).
|
||||
const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
function hasAppealTarget(proceedingType: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
@@ -103,6 +124,32 @@ function writeAppellantToURL(a: Side) {
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Slice B1 — appeal-target URL state. Empty string = no target picked
|
||||
// (the row is hidden because the proceeding isn't an appeal). Any
|
||||
// other value must be one of APPEAL_TARGETS; unknown values are
|
||||
// rejected by readAppealTargetFromURL so a stale link can't break
|
||||
// the engine filter.
|
||||
function readAppealTargetFromURL(): AppealTarget {
|
||||
const raw = new URLSearchParams(window.location.search).get("target") || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function writeAppealTargetToURL(t: AppealTarget) {
|
||||
const url = new URL(window.location.href);
|
||||
if (t === "") url.searchParams.delete("target");
|
||||
else url.searchParams.set("target", t);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Default target on first picker entry into upc.apl. m: Endentscheidung
|
||||
// is the most-common appeal target; the chip group also defaults
|
||||
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
|
||||
// sync so the URL-less default render hits the same code path.
|
||||
let currentAppealTarget: AppealTarget = "";
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
@@ -268,6 +315,13 @@ async function doCalc() {
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung,
|
||||
// default to "endentscheidung" when no chip pick is stored in URL.
|
||||
// For non-appeal proceedings the engine ignores opts.AppealTarget.
|
||||
const appealTarget = hasAppealTarget(selectedType)
|
||||
? (currentAppealTarget || "endentscheidung")
|
||||
: "";
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
@@ -276,6 +330,7 @@ async function doCalc() {
|
||||
courtId,
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
appealTarget,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
@@ -447,6 +502,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
syncAppealTargetRowVisibility();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -471,6 +527,23 @@ function syncAppellantRowVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
// syncAppealTargetRowVisibility shows the appeal-target chip group
|
||||
// when the unified upc.apl Berufung tile is selected, hides it
|
||||
// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears
|
||||
// state + URL when hiding so a stale ?target= can't leak.
|
||||
function syncAppealTargetRowVisibility() {
|
||||
const row = document.getElementById("appeal-target-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppealTarget(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppealTarget !== "") {
|
||||
currentAppealTarget = "";
|
||||
writeAppealTargetToURL("");
|
||||
syncRadioGroup("appeal-target", "endentscheidung");
|
||||
}
|
||||
}
|
||||
|
||||
function syncRadioGroup(name: string, value: string) {
|
||||
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
||||
input.checked = input.value === value;
|
||||
@@ -655,8 +728,10 @@ function initViewToggle() {
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
currentAppealTarget = readAppealTargetFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
||||
syncSideHintVisibility();
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
@@ -679,6 +754,23 @@ function initPerspectiveControls() {
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
|
||||
// Each chip change re-fetches with the new target slug so the
|
||||
// timeline re-renders against the matching rule subset.
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appeal-target]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(v)) {
|
||||
currentAppealTarget = v as AppealTarget;
|
||||
} else {
|
||||
currentAppealTarget = "";
|
||||
}
|
||||
writeAppealTargetToURL(currentAppealTarget);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
renderColumnsBody,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
@@ -392,4 +394,73 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// m's correction in m/paliad#127 (t-paliad-295) reverted half of #88's
|
||||
// header refresh: the user-perspective labels "Unsere Seite"/"Gegnerseite"
|
||||
// only make sense once the user has picked a side. While the side is
|
||||
// still "Nicht festgelegt" (side === null — the default after #120) the
|
||||
// header falls back to the semantic-neutral "Proaktiv"/"Reaktiv" labels.
|
||||
// Picking a side re-enables the #88 labels. The bucketing primitive
|
||||
// itself is unchanged — only the column-header text differs.
|
||||
describe("renderColumnsBody — side-aware column header labels (m/paliad#127)", () => {
|
||||
const dlFix = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party,
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const data: DeadlineResponse = {
|
||||
proceedingType: "upc.inf.cfi",
|
||||
proceedingName: "UPC Verletzungsverfahren",
|
||||
triggerDate: "2026-01-01",
|
||||
deadlines: [
|
||||
dlFix("claimant", "Klageschrift", "2026-01-01"),
|
||||
dlFix("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
],
|
||||
};
|
||||
|
||||
test("side=null renders Proaktiv/Gericht/Reaktiv headers", () => {
|
||||
const html = renderColumnsBody(data, { side: null });
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
expect(html).not.toContain(">Unsere Seite<");
|
||||
expect(html).not.toContain(">Gegnerseite<");
|
||||
});
|
||||
|
||||
test("side=null when opts omitted (default) still renders Proaktiv/Reaktiv", () => {
|
||||
const html = renderColumnsBody(data);
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=claimant renders Unsere Seite/Gericht/Gegnerseite headers", () => {
|
||||
const html = renderColumnsBody(data, { side: "claimant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=defendant renders Unsere Seite/Gegnerseite headers (column swap is bucketing, not labels)", () => {
|
||||
// The user-perspective labels are picked once a side is set; the
|
||||
// bucketer still routes defendant filings into the `ours` column when
|
||||
// side=defendant, so the left column's header truthfully reads
|
||||
// "Unsere Seite" regardless of which underlying party occupies it.
|
||||
const html = renderColumnsBody(data, { side: "defendant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,12 @@ export interface CalcParams {
|
||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||
// ON.
|
||||
includeHidden?: boolean;
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
|
||||
// (upc.apl) timeline to the rule subset whose applies_to_target
|
||||
// contains the requested slug. Empty = no filter. Valid values:
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -756,14 +762,29 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
// Column-header labels have two modes (m/paliad#127):
|
||||
// - side picked → "Unsere Seite" / "Gegnerseite" (the columns
|
||||
// truthfully describe whose filings sit there,
|
||||
// because the bucketer routed the user's side into
|
||||
// `ours`).
|
||||
// - side === null → "Proaktiv" / "Reaktiv" (semantic-neutral). The
|
||||
// user-perspective labels would lie here: we don't
|
||||
// know yet which party is "us", so calling the left
|
||||
// column "Unsere Seite" presumes a pick the user
|
||||
// hasn't made. The neutral Proaktiv/Reaktiv pair
|
||||
// keeps the spatial axis ("who initiates vs who
|
||||
// responds") legible while the hint chip on the
|
||||
// page nudges the user to pick a side.
|
||||
//
|
||||
// Note: the COLUMN PROJECTION does not change — the bucketing primitive
|
||||
// still routes claimant→left, defendant→right when side=null (legacy
|
||||
// claimant-on-the-left fallback). Only the HEADER label changes.
|
||||
const leftLabel = userSide === null ? t("deadlines.col.proactive") : t("deadlines.col.ours");
|
||||
const rightLabel = userSide === null ? t("deadlines.col.reactive") : t("deadlines.col.opponent");
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(leftLabel, "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
html += headerCell(rightLabel, "fr-col-opponent");
|
||||
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
@@ -796,6 +817,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
? params.perCardChoices
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -205,7 +205,6 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
|
||||
@@ -401,22 +401,6 @@ export type I18nKey =
|
||||
| "admin.rules.edit.title"
|
||||
| "admin.rules.empty"
|
||||
| "admin.rules.error.load"
|
||||
| "admin.rules.export.breadcrumb"
|
||||
| "admin.rules.export.copied"
|
||||
| "admin.rules.export.copy"
|
||||
| "admin.rules.export.copy_failed"
|
||||
| "admin.rules.export.count"
|
||||
| "admin.rules.export.download"
|
||||
| "admin.rules.export.error"
|
||||
| "admin.rules.export.field.since"
|
||||
| "admin.rules.export.heading"
|
||||
| "admin.rules.export.latest"
|
||||
| "admin.rules.export.no_pending"
|
||||
| "admin.rules.export.ok"
|
||||
| "admin.rules.export.run"
|
||||
| "admin.rules.export.running"
|
||||
| "admin.rules.export.subtitle"
|
||||
| "admin.rules.export.title"
|
||||
| "admin.rules.filter.lifecycle"
|
||||
| "admin.rules.filter.lifecycle.any"
|
||||
| "admin.rules.filter.proceeding"
|
||||
@@ -428,7 +412,6 @@ export type I18nKey =
|
||||
| "admin.rules.lifecycle.archived"
|
||||
| "admin.rules.lifecycle.draft"
|
||||
| "admin.rules.lifecycle.published"
|
||||
| "admin.rules.list.export"
|
||||
| "admin.rules.list.heading"
|
||||
| "admin.rules.list.new"
|
||||
| "admin.rules.list.subtitle"
|
||||
@@ -1201,6 +1184,12 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appeal_target.anordnung"
|
||||
| "deadlines.appeal_target.bucheinsicht"
|
||||
| "deadlines.appeal_target.endentscheidung"
|
||||
| "deadlines.appeal_target.kostenentscheidung"
|
||||
| "deadlines.appeal_target.label"
|
||||
| "deadlines.appeal_target.schadensbemessung"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
@@ -1233,6 +1222,8 @@ export type I18nKey =
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1526,6 +1517,7 @@ export type I18nKey =
|
||||
| "deadlines.trigger.label"
|
||||
| "deadlines.unavailable"
|
||||
| "deadlines.upc"
|
||||
| "deadlines.upc.apl"
|
||||
| "deadlines.upc.apl.cost"
|
||||
| "deadlines.upc.apl.merits"
|
||||
| "deadlines.upc.apl.order"
|
||||
@@ -1992,7 +1984,6 @@ export type I18nKey =
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.rules"
|
||||
| "nav.admin.rules_export"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
| "nav.akten"
|
||||
|
||||
@@ -18185,42 +18185,6 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-top: 1px solid var(--color-border, #d4d4d8);
|
||||
}
|
||||
|
||||
/* Export page */
|
||||
|
||||
.admin-rules-export-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-rules-export-controls .form-field {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.admin-rules-export-summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted, #71717a);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-rules-export-pre {
|
||||
background: var(--color-bg-subtle, #f4f4f5);
|
||||
border: 1px solid var(--color-border, #d4d4d8);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
max-height: 60vh;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Date-range picker (t-paliad-248) ------------------------------------
|
||||
Symmetric past/future chip fan around an ALLES centre, in a popover
|
||||
anchored under a closed-state trigger button. Reuses .agenda-chip /
|
||||
|
||||
@@ -28,16 +28,20 @@ function proceedingBtn(p: ProceedingDef): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
|
||||
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
|
||||
// unified "Berufung" tile (upc.apl). After picking it, the user
|
||||
// selects which decision the appeal is directed AT via the
|
||||
// .appeal-target-row chip group below — the engine then filters
|
||||
// rules whose applies_to_target contains the picked slug.
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.apl", i18nKey: "deadlines.upc.apl", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
@@ -216,6 +220,36 @@ export function renderVerfahrensablauf(): string {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
|
||||
Shown only when the unified upc.apl Berufung tile is
|
||||
selected; lets the user narrow the timeline to the
|
||||
rules whose applies_to_target contains the picked
|
||||
decision kind. URL state ?target=<slug>. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="anordnung" />
|
||||
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
|
||||
63
internal/db/migrations/134_berufung_unification.down.sql
Normal file
63
internal/db/migrations/134_berufung_unification.down.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- 134_berufung_unification — DOWN
|
||||
--
|
||||
-- Reverses the Berufung unification: un-archives the 3 old appeal
|
||||
-- proceeding_types, points the 16 rules back at their original
|
||||
-- proceeding by their applies_to_target stamp, drops the new
|
||||
-- upc.apl row, drops the two columns + their CHECK constraints.
|
||||
--
|
||||
-- The 3 old proceeding_types are recovered by code (we archived them,
|
||||
-- never deleted them — that's what makes this down-migration safe).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Un-archive the 3 old appeal proceeding_types.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = true,
|
||||
updated_at = now()
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Point rules back at their original proceeding_type by stamp.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.merits'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['endentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.cost'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['kostenentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.order'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['anordnung']::text[];
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Drop the unified upc.apl row (now orphaned).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Drop the new columns + their CHECK constraints.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_applies_to_target_chk;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS applies_to_target;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP CONSTRAINT IF EXISTS proceeding_types_appeal_target_chk;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS appeal_target;
|
||||
263
internal/db/migrations/134_berufung_unification.up.sql
Normal file
263
internal/db/migrations/134_berufung_unification.up.sql
Normal file
@@ -0,0 +1,263 @@
|
||||
-- 134_berufung_unification — Slice B1, m/paliad#124, t-paliad-298+
|
||||
--
|
||||
-- Collapses the 3 active UPC appeal proceeding_types (upc.apl.merits,
|
||||
-- upc.apl.cost, upc.apl.order — 16 rules across 3 codes) into ONE
|
||||
-- unified upc.apl proceeding type + an `appeal_target` discriminator on
|
||||
-- both proceeding_types (top-level marker) and deadline_rules
|
||||
-- (per-row applies-to set, text[] for multi-target rules).
|
||||
--
|
||||
-- ADDITIVE ONLY. The migration:
|
||||
-- 1. Adds the two columns + check constraints.
|
||||
-- 2. Inserts the new upc.apl proceeding type.
|
||||
-- 3. Audit-first: NOTICES every row about to be touched.
|
||||
-- 4. Reassigns rule rows from the 3 old types to upc.apl, stamping
|
||||
-- applies_to_target by source proceeding code.
|
||||
-- 5. Archives (is_active=false) the 3 old proceeding_types — NEVER
|
||||
-- deletes them, so any historical project_event_choices / FK
|
||||
-- references stay intact.
|
||||
--
|
||||
-- Schadensbemessung + Bucheinsicht get NO rule rows in this migration
|
||||
-- (m's 2026-05-26 decision: distinct rule sets, not shared with
|
||||
-- merits). Their appeal_target enum values are defined and addressable
|
||||
-- by CalcOptions.AppealTarget; the engine returns an empty timeline
|
||||
-- until rules are seeded in a follow-up slice (likely via
|
||||
-- /admin/rules, pairing with t-paliad-193 orphan-concept-seed).
|
||||
--
|
||||
-- See docs/design-litigation-planner-2026-05-26.md §18.1.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN appeal_target text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD CONSTRAINT proceeding_types_appeal_target_chk
|
||||
CHECK (appeal_target IS NULL OR appeal_target IN (
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.appeal_target IS
|
||||
'Top-level appeal-target marker. NULL on non-appeal proceedings. '
|
||||
'Reserved for future variants — today only the unified upc.apl row '
|
||||
'has this NULL (the actual per-rule target set lives on '
|
||||
'paliad.deadline_rules.applies_to_target).';
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN applies_to_target text[] NULL;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_applies_to_target_chk
|
||||
CHECK (
|
||||
applies_to_target IS NULL
|
||||
OR applies_to_target <@ ARRAY[
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
]::text[]
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.applies_to_target IS
|
||||
'Set of appeal_target slugs this rule applies to. NULL on rules '
|
||||
'that don''t belong to an appeal proceeding. The engine filters '
|
||||
'by CalcOptions.AppealTarget — rules whose applies_to_target '
|
||||
'contains the requested slug are emitted; others are suppressed.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Insert the unified upc.apl row.
|
||||
--
|
||||
-- Inherits default_color from the merits row (the most-used appeal
|
||||
-- track today). sort_order follows the cluster of UPC proceedings;
|
||||
-- placed just before upc.apl.merits's old slot so the chip-grouped
|
||||
-- picker UI lands Berufung in a sensible position. Tweakable later
|
||||
-- without a migration.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.proceeding_types (
|
||||
code, name, name_en, description, jurisdiction, category,
|
||||
default_color, sort_order, is_active, display_order,
|
||||
appeal_target
|
||||
)
|
||||
SELECT
|
||||
'upc.apl',
|
||||
'Berufungsverfahren',
|
||||
'Appeal',
|
||||
'Vereinheitlichtes Berufungsverfahren — wählen Sie anschließend, '
|
||||
'worauf die Berufung sich richtet (Endentscheidung, '
|
||||
'Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht).',
|
||||
'UPC',
|
||||
'fristenrechner',
|
||||
default_color,
|
||||
sort_order,
|
||||
true,
|
||||
display_order,
|
||||
NULL
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.merits';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Audit-first RAISE NOTICE pass.
|
||||
--
|
||||
-- Lists every rule row that will be reassigned + every proceeding_type
|
||||
-- row that will be archived. The migration runs to completion either
|
||||
-- way; the operator reads the notices to confirm scope before the
|
||||
-- next migration in the chain.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
upc_apl_id int;
|
||||
rules_touched int := 0;
|
||||
procs_archived int := 0;
|
||||
BEGIN
|
||||
SELECT id INTO upc_apl_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl';
|
||||
RAISE NOTICE '[mig 134] new upc.apl proceeding_type_id = %', upc_apl_id;
|
||||
|
||||
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl with applies_to_target:';
|
||||
FOR rec IN
|
||||
SELECT dr.id AS rule_id,
|
||||
pt.code AS old_proceeding,
|
||||
dr.submission_code,
|
||||
dr.name
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND dr.is_active = true
|
||||
ORDER BY pt.code, dr.sequence_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % % (%)',
|
||||
rec.old_proceeding, rec.submission_code, rec.name, rec.rule_id;
|
||||
rules_touched := rules_touched + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total rules to reassign: %', rules_touched;
|
||||
|
||||
RAISE NOTICE '[mig 134] Proceeding_types to archive (is_active=false):';
|
||||
FOR rec IN
|
||||
SELECT id, code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
ORDER BY sort_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % (id=%)', rec.code, rec.name, rec.id;
|
||||
procs_archived := procs_archived + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total proceeding_types to archive: %', procs_archived;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Reassign rule rows.
|
||||
--
|
||||
-- Stamp applies_to_target by source proceeding code, then point all
|
||||
-- 16 rules at the new upc.apl row.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 4a. upc.apl.merits → applies_to_target = {endentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['endentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.merits'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4b. upc.apl.cost → applies_to_target = {kostenentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['kostenentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.cost'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4c. upc.apl.order → applies_to_target = {anordnung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['anordnung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.order'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4d. Reassign all 16 rules to the new upc.apl proceeding_type row.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl'
|
||||
)
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Archive the 3 old proceeding_types.
|
||||
--
|
||||
-- NEVER DELETE — historical project_event_choices and project FKs
|
||||
-- (paliad.projects.proceeding_type_id) may still reference these IDs.
|
||||
-- The is_active=false flag stops them appearing in the picker but
|
||||
-- preserves FK integrity for historical reads.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false,
|
||||
updated_at = now()
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. Post-migration sanity check.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
unified_count int;
|
||||
archived_count int;
|
||||
target_distribution record;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO unified_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl' AND dr.is_active = true;
|
||||
RAISE NOTICE '[mig 134] post: rules on unified upc.apl = % (expected 16)', unified_count;
|
||||
IF unified_count <> 16 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl, got %', unified_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO archived_count
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND is_active = false;
|
||||
RAISE NOTICE '[mig 134] post: archived old appeal proceeding_types = % (expected 3)', archived_count;
|
||||
IF archived_count <> 3 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 3 archived types, got %', archived_count;
|
||||
END IF;
|
||||
|
||||
FOR target_distribution IN
|
||||
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl' AND dr.is_active = true
|
||||
GROUP BY unnest(applies_to_target)
|
||||
ORDER BY 1
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] post: applies_to_target=% count=%',
|
||||
target_distribution.target, target_distribution.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- TODO (follow-up slice, not in 134):
|
||||
--
|
||||
-- Seed rules for Schadensbemessung-as-appeal + Bucheinsicht-as-appeal.
|
||||
-- m's 2026-05-26 decision: distinct rule sets, NOT shared with merits.
|
||||
-- - Schadensbemessung: anchor on R.118.4 decision; conjecture 2/4-month
|
||||
-- merits-style track but distinct legal basis.
|
||||
-- - Bucheinsicht: anchor on R.142 (Lay-open-books decision); conjecture
|
||||
-- 15-day track per R.220.2 + R.224.2.b.
|
||||
-- Can pair with t-paliad-193 orphan-concept-seed if m wants a combined
|
||||
-- editorial pass via /admin/rules.
|
||||
-- ---------------------------------------------------------------
|
||||
@@ -299,21 +299,6 @@ func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/export-migrations?since=<audit_id>
|
||||
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
since := r.URL.Query().Get("since")
|
||||
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page handlers — serve the static SPA shells. Auth + admin gate live
|
||||
// at the route registration in handlers.go.
|
||||
@@ -327,10 +312,6 @@ func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-edit.html")
|
||||
}
|
||||
|
||||
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-export.html")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers
|
||||
// =============================================================================
|
||||
|
||||
@@ -69,6 +69,14 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// stay in the result list. Default false preserves the legacy
|
||||
// suppression. HiddenCount on the response is independent.
|
||||
IncludeHidden bool `json:"includeHidden,omitempty"`
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC
|
||||
// Berufung (upc.apl) timeline to the rule subset whose
|
||||
// applies_to_target contains the requested slug. Empty = no
|
||||
// filter. Valid values: endentscheidung | kostenentscheidung
|
||||
// | anordnung | schadensbemessung | bucheinsicht. Unknown
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
AppealTarget string `json:"appealTarget,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -116,6 +124,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -670,10 +670,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-191 Slice 11a — admin rule-editor API.
|
||||
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
|
||||
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||
protected.HandleFunc("GET /admin/rules/export", adminGate(users, gateOnboarded(handleAdminRulesExportPage)))
|
||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/rules/export-migrations", adminGate(users, handleAdminExportRuleMigrations))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
|
||||
|
||||
@@ -4,63 +4,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
||||
// from Postgres breaks the row scan with "unsupported Scan, storing
|
||||
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
||||
// error that hid every approval_request from the inbox when m's first
|
||||
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
|
||||
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
|
||||
// fixes the scan and preserves inline JSON output (no base64 cast).
|
||||
type NullableJSON []byte
|
||||
|
||||
func (n *NullableJSON) Scan(value any) error {
|
||||
if value == nil {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*n = append((*n)[:0], v...)
|
||||
return nil
|
||||
case string:
|
||||
*n = []byte(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
|
||||
}
|
||||
|
||||
func (n NullableJSON) Value() (driver.Value, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
func (n NullableJSON) MarshalJSON() ([]byte, error) {
|
||||
if len(n) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = append((*n)[:0], data...)
|
||||
return nil
|
||||
}
|
||||
// NullableJSON is a jsonb column that may be NULL. Canonical definition
|
||||
// (with sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler)
|
||||
// lives in pkg/litigationplanner — kept here as a type alias so every
|
||||
// existing models.NullableJSON reference continues to compile.
|
||||
type NullableJSON = litigationplanner.NullableJSON
|
||||
|
||||
// User extends auth.users with firm-specific profile fields. Created by the
|
||||
// Phase D onboarding flow; without a row here, the user can't see any Projects.
|
||||
@@ -584,112 +541,10 @@ type Party struct {
|
||||
}
|
||||
|
||||
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||
type DeadlineRule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
||||
// this rule's concept (joined via paliad.deadline_concept_event_types
|
||||
// where is_default = true). Lets the deadline create form auto-populate
|
||||
// the Typ chip when the user picks this rule. Hydrated by the service
|
||||
// layer; not a column. NULL when the concept has no mapped event_type.
|
||||
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Slice 9 (t-paliad-195) dropped the legacy IsMandatory /
|
||||
// IsOptional / ConditionFlag / ConditionRuleID fields — they
|
||||
// were superseded by Priority / ConditionExpr / IsCourtSet and
|
||||
// the unified calculator no longer reads them.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
|
||||
// trigger_event_id) is set after Slice 3.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain. Slice
|
||||
// 7 backfills the 8 live is_spawn=true rows.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression replacing
|
||||
// ConditionFlag (design §2.4). Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional. NullableJSON so a NULL column scans
|
||||
// cleanly (the row mishap that hid approval rows from the inbox
|
||||
// must not recur on rule rows).
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum replacing
|
||||
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'. Backfilled in
|
||||
// Slice 2; legacy callers read IsMandatory + IsOptional until
|
||||
// Slice 4 cuts them over.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic
|
||||
// (primary_party='court' OR event_type IN ('hearing','decision',
|
||||
// 'order')). Backfilled in Slice 2; legacy callers read the
|
||||
// heuristic until Slice 4.
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow (design §4.2):
|
||||
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
|
||||
// visible) | 'archived' (historical, retained for audit). Every
|
||||
// pre-Slice-1 row defaults to 'published' via the migration.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows. NULL also on net-
|
||||
// new drafts that have no prior published peer.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
|
||||
// ChoicesOffered declares which per-event-card choice-kinds this
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default). See the
|
||||
// COMMENT on paliad.deadline_rules.choices_offered for the value
|
||||
// shape. The engine and the frontend both read this column.
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
}
|
||||
// Canonical definition lives in pkg/litigationplanner.Rule — kept here
|
||||
// as a type alias so every existing models.DeadlineRule reference (sqlx
|
||||
// scans, hydration, projection service) continues to compile.
|
||||
type DeadlineRule = litigationplanner.Rule
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
@@ -721,43 +576,19 @@ type DeadlineRuleAudit struct {
|
||||
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
// management) or the lowercase dot-separated fristenrechner codes
|
||||
// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
||||
type ProceedingType struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||
Category *string `db:"category" json:"category,omitempty"`
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
|
||||
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// NULL on most proceedings — they already carry a root rule.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
}
|
||||
// ProceedingType is one of the litigation conceptual codes (INF / REV /
|
||||
// CCR / APM / APP / AMD / ZPO_CIVIL) or the lowercase dot-separated
|
||||
// fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md. Canonical
|
||||
// definition lives in pkg/litigationplanner.ProceedingType — kept here
|
||||
// as a type alias so every existing models.ProceedingType reference
|
||||
// continues to compile.
|
||||
type ProceedingType = litigationplanner.ProceedingType
|
||||
|
||||
// TriggerEvent is a UPC procedural event that can start one or more deadlines
|
||||
// running. Powers the "Was kommt nach…" Fristenrechner mode (event-driven
|
||||
// lookup, mirrored from youpc data.events).
|
||||
type TriggerEvent struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
Description string `db:"description" json:"description"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||
// whose semantic anchor is an event rather than a parent rule.
|
||||
// Canonical definition lives in pkg/litigationplanner.TriggerEvent.
|
||||
type TriggerEvent = litigationplanner.TriggerEvent
|
||||
|
||||
// EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors
|
||||
// youpc data.deadlines + the trigger half of data.deadline_events.
|
||||
|
||||
@@ -35,10 +35,12 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||
choices_offered`
|
||||
choices_offered, applies_to_target`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target`
|
||||
|
||||
// List returns active rules, optionally filtered by proceeding type.
|
||||
// Each row has ConceptDefaultEventTypeID hydrated from
|
||||
@@ -207,6 +209,44 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the
|
||||
// given id set, keyed by id. Returns nil, nil for an empty input set so
|
||||
// callers can blindly forward whatever they accumulated. Inactive rows
|
||||
// are included — the conditional-label resolution in fristenrechner.go
|
||||
// surfaces the trigger event's display name even when the catalog row
|
||||
// has been retired, which is preferable to silently falling back to
|
||||
// the (wrong) parent_id name.
|
||||
//
|
||||
// Used by FristenrechnerService.Calculate to redirect a conditional
|
||||
// rule's "abhängig von …" chip from parent_id to trigger_event_id —
|
||||
// the actual semantic anchor for rules whose data-model parent is the
|
||||
// proceeding root but whose real trigger sits in the trigger_events
|
||||
// catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the
|
||||
// opposing party's confidentiality application). See m/paliad#126.
|
||||
func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT id, code, name, name_de, description, is_active, created_at
|
||||
FROM paliad.trigger_events
|
||||
WHERE id IN (?)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build trigger_events IN query: %w", err)
|
||||
}
|
||||
query = s.db.Rebind(query)
|
||||
|
||||
var rows []models.TriggerEvent
|
||||
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err)
|
||||
}
|
||||
out := make(map[int64]models.TriggerEvent, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.ID] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListByTriggerEvent returns active rules scoped to a single trigger
|
||||
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
|
||||
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// DeadlineSearchService backs the unified Fristenrechner search bar
|
||||
@@ -921,130 +923,15 @@ func roundScore(v float64) float64 {
|
||||
return float64(int(v*10000+0.5)) / 10000
|
||||
}
|
||||
|
||||
// FormatLegalSourceDisplay renders a structured legal_source code into
|
||||
// the form HLC users read in pleadings:
|
||||
//
|
||||
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
|
||||
// UPC.RoP.139 → "UPC RoP R.139"
|
||||
// DE.PatG.82.1 → "PatG §82(1)"
|
||||
// DE.ZPO.276.1 → "ZPO §276(1)"
|
||||
// EU.EPÜ.108 → "EPÜ Art.108"
|
||||
// EU.EPC-R.79.1 → "EPC R.79(1)"
|
||||
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
|
||||
//
|
||||
// Returns the empty string for an empty input. Unknown jurisdictions
|
||||
// fall through with the structured form preserved (caller decides
|
||||
// whether to display).
|
||||
// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically
|
||||
// defined in pkg/litigationplanner — kept here as thin re-exports so
|
||||
// the existing in-package + handler call-sites compile unchanged.
|
||||
func FormatLegalSourceDisplay(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
// Malformed — return as-is so the caller still has something.
|
||||
return src
|
||||
}
|
||||
code := parts[1]
|
||||
rest := parts[2:]
|
||||
var prefix string
|
||||
switch code {
|
||||
case "RoP":
|
||||
prefix = "UPC RoP R."
|
||||
case "PatG":
|
||||
prefix = "PatG §"
|
||||
case "ZPO":
|
||||
prefix = "ZPO §"
|
||||
case "EPÜ":
|
||||
prefix = "EPÜ Art."
|
||||
case "EPC-R":
|
||||
prefix = "EPC R."
|
||||
case "RPBA":
|
||||
prefix = "RPBA Art."
|
||||
default:
|
||||
prefix = code + " "
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(prefix) + len(src))
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(rest[0])
|
||||
for _, p := range rest[1:] {
|
||||
b.WriteByte('(')
|
||||
b.WriteString(p)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
return b.String()
|
||||
return lp.FormatLegalSourceDisplay(src)
|
||||
}
|
||||
|
||||
// BuildLegalSourceURL maps a structured legal_source code to a
|
||||
// youpc.org/laws permalink when the cited body is hosted there. Today
|
||||
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
|
||||
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
|
||||
// home yet, so the helper returns the empty string for those and the
|
||||
// caller renders the display string as plain text.
|
||||
//
|
||||
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
|
||||
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
|
||||
// the law-number position are dropped; youpc resolves the page at
|
||||
// <type>.<number> granularity. The law-number is zero-padded to 3
|
||||
// digits to match how youpc stores law_number (laws-data.json carries
|
||||
// "001" / "023" / "220" forms).
|
||||
//
|
||||
// URL shape uses the hash-fragment form that youpc itself emits from
|
||||
// its laws-page redirect (handlers/laws.go:215+229) — the canonical
|
||||
// in-app deep link target. The `/laws/:type/:number` pretty route also
|
||||
// resolves the same page but redirects to the hash form anyway.
|
||||
//
|
||||
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
|
||||
// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139
|
||||
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
|
||||
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
|
||||
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
|
||||
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
|
||||
func BuildLegalSourceURL(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
var lawType string
|
||||
switch parts[0] + "." + parts[1] {
|
||||
case "UPC.RoP":
|
||||
lawType = "UPCRoP"
|
||||
case "UPC.UPCA":
|
||||
lawType = "UPCA"
|
||||
case "UPC.UPCS":
|
||||
lawType = "UPCS"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
number := padLawNumber(parts[2])
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://youpc.org/laws#" + lawType + "." + number
|
||||
}
|
||||
|
||||
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
|
||||
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
|
||||
// 112a) pass through unchanged so the URL still resolves. Empty input
|
||||
// returns the empty string.
|
||||
func padLawNumber(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if len(s) >= 3 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", 3-len(s)) + s
|
||||
return lp.BuildLegalSourceURL(src)
|
||||
}
|
||||
|
||||
// RefreshSearchView re-populates the materialised view. Safe to call on
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -507,15 +507,21 @@ func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
|
||||
wantParentCode string
|
||||
}{
|
||||
// Symptom A — backward-anchored on the court-set oral hearing.
|
||||
// Pre-pass fix: order-of-evaluation no longer matters.
|
||||
// Pre-pass fix: order-of-evaluation no longer matters. These
|
||||
// rules have no trigger_event_id, so ParentRuleCode stays on
|
||||
// the parent_id-derived value.
|
||||
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
|
||||
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
|
||||
// R.118(4) chain — parent=decision (court-set).
|
||||
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
|
||||
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
|
||||
// Symptom B — optional + both anchored on SoC (trigger anchor).
|
||||
{"upc.inf.cfi.confidentiality_response", true, "upc.inf.cfi.soc"},
|
||||
// Symptom B — optional + both, data-model parent is SoC but the
|
||||
// real trigger is the opposing party's confidentiality application.
|
||||
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
|
||||
// trigger_events catalog row (id=25), NOT the parent_id chain.
|
||||
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
|
||||
// Negative control — mandatory rule anchored on SoC must keep
|
||||
// its concrete date (no IsConditional, real DueDate).
|
||||
// its concrete date (no IsConditional, real DueDate). No
|
||||
// trigger_event_id, so parent_id-derived code stays.
|
||||
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
|
||||
}
|
||||
|
||||
@@ -546,6 +552,52 @@ func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
|
||||
// reads from the trigger_events catalog (id=25), so the user sees
|
||||
// the actual semantic anchor instead of the parent_id-derived
|
||||
// "Klageerhebung". Pin the exact DE + EN strings so a future
|
||||
// rename of the catalog row surfaces here.
|
||||
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
|
||||
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
|
||||
if !ok {
|
||||
t.Fatalf("confidentiality_response missing from response")
|
||||
}
|
||||
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
|
||||
const wantNameEN = "Application to request confidentiality from the public"
|
||||
if d.ParentRuleName != wantNameDE {
|
||||
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
|
||||
}
|
||||
if d.ParentRuleNameEN != wantNameEN {
|
||||
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
|
||||
}
|
||||
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
|
||||
// which is the regression the fix exists to prevent.
|
||||
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
|
||||
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
|
||||
}
|
||||
})
|
||||
|
||||
// Generalisation guard — translations_lodge also carries a real
|
||||
// trigger_event_id (113 = judge-rapporteur's order). Its
|
||||
// conditional chip should reference the order, not its parent_id
|
||||
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
|
||||
// uses THAT, not parent_id" contract from m/paliad#126.
|
||||
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
|
||||
d, ok := byCode["upc.inf.cfi.translations_lodge"]
|
||||
if !ok {
|
||||
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
|
||||
}
|
||||
if !d.IsConditional {
|
||||
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
|
||||
}
|
||||
if d.ParentRuleName == "Zwischenverfahren" {
|
||||
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
|
||||
}
|
||||
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
|
||||
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
|
||||
}
|
||||
})
|
||||
|
||||
// Override path: when the user anchors the oral hearing, the
|
||||
// backward-anchored R.109(1) flips back to a concrete date and
|
||||
// IsConditional clears. This is the click-to-edit unblock.
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// Country and regime constants — keep in sync with the paliad.countries
|
||||
@@ -229,38 +231,14 @@ func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime
|
||||
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
|
||||
// math bug. See t-paliad-119.
|
||||
//
|
||||
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
|
||||
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
|
||||
// separate RFC3339 parser. Holidays carries the same string-date shape.
|
||||
type AdjustmentReason struct {
|
||||
// Kind is the dominant cause; longest cause wins when several apply
|
||||
// (vacation > public_holiday > weekend).
|
||||
Kind string `json:"kind"`
|
||||
// Holidays collects every named holiday encountered while walking past
|
||||
// the non-working run, deduped by (date, name). May be empty when the
|
||||
// only cause is a weekend.
|
||||
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
||||
// VacationName, VacationStart and VacationEnd describe the contiguous
|
||||
// vacation block the original date sits in. Populated only when Kind
|
||||
// == "vacation". Span boundaries are the first/last vacation day in
|
||||
// the block (excludes the weekends that pad it).
|
||||
VacationName string `json:"vacationName,omitempty"`
|
||||
VacationStart string `json:"vacationStart,omitempty"`
|
||||
VacationEnd string `json:"vacationEnd,omitempty"`
|
||||
// OriginalWeekday is the English weekday name of the original date —
|
||||
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
||||
// can localise it.
|
||||
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
||||
}
|
||||
|
||||
// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason —
|
||||
// distinct from Holiday so dates serialise as YYYY-MM-DD strings.
|
||||
type HolidayDTO struct {
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
IsVacation bool `json:"isVacation,omitempty"`
|
||||
IsClosure bool `json:"isClosure,omitempty"`
|
||||
}
|
||||
// Canonical AdjustmentReason + HolidayDTO definitions live in
|
||||
// pkg/litigationplanner — kept here as type aliases so every existing
|
||||
// reference (HolidayService methods, JSON serialisation, projection
|
||||
// service) continues to compile.
|
||||
type (
|
||||
AdjustmentReason = litigationplanner.AdjustmentReason
|
||||
HolidayDTO = litigationplanner.HolidayDTO
|
||||
)
|
||||
|
||||
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
|
||||
// explanation. Reason is nil when wasAdjusted is false.
|
||||
|
||||
@@ -1,191 +1,63 @@
|
||||
package services
|
||||
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in the
|
||||
// codebase: the **litigation** conceptual category (INF / REV / APP /
|
||||
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
|
||||
// + Pipeline-A rules, and the **fristenrechner** code category
|
||||
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
|
||||
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
|
||||
// bind to fristenrechner codes directly, but the litigation→fristenrechner
|
||||
// mapping is still needed for the ~40 Pipeline-A rules that remain on
|
||||
// litigation proceedings and for any other surface that thinks in
|
||||
// litigation terms.
|
||||
//
|
||||
// The mapping table here is the single source of truth — see
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale + ambiguity notes, and
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
|
||||
// lowercase dot-separated naming convention applied by mig 096
|
||||
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
|
||||
// returns ok=false so callers can degrade gracefully ("no narrowing")
|
||||
// instead of guessing.
|
||||
import lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
|
||||
// Stable code constants — the strings landed by mig 096. Use these
|
||||
// throughout the codebase so a future rename only needs to touch this
|
||||
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
|
||||
// projects.proceeding_type_id) are unaffected by the rename.
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in
|
||||
// the codebase. The canonical implementations now live in
|
||||
// pkg/litigationplanner — this file keeps the existing service-level
|
||||
// names alive as re-exports so the rest of internal/services + tests
|
||||
// compile without an import-rewrite.
|
||||
//
|
||||
// See pkg/litigationplanner/proceeding_mapping.go for the logic +
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale.
|
||||
|
||||
// Stable code constants — re-exported from the package so existing
|
||||
// services / handlers can keep using the bare names.
|
||||
const (
|
||||
CodeUPCInfringement = "upc.inf.cfi"
|
||||
CodeUPCRevocation = "upc.rev.cfi"
|
||||
CodeUPCCounterclaim = "upc.ccr.cfi"
|
||||
CodeUPCPreliminary = "upc.pi.cfi"
|
||||
CodeUPCDamages = "upc.dmgs.cfi"
|
||||
CodeUPCDiscovery = "upc.disc.cfi"
|
||||
CodeUPCAppealMerits = "upc.apl.merits"
|
||||
CodeUPCAppealOrder = "upc.apl.order"
|
||||
CodeUPCAppealCost = "upc.apl.cost"
|
||||
CodeDEInfringementLG = "de.inf.lg"
|
||||
CodeDEInfringementOLG = "de.inf.olg"
|
||||
CodeDEInfringementBGH = "de.inf.bgh"
|
||||
CodeDENullityBPatG = "de.null.bpatg"
|
||||
CodeDENullityBGH = "de.null.bgh"
|
||||
CodeEPAGrant = "epa.grant.exa"
|
||||
CodeEPAOpposition = "epa.opp.opd"
|
||||
CodeEPAOppositionAppeal = "epa.opp.boa"
|
||||
CodeDPMAOpposition = "dpma.opp.dpma"
|
||||
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
|
||||
CodeDPMAAppealBGH = "dpma.appeal.bgh"
|
||||
CodeUPCInfringement = lp.CodeUPCInfringement
|
||||
CodeUPCRevocation = lp.CodeUPCRevocation
|
||||
CodeUPCCounterclaim = lp.CodeUPCCounterclaim
|
||||
CodeUPCPreliminary = lp.CodeUPCPreliminary
|
||||
CodeUPCDamages = lp.CodeUPCDamages
|
||||
CodeUPCDiscovery = lp.CodeUPCDiscovery
|
||||
CodeUPCAppealMerits = lp.CodeUPCAppealMerits
|
||||
CodeUPCAppealOrder = lp.CodeUPCAppealOrder
|
||||
CodeUPCAppealCost = lp.CodeUPCAppealCost
|
||||
CodeDEInfringementLG = lp.CodeDEInfringementLG
|
||||
CodeDEInfringementOLG = lp.CodeDEInfringementOLG
|
||||
CodeDEInfringementBGH = lp.CodeDEInfringementBGH
|
||||
CodeDENullityBPatG = lp.CodeDENullityBPatG
|
||||
CodeDENullityBGH = lp.CodeDENullityBGH
|
||||
CodeEPAGrant = lp.CodeEPAGrant
|
||||
CodeEPAOpposition = lp.CodeEPAOpposition
|
||||
CodeEPAOppositionAppeal = lp.CodeEPAOppositionAppeal
|
||||
CodeDPMAOpposition = lp.CodeDPMAOpposition
|
||||
CodeDPMAAppealBPatG = lp.CodeDPMAAppealBPatG
|
||||
CodeDPMAAppealBGH = lp.CodeDPMAAppealBGH
|
||||
)
|
||||
|
||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||
//
|
||||
// Inputs are case-sensitive — pass the canonical upper-snake form
|
||||
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
|
||||
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
|
||||
// fristenrechner code; callers should treat that as "no narrowing"
|
||||
// and leave the cascade wide-open rather than auto-pick.
|
||||
//
|
||||
// Condition flags are returned as a slice so callers can apply them
|
||||
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
|
||||
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
|
||||
// context applies.
|
||||
// Delegates to litigationplanner.MapLitigationToFristenrechner.
|
||||
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
|
||||
switch litigationCode {
|
||||
case "INF":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, nil, true
|
||||
case "DE":
|
||||
return CodeDEInfringementLG, nil, true
|
||||
}
|
||||
case "REV":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCRevocation, nil, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "CCR":
|
||||
// Counterclaim revocation — UPC fold-in is structural (the
|
||||
// counterclaim lives inside an upc.inf.cfi proceeding with the
|
||||
// with_ccr flag). DE Nichtigkeit is conceptually the same
|
||||
// adversarial-validity test, no separate flag.
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, []string{"with_ccr"}, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "AMD":
|
||||
// Amendment-application bundled into upc.inf.cfi via with_amend.
|
||||
// No DE / EPA / DPMA analogue today.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCInfringement, []string{"with_amend"}, true
|
||||
}
|
||||
case "APP":
|
||||
// Appeal is ambiguous in DE (OLG vs BGH) and the project
|
||||
// model doesn't carry the instance hint we'd need to
|
||||
// disambiguate. UPC is unambiguous — upc.apl.merits covers
|
||||
// the merits appeal track for inf/rev/ccr/damages.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCAppealMerits, nil, true
|
||||
}
|
||||
case "APM":
|
||||
// Preliminary injunction / urgency procedure — UPC-only
|
||||
// concept in the fristenrechner taxonomy.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCPreliminary, nil, true
|
||||
}
|
||||
case "OPP":
|
||||
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
|
||||
// doesn't surface from the litigation vocabulary today.
|
||||
if jurisdiction == "EPA" {
|
||||
return CodeEPAOpposition, nil, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
return lp.MapLitigationToFristenrechner(litigationCode, jurisdiction)
|
||||
}
|
||||
|
||||
// ResolveCounterclaimRouting handles the determinator's
|
||||
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
|
||||
// for taxonomic completeness, but no rules are attached to it. When the
|
||||
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
|
||||
// upc.inf.cfi with a default with_ccr=true flag — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
|
||||
//
|
||||
// `code` is the proceeding code the cascade resolved to. If it's
|
||||
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
|
||||
// []string{"with_ccr"}, true). For any other code the function returns
|
||||
// (code, nil, false) and callers proceed with the code unchanged. The
|
||||
// boolean signals "routing was applied"; the caller can surface the hint
|
||||
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||
// weiter." in the UI.
|
||||
// ResolveCounterclaimRouting handles the determinator's upc.ccr.cfi
|
||||
// illustrative-peer route. Delegates to
|
||||
// litigationplanner.ResolveCounterclaimRouting.
|
||||
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||
if route, ok := SubTrackRoutings[code]; ok {
|
||||
return route.ParentCode, route.DefaultFlags, true
|
||||
}
|
||||
return code, nil, false
|
||||
return lp.ResolveCounterclaimRouting(code)
|
||||
}
|
||||
|
||||
// SubTrackRouting describes a proceeding type that has no native rules
|
||||
// of its own and is normally rendered inside a parent proceeding's flow
|
||||
// with one or more condition flags enabled. The Procedure Roadmap
|
||||
// (verfahrensablauf) routes calc requests for these codes to the parent
|
||||
// proceeding + default flags, but preserves the user-picked code/name
|
||||
// in the response identity and surfaces a contextual note explaining
|
||||
// the framing — see m/paliad#58 and the design doc cited above.
|
||||
//
|
||||
// Adding a new sub-track is a data-only change here: extend
|
||||
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
|
||||
// renderer picks it up automatically. The note copy lives in this file
|
||||
// because it's semantic to the routing, not UI chrome.
|
||||
type SubTrackRouting struct {
|
||||
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
|
||||
Code string
|
||||
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
|
||||
ParentCode string
|
||||
// DefaultFlags are merged into the user's flag set so the
|
||||
// gated rules render. Order is preserved.
|
||||
DefaultFlags []string
|
||||
// NoteDE / NoteEN are the contextual banner above the timeline,
|
||||
// explaining that the proceeding type is normally a sub-track.
|
||||
// Plain text — the frontend renders them as a banner.
|
||||
NoteDE string
|
||||
NoteEN string
|
||||
}
|
||||
|
||||
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
|
||||
// The pattern generalises to other "sub-track" proceeding types (e.g.
|
||||
// R.30 application to amend the patent as a standalone roadmap, R.46
|
||||
// preliminary objection) once they have a proceeding-type code of their
|
||||
// own. New entries here are picked up by the spawn-as-standalone
|
||||
// renderer in FristenrechnerService.Calculate without further wiring.
|
||||
var SubTrackRoutings = map[string]SubTrackRouting{
|
||||
CodeUPCCounterclaim: {
|
||||
Code: CodeUPCCounterclaim,
|
||||
ParentCode: CodeUPCInfringement,
|
||||
DefaultFlags: []string{"with_ccr"},
|
||||
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
|
||||
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
|
||||
},
|
||||
}
|
||||
// SubTrackRoutings exposes the sub-track routing registry. SubTrackRouting
|
||||
// is aliased in fristenrechner.go.
|
||||
var SubTrackRoutings = lp.SubTrackRoutings
|
||||
|
||||
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
||||
// code, or (zero, false) if the code is not a sub-track. Used by the
|
||||
// fristenrechner Calculate path to spawn the parent flow with the sub-
|
||||
// track's default flags.
|
||||
// code, or (zero, false) if the code is not a sub-track. Delegates to
|
||||
// litigationplanner.LookupSubTrackRouting.
|
||||
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
||||
r, ok := SubTrackRoutings[code]
|
||||
return r, ok
|
||||
return lp.LookupSubTrackRouting(code)
|
||||
}
|
||||
|
||||
@@ -604,92 +604,6 @@ func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ExportMigrationsSince returns a SQL blob containing one UPDATE / INSERT
|
||||
// per audited rule change after the given audit row id. Used by the
|
||||
// admin "export changes to a migration file" flow (Q-H-5: pure SQL
|
||||
// format). Returns SQL + count + the latest audit id seen so the
|
||||
// caller can pass it as ?since= on the next call.
|
||||
//
|
||||
// v1 generates one UPDATE per audit row using the after_json snapshot.
|
||||
// Slice 11b will polish the output (re-order so foreign-key edges
|
||||
// resolve, collapse consecutive UPDATEs on the same row, format the
|
||||
// header comment with author + reason). v1 emits one statement per
|
||||
// audit row in chronological order — sufficient for hand-review.
|
||||
type ExportResult struct {
|
||||
MigrationSQL string `json:"migration_sql"`
|
||||
Count int `json:"count"`
|
||||
LatestAuditID string `json:"latest_audit_id"`
|
||||
}
|
||||
|
||||
func (s *RuleEditorService) ExportMigrationsSince(ctx context.Context, sinceAuditID string) (*ExportResult, error) {
|
||||
type auditRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id"`
|
||||
ChangedAt time.Time `db:"changed_at"`
|
||||
Action string `db:"action"`
|
||||
AfterJSON json.RawMessage `db:"after_json"`
|
||||
Reason string `db:"reason"`
|
||||
}
|
||||
var rows []auditRow
|
||||
q := `SELECT id, rule_id, changed_at, action, after_json, reason
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE migration_exported = false`
|
||||
args := []any{}
|
||||
if sinceAuditID != "" {
|
||||
sid, err := uuid.Parse(sinceAuditID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid since= uuid", ErrInvalidInput)
|
||||
}
|
||||
q += ` AND changed_at >= (SELECT changed_at FROM paliad.deadline_rule_audit WHERE id = $1)`
|
||||
args = append(args, sid)
|
||||
}
|
||||
q += ` ORDER BY changed_at ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list audit since: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("-- Auto-generated rule-editor migration export.\n")
|
||||
sb.WriteString("-- Generated at: " + time.Now().UTC().Format(time.RFC3339) + "\n")
|
||||
sb.WriteString("-- Rows: " + fmt.Sprintf("%d", len(rows)) + "\n\n")
|
||||
sb.WriteString("SELECT set_config('paliad.audit_reason',\n")
|
||||
sb.WriteString(" 'rule-editor export: replay of " + fmt.Sprintf("%d", len(rows)) + " edits', true);\n\n")
|
||||
|
||||
latest := ""
|
||||
for _, r := range rows {
|
||||
sb.WriteString("-- audit " + r.ID.String() + " (" + r.Action + " " + r.ChangedAt.Format(time.RFC3339) + "): " + sqlEscape(r.Reason) + "\n")
|
||||
switch r.Action {
|
||||
case "create", "update":
|
||||
if len(r.AfterJSON) == 0 {
|
||||
sb.WriteString("-- (no after_json — skipped)\n\n")
|
||||
continue
|
||||
}
|
||||
sb.WriteString("INSERT INTO paliad.deadline_rules\n")
|
||||
sb.WriteString(" SELECT (jsonb_populate_record(NULL::paliad.deadline_rules, '")
|
||||
sb.WriteString(sqlEscape(string(r.AfterJSON)))
|
||||
sb.WriteString("'::jsonb)).*\n")
|
||||
sb.WriteString("ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, name_en = EXCLUDED.name_en,\n")
|
||||
sb.WriteString(" duration_value = EXCLUDED.duration_value, duration_unit = EXCLUDED.duration_unit,\n")
|
||||
sb.WriteString(" timing = EXCLUDED.timing, priority = EXCLUDED.priority,\n")
|
||||
sb.WriteString(" is_court_set = EXCLUDED.is_court_set,\n")
|
||||
sb.WriteString(" condition_expr = EXCLUDED.condition_expr,\n")
|
||||
sb.WriteString(" lifecycle_state = EXCLUDED.lifecycle_state,\n")
|
||||
sb.WriteString(" updated_at = now();\n\n")
|
||||
case "delete", "archive":
|
||||
sb.WriteString("UPDATE paliad.deadline_rules SET lifecycle_state='archived', updated_at=now() WHERE id='")
|
||||
sb.WriteString(r.RuleID.String())
|
||||
sb.WriteString("';\n\n")
|
||||
}
|
||||
latest = r.ID.String()
|
||||
}
|
||||
|
||||
return &ExportResult{
|
||||
MigrationSQL: sb.String(),
|
||||
Count: len(rows),
|
||||
LatestAuditID: latest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal helpers
|
||||
// =============================================================================
|
||||
@@ -814,6 +728,3 @@ func nullableJSON(b json.RawMessage) any {
|
||||
return []byte(b)
|
||||
}
|
||||
|
||||
func sqlEscape(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
49
pkg/litigationplanner/catalog.go
Normal file
49
pkg/litigationplanner/catalog.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package litigationplanner
|
||||
|
||||
import "context"
|
||||
|
||||
// Catalog supplies proceeding-type metadata + rules for the calculator.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.deadline_rules + paliad.proceeding_types,
|
||||
// filtered to lifecycle_state='published' AND is_active=true.
|
||||
// ProjectHint scopes future per-project rule merges.
|
||||
// - embedded/upc (Slice C): in-memory map keyed by code, populated
|
||||
// once at init from the embedded JSON snapshot.
|
||||
//
|
||||
// All methods return ErrUnknownProceedingType / ErrUnknownRule when the
|
||||
// caller asks for a code/id that doesn't exist in the catalog.
|
||||
type Catalog interface {
|
||||
// LoadProceeding returns the proceeding-type metadata + the full
|
||||
// rule list (sorted by sequence_order). Caller passes the user-
|
||||
// facing proceeding code (e.g. "upc.inf.cfi"). The hint scopes a
|
||||
// future per-project rule merge — implementations that don't
|
||||
// support projects ignore it.
|
||||
LoadProceeding(ctx context.Context, code string, hint ProjectHint) (*ProceedingType, []Rule, error)
|
||||
|
||||
// LoadProceedingByID is the resolver used by CalculateRule when it
|
||||
// has a rule + needs the rule's parent proceeding metadata.
|
||||
LoadProceedingByID(ctx context.Context, id int) (*ProceedingType, error)
|
||||
|
||||
// LoadRuleByID resolves a rule UUID to the rule row. Used by
|
||||
// CalculateRule when the caller supplies CalcRuleParams.RuleID.
|
||||
LoadRuleByID(ctx context.Context, ruleID string) (*Rule, error)
|
||||
|
||||
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode)
|
||||
// + returns the parent proceeding for use in the response identity.
|
||||
// Used by CalculateRule when the caller supplies the (code, local)
|
||||
// pair from a concept-card pill.
|
||||
LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*Rule, *ProceedingType, error)
|
||||
|
||||
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted
|
||||
// rules (rules whose trigger_event_id matches). Used by
|
||||
// EventDeadlineService → Calculate via CalcOptions.TriggerEventIDFilter.
|
||||
LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]Rule, error)
|
||||
|
||||
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows
|
||||
// for the conditional-label override (t-paliad-294 /
|
||||
// m/paliad#126). Returns a map keyed by event id; missing ids
|
||||
// are simply absent (caller treats absence as "no override").
|
||||
// Empty input returns an empty map without a DB roundtrip.
|
||||
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
|
||||
}
|
||||
49
pkg/litigationplanner/courts.go
Normal file
49
pkg/litigationplanner/courts.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package litigationplanner
|
||||
|
||||
// CourtRegistry maps a court id (e.g. "upc-ld-paris", "de-bgh") to its
|
||||
// (country, regime) tuple, which drives non-working-day adjustment.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.courts (CourtService.CountryRegime).
|
||||
// - embedded/upc (Slice C): in-memory map populated from the embedded
|
||||
// JSON snapshot.
|
||||
//
|
||||
// Empty courtID falls back to (defaultCountry, defaultRegime) so callers
|
||||
// without a court_id (the abstract Verfahrensablauf path) still get
|
||||
// sensible behaviour. Returns an error when courtID is non-empty and
|
||||
// not in the registry.
|
||||
type CourtRegistry interface {
|
||||
CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error)
|
||||
}
|
||||
|
||||
// Country and regime constants — keep in sync with the paliad.countries
|
||||
// seed list and the holidays_regime_chk / courts_regime_chk constraints.
|
||||
const (
|
||||
CountryDE = "DE"
|
||||
RegimeUPC = "UPC"
|
||||
RegimeEPO = "EPO"
|
||||
)
|
||||
|
||||
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
|
||||
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple
|
||||
// a holiday lookup should default to when the caller didn't pass an
|
||||
// explicit CourtID. UPC proceedings get DE+UPC (München LD is HLC's
|
||||
// most common venue, German federal holidays plus UPC vacations apply);
|
||||
// DE / DPMA / EPA get DE-only (German federal). Future EPA-specific
|
||||
// closures will require callers to pick an EPA court explicitly so the
|
||||
// EPO regime kicks in.
|
||||
//
|
||||
// Helper kept tiny and stateless — when a caller passes a real CourtID,
|
||||
// these defaults are bypassed entirely and the court's actual country +
|
||||
// regime are used.
|
||||
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
|
||||
if jurisdiction == nil {
|
||||
return CountryDE, ""
|
||||
}
|
||||
switch *jurisdiction {
|
||||
case "UPC":
|
||||
return CountryDE, RegimeUPC
|
||||
default:
|
||||
return CountryDE, ""
|
||||
}
|
||||
}
|
||||
17
pkg/litigationplanner/doc.go
Normal file
17
pkg/litigationplanner/doc.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package litigationplanner is the canonical Fristen / Verfahrensablauf
|
||||
// compute engine — the deadline-rule model, the calendar arithmetic, the
|
||||
// condition-expression gate, the sub-track routing, and the timeline
|
||||
// composer that drives Paliad's /tools/fristenrechner,
|
||||
// /tools/verfahrensablauf, and the per-project SmartTimeline.
|
||||
//
|
||||
// The package owns its types (Rule, ProceedingType, Timeline,
|
||||
// TimelineEntry, CalcOptions, …) and exposes three interfaces for the
|
||||
// stateful inputs: Catalog (proceeding + rule lookup), HolidayCalendar
|
||||
// (non-working-day adjustment), and CourtRegistry (court → country/regime
|
||||
// resolution). Paliad implements them against its Postgres database;
|
||||
// downstream consumers (youpc.org) implement them against an embedded
|
||||
// JSON snapshot of the UPC subset.
|
||||
//
|
||||
// See docs/design-litigation-planner-2026-05-26.md (t-paliad-292 /
|
||||
// m/paliad#124) for the full design.
|
||||
package litigationplanner
|
||||
76
pkg/litigationplanner/durations.go
Normal file
76
pkg/litigationplanner/durations.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package litigationplanner
|
||||
|
||||
import "time"
|
||||
|
||||
// ApplyDuration is the unified date-arithmetic helper used by every
|
||||
// calculator path (proceeding-tree, trigger-event, CalculateRule single-
|
||||
// rule). Phase 3 Slice 4 (t-paliad-185) replaced the prior split
|
||||
// between addDuration (proceeding-tree, no timing / working_days) and
|
||||
// ApplyDurationOnCalendar (Pipeline-C, full support) with this single
|
||||
// helper.
|
||||
//
|
||||
// Returns (raw, adjusted, didAdjust, reason):
|
||||
//
|
||||
// - raw: the date strictly implied by the rule before rollover.
|
||||
// - adjusted: post-rollover for calendar units. 'working_days' lands
|
||||
// on a working day by construction so raw == adjusted there.
|
||||
// - didAdjust: true iff rollover moved the date.
|
||||
// - reason: populated when didAdjust is true; nil otherwise.
|
||||
//
|
||||
// timing='before' negates the sign. timing='after' (or any other value
|
||||
// including the empty string) keeps it positive — preserves the pre-
|
||||
// Slice-4 behaviour for proceeding-tree rules whose Timing field is
|
||||
// sometimes NULL (mig 003 defaults to 'after' but legacy callers pass
|
||||
// r.Timing dereferenced).
|
||||
func ApplyDuration(
|
||||
base time.Time, value int, unit, timing, country, regime string, holidays HolidayCalendar,
|
||||
) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
switch unit {
|
||||
case "days":
|
||||
raw = base.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
raw = base.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
raw = base.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
raw = AddWorkingDays(base, sign*value, country, regime, holidays)
|
||||
// Working-day arithmetic lands on a working day by construction
|
||||
// — the per-step skip loop in AddWorkingDays already passes over
|
||||
// weekends and holidays. No post-rollover required.
|
||||
return raw, raw, false, nil
|
||||
default:
|
||||
raw = base
|
||||
}
|
||||
adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime)
|
||||
return raw, adjusted, didAdjust, reason
|
||||
}
|
||||
|
||||
// AddWorkingDays advances from `from` by `n` working days, skipping
|
||||
// weekends and holidays applicable to the given country/regime. Negative
|
||||
// n walks backward. n=0 keeps the input date as-is (caller decides
|
||||
// whether to roll forward via AdjustForNonWorkingDays).
|
||||
//
|
||||
// Bounded by an inner 30-step skip per advance — vacation runs in our
|
||||
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
|
||||
func AddWorkingDays(from time.Time, n int, country, regime string, holidays HolidayCalendar) time.Time {
|
||||
if n == 0 {
|
||||
return from
|
||||
}
|
||||
step := 1
|
||||
if n < 0 {
|
||||
step = -1
|
||||
n = -n
|
||||
}
|
||||
cur := from
|
||||
for i := 0; i < n; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
927
pkg/litigationplanner/engine.go
Normal file
927
pkg/litigationplanner/engine.go
Normal file
@@ -0,0 +1,927 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
// Preserves the pre-Phase-C in-memory calculator's classification:
|
||||
//
|
||||
// - Rules with duration_value = 0 and no parent_id → IsRootEvent
|
||||
// (due date = trigger date)
|
||||
// - Rules with duration_value = 0 and a parent_id → IsCourtSet
|
||||
// (due date empty, UI shows "court-set" placeholder)
|
||||
// - All other rules → calculate from either the trigger date (no parent)
|
||||
// or the previously-computed date for their parent rule.
|
||||
//
|
||||
// Audit-driven extensions:
|
||||
//
|
||||
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
|
||||
// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr").
|
||||
// - opts.PriorityDateStr overrides the anchor for rules with
|
||||
// anchor_alt='priority_date' (e.g. epa.grant.exa publication date
|
||||
// is 18mo from priority, not filing).
|
||||
// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the
|
||||
// caller redirect a downstream rule's parent anchor to a user-set
|
||||
// date.
|
||||
func Calculate(
|
||||
ctx context.Context,
|
||||
proceedingCode string,
|
||||
triggerDateStr string,
|
||||
opts CalcOptions,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*Timeline, error) {
|
||||
// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
|
||||
// branch (Pipeline-C unified rules). proceedingCode is ignored on
|
||||
// this path.
|
||||
if opts.TriggerEventIDFilter != nil {
|
||||
return calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts, catalog, holidays, courts)
|
||||
}
|
||||
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
var priorityDate *time.Time
|
||||
if opts.PriorityDateStr != "" {
|
||||
pd, err := time.Parse("2006-01-02", opts.PriorityDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err)
|
||||
}
|
||||
priorityDate = &pd
|
||||
}
|
||||
flagSet := make(map[string]struct{}, len(opts.Flags))
|
||||
for _, f := range opts.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
// v1 simplification (t-paliad-265): when any IncludeCCRFor entry
|
||||
// exists, we treat with_ccr as set in the flag context.
|
||||
if len(opts.IncludeCCRFor) > 0 {
|
||||
flagSet["with_ccr"] = struct{}{}
|
||||
}
|
||||
|
||||
// Parse anchor overrides up-front so a malformed date errors out
|
||||
// before we start walking rules.
|
||||
overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides))
|
||||
for code, dateStr := range opts.AnchorOverrides {
|
||||
od, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err)
|
||||
}
|
||||
overrideDates[code] = od
|
||||
}
|
||||
|
||||
// Look up proceeding type metadata.
|
||||
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sub-track routing (m/paliad#58). When the user picks a proceeding
|
||||
// that has no native rules and is normally a sub-track of another
|
||||
// proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route
|
||||
// rule lookup to the parent and merge the default flags into the
|
||||
// user's flag set. The response identity stays on the user-picked
|
||||
// proceeding so the page header still reads "Counterclaim for
|
||||
// Revocation", but the timeline body is the parent's full flow with
|
||||
// the sub-track flag enabled.
|
||||
var subTrackNote SubTrackRouting
|
||||
var hasSubTrackNote bool
|
||||
pt := pickedProceeding
|
||||
if route, ok := LookupSubTrackRouting(proceedingCode); ok {
|
||||
subTrackNote = route
|
||||
hasSubTrackNote = true
|
||||
parentPt, parentRules, err := catalog.LoadProceeding(ctx, route.ParentCode, opts.ProjectHint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, err)
|
||||
}
|
||||
pt = parentPt
|
||||
rules = parentRules
|
||||
// Merge default flags into the user's flag set so the gated
|
||||
// rules render. User-supplied flags win on conflict.
|
||||
for _, f := range route.DefaultFlags {
|
||||
if _, exists := flagSet[f]; !exists {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve (country, regime) for non-working-day adjustment. Court
|
||||
// wins when supplied; otherwise default by proceeding regime.
|
||||
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
||||
country, regime, err := courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
||||
}
|
||||
|
||||
if len(opts.RuleOverrides) > 0 {
|
||||
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
||||
}
|
||||
|
||||
// AppealTarget filter (Slice B1, m/paliad#124 §18.1). When set,
|
||||
// keep only rules whose AppliesToTarget contains the requested
|
||||
// slug. Unknown slugs short-circuit to no-op (defensive: a stale
|
||||
// frontend chip shouldn't break the render). Empty AppliesToTarget
|
||||
// on a rule means "doesn't belong to an appeal target" — such a
|
||||
// rule is suppressed under any non-empty AppealTarget filter.
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
filtered := make([]Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
for _, t := range r.AppliesToTarget {
|
||||
if t == opts.AppealTarget {
|
||||
filtered = append(filtered, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rules = filtered
|
||||
}
|
||||
|
||||
// ruleByID lets the conditional-rendering branches resolve a parent
|
||||
// rule's display fields (submission_code, name, name_en) for the
|
||||
// "abhängig von <ParentRuleName>" chip without re-scanning the rules
|
||||
// slice on every iteration. (t-paliad-289)
|
||||
ruleByID := make(map[uuid.UUID]Rule, len(rules))
|
||||
for _, r := range rules {
|
||||
ruleByID[r.ID] = r
|
||||
}
|
||||
|
||||
// triggerEventByID powers the trigger-event override on the
|
||||
// conditional-label chip (m/paliad#126 / t-paliad-294). When a rule
|
||||
// carries a real paliad.trigger_events row, that catalog event —
|
||||
// not the rule's parent_id — is the rule's actual semantic anchor.
|
||||
// The override fires below when stamping ParentRule* on the wire so
|
||||
// the chip reads e.g. "abhängig von Antrag auf Vertraulichkeit
|
||||
// gegenüber der Öffentlichkeit" for R.262(2) — instead of the
|
||||
// (misleading) parent_id-derived "abhängig von Klageerhebung".
|
||||
//
|
||||
// Bulk-loaded in one round-trip; trees in the live corpus carry at
|
||||
// most a handful of trigger_event_id-bearing rules (2 today on
|
||||
// upc.inf.cfi), so the IN(...) is small.
|
||||
var triggerIDs []int64
|
||||
seenTrigger := make(map[int64]struct{}, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.TriggerEventID == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
|
||||
continue
|
||||
}
|
||||
seenTrigger[*r.TriggerEventID] = struct{}{}
|
||||
triggerIDs = append(triggerIDs, *r.TriggerEventID)
|
||||
}
|
||||
triggerEventByID, err := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
||||
}
|
||||
|
||||
// Walk the rule list in sequence_order (already sorted by the
|
||||
// catalog query) and compute each entry, keeping a code→date map so
|
||||
// RelativeTo / parent_id references resolve to the adjusted
|
||||
// predecessor date.
|
||||
computed := make(map[string]time.Time, len(rules))
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]TimelineEntry, 0, len(rules))
|
||||
|
||||
skipRules := opts.SkipRules
|
||||
perCardAppellant := opts.PerCardAppellant
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
hiddenCount := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
||||
// Suppression semantic preserved: when the gate fires false
|
||||
// AND no alt_* values exist, the rule is dropped from the
|
||||
// timeline entirely (purely conditional). When alt_* values
|
||||
// exist, the gate-false branch still renders, just without
|
||||
// the alt-swap.
|
||||
gateMet := EvalConditionExpr([]byte(r.ConditionExpr), flagSet)
|
||||
if !gateMet && r.AltDurationValue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// SkipRules suppression (t-paliad-265).
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||
// instead of dropping it.
|
||||
var isHidden bool
|
||||
if r.SubmissionCode != nil {
|
||||
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
||||
hiddenCount++
|
||||
if !opts.IncludeHidden {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
isHidden = true
|
||||
}
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// AppellantContext propagation. A rule with its own
|
||||
// PerCardAppellant pick stamps its UUID with that value.
|
||||
// Otherwise inherit from parent if the parent had a context.
|
||||
var ctxVal string
|
||||
if r.SubmissionCode != nil {
|
||||
if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
|
||||
ctxVal = v
|
||||
}
|
||||
}
|
||||
if ctxVal == "" && r.ParentID != nil {
|
||||
if v, ok := appellantContext[*r.ParentID]; ok {
|
||||
ctxVal = v
|
||||
}
|
||||
}
|
||||
if ctxVal != "" {
|
||||
appellantContext[r.ID] = ctxVal
|
||||
}
|
||||
|
||||
d := TimelineEntry{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
AppellantContext: ctxVal,
|
||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||
IsHidden: isHidden,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
}
|
||||
if r.PrimaryParty != nil {
|
||||
d.Party = *r.PrimaryParty
|
||||
}
|
||||
if r.RuleCode != nil {
|
||||
d.RuleRef = *r.RuleCode
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
||||
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
}
|
||||
if r.DeadlineNotesEn != nil {
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
|
||||
// Resolve the parent rule once so every conditional-rendering
|
||||
// branch (incl. the optional-not-recorded path below) can stamp
|
||||
// ParentRule* on the wire without re-scanning. Populated even
|
||||
// for non-conditional rows — the frontend dependency-footer
|
||||
// ("Folgt aus …") already consumes this on regular projected
|
||||
// rows. (t-paliad-289)
|
||||
var parentRule *Rule
|
||||
if r.ParentID != nil {
|
||||
if pr, ok := ruleByID[*r.ParentID]; ok {
|
||||
parentRule = &pr
|
||||
if pr.SubmissionCode != nil {
|
||||
d.ParentRuleCode = *pr.SubmissionCode
|
||||
}
|
||||
d.ParentRuleName = pr.Name
|
||||
d.ParentRuleNameEN = pr.NameEN
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event override on the user-facing dependency identity
|
||||
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
||||
// trigger_event_id, that catalog event is the actual semantic
|
||||
// anchor — not the parent_id node, which is only the calc-time
|
||||
// arithmetic anchor. Only the user-facing wire fields shift;
|
||||
// parentRule (and the parent_id chain feeding parentIsCourtSet
|
||||
// and the calc-time arithmetic below) stays anchored on the
|
||||
// rule tree.
|
||||
if r.TriggerEventID != nil {
|
||||
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||
d.ParentRuleCode = te.Code
|
||||
d.ParentRuleName = te.NameDE
|
||||
d.ParentRuleNameEN = te.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate court-set status from a parent rule whose date the
|
||||
// court determines: if the anchor itself has no real date,
|
||||
// nothing downstream can be computed either — UNLESS the user
|
||||
// has supplied an override date for the parent.
|
||||
parentOverridden := false
|
||||
if r.ParentID != nil && courtSet[*r.ParentID] {
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.SubmissionCode != nil {
|
||||
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||
parentOverridden = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden
|
||||
|
||||
// Zero-duration rules fall into one of four buckets:
|
||||
// 1. parent=nil, not court-determined → IsRootEvent (trigger anchor)
|
||||
// 2. parent=nil, court-determined → IsCourtSet
|
||||
// 3. parent set, court-determined → IsCourtSet (waypoint)
|
||||
// 4. parent set, NOT court-determined → "filed-with-parent"
|
||||
//
|
||||
// AnchorOverrides: when the user has set a date for any zero-
|
||||
// duration rule, that override wins over both the court-set
|
||||
// placeholder and the parent-inheritance.
|
||||
if r.DurationValue == 0 {
|
||||
if r.SubmissionCode != nil {
|
||||
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
|
||||
d.DueDate = ov.Format("2006-01-02")
|
||||
d.OriginalDate = d.DueDate
|
||||
d.IsOverridden = true
|
||||
computed[*r.SubmissionCode] = ov
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if r.ParentID == nil && !r.IsCourtSet {
|
||||
// Bucket 1: timeline anchor.
|
||||
d.IsRootEvent = true
|
||||
d.DueDate = triggerDateStr
|
||||
d.OriginalDate = triggerDateStr
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = triggerDate
|
||||
}
|
||||
} else if r.ParentID != nil && !r.IsCourtSet {
|
||||
// Bucket 4: filed-with-parent. Inherit parent's date.
|
||||
if parentIsCourtSet {
|
||||
// Indirect: rule isn't itself court-determined,
|
||||
// it's blocked because its parent is.
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
} else {
|
||||
var parentDate time.Time
|
||||
var haveParentDate bool
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.SubmissionCode != nil {
|
||||
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||
parentDate = ov
|
||||
haveParentDate = true
|
||||
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
|
||||
parentDate = ref
|
||||
haveParentDate = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if haveParentDate {
|
||||
d.DueDate = parentDate.Format("2006-01-02")
|
||||
d.OriginalDate = d.DueDate
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = parentDate
|
||||
}
|
||||
} else {
|
||||
// Parent not yet computed (defensive).
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Buckets 2 + 3: court-determined directly.
|
||||
d.IsCourtSet = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
// If the parent is court-determined and not overridden we have
|
||||
// no real anchor date; surface this rule as court-set too
|
||||
// rather than fabricating one off the trigger date. IsConditional
|
||||
// surfaces the "abhängig von <ParentRuleName>" UX (t-paliad-289).
|
||||
if parentIsCourtSet {
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
// Anchor: prefer alt-anchor (e.g. priority_date for
|
||||
// epa.grant.exa publish) when supplied, then parent's computed
|
||||
// date (or user override), then trigger date.
|
||||
baseDate := triggerDate
|
||||
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
baseDate = *priorityDate
|
||||
} else if r.ParentID != nil {
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.SubmissionCode != nil {
|
||||
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||
baseDate = ov
|
||||
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
|
||||
baseDate = ref
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag-conditioned alt-swap (legacy with_ccr pattern): when the
|
||||
// gate fires AND alt_* values exist, swap the primary duration
|
||||
// to the alt values. This is distinct from combine_op below —
|
||||
// alt-swap is a one-or-the-other choice keyed on flags, whereas
|
||||
// combine_op computes both legs and picks max/min.
|
||||
durationValue := r.DurationValue
|
||||
durationUnit := r.DurationUnit
|
||||
timing := ""
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
|
||||
durationValue = *r.AltDurationValue
|
||||
if r.AltDurationUnit != nil {
|
||||
durationUnit = *r.AltDurationUnit
|
||||
}
|
||||
if r.AltRuleCode != nil {
|
||||
d.RuleRef = *r.AltRuleCode
|
||||
}
|
||||
}
|
||||
|
||||
// User override on this rule: replace the calculated date with
|
||||
// the user's date. Skip holiday rollover — the user's date is
|
||||
// authoritative.
|
||||
if r.SubmissionCode != nil {
|
||||
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
|
||||
d.OriginalDate = ov.Format("2006-01-02")
|
||||
d.DueDate = ov.Format("2006-01-02")
|
||||
d.WasAdjusted = false
|
||||
d.AdjustmentReason = nil
|
||||
d.IsOverridden = true
|
||||
computed[*r.SubmissionCode] = ov
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
origDate, adjusted, wasAdj, reason := ApplyDuration(
|
||||
baseDate, durationValue, durationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
|
||||
// combine_op composite: compute the alt leg too, apply max/min.
|
||||
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||
altOrig, altAdj, altWasAdj, altReason := ApplyDuration(
|
||||
baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(adjusted) {
|
||||
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(adjusted) {
|
||||
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.OriginalDate = origDate.Format("2006-01-02")
|
||||
d.DueDate = adjusted.Format("2006-01-02")
|
||||
d.WasAdjusted = wasAdj
|
||||
d.AdjustmentReason = reason
|
||||
|
||||
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
|
||||
// Rules with priority='optional' AND primary_party='both' whose
|
||||
// data-model parent is the proceeding's trigger anchor (parent
|
||||
// has parent_id=NULL and is not court-set, i.e. the SoC root
|
||||
// rule) represent a rule whose REAL triggering event sits
|
||||
// outside the rule data — e.g. R.262(2) Erwiderung auf
|
||||
// Vertraulichkeitsantrag anchors on SoC in the data, but the
|
||||
// real trigger is the opposing party's confidentiality motion
|
||||
// which may never happen. Without an explicit anchor on the
|
||||
// rule itself, the projection must NOT claim a concrete date.
|
||||
if !d.IsOverridden && !d.IsConditional &&
|
||||
r.Priority == "optional" &&
|
||||
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
|
||||
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
d.WasAdjusted = false
|
||||
d.AdjustmentReason = nil
|
||||
// Mark this rule's ID as having an uncertain anchor so
|
||||
// rules chaining off it also surface conditional via the
|
||||
// parentIsCourtSet path.
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = adjusted
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
// t-paliad-296: within consecutive runs of rules sharing the same
|
||||
// trigger group (parent_id + trigger_event_id), reorder by duration
|
||||
// ascending so optional events following the same anchor render in
|
||||
// their likely-sequence order. Different trigger groups keep their
|
||||
// proceeding-sequence position — the chunk walk only sorts adjacent
|
||||
// same-group rows. Court-set / conditional rows sort LAST.
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding`.
|
||||
if pickedProceeding.TriggerEventLabelDE != nil {
|
||||
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
|
||||
}
|
||||
if pickedProceeding.TriggerEventLabelEN != nil {
|
||||
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
|
||||
}
|
||||
if hasSubTrackNote {
|
||||
resp.ContextualNote = subTrackNote.NoteDE
|
||||
resp.ContextualNoteEN = subTrackNote.NoteEN
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// calculateByTriggerEvent renders the Pipeline-C timeline for an event
|
||||
// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id
|
||||
// chains), have no flag gating, no priority_date alt-anchor, no party
|
||||
// classification, and no IsRootEvent / IsCourtSet semantics. The math
|
||||
// is just: base + (timing-signed) duration → optional alt-leg combine
|
||||
// → optional weekend/holiday rollover for calendar units.
|
||||
//
|
||||
// Timeline.ProceedingType / ProceedingName stay empty —
|
||||
// EventDeadlineService owns the trigger-event metadata.
|
||||
func calculateByTriggerEvent(
|
||||
ctx context.Context,
|
||||
triggerEventID int64,
|
||||
triggerDateStr string,
|
||||
opts CalcOptions,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*Timeline, error) {
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
// Pipeline-C rules originate from youpc's UPC-flavoured deadline
|
||||
// corpus — DE / UPC defaults match the legacy EventDeadlineService.
|
||||
country, regime, err := courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
||||
}
|
||||
|
||||
rules, err := catalog.LoadRulesByTriggerEvent(ctx, triggerEventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(opts.RuleOverrides) > 0 {
|
||||
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
||||
}
|
||||
|
||||
deadlines := make([]TimelineEntry, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
timing := ""
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
baseRaw, baseAdj, baseChanged, baseReason := ApplyDuration(
|
||||
triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
picked := baseAdj
|
||||
original := baseRaw
|
||||
wasAdj := baseChanged
|
||||
reason := baseReason
|
||||
|
||||
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||
altRaw, altAdj, altChanged, altReason := ApplyDuration(
|
||||
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(baseAdj) {
|
||||
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(baseAdj) {
|
||||
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d := TimelineEntry{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
DueDate: picked.Format("2006-01-02"),
|
||||
OriginalDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
AdjustmentReason: reason,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
}
|
||||
if r.PrimaryParty != nil {
|
||||
d.Party = *r.PrimaryParty
|
||||
}
|
||||
if r.RuleCode != nil {
|
||||
d.RuleRef = *r.RuleCode
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
||||
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
}
|
||||
if r.DeadlineNotesEn != nil {
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
return &Timeline{
|
||||
// Trigger-event responses don't carry proceeding metadata —
|
||||
// EventDeadlineService.Calculate fills the trigger fields in
|
||||
// the legacy CalculateResponse shape. Leaving these empty is
|
||||
// the stable contract.
|
||||
ProceedingType: "",
|
||||
ProceedingName: "",
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CalculateRule computes a single deadline from a rule + trigger date.
|
||||
// Used by the v4 result-card click flow. Distinct from Calculate: no
|
||||
// parent-chain walk, no full-timeline rendering — just one date out.
|
||||
//
|
||||
// When the rule is court-determined, DueDate is empty and
|
||||
// IsCourtSet=true; the caller should disable the "Add to project" CTA.
|
||||
//
|
||||
// When the rule has a condition_expr gate and the caller's Flags
|
||||
// satisfy it AND alt_duration_value is non-NULL, the calc swaps to
|
||||
// alt_*. When the gate is not satisfied, the calc still proceeds with
|
||||
// the base duration_value and surfaces FlagsRequired.
|
||||
func CalculateRule(
|
||||
ctx context.Context,
|
||||
params CalcRuleParams,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*RuleCalculation, error) {
|
||||
triggerDate, err := time.Parse("2006-01-02", params.TriggerDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err)
|
||||
}
|
||||
|
||||
rule, pt, err := resolveRule(ctx, params, catalog)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mandWire, _ := wireFlagsFromPriority(rule.Priority)
|
||||
out := &RuleCalculation{
|
||||
Rule: RuleCalculationRule{
|
||||
ID: rule.ID.String(),
|
||||
NameDE: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
DurationValue: rule.DurationValue,
|
||||
DurationUnit: rule.DurationUnit,
|
||||
IsMandatory: mandWire,
|
||||
},
|
||||
Proceeding: RuleCalculationProceeding{
|
||||
Code: pt.Code,
|
||||
NameDE: pt.Name,
|
||||
NameEN: pt.NameEN,
|
||||
},
|
||||
TriggerDate: params.TriggerDate,
|
||||
}
|
||||
if rule.SubmissionCode != nil {
|
||||
out.Rule.LocalCode = *rule.SubmissionCode
|
||||
}
|
||||
if rule.RuleCode != nil {
|
||||
out.Rule.RuleRef = *rule.RuleCode
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
out.Rule.LegalSource = *rule.LegalSource
|
||||
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
|
||||
out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
out.Rule.Party = *rule.PrimaryParty
|
||||
}
|
||||
if rule.DeadlineNotes != nil {
|
||||
out.Rule.NotesDE = *rule.DeadlineNotes
|
||||
}
|
||||
if rule.DeadlineNotesEn != nil {
|
||||
out.Rule.NotesEN = *rule.DeadlineNotesEn
|
||||
}
|
||||
// Slice 9 (t-paliad-195) replacement for the dropped condition_flag
|
||||
// text[] enumeration: walk the jsonb gate to pull out flag-leaf
|
||||
// names. Returns nil on an unconditional rule.
|
||||
out.FlagsRequired = ExtractFlagsFromExpr(rule.ConditionExpr)
|
||||
|
||||
// Court-determined: no calculable date.
|
||||
if rule.IsCourtSet {
|
||||
out.IsCourtSet = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Resolve flag-conditional duration via the unified condition_expr
|
||||
// evaluator.
|
||||
flagSet := make(map[string]struct{}, len(params.Flags))
|
||||
for _, f := range params.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
durationValue := rule.DurationValue
|
||||
durationUnit := rule.DurationUnit
|
||||
gateMet := EvalConditionExpr([]byte(rule.ConditionExpr), flagSet)
|
||||
if gateMet && HasConditionExpr(rule.ConditionExpr) {
|
||||
out.FlagsApplied = out.FlagsRequired
|
||||
if rule.AltDurationValue != nil {
|
||||
durationValue = *rule.AltDurationValue
|
||||
}
|
||||
if rule.AltDurationUnit != nil {
|
||||
durationUnit = *rule.AltDurationUnit
|
||||
}
|
||||
if rule.AltRuleCode != nil {
|
||||
out.Rule.RuleRef = *rule.AltRuleCode
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-duration non-court-determined rules are "filed at the same
|
||||
// time as parent" markers: effectively mean "due on the trigger
|
||||
// date itself".
|
||||
if durationValue == 0 {
|
||||
out.OriginalDate = params.TriggerDate
|
||||
out.DueDate = params.TriggerDate
|
||||
return out, nil
|
||||
}
|
||||
|
||||
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
||||
country, regime, err := courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err)
|
||||
}
|
||||
|
||||
timing := ""
|
||||
if rule.Timing != nil {
|
||||
timing = *rule.Timing
|
||||
}
|
||||
endDate, adjusted, wasAdj, reason := ApplyDuration(
|
||||
triggerDate, durationValue, durationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
out.OriginalDate = endDate.Format("2006-01-02")
|
||||
out.DueDate = adjusted.Format("2006-01-02")
|
||||
out.WasAdjusted = wasAdj
|
||||
out.AdjustmentReason = reason
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveRule resolves CalcRuleParams to a rule + its proceeding type.
|
||||
// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The
|
||||
// frontend uses the latter form (it has the pill context) and the
|
||||
// programmatic / test caller can use the former.
|
||||
func resolveRule(ctx context.Context, params CalcRuleParams, catalog Catalog) (*Rule, *ProceedingType, error) {
|
||||
if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
|
||||
return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
|
||||
}
|
||||
|
||||
if params.RuleID != "" {
|
||||
rule, err := catalog.LoadRuleByID(ctx, params.RuleID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if rule.ProceedingTypeID == nil {
|
||||
return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
|
||||
}
|
||||
pt, err := catalog.LoadProceedingByID(ctx, *rule.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
|
||||
}
|
||||
return rule, pt, nil
|
||||
}
|
||||
|
||||
rule, pt, err := catalog.LoadRuleByCode(ctx, params.ProceedingCode, params.RuleLocalCode)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return rule, pt, nil
|
||||
}
|
||||
|
||||
// ApplyRuleOverrides replaces rules whose ID appears in `overrides`
|
||||
// with the override row, and appends any override whose ID isn't in
|
||||
// the source list (net-new drafts the rule editor wants to preview).
|
||||
//
|
||||
// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor
|
||||
// passes the draft as an override so Calculate runs against the
|
||||
// proposed shape without writing to the DB. Empty overrides slice =
|
||||
// pass-through.
|
||||
func ApplyRuleOverrides(src, overrides []Rule) []Rule {
|
||||
if len(overrides) == 0 {
|
||||
return src
|
||||
}
|
||||
byID := make(map[uuid.UUID]Rule, len(overrides))
|
||||
for _, o := range overrides {
|
||||
byID[o.ID] = o
|
||||
}
|
||||
out := make([]Rule, 0, len(src)+len(overrides))
|
||||
seen := make(map[uuid.UUID]bool, len(overrides))
|
||||
for _, r := range src {
|
||||
if ov, ok := byID[r.ID]; ok {
|
||||
out = append(out, ov)
|
||||
seen[ov.ID] = true
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
for _, o := range overrides {
|
||||
if seen[o.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
|
||||
// pair from the unified priority enum so the wire shape stays
|
||||
// pixel-identical. Mapping mirrors mig 083's backfill (per design §2.3):
|
||||
//
|
||||
// 'mandatory' → (true, false)
|
||||
// 'optional' → (true, true)
|
||||
// 'recommended' → (false, false)
|
||||
// 'informational' → (false, false)
|
||||
// (unknown) → (true, false)
|
||||
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
||||
switch priority {
|
||||
case "mandatory":
|
||||
return true, false
|
||||
case "optional":
|
||||
return true, true
|
||||
case "recommended":
|
||||
return false, false
|
||||
case "informational":
|
||||
return false, false
|
||||
default:
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
|
||||
// AllFlagsSet is retained as a tiny utility for callers that have a
|
||||
// flat list of flag strings + a flag-set lookup. The new condition_expr
|
||||
// gate is the canonical evaluator; this helper exists for forward-
|
||||
// compat with any future caller that wants the legacy AND-over-list
|
||||
// semantic without rebuilding the jsonb.
|
||||
func AllFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
return allFlagsSet(required, set)
|
||||
}
|
||||
|
||||
// WireFlagsFromPriority is the public form of wireFlagsFromPriority so
|
||||
// the paliad-side test suite (which historically asserted the mapping
|
||||
// directly) can still test the contract.
|
||||
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
||||
return wireFlagsFromPriority(priority)
|
||||
}
|
||||
145
pkg/litigationplanner/expr.go
Normal file
145
pkg/litigationplanner/expr.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package litigationplanner
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// allFlagsSet returns true when every element of `required` is present in
|
||||
// `set`. Empty `required` returns true (no condition). Retained as the
|
||||
// fallback predicate used by EvalConditionExpr when condition_expr is
|
||||
// NULL but the legacy condition_flag text[] is set — preserves
|
||||
// transition-window behaviour for any row Slice 2 missed (it shouldn't,
|
||||
// but defensive).
|
||||
func allFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
for _, f := range required {
|
||||
if _, ok := set[f]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EvalConditionExpr returns true iff the rule's gate predicate is
|
||||
// satisfied for the caller's flag set. Drives flag-conditional rendering
|
||||
// + flag-conditional alt-swap throughout the calculator.
|
||||
//
|
||||
// Grammar (design §2.4 long form, mig 084 backfill):
|
||||
//
|
||||
// {"flag": "<name>"} — leaf: true iff <name> ∈ flags
|
||||
// {"op": "and", "args": [<n>...]} — true iff every arg evaluates true
|
||||
// {"op": "or", "args": [<n>...]} — true iff any arg evaluates true
|
||||
// {"op": "not", "args": [<one>]} — true iff the single arg is false
|
||||
//
|
||||
// NULL / empty / "null" expression → true (unconditional). Malformed
|
||||
// JSON → true (defensive: the rule still renders, the lawyer sees
|
||||
// it even if the gate is broken).
|
||||
//
|
||||
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
|
||||
// text[] column; the fallback that AND'd over it is gone. Any future
|
||||
// row needing array-of-flags semantics writes the equivalent
|
||||
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
|
||||
func EvalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
||||
if len(expr) == 0 || string(expr) == "null" {
|
||||
return true
|
||||
}
|
||||
return EvalConditionExprNode(expr, flags)
|
||||
}
|
||||
|
||||
// EvalConditionExprNode walks one node of the condition_expr jsonb
|
||||
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
|
||||
// depth + arg count); pre-Slice-11 backfilled rows have at most a
|
||||
// 2-arg AND (mig 084).
|
||||
func EvalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
|
||||
var node struct {
|
||||
Flag string `json:"flag"`
|
||||
Op string `json:"op"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
// Malformed → unconditional. The Slice 11 editor's validation
|
||||
// will block such writes; in the live corpus today mig 084's
|
||||
// jsonb_build_object output is well-formed by construction.
|
||||
return true
|
||||
}
|
||||
if node.Flag != "" {
|
||||
_, ok := flags[node.Flag]
|
||||
return ok
|
||||
}
|
||||
switch node.Op {
|
||||
case "and":
|
||||
for _, a := range node.Args {
|
||||
if !EvalConditionExprNode(a, flags) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "or":
|
||||
for _, a := range node.Args {
|
||||
if EvalConditionExprNode(a, flags) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "not":
|
||||
if len(node.Args) != 1 {
|
||||
// Malformed NOT — fall through to unconditional rather
|
||||
// than risk suppressing a rule the lawyer expects to see.
|
||||
return true
|
||||
}
|
||||
return !EvalConditionExprNode(node.Args[0], flags)
|
||||
}
|
||||
// Unknown op (forward-compat with editor extensions): treat as
|
||||
// unconditional so the rule still renders.
|
||||
return true
|
||||
}
|
||||
|
||||
// HasConditionExpr returns true when the rule carries a non-empty,
|
||||
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
|
||||
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
|
||||
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
|
||||
// when the gate flips to met, swap to alt".
|
||||
func HasConditionExpr(expr NullableJSON) bool {
|
||||
if len(expr) == 0 {
|
||||
return false
|
||||
}
|
||||
s := string(expr)
|
||||
return s != "null" && s != "{}"
|
||||
}
|
||||
|
||||
// ExtractFlagsFromExpr walks the jsonb gate and returns the unique
|
||||
// flag names referenced as {"flag":"<name>"} leaves. Used by
|
||||
// CalculateRule's response (FlagsRequired) so the result-card calc
|
||||
// panel can render flag checkboxes for each gate input. Replaces the
|
||||
// dropped condition_flag text[] enumeration. Returns nil on a NULL
|
||||
// expression or one that contains no flag leaves.
|
||||
func ExtractFlagsFromExpr(expr NullableJSON) []string {
|
||||
if !HasConditionExpr(expr) {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
walkFlagLeaves([]byte(expr), seen)
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for f := range seen {
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func walkFlagLeaves(raw []byte, into map[string]struct{}) {
|
||||
var node struct {
|
||||
Flag string `json:"flag"`
|
||||
Op string `json:"op"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
return
|
||||
}
|
||||
if node.Flag != "" {
|
||||
into[node.Flag] = struct{}{}
|
||||
return
|
||||
}
|
||||
for _, a := range node.Args {
|
||||
walkFlagLeaves(a, into)
|
||||
}
|
||||
}
|
||||
25
pkg/litigationplanner/holidays.go
Normal file
25
pkg/litigationplanner/holidays.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package litigationplanner
|
||||
|
||||
import "time"
|
||||
|
||||
// HolidayCalendar adjusts dates onto working days for a given
|
||||
// (country, regime) pair. The calculator only needs three primitives:
|
||||
//
|
||||
// - IsNonWorkingDay — used by the addWorkingDays walker
|
||||
// - AdjustForNonWorkingDays — forward snap (timing='after')
|
||||
// - AdjustForNonWorkingDaysBackward — backward snap (timing='before')
|
||||
// - AdjustForNonWorkingDaysWithReason — like the forward snap but
|
||||
// also returns *AdjustmentReason so the timeline can render the
|
||||
// "rolled past holiday X" footer in TimelineEntry.AdjustmentReason.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.holidays, caches per-year, merges DE
|
||||
// federal fallback.
|
||||
// - embedded/upc (Slice C): in-memory year-keyed map populated from
|
||||
// the embedded JSON snapshot.
|
||||
type HolidayCalendar interface {
|
||||
IsNonWorkingDay(date time.Time, country, regime string) bool
|
||||
AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool)
|
||||
AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool)
|
||||
AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *AdjustmentReason)
|
||||
}
|
||||
123
pkg/litigationplanner/legal_source.go
Normal file
123
pkg/litigationplanner/legal_source.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package litigationplanner
|
||||
|
||||
import "strings"
|
||||
|
||||
// FormatLegalSourceDisplay renders a structured legal_source code into
|
||||
// the form HLC users read in pleadings:
|
||||
//
|
||||
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
|
||||
// UPC.RoP.139 → "UPC RoP R.139"
|
||||
// DE.PatG.82.1 → "PatG §82(1)"
|
||||
// DE.ZPO.276.1 → "ZPO §276(1)"
|
||||
// EU.EPÜ.108 → "EPÜ Art.108"
|
||||
// EU.EPC-R.79.1 → "EPC R.79(1)"
|
||||
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
|
||||
//
|
||||
// Returns the empty string for an empty input. Unknown jurisdictions
|
||||
// fall through with the structured form preserved (caller decides
|
||||
// whether to display).
|
||||
func FormatLegalSourceDisplay(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
// Malformed — return as-is so the caller still has something.
|
||||
return src
|
||||
}
|
||||
code := parts[1]
|
||||
rest := parts[2:]
|
||||
var prefix string
|
||||
switch code {
|
||||
case "RoP":
|
||||
prefix = "UPC RoP R."
|
||||
case "PatG":
|
||||
prefix = "PatG §"
|
||||
case "ZPO":
|
||||
prefix = "ZPO §"
|
||||
case "EPÜ":
|
||||
prefix = "EPÜ Art."
|
||||
case "EPC-R":
|
||||
prefix = "EPC R."
|
||||
case "RPBA":
|
||||
prefix = "RPBA Art."
|
||||
default:
|
||||
prefix = code + " "
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(prefix) + len(src))
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(rest[0])
|
||||
for _, p := range rest[1:] {
|
||||
b.WriteByte('(')
|
||||
b.WriteString(p)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BuildLegalSourceURL maps a structured legal_source code to a
|
||||
// youpc.org/laws permalink when the cited body is hosted there. Today
|
||||
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
|
||||
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
|
||||
// home yet, so the helper returns the empty string for those and the
|
||||
// caller renders the display string as plain text.
|
||||
//
|
||||
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
|
||||
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
|
||||
// the law-number position are dropped; youpc resolves the page at
|
||||
// <type>.<number> granularity. The law-number is zero-padded to 3
|
||||
// digits to match how youpc stores law_number (laws-data.json carries
|
||||
// "001" / "023" / "220" forms).
|
||||
//
|
||||
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
|
||||
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
|
||||
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
|
||||
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
|
||||
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
|
||||
func BuildLegalSourceURL(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
var lawType string
|
||||
switch parts[0] + "." + parts[1] {
|
||||
case "UPC.RoP":
|
||||
lawType = "UPCRoP"
|
||||
case "UPC.UPCA":
|
||||
lawType = "UPCA"
|
||||
case "UPC.UPCS":
|
||||
lawType = "UPCS"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
number := padLawNumber(parts[2])
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://youpc.org/laws#" + lawType + "." + number
|
||||
}
|
||||
|
||||
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
|
||||
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
|
||||
// 112a) pass through unchanged so the URL still resolves. Empty input
|
||||
// returns the empty string.
|
||||
func padLawNumber(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if len(s) >= 3 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", 3-len(s)) + s
|
||||
}
|
||||
139
pkg/litigationplanner/proceeding_mapping.go
Normal file
139
pkg/litigationplanner/proceeding_mapping.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package litigationplanner
|
||||
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in the
|
||||
// codebase: the **litigation** conceptual category (INF / REV / APP /
|
||||
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
|
||||
// + Pipeline-A rules, and the **fristenrechner** code category
|
||||
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
|
||||
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
|
||||
// bind to fristenrechner codes directly, but the litigation→fristenrechner
|
||||
// mapping is still needed for the ~40 Pipeline-A rules that remain on
|
||||
// litigation proceedings and for any other surface that thinks in
|
||||
// litigation terms.
|
||||
//
|
||||
// The mapping table here is the single source of truth — see
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale + ambiguity notes, and
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
|
||||
// lowercase dot-separated naming convention applied by mig 096
|
||||
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
|
||||
// returns ok=false so callers can degrade gracefully ("no narrowing")
|
||||
// instead of guessing.
|
||||
|
||||
// Stable code constants — the strings landed by mig 096. Use these
|
||||
// throughout the codebase so a future rename only needs to touch this
|
||||
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
|
||||
// projects.proceeding_type_id) are unaffected by the rename.
|
||||
const (
|
||||
CodeUPCInfringement = "upc.inf.cfi"
|
||||
CodeUPCRevocation = "upc.rev.cfi"
|
||||
CodeUPCCounterclaim = "upc.ccr.cfi"
|
||||
CodeUPCPreliminary = "upc.pi.cfi"
|
||||
CodeUPCDamages = "upc.dmgs.cfi"
|
||||
CodeUPCDiscovery = "upc.disc.cfi"
|
||||
CodeUPCAppealMerits = "upc.apl.merits"
|
||||
CodeUPCAppealOrder = "upc.apl.order"
|
||||
CodeUPCAppealCost = "upc.apl.cost"
|
||||
CodeDEInfringementLG = "de.inf.lg"
|
||||
CodeDEInfringementOLG = "de.inf.olg"
|
||||
CodeDEInfringementBGH = "de.inf.bgh"
|
||||
CodeDENullityBPatG = "de.null.bpatg"
|
||||
CodeDENullityBGH = "de.null.bgh"
|
||||
CodeEPAGrant = "epa.grant.exa"
|
||||
CodeEPAOpposition = "epa.opp.opd"
|
||||
CodeEPAOppositionAppeal = "epa.opp.boa"
|
||||
CodeDPMAOpposition = "dpma.opp.dpma"
|
||||
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
|
||||
CodeDPMAAppealBGH = "dpma.appeal.bgh"
|
||||
)
|
||||
|
||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||
//
|
||||
// Inputs are case-sensitive — pass the canonical upper-snake form
|
||||
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
|
||||
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
|
||||
// fristenrechner code; callers should treat that as "no narrowing"
|
||||
// and leave the cascade wide-open rather than auto-pick.
|
||||
//
|
||||
// Condition flags are returned as a slice so callers can apply them
|
||||
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
|
||||
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
|
||||
// context applies.
|
||||
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
|
||||
switch litigationCode {
|
||||
case "INF":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, nil, true
|
||||
case "DE":
|
||||
return CodeDEInfringementLG, nil, true
|
||||
}
|
||||
case "REV":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCRevocation, nil, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "CCR":
|
||||
// Counterclaim revocation — UPC fold-in is structural (the
|
||||
// counterclaim lives inside an upc.inf.cfi proceeding with the
|
||||
// with_ccr flag). DE Nichtigkeit is conceptually the same
|
||||
// adversarial-validity test, no separate flag.
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, []string{"with_ccr"}, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "AMD":
|
||||
// Amendment-application bundled into upc.inf.cfi via with_amend.
|
||||
// No DE / EPA / DPMA analogue today.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCInfringement, []string{"with_amend"}, true
|
||||
}
|
||||
case "APP":
|
||||
// Appeal is ambiguous in DE (OLG vs BGH) and the project
|
||||
// model doesn't carry the instance hint we'd need to
|
||||
// disambiguate. UPC is unambiguous — upc.apl.merits covers
|
||||
// the merits appeal track for inf/rev/ccr/damages.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCAppealMerits, nil, true
|
||||
}
|
||||
case "APM":
|
||||
// Preliminary injunction / urgency procedure — UPC-only
|
||||
// concept in the fristenrechner taxonomy.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCPreliminary, nil, true
|
||||
}
|
||||
case "OPP":
|
||||
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
|
||||
// doesn't surface from the litigation vocabulary today.
|
||||
if jurisdiction == "EPA" {
|
||||
return CodeEPAOpposition, nil, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// ResolveCounterclaimRouting handles the determinator's
|
||||
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
|
||||
// for taxonomic completeness, but no rules are attached to it. When the
|
||||
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
|
||||
// upc.inf.cfi with a default with_ccr=true flag — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
|
||||
//
|
||||
// `code` is the proceeding code the cascade resolved to. If it's
|
||||
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
|
||||
// []string{"with_ccr"}, true). For any other code the function returns
|
||||
// (code, nil, false) and callers proceed with the code unchanged. The
|
||||
// boolean signals "routing was applied"; the caller can surface the hint
|
||||
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||
// weiter." in the UI.
|
||||
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||
if route, ok := SubTrackRoutings[code]; ok {
|
||||
return route.ParentCode, route.DefaultFlags, true
|
||||
}
|
||||
return code, nil, false
|
||||
}
|
||||
151
pkg/litigationplanner/sort.go
Normal file
151
pkg/litigationplanner/sort.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SortDeadlinesByDurationWithinTriggerGroup is the public form of
|
||||
// sortDeadlinesByDurationWithinTriggerGroup. Exported so paliad's
|
||||
// test suite (which historically reached the helper directly) can
|
||||
// keep invoking it via a tiny wrapper.
|
||||
func SortDeadlinesByDurationWithinTriggerGroup(
|
||||
deadlines []TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) {
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
}
|
||||
|
||||
// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of
|
||||
// deadlines whose underlying rule shares the same trigger group
|
||||
// (parent_id + trigger_event_id) and reorders each run in place by
|
||||
// duration ascending. Different trigger groups keep their original
|
||||
// proceeding-sequence position — the walk only ever permutes adjacent
|
||||
// same-group rows.
|
||||
//
|
||||
// Sort key (within a run):
|
||||
// 1. Conditional / court-set rows (no concrete date in the duration
|
||||
// ladder) sort LAST, tiebroken by submission_code.
|
||||
// 2. duration_unit weight ASC: days/working_days < weeks < months < years
|
||||
// 3. duration_value ASC
|
||||
// 4. submission_code ASC (deterministic tiebreak)
|
||||
//
|
||||
// Issue: m/paliad#128 — post-decision optional events (R.151/R.353
|
||||
// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog
|
||||
// order instead of likely-sequence order. (t-paliad-296)
|
||||
func sortDeadlinesByDurationWithinTriggerGroup(
|
||||
deadlines []TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) {
|
||||
if len(deadlines) < 2 {
|
||||
return
|
||||
}
|
||||
n := len(deadlines)
|
||||
i := 0
|
||||
for i < n {
|
||||
gid := triggerGroupKey(deadlines[i], ruleByID)
|
||||
j := i + 1
|
||||
for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid {
|
||||
j++
|
||||
}
|
||||
// Root rules (no parent and no trigger_event) get gid="" and
|
||||
// would otherwise collapse into one big run. Skip the sort for
|
||||
// the "root" pseudo-group — each root rule represents its own
|
||||
// anchor (SoC, oral hearing, decision …) and the proceeding-
|
||||
// sequence order between them must be preserved.
|
||||
if j-i > 1 && gid != "" {
|
||||
chunk := deadlines[i:j]
|
||||
sort.SliceStable(chunk, func(a, b int) bool {
|
||||
return durationLessForSort(chunk[a], chunk[b], ruleByID)
|
||||
})
|
||||
}
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
// triggerGroupKey returns a string key identifying which trigger group
|
||||
// a deadline belongs to. Same key = same group = candidates for sort.
|
||||
// Empty string means "root" (no parent, no trigger_event) — used as a
|
||||
// sentinel by the caller to skip sorting roots against each other.
|
||||
func triggerGroupKey(d TimelineEntry, ruleByID map[uuid.UUID]Rule) string {
|
||||
rid, err := uuid.Parse(d.RuleID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
r, ok := ruleByID[rid]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
return "p:" + r.ParentID.String()
|
||||
}
|
||||
if r.TriggerEventID != nil {
|
||||
return fmt.Sprintf("t:%d", *r.TriggerEventID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// durationLessForSort compares two deadlines for the duration-ascending
|
||||
// sort. Court-set / conditional rows (no concrete date) sort LAST
|
||||
// regardless of duration — they don't fit the duration ladder.
|
||||
func durationLessForSort(
|
||||
a, b TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) bool {
|
||||
aLast := a.IsCourtSet || a.IsConditional
|
||||
bLast := b.IsCourtSet || b.IsConditional
|
||||
if aLast != bLast {
|
||||
return !aLast
|
||||
}
|
||||
if aLast && bLast {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
|
||||
ra := lookupRuleFromDeadline(a, ruleByID)
|
||||
rb := lookupRuleFromDeadline(b, ruleByID)
|
||||
|
||||
wa := durationUnitWeight(ra.DurationUnit)
|
||||
wb := durationUnitWeight(rb.DurationUnit)
|
||||
if wa != wb {
|
||||
return wa < wb
|
||||
}
|
||||
if ra.DurationValue != rb.DurationValue {
|
||||
return ra.DurationValue < rb.DurationValue
|
||||
}
|
||||
return a.Code < b.Code
|
||||
}
|
||||
|
||||
func lookupRuleFromDeadline(
|
||||
d TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) Rule {
|
||||
if d.RuleID == "" {
|
||||
return Rule{}
|
||||
}
|
||||
rid, err := uuid.Parse(d.RuleID)
|
||||
if err != nil {
|
||||
return Rule{}
|
||||
}
|
||||
return ruleByID[rid]
|
||||
}
|
||||
|
||||
// durationUnitWeight maps a duration unit to its sort weight so the
|
||||
// trigger-group sort can order shorter durations first. days and
|
||||
// working_days share weight 0 (both are sub-week granularities);
|
||||
// unknown units sort to the end so they're visible as a tail rather
|
||||
// than silently winning.
|
||||
func durationUnitWeight(unit string) int {
|
||||
switch unit {
|
||||
case "days", "working_days":
|
||||
return 0
|
||||
case "weeks":
|
||||
return 1
|
||||
case "months":
|
||||
return 2
|
||||
case "years":
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
53
pkg/litigationplanner/subtrack.go
Normal file
53
pkg/litigationplanner/subtrack.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package litigationplanner
|
||||
|
||||
// SubTrackRouting describes a proceeding type that has no native rules
|
||||
// of its own and is normally rendered inside a parent proceeding's flow
|
||||
// with one or more condition flags enabled. The Procedure Roadmap
|
||||
// (verfahrensablauf) routes calc requests for these codes to the parent
|
||||
// proceeding + default flags, but preserves the user-picked code/name
|
||||
// in the response identity and surfaces a contextual note explaining
|
||||
// the framing — see m/paliad#58 and the design doc cited above.
|
||||
//
|
||||
// Adding a new sub-track is a data-only change here: extend
|
||||
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
|
||||
// renderer picks it up automatically. The note copy lives in this file
|
||||
// because it's semantic to the routing, not UI chrome.
|
||||
type SubTrackRouting struct {
|
||||
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
|
||||
Code string
|
||||
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
|
||||
ParentCode string
|
||||
// DefaultFlags are merged into the user's flag set so the
|
||||
// gated rules render. Order is preserved.
|
||||
DefaultFlags []string
|
||||
// NoteDE / NoteEN are the contextual banner above the timeline,
|
||||
// explaining that the proceeding type is normally a sub-track.
|
||||
// Plain text — the frontend renders them as a banner.
|
||||
NoteDE string
|
||||
NoteEN string
|
||||
}
|
||||
|
||||
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
|
||||
// The pattern generalises to other "sub-track" proceeding types (e.g.
|
||||
// R.30 application to amend the patent as a standalone roadmap, R.46
|
||||
// preliminary objection) once they have a proceeding-type code of their
|
||||
// own. New entries here are picked up by the spawn-as-standalone
|
||||
// renderer in Calculate without further wiring.
|
||||
var SubTrackRoutings = map[string]SubTrackRouting{
|
||||
CodeUPCCounterclaim: {
|
||||
Code: CodeUPCCounterclaim,
|
||||
ParentCode: CodeUPCInfringement,
|
||||
DefaultFlags: []string{"with_ccr"},
|
||||
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
|
||||
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
|
||||
},
|
||||
}
|
||||
|
||||
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
||||
// code, or (zero, false) if the code is not a sub-track. Used by the
|
||||
// fristenrechner Calculate path to spawn the parent flow with the sub-
|
||||
// track's default flags.
|
||||
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
||||
r, ok := SubTrackRoutings[code]
|
||||
return r, ok
|
||||
}
|
||||
499
pkg/litigationplanner/types.go
Normal file
499
pkg/litigationplanner/types.go
Normal file
@@ -0,0 +1,499 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
||||
// from Postgres breaks the row scan with "unsupported Scan, storing
|
||||
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
||||
// error that hid every approval_request from the inbox when m's first
|
||||
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
|
||||
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
|
||||
// fixes the scan and preserves inline JSON output (no base64 cast).
|
||||
type NullableJSON []byte
|
||||
|
||||
// Scan implements sql.Scanner.
|
||||
func (n *NullableJSON) Scan(value any) error {
|
||||
if value == nil {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*n = append((*n)[:0], v...)
|
||||
return nil
|
||||
case string:
|
||||
*n = []byte(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer.
|
||||
func (n NullableJSON) Value() (driver.Value, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
// MarshalJSON emits the raw JSON bytes (or "null").
|
||||
func (n NullableJSON) MarshalJSON() ([]byte, error) {
|
||||
if len(n) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON consumes raw JSON bytes (literal "null" maps to nil).
|
||||
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = append((*n)[:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||
//
|
||||
// JSON + db tags are intentionally identical to the historical
|
||||
// paliad.deadline_rules row shape — sqlx scans onto Rule directly and
|
||||
// the wire bytes the frontend reads are unchanged from the pre-extract
|
||||
// shape.
|
||||
type Rule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
||||
// this rule's concept (joined via paliad.deadline_concept_event_types
|
||||
// where is_default = true). Lets the deadline create form auto-populate
|
||||
// the Typ chip when the user picks this rule. Hydrated by the service
|
||||
// layer; not a column. NULL when the concept has no mapped event_type.
|
||||
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression. Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional.
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic (primary_party='court'
|
||||
// OR event_type IN ('hearing','decision','order')).
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow:
|
||||
// 'draft' | 'published' | 'archived'.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
|
||||
// ChoicesOffered declares which per-event-card choice-kinds this
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default).
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
|
||||
// AppliesToTarget is the per-rule applies-to set for the unified
|
||||
// UPC Berufung proceeding type (Slice B1, mig 134, m/paliad#124
|
||||
// §18.1). Each element ∈ AppealTargets. NULL on rules outside
|
||||
// the appeal proceeding. The engine filters by this when
|
||||
// CalcOptions.AppealTarget is set.
|
||||
AppliesToTarget pq.StringArray `db:"applies_to_target" json:"appliesToTarget,omitempty"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
|
||||
// /APM/APP/AMD/ZPO_CIVIL — matter management) or the lowercase dot-
|
||||
// separated fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) —
|
||||
// see docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
||||
type ProceedingType struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||
Category *string `db:"category" json:"category,omitempty"`
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
|
||||
// AppealTarget is the top-level appeal-target marker (Slice B1, mig
|
||||
// 134). NULL on non-appeal proceedings. Reserved for future variants
|
||||
// — today the unified upc.apl row has this NULL (per-rule targets
|
||||
// live on Rule.AppliesToTarget).
|
||||
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
|
||||
}
|
||||
|
||||
// AdjustmentReason describes why a date was rolled forward / backward
|
||||
// off a non-working day. Populated by HolidayCalendar implementations
|
||||
// when AdjustForNonWorkingDaysWithReason moves the date.
|
||||
//
|
||||
// Date fields are JSON-serialised as YYYY-MM-DD strings (matching
|
||||
// TimelineEntry.DueDate / OriginalDate) so the frontend doesn't need a
|
||||
// separate RFC3339 parser.
|
||||
type AdjustmentReason struct {
|
||||
// Kind is the dominant cause; longest cause wins when several apply
|
||||
// (vacation > public_holiday > weekend).
|
||||
Kind string `json:"kind"`
|
||||
// Holidays collects every named holiday encountered while walking
|
||||
// past the non-working run, deduped by (date, name). May be empty
|
||||
// when the only cause is a weekend.
|
||||
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
||||
// VacationName, VacationStart and VacationEnd describe the
|
||||
// contiguous vacation block the original date sits in. Populated
|
||||
// only when Kind == "vacation". Span boundaries are the first/last
|
||||
// vacation day in the block (excludes the weekends that pad it).
|
||||
VacationName string `json:"vacationName,omitempty"`
|
||||
VacationStart string `json:"vacationStart,omitempty"`
|
||||
VacationEnd string `json:"vacationEnd,omitempty"`
|
||||
// OriginalWeekday is the English weekday name of the original date —
|
||||
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
||||
// can localise it.
|
||||
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
||||
}
|
||||
|
||||
// HolidayDTO is the JSON shape for a holiday emitted in
|
||||
// AdjustmentReason — distinct from a DB-level Holiday row so dates
|
||||
// serialise as YYYY-MM-DD strings.
|
||||
type HolidayDTO struct {
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
IsVacation bool `json:"isVacation,omitempty"`
|
||||
IsClosure bool `json:"isClosure,omitempty"`
|
||||
}
|
||||
|
||||
// CalcOptions carries optional inputs for Calculate. Callers can leave
|
||||
// fields empty/nil for the legacy behaviour.
|
||||
//
|
||||
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with
|
||||
// anchor_alt='priority_date' (e.g. epa.grant.exa.ep_grant.publish
|
||||
// per Art. 93 EPÜ) use this date as their base instead of the
|
||||
// parent's adjusted date / the trigger date.
|
||||
// - Flags: lowercase string flags from the UI (e.g. "with_ccr",
|
||||
// "with_amend"). Drive condition_expr evaluation + flag-keyed
|
||||
// alt-swap.
|
||||
// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides
|
||||
// of the computed deadline date. When a child rule chains off a
|
||||
// parent whose code is in AnchorOverrides, the override date is
|
||||
// used as the anchor instead of the parent's calculated date.
|
||||
// - CourtID picks the forum the proceeding is filed in (e.g.
|
||||
// "upc-ld-paris", "de-bgh"). The calculator resolves it to
|
||||
// (country, regime) for non-working-day computation.
|
||||
// - TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
|
||||
// rules: when non-nil, the proceedingCode argument is ignored and
|
||||
// the engine selects rules WHERE trigger_event_id = *filter.
|
||||
// - RuleOverrides substitutes specific rules in the calculator's
|
||||
// rule list with caller-supplied in-memory rows. Used by the
|
||||
// rule-editor preview.
|
||||
// - PerCardAppellant / SkipRules / IncludeCCRFor / IncludeHidden
|
||||
// drive per-event-card choice overlays (t-paliad-265, t-paliad-290).
|
||||
// - ProjectHint scopes the catalog lookup to a project context
|
||||
// (paliad's catalog uses this to merge in project-scoped rules
|
||||
// in future slices; v1 catalogs may ignore it).
|
||||
type CalcOptions struct {
|
||||
PriorityDateStr string
|
||||
Flags []string
|
||||
AnchorOverrides map[string]string
|
||||
CourtID string
|
||||
TriggerEventIDFilter *int64
|
||||
RuleOverrides []Rule
|
||||
|
||||
PerCardAppellant map[string]string
|
||||
SkipRules map[string]struct{}
|
||||
IncludeCCRFor map[string]struct{}
|
||||
IncludeHidden bool
|
||||
|
||||
ProjectHint ProjectHint
|
||||
|
||||
// AppealTarget narrows the timeline to rules whose AppliesToTarget
|
||||
// contains the requested slug. Empty = no filter. Set to one of
|
||||
// AppealTargets for the unified UPC Berufung picker (Slice B1,
|
||||
// m/paliad#124 §18.1). Unknown slugs are silently dropped (no
|
||||
// filter applied) so a stale frontend chip doesn't break the
|
||||
// timeline render — see IsValidAppealTarget.
|
||||
AppealTarget string
|
||||
}
|
||||
|
||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||
// catalog uses ProjectID to merge in project-scoped rules in a future
|
||||
// slice (m/paliad#124 §6 — currently dropped per m's 2026-05-26
|
||||
// decision; the field stays for forward-compat). Other catalogs (the
|
||||
// embedded UPC snapshot used by youpc.org) ignore the hint.
|
||||
//
|
||||
// Zero value = no project context (the abstract Verfahrensablauf /
|
||||
// public Fristenrechner case).
|
||||
type ProjectHint struct {
|
||||
ProjectID uuid.UUID
|
||||
}
|
||||
|
||||
// CalcRuleParams identifies a single rule and the inputs needed to
|
||||
// compute one deadline from it. Caller supplies either RuleID OR the
|
||||
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
|
||||
// hand from the concept-card pill it just received a click on.
|
||||
type CalcRuleParams struct {
|
||||
RuleID string // optional — UUID
|
||||
ProceedingCode string // optional — used with RuleLocalCode
|
||||
RuleLocalCode string // optional — paliad.deadline_rules.submission_code
|
||||
TriggerDate string // required — YYYY-MM-DD
|
||||
Flags []string // optional — condition_flag inputs
|
||||
CourtID string // optional — selects holiday calendar
|
||||
}
|
||||
|
||||
// Timeline is the package's structured return for Calculate. JSON tags
|
||||
// are aligned with paliad's historical UIResponse so handlers can serve
|
||||
// it directly — the wire bytes the frontend reads are unchanged.
|
||||
type Timeline struct {
|
||||
ProceedingType string `json:"proceedingType"`
|
||||
ProceedingName string `json:"proceedingName"`
|
||||
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
Deadlines []TimelineEntry `json:"deadlines"`
|
||||
ContextualNote string `json:"contextualNote,omitempty"`
|
||||
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
}
|
||||
|
||||
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
||||
// interface (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
||||
type TimelineEntry struct {
|
||||
RuleID string `json:"ruleId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
Party string `json:"party"`
|
||||
Priority string `json:"priority"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
DueDate string `json:"dueDate"`
|
||||
OriginalDate string `json:"originalDate"`
|
||||
WasAdjusted bool `json:"wasAdjusted"`
|
||||
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||
IsRootEvent bool `json:"isRootEvent"`
|
||||
IsCourtSet bool `json:"isCourtSet"`
|
||||
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
|
||||
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
||||
// IsConditional signals the rule's anchor is uncertain — no
|
||||
// concrete date can be projected. Set when the rule depends on:
|
||||
// - a court-set ancestor whose date isn't anchored (overlaps
|
||||
// with IsCourtSetIndirect; the two are kept distinct because
|
||||
// IsCourtSet wraps a specific UX message "wird vom Gericht
|
||||
// bestimmt", whereas IsConditional is the broader "render as
|
||||
// 'abhängig von <parent>'" signal)
|
||||
// - timing='before' rules whose forward anchor isn't set
|
||||
// - optional opposing-side rules whose true triggering event
|
||||
// hasn't been recorded for this project (e.g. R.262(2)
|
||||
// Erwiderung auf Vertraulichkeitsantrag)
|
||||
// When true, DueDate and OriginalDate are empty and the frontend
|
||||
// renders an "abhängig von <ParentRuleName>" chip in place of a
|
||||
// date. Suppressed by an explicit user anchor. (t-paliad-289)
|
||||
IsConditional bool `json:"isConditional,omitempty"`
|
||||
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
|
||||
// parent's identity so the frontend can render
|
||||
// "abhängig von <ParentRuleName>" when IsConditional=true.
|
||||
// Populated whenever the rule has a parent_id, not only when
|
||||
// conditional — keeps the wire shape stable. Empty for root rules.
|
||||
// When a rule has a real trigger_event_id, these fields are
|
||||
// overridden to point at the trigger_events catalog row instead of
|
||||
// the parent_id chain (t-paliad-294 / m/paliad#126).
|
||||
ParentRuleCode string `json:"parentRuleCode,omitempty"`
|
||||
ParentRuleName string `json:"parentRuleName,omitempty"`
|
||||
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
|
||||
IsOverridden bool `json:"isOverridden,omitempty"`
|
||||
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
IsHidden bool `json:"isHidden,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculation is the single-rule calc response that backs the
|
||||
// result-card click → calc-panel flow. Distinct from TimelineEntry
|
||||
// (which represents one rendered row inside a full-proceeding
|
||||
// response): RuleCalculation is self-contained.
|
||||
type RuleCalculation struct {
|
||||
Rule RuleCalculationRule `json:"rule"`
|
||||
Proceeding RuleCalculationProceeding `json:"proceeding"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
OriginalDate string `json:"originalDate"`
|
||||
DueDate string `json:"dueDate"`
|
||||
WasAdjusted bool `json:"wasAdjusted"`
|
||||
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||
IsCourtSet bool `json:"isCourtSet"`
|
||||
FlagsApplied []string `json:"flagsApplied,omitempty"`
|
||||
FlagsRequired []string `json:"flagsRequired,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculationRule mirrors the small subset of Rule the
|
||||
// frontend needs to render the calc panel.
|
||||
type RuleCalculationRule struct {
|
||||
ID string `json:"id"`
|
||||
LocalCode string `json:"localCode,omitempty"`
|
||||
NameDE string `json:"nameDE"`
|
||||
NameEN string `json:"nameEN"`
|
||||
RuleRef string `json:"ruleRef,omitempty"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
DurationValue int `json:"durationValue"`
|
||||
DurationUnit string `json:"durationUnit"`
|
||||
Party string `json:"party,omitempty"`
|
||||
IsMandatory bool `json:"isMandatory"`
|
||||
NotesDE string `json:"notesDE,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculationProceeding identifies the proceeding context for the
|
||||
// rule. Used by the frontend for display + by the add-to-project flow.
|
||||
type RuleCalculationProceeding struct {
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"nameDE"`
|
||||
NameEN string `json:"nameEN"`
|
||||
}
|
||||
|
||||
// FristenrechnerType mirrors the /api/tools/proceeding-types response
|
||||
// metadata.
|
||||
type FristenrechnerType struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||
// whose semantic anchor is an event rather than a parent rule (the
|
||||
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
|
||||
// triggered by the opposing party's confidentiality application, not
|
||||
// by the SoC parent rule). The conditional-rendering branch reads
|
||||
// this when stamping ParentRule* on the wire.
|
||||
type TriggerEvent struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
Description string `db:"description" json:"description"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Sentinel errors surfaced by Calculate / CalculateRule / Catalog
|
||||
// implementations. Handlers map these to HTTP statuses.
|
||||
var (
|
||||
ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
||||
ErrUnknownRule = errors.New("unknown rule")
|
||||
)
|
||||
|
||||
// AppealTarget* are the canonical slugs for the unified UPC Berufung
|
||||
// proceeding type's appeal-target discriminator (Slice B1, m/paliad#124
|
||||
// §18.1). The verfahrensablauf picker renders one "Berufung" entry;
|
||||
// the user then picks one of these five targets and the engine filters
|
||||
// rules whose AppliesToTarget contains the requested slug.
|
||||
//
|
||||
// Schadensbemessung + Bucheinsicht have no rule rows in migration 134;
|
||||
// per m's 2026-05-26 decision they are distinct from the merits track
|
||||
// and their rule sets will be seeded in a follow-up slice (paired with
|
||||
// t-paliad-193 orphan-concept-seed or editorial via /admin/rules).
|
||||
// CalcOptions.AppealTarget="schadensbemessung" or "bucheinsicht"
|
||||
// currently returns an empty timeline.
|
||||
const (
|
||||
AppealTargetEndentscheidung = "endentscheidung"
|
||||
AppealTargetKostenentscheidung = "kostenentscheidung"
|
||||
AppealTargetAnordnung = "anordnung"
|
||||
AppealTargetSchadensbemessung = "schadensbemessung"
|
||||
AppealTargetBucheinsicht = "bucheinsicht"
|
||||
)
|
||||
|
||||
// AppealTargets is the canonical ordered list for UI chip rendering +
|
||||
// validation. Order matches the design doc + the frontend's i18n key
|
||||
// ordering — do not reorder without coordinating with the chip-group
|
||||
// renderer.
|
||||
var AppealTargets = []string{
|
||||
AppealTargetEndentscheidung,
|
||||
AppealTargetKostenentscheidung,
|
||||
AppealTargetAnordnung,
|
||||
AppealTargetSchadensbemessung,
|
||||
AppealTargetBucheinsicht,
|
||||
}
|
||||
|
||||
// IsValidAppealTarget returns true for empty (no filter requested) or
|
||||
// any of the five canonical slugs. The engine uses this to gate the
|
||||
// CalcOptions.AppealTarget filter — an unknown slug is silently
|
||||
// dropped (no filter applied) rather than producing an error, so a
|
||||
// stale frontend chip doesn't break the timeline render.
|
||||
func IsValidAppealTarget(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
for _, t := range AppealTargets {
|
||||
if t == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user