Compare commits
16 Commits
mai/cronus
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| 71e8023784 | |||
| d190fbe0a4 | |||
| e0a82d9f9e | |||
| d326f9aa4a | |||
| 026ad2d5ee | |||
| 13a65a6d6e | |||
| bd7896ef68 | |||
| 946f373651 | |||
| 94310ba498 | |||
| 5834e3dc66 | |||
| b27d402156 | |||
| 14290294b4 | |||
| 6b970da774 | |||
| 9359e99a6b | |||
| 2c0efc396c | |||
| 5c6a0095e3 |
@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
|
|||||||
import { Footer } from "./components/Footer";
|
import { Footer } from "./components/Footer";
|
||||||
import { PWAHead } from "./components/PWAHead";
|
import { PWAHead } from "./components/PWAHead";
|
||||||
|
|
||||||
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||||
// 37-column rule row plus a side panel with the preview widget and the
|
// 37-column rule row plus a side panel with the preview widget and the
|
||||||
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
|
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
|
||||||
// rule's current state (draft/published/archived). Every write goes
|
// rule's current state (draft/published/archived). Every write goes
|
||||||
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<PWAHead />
|
<PWAHead />
|
||||||
<title data-i18n="admin.rules.edit.title">Regel bearbeiten — Paliad</title>
|
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten — Paliad</title>
|
||||||
<link rel="stylesheet" href="/assets/global.css" />
|
<link rel="stylesheet" href="/assets/global.css" />
|
||||||
</head>
|
</head>
|
||||||
<body className="has-sidebar">
|
<body className="has-sidebar">
|
||||||
<Sidebar currentPath="/admin/rules" />
|
<Sidebar currentPath="/admin/procedural-events" />
|
||||||
<BottomNav currentPath="/admin/rules" />
|
<BottomNav currentPath="/admin/procedural-events" />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section className="tool-page">
|
<section className="tool-page">
|
||||||
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
|
|||||||
<div className="tool-header admin-rules-edit-header">
|
<div className="tool-header admin-rules-edit-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="admin-rules-breadcrumb">
|
<p className="admin-rules-breadcrumb">
|
||||||
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">← Regeln verwalten</a>
|
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">← Regeln verwalten</a>
|
||||||
</p>
|
</p>
|
||||||
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
|
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
|
||||||
<div className="admin-rules-edit-meta">
|
<div className="admin-rules-edit-meta">
|
||||||
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-rules-edit-row">
|
<div className="admin-rules-edit-row">
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
|
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
|
||||||
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
|
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-rules-edit-row">
|
<div className="admin-rules-edit-row">
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
|
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
|
||||||
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
|
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
|
|||||||
<input type="text" id="f-primary-party" className="admin-rules-input" />
|
<input type="text" id="f-primary-party" className="admin-rules-input" />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
|
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
|
||||||
<input type="text" id="f-event-type" className="admin-rules-input" />
|
<input type="text" id="f-event-type" className="admin-rules-input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
|
|||||||
import { Footer } from "./components/Footer";
|
import { Footer } from "./components/Footer";
|
||||||
import { PWAHead } from "./components/PWAHead";
|
import { PWAHead } from "./components/PWAHead";
|
||||||
|
|
||||||
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
|
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||||
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
|
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
|
||||||
// admin can hand-bind each legacy deadline to one of the candidate
|
// admin can hand-bind each legacy deadline to one of the candidate
|
||||||
// rule_ids. Both surfaces share the same page shell to keep navigation
|
// rule_ids. Both surfaces share the same page shell to keep navigation
|
||||||
@@ -21,25 +21,25 @@ export function renderAdminRulesList(): string {
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<PWAHead />
|
<PWAHead />
|
||||||
<title data-i18n="admin.rules.list.title">Regeln verwalten — Paliad</title>
|
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten — Paliad</title>
|
||||||
<link rel="stylesheet" href="/assets/global.css" />
|
<link rel="stylesheet" href="/assets/global.css" />
|
||||||
</head>
|
</head>
|
||||||
<body className="has-sidebar">
|
<body className="has-sidebar">
|
||||||
<Sidebar currentPath="/admin/rules" />
|
<Sidebar currentPath="/admin/procedural-events" />
|
||||||
<BottomNav currentPath="/admin/rules" />
|
<BottomNav currentPath="/admin/procedural-events" />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section className="tool-page">
|
<section className="tool-page">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="tool-header">
|
<div className="tool-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
|
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
|
||||||
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
|
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
|
||||||
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.
|
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-rules-header-actions">
|
<div className="admin-rules-header-actions">
|
||||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
|
||||||
+ Neue Regel
|
+ Neue Regel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +101,7 @@ export function renderAdminRulesList(): string {
|
|||||||
<table className="entity-table admin-rules-table">
|
<table className="entity-table admin-rules-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
|
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
|
||||||
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
|
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
|
||||||
<th data-i18n="admin.rules.col.name">Name</th>
|
<th data-i18n="admin.rules.col.name">Name</th>
|
||||||
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
|
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function renderAdmin(): string {
|
|||||||
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
||||||
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/rules" className="card card-link">
|
<a href="/admin/procedural-events" className="card card-link">
|
||||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
||||||
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
|
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
|
||||||
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>
|
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||||
import { initSidebar } from "./sidebar";
|
import { initSidebar } from "./sidebar";
|
||||||
|
|
||||||
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
|
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
|
||||||
// row, drives every form field, the preview widget, the audit-log
|
// row, drives every form field, the preview widget, the audit-log
|
||||||
// timeline and the lifecycle action bar. Every write is gated behind
|
// timeline and the lifecycle action bar. Every write is gated behind
|
||||||
// a reason modal — the ≥10-char rule is enforced client-side per
|
// a reason modal — the ≥10-char rule is enforced client-side per
|
||||||
@@ -106,7 +106,7 @@ function fmtDateTime(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseRuleIDFromPath(): string {
|
function parseRuleIDFromPath(): string {
|
||||||
// /admin/rules/{uuid}/edit
|
// /admin/procedural-events/{uuid}/edit
|
||||||
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||||
return m ? decodeURIComponent(m[1]) : "";
|
return m ? decodeURIComponent(m[1]) : "";
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRule(): Promise<void> {
|
async function loadRule(): Promise<void> {
|
||||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
|
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 404) {
|
if (resp.status === 404) {
|
||||||
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
|
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
|
||||||
@@ -198,7 +198,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
|
|||||||
auditEntries = [];
|
auditEntries = [];
|
||||||
auditOffset = 0;
|
auditOffset = 0;
|
||||||
}
|
}
|
||||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const body = await resp.json();
|
const body = await resp.json();
|
||||||
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
|
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
|
||||||
@@ -508,7 +508,7 @@ async function doSaveDraft(reason: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
payload.reason = reason;
|
payload.reason = reason;
|
||||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
|
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -530,7 +530,7 @@ async function doSaveDraft(reason: string) {
|
|||||||
|
|
||||||
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
|
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
|
||||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
|
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ reason }),
|
body: JSON.stringify({ reason }),
|
||||||
@@ -552,7 +552,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
|
|||||||
|
|
||||||
async function doClone(reason: string) {
|
async function doClone(reason: string) {
|
||||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ reason }),
|
body: JSON.stringify({ reason }),
|
||||||
@@ -565,7 +565,7 @@ async function doClone(reason: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newRule = await resp.json() as Rule;
|
const newRule = await resp.json() as Rule;
|
||||||
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
|
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------
|
// --------------------------------------------------------------------
|
||||||
@@ -591,7 +591,7 @@ async function runPreview() {
|
|||||||
if (flagsRaw) qs.set("flags", flagsRaw);
|
if (flagsRaw) qs.set("flags", flagsRaw);
|
||||||
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
|
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
|
||||||
out.style.display = "";
|
out.style.display = "";
|
||||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;
|
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||||
import { initSidebar } from "./sidebar";
|
import { initSidebar } from "./sidebar";
|
||||||
|
|
||||||
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
|
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
|
||||||
// by proceeding type, trigger event, lifecycle state, free-text query)
|
// by proceeding type, trigger event, lifecycle state, free-text query)
|
||||||
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
|
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
|
||||||
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
|
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
|
||||||
// "Pick" affordance with an inline reason prompt that posts to
|
// "Pick" affordance with an inline reason prompt that posts to
|
||||||
// /admin/api/orphans/{id}/resolve.
|
// /admin/api/orphans/{id}/resolve.
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ function buildFilterURL(): string {
|
|||||||
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
|
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
|
||||||
if (activeQuery) qs.set("q", activeQuery);
|
if (activeQuery) qs.set("q", activeQuery);
|
||||||
qs.set("limit", "500");
|
qs.set("limit", "500");
|
||||||
return "/admin/api/rules?" + qs.toString();
|
return "/admin/api/procedural-events?" + qs.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProceedings(): Promise<void> {
|
async function loadProceedings(): Promise<void> {
|
||||||
@@ -248,7 +248,7 @@ function renderRulesTable() {
|
|||||||
if (target && (target.closest("a") || target.closest("button"))) return;
|
if (target && (target.closest("a") || target.closest("button"))) return;
|
||||||
const id = row.dataset.rowId;
|
const id = row.dataset.rowId;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
|
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -392,7 +392,7 @@ async function submitReasonModal(ev: Event) {
|
|||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resp = await fetch("/admin/api/rules", {
|
const resp = await fetch("/admin/api/procedural-events", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -416,7 +416,7 @@ async function submitReasonModal(ev: Event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const created = await resp.json();
|
const created = await resp.json();
|
||||||
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
|
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2905,10 +2905,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
|
|
||||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||||
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
|
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
|
||||||
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
|
// t-paliad-305 Slice B.6 (2026-05-26) — canonical URL moved to
|
||||||
// (URL change is Slice B.6); the visible labels rename. Canonical
|
// `/admin/procedural-events` (301 redirects from /admin/rules*).
|
||||||
// `admin.procedural_events.*` aliases live after the EN block — they
|
// The i18n keys `admin.rules.*` are kept as the corpus until a
|
||||||
// pin the contract for when .tsx files rebind in Slice B (B.5).
|
// follow-up slice migrates each reference; canonical
|
||||||
|
// `admin.procedural_events.*` aliases live after the EN block.
|
||||||
"nav.admin.rules": "Verfahrensschritte verwalten",
|
"nav.admin.rules": "Verfahrensschritte verwalten",
|
||||||
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
||||||
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||||
|
|||||||
@@ -1317,6 +1317,26 @@ function paintSectionList(): void {
|
|||||||
for (const sec of sections) {
|
for (const sec of sections) {
|
||||||
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
|
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t-paliad-318 Slice F — "+ Abschnitt hinzufügen" trailing
|
||||||
|
// affordance + "Reihenfolge speichern" affordance (only visible
|
||||||
|
// after a manual reorder; surfaced by paintReorderControls when
|
||||||
|
// pendingReorder is set).
|
||||||
|
let trailer = document.getElementById("submission-draft-sections-trailer");
|
||||||
|
if (!trailer) {
|
||||||
|
trailer = document.createElement("div");
|
||||||
|
trailer.id = "submission-draft-sections-trailer";
|
||||||
|
trailer.className = "submission-draft-sections-trailer";
|
||||||
|
wrap.appendChild(trailer);
|
||||||
|
}
|
||||||
|
trailer.innerHTML = "";
|
||||||
|
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.type = "button";
|
||||||
|
addBtn.className = "btn-small btn-secondary";
|
||||||
|
addBtn.textContent = isEN() ? "+ Add section" : "+ Abschnitt hinzufügen";
|
||||||
|
addBtn.addEventListener("click", () => openAddSectionForm(trailer!));
|
||||||
|
trailer.appendChild(addBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
|
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
|
||||||
@@ -1325,9 +1345,29 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
|
|||||||
li.dataset.sectionId = sec.id;
|
li.dataset.sectionId = sec.id;
|
||||||
if (!sec.included) li.classList.add("submission-draft-section--excluded");
|
if (!sec.included) li.classList.add("submission-draft-section--excluded");
|
||||||
|
|
||||||
|
// t-paliad-318 Slice F — drag-and-drop reorder. Native HTML5 DnD,
|
||||||
|
// no external library. The drag handle is the only draggable
|
||||||
|
// affordance so clicks inside the editor area don't accidentally
|
||||||
|
// trigger a drag.
|
||||||
|
li.draggable = false; // overridden via the handle below
|
||||||
|
li.addEventListener("dragover", (ev) => onSectionDragOver(ev, li));
|
||||||
|
li.addEventListener("drop", (ev) => onSectionDrop(ev, li));
|
||||||
|
li.addEventListener("dragleave", () => li.classList.remove("submission-draft-section--drop-target"));
|
||||||
|
|
||||||
const head = document.createElement("header");
|
const head = document.createElement("header");
|
||||||
head.className = "submission-draft-section-head";
|
head.className = "submission-draft-section-head";
|
||||||
|
|
||||||
|
// Drag handle — making just this element draggable scoped the
|
||||||
|
// gesture so contentEditable selections still work.
|
||||||
|
const handle = document.createElement("span");
|
||||||
|
handle.className = "submission-draft-section-handle";
|
||||||
|
handle.draggable = true;
|
||||||
|
handle.title = isEN() ? "Drag to reorder" : "Zum Sortieren ziehen";
|
||||||
|
handle.textContent = "⋮⋮";
|
||||||
|
handle.addEventListener("dragstart", (ev) => onSectionDragStart(ev, sec.id));
|
||||||
|
handle.addEventListener("dragend", () => onSectionDragEnd(li));
|
||||||
|
head.appendChild(handle);
|
||||||
|
|
||||||
const title = document.createElement("h3");
|
const title = document.createElement("h3");
|
||||||
title.className = "submission-draft-section-title";
|
title.className = "submission-draft-section-title";
|
||||||
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
|
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
|
||||||
@@ -1356,6 +1396,16 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
|
|||||||
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
|
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
|
||||||
head.appendChild(toggle);
|
head.appendChild(toggle);
|
||||||
|
|
||||||
|
// t-paliad-318 Slice F — per-section delete. Removes the row.
|
||||||
|
// Confirmation guard prevents accidental loss of typed prose.
|
||||||
|
const del = document.createElement("button");
|
||||||
|
del.type = "button";
|
||||||
|
del.className = "btn-small btn-link-danger submission-draft-section-delete";
|
||||||
|
del.textContent = isEN() ? "Delete" : "Entfernen";
|
||||||
|
del.title = isEN() ? "Remove this section from the draft" : "Abschnitt aus dem Entwurf entfernen";
|
||||||
|
del.addEventListener("click", () => onSectionDelete(sec));
|
||||||
|
head.appendChild(del);
|
||||||
|
|
||||||
li.appendChild(head);
|
li.appendChild(head);
|
||||||
|
|
||||||
// Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
|
// Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
|
||||||
@@ -1614,6 +1664,214 @@ async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void
|
|||||||
await patchSection(sec.id, { included: !sec.included });
|
await patchSection(sec.id, { included: !sec.included });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// t-paliad-318 Slice F — reorder / delete / add
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let dragSourceID: string | null = null;
|
||||||
|
|
||||||
|
function onSectionDragStart(ev: DragEvent, sectionID: string): void {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
dragSourceID = sectionID;
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
ev.dataTransfer.setData("text/plain", sectionID);
|
||||||
|
const parentLi = (ev.target as HTMLElement).closest("li");
|
||||||
|
if (parentLi) parentLi.classList.add("submission-draft-section--dragging");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDragOver(ev: DragEvent, li: HTMLLIElement): void {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
|
||||||
|
if (dragSourceID && dragSourceID !== li.dataset.sectionId) {
|
||||||
|
li.classList.add("submission-draft-section--drop-target");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDragEnd(li: HTMLLIElement): void {
|
||||||
|
li.classList.remove("submission-draft-section--dragging");
|
||||||
|
document.querySelectorAll(".submission-draft-section--drop-target").forEach((el) => {
|
||||||
|
el.classList.remove("submission-draft-section--drop-target");
|
||||||
|
});
|
||||||
|
dragSourceID = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSectionDrop(ev: DragEvent, targetLi: HTMLLIElement): Promise<void> {
|
||||||
|
ev.preventDefault();
|
||||||
|
targetLi.classList.remove("submission-draft-section--drop-target");
|
||||||
|
const sourceID = dragSourceID;
|
||||||
|
dragSourceID = null;
|
||||||
|
document.querySelectorAll(".submission-draft-section--dragging").forEach((el) => {
|
||||||
|
el.classList.remove("submission-draft-section--dragging");
|
||||||
|
});
|
||||||
|
if (!sourceID || !state.view?.sections) return;
|
||||||
|
const targetID = targetLi.dataset.sectionId;
|
||||||
|
if (!targetID || sourceID === targetID) return;
|
||||||
|
|
||||||
|
const ids = state.view.sections.map(s => s.id);
|
||||||
|
const fromIdx = ids.indexOf(sourceID);
|
||||||
|
const toIdx = ids.indexOf(targetID);
|
||||||
|
if (fromIdx < 0 || toIdx < 0) return;
|
||||||
|
|
||||||
|
// Splice source out, insert at target position. "Drop on row X"
|
||||||
|
// semantics: source lands JUST BEFORE the target row.
|
||||||
|
ids.splice(fromIdx, 1);
|
||||||
|
const insertAt = ids.indexOf(targetID);
|
||||||
|
ids.splice(insertAt, 0, sourceID);
|
||||||
|
|
||||||
|
await reorderSections(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderSections(ids: string[]): Promise<void> {
|
||||||
|
if (!state.view) return;
|
||||||
|
const draftID = state.view.draft.id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/submission-drafts/${draftID}/sections/reorder`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ section_order: ids }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn("reorder failed", res.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await res.json() as { sections?: SubmissionSectionJSON[] };
|
||||||
|
if (state.view && body.sections) state.view.sections = body.sections;
|
||||||
|
paintSectionList();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("reorder error", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSectionDelete(sec: SubmissionSectionJSON): Promise<void> {
|
||||||
|
const label = isEN() ? sec.label_en : sec.label_de;
|
||||||
|
const confirmMsg = isEN()
|
||||||
|
? `Delete section "${label}"? This cannot be undone.`
|
||||||
|
: `Abschnitt "${label}" entfernen? Diese Aktion kann nicht rückgängig gemacht werden.`;
|
||||||
|
if (!confirm(confirmMsg)) return;
|
||||||
|
if (!state.view) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/submission-drafts/${state.view.draft.id}/sections/${sec.id}`,
|
||||||
|
{ method: "DELETE", credentials: "include" },
|
||||||
|
);
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
console.warn("delete section failed", res.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.view.sections) {
|
||||||
|
state.view.sections = state.view.sections.filter(s => s.id !== sec.id);
|
||||||
|
}
|
||||||
|
paintSectionList();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("delete section error", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddSectionForm(host: HTMLElement): void {
|
||||||
|
// If already open, close (toggle).
|
||||||
|
const existing = host.querySelector(".submission-draft-add-section");
|
||||||
|
if (existing) {
|
||||||
|
existing.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "submission-draft-add-section";
|
||||||
|
form.addEventListener("submit", (ev) => { ev.preventDefault(); void submitAddSection(form); });
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ name: "section_key", label: isEN() ? "Slug" : "Slug", required: true, placeholder: "berufungsantraege" },
|
||||||
|
{ name: "label_de", label: "Label (DE)", required: true, placeholder: "Berufungsanträge" },
|
||||||
|
{ name: "label_en", label: "Label (EN)", required: true, placeholder: "Appeal requests" },
|
||||||
|
];
|
||||||
|
for (const f of fields) {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "submission-draft-add-section-row";
|
||||||
|
const lab = document.createElement("span");
|
||||||
|
lab.textContent = f.label + (f.required ? " *" : "");
|
||||||
|
row.appendChild(lab);
|
||||||
|
const inp = document.createElement("input");
|
||||||
|
inp.type = "text";
|
||||||
|
inp.name = f.name;
|
||||||
|
inp.className = "entity-form-input";
|
||||||
|
inp.required = f.required;
|
||||||
|
inp.placeholder = f.placeholder;
|
||||||
|
row.appendChild(inp);
|
||||||
|
form.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindRow = document.createElement("label");
|
||||||
|
kindRow.className = "submission-draft-add-section-row";
|
||||||
|
const kindLab = document.createElement("span");
|
||||||
|
kindLab.textContent = isEN() ? "Kind" : "Typ";
|
||||||
|
kindRow.appendChild(kindLab);
|
||||||
|
const kindSel = document.createElement("select");
|
||||||
|
kindSel.name = "kind";
|
||||||
|
kindSel.className = "entity-form-input";
|
||||||
|
for (const opt of ["prose", "requests", "evidence"]) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = opt;
|
||||||
|
o.textContent = opt;
|
||||||
|
kindSel.appendChild(o);
|
||||||
|
}
|
||||||
|
kindRow.appendChild(kindSel);
|
||||||
|
form.appendChild(kindRow);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "submission-draft-add-section-actions";
|
||||||
|
const ok = document.createElement("button");
|
||||||
|
ok.type = "submit";
|
||||||
|
ok.className = "btn-small btn-primary btn-cta-lime";
|
||||||
|
ok.textContent = isEN() ? "Add" : "Hinzufügen";
|
||||||
|
actions.appendChild(ok);
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.className = "btn-small btn-secondary";
|
||||||
|
cancel.textContent = isEN() ? "Cancel" : "Abbrechen";
|
||||||
|
cancel.addEventListener("click", () => form.remove());
|
||||||
|
actions.appendChild(cancel);
|
||||||
|
form.appendChild(actions);
|
||||||
|
|
||||||
|
host.appendChild(form);
|
||||||
|
setTimeout(() => (form.querySelector('input[name="section_key"]') as HTMLInputElement | null)?.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAddSection(form: HTMLFormElement): Promise<void> {
|
||||||
|
if (!state.view) return;
|
||||||
|
const data = new FormData(form);
|
||||||
|
const payload = {
|
||||||
|
section_key: String(data.get("section_key") ?? "").trim(),
|
||||||
|
kind: String(data.get("kind") ?? "prose"),
|
||||||
|
label_de: String(data.get("label_de") ?? "").trim(),
|
||||||
|
label_en: String(data.get("label_en") ?? "").trim(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/submission-drafts/${state.view.draft.id}/sections`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||||
|
alert(body.error ?? `HTTP ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await res.json() as SubmissionSectionJSON;
|
||||||
|
if (state.view.sections) state.view.sections.push(created);
|
||||||
|
form.remove();
|
||||||
|
paintSectionList();
|
||||||
|
} catch (err) {
|
||||||
|
alert(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// t-paliad-315 Slice C — building-block picker modal
|
// t-paliad-315 Slice C — building-block picker modal
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
|||||||
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
||||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
{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/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", 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. */}
|
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||||
|
|||||||
@@ -6294,6 +6294,71 @@ dialog.modal::backdrop {
|
|||||||
background: var(--color-bg-elev-2, var(--color-bg-elev-1));
|
background: var(--color-bg-elev-2, var(--color-bg-elev-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* t-paliad-318 Slice F — drag-and-drop reorder + add / delete affordances. */
|
||||||
|
.submission-draft-section-handle {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-section-handle:hover {
|
||||||
|
background: var(--color-bg-subtle, var(--color-bg-elev-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-section-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-section--dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-section--drop-target {
|
||||||
|
border-top: 2px solid var(--color-accent-fg, var(--color-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-section-delete {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-sections-trailer {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-add-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-bg-elev-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-add-section-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-add-section-row > span {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-draft-add-section-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* t-paliad-315 Slice C — building-block picker modal */
|
/* t-paliad-315 Slice C — building-block picker modal */
|
||||||
.submission-draft-section-bb-btn {
|
.submission-draft-section-bb-btn {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
|||||||
@@ -109,11 +109,21 @@ ALTER TABLE paliad.deadlines
|
|||||||
ALTER TABLE paliad.deadlines
|
ALTER TABLE paliad.deadlines
|
||||||
DROP COLUMN IF EXISTS rule_id;
|
DROP COLUMN IF EXISTS rule_id;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 6a. Drop the deadline_search materialized view, which has a
|
||||||
|
-- direct dependency on paliad.deadline_rules (mig 077). We
|
||||||
|
-- recreate it after the DROP, re-pointed at deadline_rules_unified
|
||||||
|
-- so reads keep working. All 11 indexes are recreated alongside.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||||
|
|
||||||
-- ---------------------------------------------------------------
|
-- ---------------------------------------------------------------
|
||||||
-- 6. DROP TABLE paliad.deadline_rules. Now that:
|
-- 6. DROP TABLE paliad.deadline_rules. Now that:
|
||||||
-- - dependent FKs are re-pointed to sequencing_rules,
|
-- - dependent FKs are re-pointed to sequencing_rules,
|
||||||
-- - the audit trigger is dropped,
|
-- - the audit trigger is dropped,
|
||||||
-- - deadlines.rule_id is gone,
|
-- - deadlines.rule_id is gone,
|
||||||
|
-- - the deadline_search matview is gone,
|
||||||
-- nothing references the table anymore. The self-FKs
|
-- nothing references the table anymore. The self-FKs
|
||||||
-- (deadline_rules.parent_id, .draft_of) drop with the table.
|
-- (deadline_rules.parent_id, .draft_of) drop with the table.
|
||||||
-- ---------------------------------------------------------------
|
-- ---------------------------------------------------------------
|
||||||
@@ -304,15 +314,28 @@ CREATE TRIGGER deadline_rules_unified_update
|
|||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_snapshot_count int;
|
v_snapshot_count int;
|
||||||
|
v_sr_count int;
|
||||||
v_view_count int;
|
v_view_count int;
|
||||||
v_dr_table_exists int;
|
v_dr_table_exists int;
|
||||||
v_rule_id_col int;
|
v_rule_id_col int;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT COUNT(*) INTO v_snapshot_count FROM paliad.deadline_rules_pre_140;
|
-- B.2 dual-write was implemented only for the active+published lifecycle
|
||||||
|
-- (the scope of the read paths and B.4's pre-flip drift check). Archived
|
||||||
|
-- + draft rows in deadline_rules were never replicated to sequencing_rules
|
||||||
|
-- (they had no production read path). Snapshot includes them all (CREATE
|
||||||
|
-- TABLE AS is unfiltered), so we compare on the same filter B.2 actually
|
||||||
|
-- maintained. Drafts/archived rows are preserved in paliad.deadline_rules_pre_140
|
||||||
|
-- for forensic + future-backfill use.
|
||||||
|
SELECT COUNT(*) INTO v_snapshot_count
|
||||||
|
FROM paliad.deadline_rules_pre_140
|
||||||
|
WHERE is_active = true AND lifecycle_state = 'published';
|
||||||
|
SELECT COUNT(*) INTO v_sr_count
|
||||||
|
FROM paliad.sequencing_rules
|
||||||
|
WHERE is_active = true AND lifecycle_state = 'published';
|
||||||
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
|
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
|
||||||
IF v_snapshot_count <> v_view_count THEN
|
IF v_snapshot_count <> v_sr_count THEN
|
||||||
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot has % rows, view has % rows — drift between final state and snapshot',
|
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot active+published has % rows, sequencing_rules active+published has % rows — dual-write drift',
|
||||||
v_snapshot_count, v_view_count;
|
v_snapshot_count, v_sr_count;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
SELECT COUNT(*) INTO v_dr_table_exists
|
SELECT COUNT(*) INTO v_dr_table_exists
|
||||||
@@ -329,6 +352,97 @@ BEGIN
|
|||||||
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
|
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, view=% rows, INSTEAD OF triggers active',
|
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, sequencing_rules=% rows, view (filtered)=% rows, INSTEAD OF triggers active',
|
||||||
v_snapshot_count, v_view_count;
|
v_snapshot_count, v_sr_count, v_view_count;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 8. Recreate paliad.deadline_search materialized view against
|
||||||
|
-- deadline_rules_unified (same column shape — sr.id is the new
|
||||||
|
-- dr.id, etc.). Definition mirrors mig 077; only the FROM table
|
||||||
|
-- name changes. All 11 indexes restored.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||||
|
SELECT 'rule'::text AS kind,
|
||||||
|
('r:'::text || (dr.id)::text) AS row_key,
|
||||||
|
dc.id AS concept_id,
|
||||||
|
dc.slug AS concept_slug,
|
||||||
|
dc.name_de AS concept_name_de,
|
||||||
|
dc.name_en AS concept_name_en,
|
||||||
|
dc.description AS concept_description,
|
||||||
|
dc.aliases AS concept_aliases,
|
||||||
|
dc.party AS concept_party,
|
||||||
|
dc.category AS concept_category,
|
||||||
|
dc.sort_order AS concept_sort_order,
|
||||||
|
dr.id AS rule_id,
|
||||||
|
NULL::bigint AS trigger_event_id,
|
||||||
|
pt.code AS proceeding_code,
|
||||||
|
pt.name AS proceeding_name_de,
|
||||||
|
pt.name_en AS proceeding_name_en,
|
||||||
|
pt.jurisdiction,
|
||||||
|
pt.display_order AS proceeding_display_order,
|
||||||
|
dr.submission_code AS rule_local_code,
|
||||||
|
dr.name AS rule_name_de,
|
||||||
|
dr.name_en AS rule_name_en,
|
||||||
|
dr.legal_source,
|
||||||
|
dr.rule_code,
|
||||||
|
dr.duration_value,
|
||||||
|
dr.duration_unit,
|
||||||
|
dr.timing,
|
||||||
|
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||||
|
FROM paliad.deadline_rules_unified dr
|
||||||
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||||
|
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||||
|
WHERE dr.is_active AND pt.is_active AND pt.category = 'fristenrechner'::text
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'trigger'::text AS kind,
|
||||||
|
('t:'::text || (te.id)::text) AS row_key,
|
||||||
|
dc.id AS concept_id,
|
||||||
|
dc.slug AS concept_slug,
|
||||||
|
dc.name_de AS concept_name_de,
|
||||||
|
dc.name_en AS concept_name_en,
|
||||||
|
dc.description AS concept_description,
|
||||||
|
dc.aliases AS concept_aliases,
|
||||||
|
dc.party AS concept_party,
|
||||||
|
dc.category AS concept_category,
|
||||||
|
dc.sort_order AS concept_sort_order,
|
||||||
|
NULL::uuid AS rule_id,
|
||||||
|
te.id AS trigger_event_id,
|
||||||
|
NULL::text AS proceeding_code,
|
||||||
|
NULL::text AS proceeding_name_de,
|
||||||
|
NULL::text AS proceeding_name_en,
|
||||||
|
'cross-cutting'::text AS jurisdiction,
|
||||||
|
9999 AS proceeding_display_order,
|
||||||
|
te.code AS rule_local_code,
|
||||||
|
te.name_de AS rule_name_de,
|
||||||
|
te.name AS rule_name_en,
|
||||||
|
dr_trig.legal_source,
|
||||||
|
NULL::text AS rule_code,
|
||||||
|
NULL::integer AS duration_value,
|
||||||
|
NULL::text AS duration_unit,
|
||||||
|
NULL::text AS timing,
|
||||||
|
dc.party AS effective_party
|
||||||
|
FROM paliad.trigger_events te
|
||||||
|
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||||
|
LEFT JOIN paliad.deadline_rules_unified dr_trig
|
||||||
|
ON dr_trig.trigger_event_id = te.id
|
||||||
|
AND dr_trig.proceeding_type_id IS NULL
|
||||||
|
AND dr_trig.is_active
|
||||||
|
AND dr_trig.lifecycle_state = 'published'::text
|
||||||
|
WHERE te.is_active
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||||
|
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||||
|
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||||
|
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||||
|
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||||
|
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||||
|
|
||||||
|
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- t-paliad-317: revert specialist base seed rows.
|
||||||
|
|
||||||
|
DELETE FROM paliad.submission_bases WHERE slug IN ('lg-duesseldorf', 'upc-formal');
|
||||||
128
internal/db/migrations/150_submission_bases_specialist.up.sql
Normal file
128
internal/db/migrations/150_submission_bases_specialist.up.sql
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
-- t-paliad-317 (m/paliad#141): Composer Slice E — specialist bases.
|
||||||
|
--
|
||||||
|
-- Two firm-agnostic bases for proceeding-family-specific styling:
|
||||||
|
--
|
||||||
|
-- lg-duesseldorf — DE LG (de.inf.lg) conservative German legal style.
|
||||||
|
-- Times New Roman 11pt; black headings.
|
||||||
|
-- upc-formal — UPC court of first instance (upc.inf.cfi) formal
|
||||||
|
-- style. Calibri 11pt body; UPC-blue (1F3864) headings;
|
||||||
|
-- Cambria italic for blockquotes.
|
||||||
|
--
|
||||||
|
-- The .docx body for each is a minimal Composer-mode skeleton with
|
||||||
|
-- the 10 default section anchors and an empty rels envelope. The
|
||||||
|
-- styles.xml declares the {prefix}-Body / -Heading1/2/3 / -ListBullet
|
||||||
|
-- / -ListNumber / -Quote paragraph styles + a "Hyperlink" character
|
||||||
|
-- style (matches the MD walker's emitted r:id="rIdComposerN" link
|
||||||
|
-- runs from Slice D).
|
||||||
|
--
|
||||||
|
-- Generator: scripts/gen-submission-base/main.go (each preset hard-
|
||||||
|
-- codes the typography). The .docx files are uploaded to Gitea at
|
||||||
|
-- 6 - material/Templates/Word/Paliad/Composer/{slug}.docx as mAi.
|
||||||
|
--
|
||||||
|
-- The mig is additive only: ON CONFLICT (slug) DO NOTHING keeps a
|
||||||
|
-- re-run safe and existing rows untouched.
|
||||||
|
|
||||||
|
INSERT INTO paliad.submission_bases
|
||||||
|
(slug, firm, proceeding_family, label_de, label_en,
|
||||||
|
description_de, description_en,
|
||||||
|
gitea_path, section_spec, is_default_for)
|
||||||
|
VALUES
|
||||||
|
('lg-duesseldorf', NULL, 'de.inf.lg',
|
||||||
|
'LG-Düsseldorf-Stil', 'LG-Düsseldorf style',
|
||||||
|
'Konservativer DE-LG-Stil: Times New Roman 11pt, schlichte Überschriften.',
|
||||||
|
'Conservative DE LG style: Times New Roman 11pt, plain headings.',
|
||||||
|
'6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx',
|
||||||
|
jsonb_build_object(
|
||||||
|
'version', 1,
|
||||||
|
'stylemap', jsonb_build_object(
|
||||||
|
'paragraph', 'LG-Body',
|
||||||
|
'heading_1', 'LG-Heading1',
|
||||||
|
'heading_2', 'LG-Heading2',
|
||||||
|
'heading_3', 'LG-Heading3',
|
||||||
|
'list_bullet', 'LG-ListBullet',
|
||||||
|
'list_numbered', 'LG-ListNumber',
|
||||||
|
'blockquote', 'LG-Quote'
|
||||||
|
),
|
||||||
|
'defaults', jsonb_build_array(
|
||||||
|
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
|
||||||
|
'included',true,
|
||||||
|
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
|
||||||
|
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
|
||||||
|
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
|
||||||
|
'included',true,
|
||||||
|
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||||
|
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}'),
|
||||||
|
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
|
||||||
|
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
|
||||||
|
'included',true,
|
||||||
|
'seed_md_de', E'Mit freundlichen Grüßen',
|
||||||
|
'seed_md_en', E'Yours sincerely,'),
|
||||||
|
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
|
||||||
|
'included',true,
|
||||||
|
'seed_md_de', E'{{user.display_name}}',
|
||||||
|
'seed_md_en', E'{{user.display_name}}')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'{}'::text[]
|
||||||
|
),
|
||||||
|
('upc-formal', NULL, 'upc.inf.cfi',
|
||||||
|
'UPC-Verfahren', 'UPC formal',
|
||||||
|
'UPC-Verfahrensstil: Calibri 11pt, UPC-blaue Überschriften, Cambria-Zitate.',
|
||||||
|
'UPC court style: Calibri 11pt, UPC-blue headings, Cambria quotes.',
|
||||||
|
'6 - material/Templates/Word/Paliad/Composer/upc-formal.docx',
|
||||||
|
jsonb_build_object(
|
||||||
|
'version', 1,
|
||||||
|
'stylemap', jsonb_build_object(
|
||||||
|
'paragraph', 'UPC-Body',
|
||||||
|
'heading_1', 'UPC-Heading1',
|
||||||
|
'heading_2', 'UPC-Heading2',
|
||||||
|
'heading_3', 'UPC-Heading3',
|
||||||
|
'list_bullet', 'UPC-ListBullet',
|
||||||
|
'list_numbered', 'UPC-ListNumber',
|
||||||
|
'blockquote', 'UPC-Quote'
|
||||||
|
),
|
||||||
|
'defaults', jsonb_build_array(
|
||||||
|
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
|
||||||
|
'included',true,
|
||||||
|
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
|
||||||
|
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
|
||||||
|
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
|
||||||
|
'included',true,
|
||||||
|
'seed_md_de', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}',
|
||||||
|
'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}'),
|
||||||
|
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
|
||||||
|
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
|
||||||
|
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
|
||||||
|
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
|
||||||
|
'included',true,
|
||||||
|
'seed_md_de', E'Mit freundlichen Grüßen',
|
||||||
|
'seed_md_en', E'Yours sincerely,'),
|
||||||
|
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
|
||||||
|
'included',true,
|
||||||
|
'seed_md_de', E'{{user.display_name}}',
|
||||||
|
'seed_md_en', E'{{user.display_name}}')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'{}'::text[]
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- 151_dedupe_null_procedural_events (down) — t-paliad-319 / m/paliad#144
|
||||||
|
--
|
||||||
|
-- Best-effort restore from paliad.procedural_events_pre_151 and
|
||||||
|
-- paliad.sequencing_rules_pre_151. Re-points the reparented
|
||||||
|
-- sequencing_rules back at their original procedural_event_id and
|
||||||
|
-- reactivates the archived duplicates with the lifecycle_state +
|
||||||
|
-- is_active they had before the up migration.
|
||||||
|
--
|
||||||
|
-- Catastrophic-recovery path only; the normal revert is to leave the
|
||||||
|
-- dedupe in place (it is purely cosmetic).
|
||||||
|
|
||||||
|
-- 1. Re-point sequencing_rules.procedural_event_id back to its
|
||||||
|
-- pre-mig-151 value. The snapshot row is keyed by sr.id so the
|
||||||
|
-- join is 1:1 and idempotent.
|
||||||
|
UPDATE paliad.sequencing_rules sr
|
||||||
|
SET procedural_event_id = s.original_procedural_event_id,
|
||||||
|
updated_at = now()
|
||||||
|
FROM paliad.sequencing_rules_pre_151 s
|
||||||
|
WHERE sr.id = s.id;
|
||||||
|
|
||||||
|
-- 2. Reactivate the archived duplicates with their snapshot lifecycle.
|
||||||
|
UPDATE paliad.procedural_events pe
|
||||||
|
SET is_active = s.is_active,
|
||||||
|
lifecycle_state = s.lifecycle_state,
|
||||||
|
updated_at = now()
|
||||||
|
FROM paliad.procedural_events_pre_151 s
|
||||||
|
WHERE pe.id = s.id;
|
||||||
|
|
||||||
|
-- 3. Drop the snapshot tables — the data is back in place.
|
||||||
|
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_151;
|
||||||
|
DROP TABLE IF EXISTS paliad.procedural_events_pre_151;
|
||||||
229
internal/db/migrations/151_dedupe_null_procedural_events.up.sql
Normal file
229
internal/db/migrations/151_dedupe_null_procedural_events.up.sql
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
-- 151_dedupe_null_procedural_events — t-paliad-319 / m/paliad#144
|
||||||
|
--
|
||||||
|
-- Purpose: ~14 paliad.procedural_events rows with synthetic null.<8hex>
|
||||||
|
-- codes (minted by mig 136 from the legacy paliad.deadline_rules rows
|
||||||
|
-- whose submission_code was NULL) share user-visible names. The
|
||||||
|
-- /admin/procedural-events list shows multiple entries for the same legal
|
||||||
|
-- concept (worst offender: "Mängelbeseitigung / Zahlung" × 6). This
|
||||||
|
-- migration consolidates every name-group onto a single canonical row,
|
||||||
|
-- reparents the sequencing_rules pointing at the duplicates, and archives
|
||||||
|
-- the duplicates without deleting them.
|
||||||
|
--
|
||||||
|
-- Scope verified live before write (Supabase MCP, 2026-05-26):
|
||||||
|
-- * 5 name-groups, 14 duplicate rows total (1 canonical + 1–5 dups per
|
||||||
|
-- group). Every duplicate has exactly 1 sequencing_rule pointing at it.
|
||||||
|
-- * 0 paliad.deadlines reference any duplicate.
|
||||||
|
-- * 0 procedural_events.draft_of references any duplicate.
|
||||||
|
-- * No audit trigger on procedural_events or sequencing_rules — only
|
||||||
|
-- the INSTEAD OF triggers on deadline_rules_unified (mig 140), which
|
||||||
|
-- do not fire on direct table writes. No set_config('paliad.audit_reason')
|
||||||
|
-- needed.
|
||||||
|
--
|
||||||
|
-- Canonical selection: ROW_NUMBER() OVER (PARTITION BY name ORDER BY
|
||||||
|
-- created_at, id::text). Every duplicate in current data shares the same
|
||||||
|
-- created_at (mig 136 bulk insert), so the deterministic tiebreaker is
|
||||||
|
-- the UUID's lexicographic order.
|
||||||
|
--
|
||||||
|
-- Hard constraints honoured:
|
||||||
|
-- * No deletions. Duplicates flip to is_active=false +
|
||||||
|
-- lifecycle_state='archived'. The rows stay in the table for audit.
|
||||||
|
-- * Reparent sequencing_rules.procedural_event_id duplicate → canonical
|
||||||
|
-- BEFORE archiving, so no FK ever points at an archived PE.
|
||||||
|
-- * Snapshot the affected procedural_events + sequencing_rules into
|
||||||
|
-- paliad.procedural_events_pre_151 / paliad.sequencing_rules_pre_151
|
||||||
|
-- in the same TX, mirroring precedent (migs 091/093/095/098/140).
|
||||||
|
--
|
||||||
|
-- Down: best-effort restore from the snapshots. See .down.sql.
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) in a
|
||||||
|
-- TEMP table used by every subsequent step.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TEMP TABLE tmp_pe_dedupe ON COMMIT DROP AS
|
||||||
|
WITH dupe_names AS (
|
||||||
|
SELECT name
|
||||||
|
FROM paliad.procedural_events
|
||||||
|
WHERE code LIKE 'null.%'
|
||||||
|
GROUP BY name
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT pe.id,
|
||||||
|
pe.code,
|
||||||
|
pe.name,
|
||||||
|
pe.created_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY pe.name
|
||||||
|
ORDER BY pe.created_at, pe.id::text
|
||||||
|
) AS rn
|
||||||
|
FROM paliad.procedural_events pe
|
||||||
|
WHERE pe.code LIKE 'null.%'
|
||||||
|
AND pe.name IN (SELECT name FROM dupe_names)
|
||||||
|
),
|
||||||
|
canonicals AS (
|
||||||
|
SELECT name,
|
||||||
|
id AS canonical_id,
|
||||||
|
code AS canonical_code
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn = 1
|
||||||
|
)
|
||||||
|
SELECT r.id AS duplicate_id,
|
||||||
|
r.code AS duplicate_code,
|
||||||
|
r.name,
|
||||||
|
c.canonical_id,
|
||||||
|
c.canonical_code
|
||||||
|
FROM ranked r
|
||||||
|
JOIN canonicals c ON c.name = r.name
|
||||||
|
WHERE r.rn > 1;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 2. Snapshot. Captures the rows that change so .down has a clean
|
||||||
|
-- source of truth; mirrors the pre_091/093/095/098/140 precedent.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE paliad.procedural_events_pre_151 AS
|
||||||
|
SELECT pe.*
|
||||||
|
FROM paliad.procedural_events pe
|
||||||
|
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||||
|
|
||||||
|
COMMENT ON TABLE paliad.procedural_events_pre_151 IS
|
||||||
|
'Snapshot (mig 151, t-paliad-319) of the null.* procedural_events '
|
||||||
|
'duplicates that were archived in favour of their canonical name-mate. '
|
||||||
|
'Read-only forensic + revert source. Mirrors precedent pre_091/093/'
|
||||||
|
'095/098/140.';
|
||||||
|
|
||||||
|
CREATE TABLE paliad.sequencing_rules_pre_151 AS
|
||||||
|
SELECT sr.id,
|
||||||
|
sr.procedural_event_id AS original_procedural_event_id
|
||||||
|
FROM paliad.sequencing_rules sr
|
||||||
|
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||||
|
|
||||||
|
COMMENT ON TABLE paliad.sequencing_rules_pre_151 IS
|
||||||
|
'Snapshot (mig 151, t-paliad-319) of sequencing_rules.procedural_event_id '
|
||||||
|
'before reparenting from null.* duplicates onto their canonical PE. '
|
||||||
|
'Read-only forensic + revert source.';
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 3. Audit log — per-row NOTICE so the migration output captures
|
||||||
|
-- exactly which duplicate folded into which canonical, including
|
||||||
|
-- the sr_count for the duplicate (always 1 in current data, but
|
||||||
|
-- the RAISE keeps the audit honest if the scope grows later).
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rec record;
|
||||||
|
v_dup_count int;
|
||||||
|
v_grp_count int;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*), COUNT(DISTINCT name)
|
||||||
|
INTO v_dup_count, v_grp_count
|
||||||
|
FROM tmp_pe_dedupe;
|
||||||
|
|
||||||
|
RAISE NOTICE '[mig 151] dedupe scope: % duplicate rows across % name-groups',
|
||||||
|
v_dup_count, v_grp_count;
|
||||||
|
|
||||||
|
FOR rec IN
|
||||||
|
SELECT d.duplicate_id,
|
||||||
|
d.duplicate_code,
|
||||||
|
d.name,
|
||||||
|
d.canonical_id,
|
||||||
|
d.canonical_code,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM paliad.sequencing_rules sr
|
||||||
|
WHERE sr.procedural_event_id = d.duplicate_id) AS sr_count
|
||||||
|
FROM tmp_pe_dedupe d
|
||||||
|
ORDER BY d.name, d.duplicate_id
|
||||||
|
LOOP
|
||||||
|
RAISE NOTICE '[mig 151] dup % (%) -> canonical % (%) — sr_count=%',
|
||||||
|
rec.duplicate_id, rec.duplicate_code,
|
||||||
|
rec.canonical_id, rec.canonical_code,
|
||||||
|
rec.sr_count;
|
||||||
|
RAISE NOTICE '[mig 151] name: %', rec.name;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 4. Reparent sequencing_rules.procedural_event_id duplicate → canonical.
|
||||||
|
-- sequencing_rules_pe_proc_lifecycle_idx is non-unique, so collapsing
|
||||||
|
-- multiple sr onto one PE is by design.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE paliad.sequencing_rules sr
|
||||||
|
SET procedural_event_id = d.canonical_id,
|
||||||
|
updated_at = now()
|
||||||
|
FROM tmp_pe_dedupe d
|
||||||
|
WHERE sr.procedural_event_id = d.duplicate_id;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 5. Archive the duplicates. No deletion — audit trail preserved.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE paliad.procedural_events pe
|
||||||
|
SET is_active = false,
|
||||||
|
lifecycle_state = 'archived',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 6. POST assertions. Any failure rolls the migration back.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_surviving_groups int;
|
||||||
|
v_expected_count int;
|
||||||
|
v_archived_count int;
|
||||||
|
v_orphan_sr int;
|
||||||
|
BEGIN
|
||||||
|
-- (a) Acceptance criterion 2: no name-group still has >1 active+
|
||||||
|
-- published null.* row.
|
||||||
|
SELECT COUNT(*) INTO v_surviving_groups
|
||||||
|
FROM (
|
||||||
|
SELECT name
|
||||||
|
FROM paliad.procedural_events
|
||||||
|
WHERE code LIKE 'null.%'
|
||||||
|
AND is_active = true
|
||||||
|
AND lifecycle_state = 'published'
|
||||||
|
GROUP BY name
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) s;
|
||||||
|
|
||||||
|
IF v_surviving_groups > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'[mig 151] FAILED POST: % name-groups still have >1 active+published null.* rows',
|
||||||
|
v_surviving_groups;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- (b) Every targeted duplicate is now archived.
|
||||||
|
SELECT COUNT(*) INTO v_expected_count FROM tmp_pe_dedupe;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO v_archived_count
|
||||||
|
FROM paliad.procedural_events pe
|
||||||
|
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe)
|
||||||
|
AND pe.is_active = false
|
||||||
|
AND pe.lifecycle_state = 'archived';
|
||||||
|
|
||||||
|
IF v_archived_count <> v_expected_count THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'[mig 151] FAILED POST: archived %/% duplicates',
|
||||||
|
v_archived_count, v_expected_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- (c) Acceptance criterion 4: no sequencing_rule still points at
|
||||||
|
-- an archived duplicate.
|
||||||
|
SELECT COUNT(*) INTO v_orphan_sr
|
||||||
|
FROM paliad.sequencing_rules sr
|
||||||
|
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||||
|
|
||||||
|
IF v_orphan_sr > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'[mig 151] FAILED POST: % sequencing_rules still point at archived PE duplicates',
|
||||||
|
v_orphan_sr;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE '[mig 151] OK — archived % duplicates across % name-groups; 0 orphan sequencing_rules',
|
||||||
|
v_archived_count,
|
||||||
|
(SELECT COUNT(DISTINCT name) FROM tmp_pe_dedupe);
|
||||||
|
END $$;
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/internal/models"
|
||||||
"mgit.msbls.de/m/paliad/internal/services"
|
"mgit.msbls.de/m/paliad/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,60 @@ import (
|
|||||||
// is mapped to 409 Conflict so the editor UI can show a clear "must
|
// is mapped to 409 Conflict so the editor UI can show a clear "must
|
||||||
// clone first" hint.
|
// clone first" hint.
|
||||||
|
|
||||||
|
// Slice B.5 (t-paliad-305) JSON envelope renames:
|
||||||
|
//
|
||||||
|
// - submission_code → code (procedural-event identifier)
|
||||||
|
// - event_type → event_kind (procedural-event taxonomy)
|
||||||
|
//
|
||||||
|
// Wire compatibility: every response emits BOTH the legacy and the
|
||||||
|
// canonical keys for one slice (see Deprecation HTTP header on the
|
||||||
|
// response). Input bodies accept either name on the request; the
|
||||||
|
// canonical key wins when both are present.
|
||||||
|
//
|
||||||
|
// adminRuleResponse wraps models.DeadlineRule (= litigationplanner.Rule)
|
||||||
|
// to add the canonical `code` + `event_kind` fields alongside the
|
||||||
|
// historical `submission_code` + `event_type` already on Rule's tags.
|
||||||
|
// The embedded *models.DeadlineRule carries every existing tag through
|
||||||
|
// json.Marshal unchanged; the wrapper only ADDS the two new keys.
|
||||||
|
type adminRuleResponse struct {
|
||||||
|
*models.DeadlineRule
|
||||||
|
Code *string `json:"code,omitempty"`
|
||||||
|
EventKind *string `json:"event_kind,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapRuleResponse builds the dual-emit wrapper from a service result.
|
||||||
|
// Same values, two keys per concept — no semantic change.
|
||||||
|
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
|
||||||
|
if r == nil {
|
||||||
|
return adminRuleResponse{}
|
||||||
|
}
|
||||||
|
return adminRuleResponse{
|
||||||
|
DeadlineRule: r,
|
||||||
|
Code: r.SubmissionCode,
|
||||||
|
EventKind: r.EventType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapRuleListResponse maps a slice of service results into the
|
||||||
|
// dual-emit wrapper. Used by the LIST endpoint.
|
||||||
|
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse {
|
||||||
|
out := make([]adminRuleResponse, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
out[i] = wrapRuleResponse(&rows[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRuleDeprecationHeaders writes the IETF "Deprecation" + "Sunset"
|
||||||
|
// HTTP headers signaling that the legacy `submission_code` /
|
||||||
|
// `event_type` JSON keys are being retired in favour of `code` /
|
||||||
|
// `event_kind`. RFC 8594 (Sunset) + draft-ietf-httpapi-deprecation-header.
|
||||||
|
// Clients should migrate within one slice cycle.
|
||||||
|
func adminRuleDeprecationHeaders(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Deprecation", `true; key="submission_code,event_type"`)
|
||||||
|
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/93>; rel="deprecation"`)
|
||||||
|
}
|
||||||
|
|
||||||
// GET /admin/api/rules — paginated list with filters.
|
// GET /admin/api/rules — paginated list with filters.
|
||||||
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||||
@@ -73,7 +128,8 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeRuleEditorError(w, err)
|
writeRuleEditorError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, rows)
|
adminRuleDeprecationHeaders(w)
|
||||||
|
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /admin/api/rules/{id}
|
// GET /admin/api/rules/{id}
|
||||||
@@ -91,7 +147,8 @@ func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeRuleEditorError(w, err)
|
writeRuleEditorError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, row)
|
adminRuleDeprecationHeaders(w)
|
||||||
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/api/rules — create draft.
|
// POST /admin/api/rules — create draft.
|
||||||
@@ -108,12 +165,15 @@ func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
|
||||||
|
body.CreateRuleInput.CoalesceCanonicalKeys()
|
||||||
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
|
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeRuleEditorError(w, err)
|
writeRuleEditorError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, row)
|
adminRuleDeprecationHeaders(w)
|
||||||
|
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /admin/api/rules/{id} — partial update of a draft.
|
// PATCH /admin/api/rules/{id} — partial update of a draft.
|
||||||
@@ -134,12 +194,15 @@ func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
|
||||||
|
body.RulePatch.CoalesceCanonicalKeys()
|
||||||
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
|
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeRuleEditorError(w, err)
|
writeRuleEditorError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, row)
|
adminRuleDeprecationHeaders(w)
|
||||||
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/api/rules/{id}/clone-as-draft
|
// POST /admin/api/rules/{id}/clone-as-draft
|
||||||
@@ -161,7 +224,8 @@ func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeRuleEditorError(w, err)
|
writeRuleEditorError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, row)
|
adminRuleDeprecationHeaders(w)
|
||||||
|
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/api/rules/{id}/publish
|
// POST /admin/api/rules/{id}/publish
|
||||||
@@ -183,7 +247,8 @@ func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeRuleEditorError(w, err)
|
writeRuleEditorError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, row)
|
adminRuleDeprecationHeaders(w)
|
||||||
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/api/rules/{id}/archive
|
// POST /admin/api/rules/{id}/archive
|
||||||
@@ -205,7 +270,8 @@ func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeRuleEditorError(w, err)
|
writeRuleEditorError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, row)
|
adminRuleDeprecationHeaders(w)
|
||||||
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/api/rules/{id}/restore
|
// POST /admin/api/rules/{id}/restore
|
||||||
@@ -227,7 +293,8 @@ func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeRuleEditorError(w, err)
|
writeRuleEditorError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, row)
|
adminRuleDeprecationHeaders(w)
|
||||||
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
|
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
|
||||||
@@ -419,3 +486,66 @@ func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice B.6 (t-paliad-305) — 301 redirect helpers for the legacy
|
||||||
|
// /admin/rules* paths. New canonical paths live under
|
||||||
|
// /admin/procedural-events; the redirects keep external bookmarks,
|
||||||
|
// audit-log entries, and curl scripts working through one
|
||||||
|
// deprecation cycle.
|
||||||
|
//
|
||||||
|
// Three flavours:
|
||||||
|
//
|
||||||
|
// * redirectToProceduralEvents(newPath) — fixed redirect target
|
||||||
|
// (used by the parameter-less paths /admin/rules and
|
||||||
|
// /admin/api/rules).
|
||||||
|
// * redirectToProceduralEventEdit — page path with {id}/edit suffix.
|
||||||
|
// * redirectToProceduralEventAPI(suffix) — JSON API paths that carry
|
||||||
|
// an {id} and optional suffix (/clone-as-draft, /publish, …).
|
||||||
|
//
|
||||||
|
// All emit 301 Moved Permanently — caches and browsers learn the new
|
||||||
|
// URL once and stop hitting the legacy path. The IETF Deprecation
|
||||||
|
// header is added so machine clients see the migration signal
|
||||||
|
// alongside the redirect.
|
||||||
|
|
||||||
|
// redirectToProceduralEvents returns an http.HandlerFunc that 301s to
|
||||||
|
// the supplied destination path. Query string is preserved.
|
||||||
|
func redirectToProceduralEvents(dst string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
target := dst
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
target += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
w.Header().Set("Deprecation", `true; path="/admin/rules"`)
|
||||||
|
w.Header().Set("Link", `</admin/procedural-events>; rel="successor-version"`)
|
||||||
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectToProceduralEventEdit 301s GET /admin/rules/{id}/edit →
|
||||||
|
// /admin/procedural-events/{id}/edit.
|
||||||
|
func redirectToProceduralEventEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
target := "/admin/procedural-events/" + id + "/edit"
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
target += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
w.Header().Set("Deprecation", `true; path="/admin/rules/{id}/edit"`)
|
||||||
|
w.Header().Set("Link", `</admin/procedural-events/{id}/edit>; rel="successor-version"`)
|
||||||
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectToProceduralEventAPI 301s /admin/api/rules/{id}[/suffix] →
|
||||||
|
// /admin/api/procedural-events/{id}[/suffix]. The optional suffix
|
||||||
|
// covers /clone-as-draft, /publish, /archive, /restore, /audit, /preview.
|
||||||
|
func redirectToProceduralEventAPI(suffix string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
target := "/admin/api/procedural-events/" + id + suffix
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
target += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
w.Header().Set("Deprecation", `true; path="/admin/api/rules/{id}`+suffix+`"`)
|
||||||
|
w.Header().Set("Link", `</admin/api/procedural-events/{id}`+suffix+`>; rel="successor-version"`)
|
||||||
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,8 +113,34 @@ var fileRegistry = map[string]fileEntry{
|
|||||||
RepoName: "mWorkRepo",
|
RepoName: "mWorkRepo",
|
||||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
|
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
|
||||||
},
|
},
|
||||||
|
// t-paliad-317 Composer Slice E — specialist firm-agnostic bases.
|
||||||
|
// Both live under Composer/ (not under HLC/) so a future non-HLC
|
||||||
|
// deployment serves the same cross-firm files. Body = anchor-only
|
||||||
|
// per Slice B; styles.xml carries the preset's typography.
|
||||||
|
composerBaseLGDuesseldorfSlug: {
|
||||||
|
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
|
||||||
|
DownloadName: "LG-Düsseldorf Stil.docx",
|
||||||
|
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
RepoOwner: "m",
|
||||||
|
RepoName: "mWorkRepo",
|
||||||
|
FilePath: "6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
|
||||||
|
},
|
||||||
|
composerBaseUPCFormalSlug: {
|
||||||
|
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/upc-formal.docx",
|
||||||
|
DownloadName: "UPC formal.docx",
|
||||||
|
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
RepoOwner: "m",
|
||||||
|
RepoName: "mWorkRepo",
|
||||||
|
FilePath: "6 - material/Templates/Word/Paliad/Composer/upc-formal.docx",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t-paliad-317 Composer Slice E — slugs for the new specialist bases.
|
||||||
|
const (
|
||||||
|
composerBaseLGDuesseldorfSlug = "submission/composer/lg-duesseldorf.docx"
|
||||||
|
composerBaseUPCFormalSlug = "submission/composer/upc-formal.docx"
|
||||||
|
)
|
||||||
|
|
||||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||||
// the shared fileRegistry cache. Exported via a const so handler code
|
// the shared fileRegistry cache. Exported via a const so handler code
|
||||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||||
@@ -413,6 +439,8 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
|||||||
var composerBaseSlugMap = map[string]string{
|
var composerBaseSlugMap = map[string]string{
|
||||||
"hlc-letterhead": firmSkeletonSubmissionSlug,
|
"hlc-letterhead": firmSkeletonSubmissionSlug,
|
||||||
"neutral": skeletonSubmissionSlug,
|
"neutral": skeletonSubmissionSlug,
|
||||||
|
"lg-duesseldorf": composerBaseLGDuesseldorfSlug,
|
||||||
|
"upc-formal": composerBaseUPCFormalSlug,
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
|
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
|
||||||
|
|||||||
@@ -432,6 +432,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
|||||||
// for inline editor autosave. URL keyed on draft_id + section_id;
|
// for inline editor autosave. URL keyed on draft_id + section_id;
|
||||||
// owner-scoped via SubmissionDraftService.Get.
|
// owner-scoped via SubmissionDraftService.Get.
|
||||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}/sections/{section_id}", handlePatchSubmissionSection)
|
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}/sections/{section_id}", handlePatchSubmissionSection)
|
||||||
|
// t-paliad-318 (m/paliad#141) Composer Slice F — add custom
|
||||||
|
// section, delete section, reorder.
|
||||||
|
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections", handleCreateSubmissionSection)
|
||||||
|
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}/sections/{section_id}", handleDeleteSubmissionSection)
|
||||||
|
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections/reorder", handleReorderSubmissionSections)
|
||||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks
|
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks
|
||||||
// library. Lawyer-facing picker + paste mechanic.
|
// library. Lawyer-facing picker + paste mechanic.
|
||||||
protected.HandleFunc("GET /api/submission-building-blocks", handleListBuildingBlocks)
|
protected.HandleFunc("GET /api/submission-building-blocks", handleListBuildingBlocks)
|
||||||
@@ -722,18 +727,43 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
|||||||
// t-paliad-089 — admin Event-Type moderation panel.
|
// t-paliad-089 — admin Event-Type moderation panel.
|
||||||
// t-paliad-191 Slice 11a — admin rule-editor API.
|
// t-paliad-191 Slice 11a — admin rule-editor API.
|
||||||
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
|
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
|
||||||
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
// Slice B.6 (t-paliad-305) — canonical URL paths under
|
||||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
// /admin/procedural-events with 301 redirects from the legacy
|
||||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
|
// /admin/rules paths so existing bookmarks and audit-log
|
||||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
|
// entries continue to resolve. New paths point at the same
|
||||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
|
// handlers; the canonical-URL name aligns with the umbrella
|
||||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
|
// term locked in Slice A.
|
||||||
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
|
protected.HandleFunc("GET /admin/procedural-events", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||||
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, handleAdminPublishRule))
|
protected.HandleFunc("GET /admin/procedural-events/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||||
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, handleAdminArchiveRule))
|
protected.HandleFunc("GET /admin/api/procedural-events", adminGate(users, handleAdminListRules))
|
||||||
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, handleAdminRestoreRule))
|
protected.HandleFunc("GET /admin/api/procedural-events/{id}", adminGate(users, handleAdminGetRule))
|
||||||
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
|
protected.HandleFunc("POST /admin/api/procedural-events", adminGate(users, handleAdminCreateRule))
|
||||||
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, handleAdminPreviewRule))
|
protected.HandleFunc("PATCH /admin/api/procedural-events/{id}", adminGate(users, handleAdminPatchRule))
|
||||||
|
protected.HandleFunc("POST /admin/api/procedural-events/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
|
||||||
|
protected.HandleFunc("POST /admin/api/procedural-events/{id}/publish", adminGate(users, handleAdminPublishRule))
|
||||||
|
protected.HandleFunc("POST /admin/api/procedural-events/{id}/archive", adminGate(users, handleAdminArchiveRule))
|
||||||
|
protected.HandleFunc("POST /admin/api/procedural-events/{id}/restore", adminGate(users, handleAdminRestoreRule))
|
||||||
|
protected.HandleFunc("GET /admin/api/procedural-events/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
|
||||||
|
protected.HandleFunc("GET /admin/api/procedural-events/{id}/preview", adminGate(users, handleAdminPreviewRule))
|
||||||
|
|
||||||
|
// Legacy /admin/rules paths — 301 redirect to the canonical
|
||||||
|
// /admin/procedural-events paths. One-slice deprecation window
|
||||||
|
// per design §8.2 (B.6 optional; m authorised the rename
|
||||||
|
// 2026-05-26). After the next slice that audits external
|
||||||
|
// references, these can be retired entirely.
|
||||||
|
protected.HandleFunc("GET /admin/rules", adminGate(users, redirectToProceduralEvents("/admin/procedural-events")))
|
||||||
|
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, redirectToProceduralEventEdit))
|
||||||
|
protected.HandleFunc("GET /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
|
||||||
|
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
|
||||||
|
protected.HandleFunc("POST /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
|
||||||
|
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
|
||||||
|
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, redirectToProceduralEventAPI("/clone-as-draft")))
|
||||||
|
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, redirectToProceduralEventAPI("/publish")))
|
||||||
|
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, redirectToProceduralEventAPI("/archive")))
|
||||||
|
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, redirectToProceduralEventAPI("/restore")))
|
||||||
|
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, redirectToProceduralEventAPI("/audit")))
|
||||||
|
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, redirectToProceduralEventAPI("/preview")))
|
||||||
|
|
||||||
protected.HandleFunc("GET /admin/api/orphans", adminGate(users, handleAdminListOrphans))
|
protected.HandleFunc("GET /admin/api/orphans", adminGate(users, handleAdminListOrphans))
|
||||||
protected.HandleFunc("POST /admin/api/orphans/{id}/resolve", adminGate(users, handleAdminResolveOrphan))
|
protected.HandleFunc("POST /admin/api/orphans/{id}/resolve", adminGate(users, handleAdminResolveOrphan))
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"mgit.msbls.de/m/paliad/internal/services"
|
"mgit.msbls.de/m/paliad/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,6 +132,188 @@ func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Slice F — add custom section / delete section / reorder
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type submissionSectionCreateInput struct {
|
||||||
|
SectionKey string `json:"section_key"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
LabelDE string `json:"label_de"`
|
||||||
|
LabelEN string `json:"label_en"`
|
||||||
|
ContentMDDE string `json:"content_md_de,omitempty"`
|
||||||
|
ContentMDEN string `json:"content_md_en,omitempty"`
|
||||||
|
OrderIndex int `json:"order_index,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateSubmissionSection backs POST /api/submission-drafts/{draft_id}/sections.
|
||||||
|
// Adds a new (custom) section to the draft. Owner-scoped via
|
||||||
|
// SubmissionDraftService.Get.
|
||||||
|
func handleCreateSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireDB(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
|
||||||
|
writeSubmissionDraftServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input submissionSectionCreateInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := dbSvc.submissionSection.Create(ctx, services.SectionCreateInput{
|
||||||
|
DraftID: draftID,
|
||||||
|
SectionKey: input.SectionKey,
|
||||||
|
Kind: input.Kind,
|
||||||
|
LabelDE: input.LabelDE,
|
||||||
|
LabelEN: input.LabelEN,
|
||||||
|
ContentMDDE: input.ContentMDDE,
|
||||||
|
ContentMDEN: input.ContentMDEN,
|
||||||
|
OrderIndex: input.OrderIndex,
|
||||||
|
Included: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, services.ErrInvalidInput) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, sectionJSONFromService(created))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteSubmissionSection backs DELETE /api/submission-drafts/{draft_id}/sections/{section_id}.
|
||||||
|
// Owner-scoped via SubmissionDraftService.Get + section-belongs-to-draft cross-check.
|
||||||
|
func handleDeleteSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireDB(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
||||||
|
if err != nil {
|
||||||
|
writeSubmissionDraftServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sec, err := dbSvc.submissionSection.Get(ctx, sectionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sec.DraftID != draft.ID {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := dbSvc.submissionSection.Delete(ctx, sectionID); err != nil {
|
||||||
|
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type submissionSectionReorderInput struct {
|
||||||
|
SectionOrder []string `json:"section_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReorderSubmissionSections backs POST /api/submission-drafts/{draft_id}/sections/reorder.
|
||||||
|
// Accepts a sequence of section_ids; rewrites every row's order_index
|
||||||
|
// to (1, 2, 3, …) × 10 in the supplied order. Returns the refreshed
|
||||||
|
// section list.
|
||||||
|
func handleReorderSubmissionSections(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireDB(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
|
||||||
|
writeSubmissionDraftServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input submissionSectionReorderInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order := make([]uuid.UUID, 0, len(input.SectionOrder))
|
||||||
|
for _, raw := range input.SectionOrder {
|
||||||
|
id, err := uuid.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid section id in order list"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order = append(order, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := dbSvc.submissionSection.Reorder(ctx, draftID, order)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]submissionSectionJSON, 0, len(rows))
|
||||||
|
for _, sec := range rows {
|
||||||
|
out = append(out, sectionJSONFromService(&sec))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"sections": out})
|
||||||
|
}
|
||||||
|
|
||||||
// sectionJSONFromService projects a services.SubmissionSection into the
|
// sectionJSONFromService projects a services.SubmissionSection into the
|
||||||
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
|
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
|
||||||
// emits under .sections[].
|
// emits under .sections[].
|
||||||
|
|||||||
@@ -553,6 +553,51 @@ type Party struct {
|
|||||||
// scans, hydration, projection service) continues to compile.
|
// scans, hydration, projection service) continues to compile.
|
||||||
type DeadlineRule = litigationplanner.Rule
|
type DeadlineRule = litigationplanner.Rule
|
||||||
|
|
||||||
|
// SequencingRule is the Slice B.5 (t-paliad-305) canonical name for what
|
||||||
|
// the legacy schema called a "deadline rule". Alias to DeadlineRule so
|
||||||
|
// existing call-sites compile unchanged while new code can adopt the
|
||||||
|
// procedural-event vocabulary. Same struct, same db / json tags.
|
||||||
|
type SequencingRule = DeadlineRule
|
||||||
|
|
||||||
|
// ProceduralEvent mirrors paliad.procedural_events — the "what kind of
|
||||||
|
// step is this in the proceeding" identity row. New struct introduced
|
||||||
|
// in Slice B.5 (t-paliad-305) for code that needs the procedural-event
|
||||||
|
// columns alone. Most consumers still pull the merged shape via
|
||||||
|
// SequencingRule through the paliad.deadline_rules_unified view; this
|
||||||
|
// struct unlocks per-PE reads/writes without going through the view.
|
||||||
|
type ProceduralEvent struct {
|
||||||
|
ID uuid.UUID `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"`
|
||||||
|
EventKind *string `db:"event_kind" json:"event_kind,omitempty"`
|
||||||
|
PrimaryPartyDefault *string `db:"primary_party_default" json:"primary_party_default,omitempty"`
|
||||||
|
LegalSourceID *uuid.UUID `db:"legal_source_id" json:"legal_source_id,omitempty"`
|
||||||
|
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||||
|
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||||
|
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||||
|
PublishedAt *time.Time `db:"published_at" json:"published_at,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegalSource mirrors paliad.legal_sources — the source-of-law citation
|
||||||
|
// rows that procedural events anchor against. pretty_de / pretty_en are
|
||||||
|
// nullable on disk; readers fall back to
|
||||||
|
// internal/services/submission_vars.go:legalSourcePretty when missing.
|
||||||
|
type LegalSource struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
Citation string `db:"citation" json:"citation"`
|
||||||
|
Jurisdiction string `db:"jurisdiction" json:"jurisdiction"`
|
||||||
|
PrettyDE *string `db:"pretty_de" json:"pretty_de,omitempty"`
|
||||||
|
PrettyEN *string `db:"pretty_en" json:"pretty_en,omitempty"`
|
||||||
|
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||||
// append-only audit log for every change to paliad.deadline_rules.
|
// append-only audit log for every change to paliad.deadline_rules.
|
||||||
// Written by the AFTER-trigger (raw create / update / delete) and by
|
// Written by the AFTER-trigger (raw create / update / delete) and by
|
||||||
|
|||||||
@@ -10,12 +10,24 @@ import (
|
|||||||
"mgit.msbls.de/m/paliad/internal/models"
|
"mgit.msbls.de/m/paliad/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeadlineRuleService reads paliad.deadline_rules + paliad.proceeding_types.
|
// DeadlineRuleService reads paliad.deadline_rules_unified (mig 139 view
|
||||||
// Rules are static reference data; no visibility check needed.
|
// projecting paliad.sequencing_rules + procedural_events +
|
||||||
|
// legal_sources back to the legacy column shape after mig 140 dropped
|
||||||
|
// the underlying table) + paliad.proceeding_types. Rules are static
|
||||||
|
// reference data; no visibility check needed.
|
||||||
type DeadlineRuleService struct {
|
type DeadlineRuleService struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SequencingRuleService is the Slice B.5 (t-paliad-305) canonical name
|
||||||
|
// for DeadlineRuleService. Alias preserves every existing call-site
|
||||||
|
// while new code can adopt the procedural-event vocabulary.
|
||||||
|
type SequencingRuleService = DeadlineRuleService
|
||||||
|
|
||||||
|
// NewSequencingRuleService is the canonical constructor name; alias to
|
||||||
|
// NewDeadlineRuleService for now. Both return the same underlying type.
|
||||||
|
var NewSequencingRuleService = NewDeadlineRuleService
|
||||||
|
|
||||||
// NewDeadlineRuleService wires the service to the pool.
|
// NewDeadlineRuleService wires the service to the pool.
|
||||||
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||||
return &DeadlineRuleService{db: db}
|
return &DeadlineRuleService{db: db}
|
||||||
|
|||||||
@@ -76,7 +76,13 @@ type RulePatch struct {
|
|||||||
NameEN *string `json:"name_en,omitempty"`
|
NameEN *string `json:"name_en,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||||
|
// EventType is the legacy JSON key; EventKind is the Slice B.5
|
||||||
|
// canonical name. Decoder accepts either — coalescePatchKeys()
|
||||||
|
// resolves the canonical to the legacy field if only EventKind
|
||||||
|
// was sent. Same uuid wire shape; emit-side wraps via
|
||||||
|
// adminRuleResponse to expose both keys for one slice.
|
||||||
EventType *string `json:"event_type,omitempty"`
|
EventType *string `json:"event_type,omitempty"`
|
||||||
|
EventKind *string `json:"event_kind,omitempty"`
|
||||||
DurationValue *int `json:"duration_value,omitempty"`
|
DurationValue *int `json:"duration_value,omitempty"`
|
||||||
DurationUnit *string `json:"duration_unit,omitempty"`
|
DurationUnit *string `json:"duration_unit,omitempty"`
|
||||||
Timing *string `json:"timing,omitempty"`
|
Timing *string `json:"timing,omitempty"`
|
||||||
@@ -101,6 +107,24 @@ type RulePatch struct {
|
|||||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
|
||||||
|
// JSON aliases into the legacy field positions so the rest of the
|
||||||
|
// service can keep using the existing field names. Canonical wins
|
||||||
|
// when both are sent.
|
||||||
|
//
|
||||||
|
// json:"event_kind" → EventType (legacy)
|
||||||
|
//
|
||||||
|
// Called by the handler immediately after json.Decode. New code can
|
||||||
|
// adopt the canonical naming; legacy callers continue to work.
|
||||||
|
func (p *RulePatch) CoalesceCanonicalKeys() {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.EventKind != nil {
|
||||||
|
p.EventType = p.EventKind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CreateRuleInput is the create payload — a full rule row in draft
|
// CreateRuleInput is the create payload — a full rule row in draft
|
||||||
// state. Required fields enforce schema NOT-NULL on insert (name,
|
// state. Required fields enforce schema NOT-NULL on insert (name,
|
||||||
// name_en, duration_value, duration_unit).
|
// name_en, duration_value, duration_unit).
|
||||||
@@ -111,9 +135,16 @@ type CreateRuleInput struct {
|
|||||||
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
|
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
|
||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||||
|
// SubmissionCode is the legacy JSON key; Code is the Slice B.5
|
||||||
|
// canonical name. Decoder accepts either — CoalesceCanonicalKeys()
|
||||||
|
// folds Code → SubmissionCode if only the canonical was sent.
|
||||||
SubmissionCode *string `json:"submission_code,omitempty"`
|
SubmissionCode *string `json:"submission_code,omitempty"`
|
||||||
|
Code *string `json:"code,omitempty"`
|
||||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||||
|
// EventType is the legacy JSON key; EventKind is the Slice B.5
|
||||||
|
// canonical name. Same dual-accept pattern as SubmissionCode/Code.
|
||||||
EventType *string `json:"event_type,omitempty"`
|
EventType *string `json:"event_type,omitempty"`
|
||||||
|
EventKind *string `json:"event_kind,omitempty"`
|
||||||
DurationValue int `json:"duration_value"`
|
DurationValue int `json:"duration_value"`
|
||||||
DurationUnit string `json:"duration_unit"`
|
DurationUnit string `json:"duration_unit"`
|
||||||
Timing *string `json:"timing,omitempty"`
|
Timing *string `json:"timing,omitempty"`
|
||||||
@@ -135,6 +166,24 @@ type CreateRuleInput struct {
|
|||||||
SequenceOrder int `json:"sequence_order"`
|
SequenceOrder int `json:"sequence_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
|
||||||
|
// JSON aliases into the legacy field positions. Canonical wins when
|
||||||
|
// both are sent. Called by the handler immediately after json.Decode.
|
||||||
|
//
|
||||||
|
// json:"code" → SubmissionCode (legacy)
|
||||||
|
// json:"event_kind" → EventType (legacy)
|
||||||
|
func (in *CreateRuleInput) CoalesceCanonicalKeys() {
|
||||||
|
if in == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Code != nil {
|
||||||
|
in.SubmissionCode = in.Code
|
||||||
|
}
|
||||||
|
if in.EventKind != nil {
|
||||||
|
in.EventType = in.EventKind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create inserts a new rule as lifecycle_state='draft' with
|
// Create inserts a new rule as lifecycle_state='draft' with
|
||||||
// published_at=NULL. The caller's reason is set on the session BEFORE
|
// published_at=NULL. The caller's reason is set on the session BEFORE
|
||||||
// the INSERT so the mig 079 trigger writes an audit row with the
|
// the INSERT so the mig 079 trigger writes an audit row with the
|
||||||
|
|||||||
@@ -368,6 +368,89 @@ func TestComposer_HyperlinkDedupesByURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice E — base swap preserves section content; only chrome / styles
|
||||||
|
// change. This is the design's "Markdown is base-agnostic" contract
|
||||||
|
// from Q10 + §5.3 ratification. We compose the SAME section text
|
||||||
|
// against two bases with DIFFERENT stylemaps and verify:
|
||||||
|
// 1. The section text appears in both outputs.
|
||||||
|
// 2. Each base applies its OWN paragraph style (the stylemap diff
|
||||||
|
// is the only visible delta in the document body).
|
||||||
|
|
||||||
|
func TestComposer_BaseSwapPreservesContent(t *testing.T) {
|
||||||
|
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
|
||||||
|
baseBytes := minimalBaseBytes(t, body)
|
||||||
|
|
||||||
|
// Base A: HLC-style stylemap.
|
||||||
|
hlc := &SubmissionBase{
|
||||||
|
ID: uuid.New(), Slug: "hlc-test",
|
||||||
|
SectionSpec: BaseSectionSpec{
|
||||||
|
Stylemap: map[string]string{
|
||||||
|
"paragraph": "HLpat-Body-B0",
|
||||||
|
"heading_1": "HLpat-Heading-H1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Base B: LG-style stylemap.
|
||||||
|
lg := &SubmissionBase{
|
||||||
|
ID: uuid.New(), Slug: "lg-test",
|
||||||
|
SectionSpec: BaseSectionSpec{
|
||||||
|
Stylemap: map[string]string{
|
||||||
|
"paragraph": "LG-Body",
|
||||||
|
"heading_1": "LG-Heading1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identical Markdown content rendered against each base.
|
||||||
|
md := "# Heading line\n\nA paragraph of substantive prose."
|
||||||
|
sections := []SubmissionSection{
|
||||||
|
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
|
||||||
|
}
|
||||||
|
|
||||||
|
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||||
|
hlcOut, err := composer.Compose(context.Background(), ComposeOptions{
|
||||||
|
Sections: sections, Base: hlc, BaseBytes: baseBytes, Lang: "de",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compose hlc: %v", err)
|
||||||
|
}
|
||||||
|
lgOut, err := composer.Compose(context.Background(), ComposeOptions{
|
||||||
|
Sections: sections, Base: lg, BaseBytes: baseBytes, Lang: "de",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compose lg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hlcXML := extractDocumentXML(t, hlcOut)
|
||||||
|
lgXML := extractDocumentXML(t, lgOut)
|
||||||
|
|
||||||
|
// Content survives both ways.
|
||||||
|
for _, want := range []string{"Heading line", "A paragraph of substantive prose."} {
|
||||||
|
if !strings.Contains(hlcXML, want) {
|
||||||
|
t.Errorf("HLC output missing content %q", want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lgXML, want) {
|
||||||
|
t.Errorf("LG output missing content %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stylemap diff actually shows up in the body — HLC's headings
|
||||||
|
// use HLpat-Heading-H1, LG's use LG-Heading1. If the composer
|
||||||
|
// silently passed the wrong stylemap, this would fire.
|
||||||
|
if !strings.Contains(hlcXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
|
||||||
|
t.Errorf("HLC heading style missing: %s", hlcXML)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lgXML, `<w:pStyle w:val="LG-Heading1"/>`) {
|
||||||
|
t.Errorf("LG heading style missing: %s", lgXML)
|
||||||
|
}
|
||||||
|
if strings.Contains(hlcXML, `<w:pStyle w:val="LG-Heading1"/>`) {
|
||||||
|
t.Errorf("HLC output leaked LG style: %s", hlcXML)
|
||||||
|
}
|
||||||
|
if strings.Contains(lgXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
|
||||||
|
t.Errorf("LG output leaked HLC style: %s", lgXML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestComposer_OrderIndexAscending(t *testing.T) {
|
func TestComposer_OrderIndexAscending(t *testing.T) {
|
||||||
base := composerBase()
|
base := composerBase()
|
||||||
// No anchors → both sections append in order_index ASC order
|
// No anchors → both sections append in order_index ASC order
|
||||||
|
|||||||
@@ -178,6 +178,130 @@ func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch
|
|||||||
return &sec, nil
|
return &sec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SectionCreateInput is the payload for adding a new (lawyer-custom)
|
||||||
|
// section to a draft (t-paliad-318 Slice F).
|
||||||
|
type SectionCreateInput struct {
|
||||||
|
DraftID uuid.UUID
|
||||||
|
SectionKey string
|
||||||
|
Kind string
|
||||||
|
LabelDE string
|
||||||
|
LabelEN string
|
||||||
|
ContentMDDE string
|
||||||
|
ContentMDEN string
|
||||||
|
OrderIndex int // 0 = append at end
|
||||||
|
Included bool // defaults to true if not specified at the handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new section row for the draft. The section_key
|
||||||
|
// must not already exist on this draft (UNIQUE constraint at the DB
|
||||||
|
// catches collisions and surfaces as ErrInvalidInput).
|
||||||
|
//
|
||||||
|
// OrderIndex=0 means "auto-assign at the end" — the service queries
|
||||||
|
// the current max(order_index) and increments. Non-zero values insert
|
||||||
|
// at the requested position; the caller is responsible for any
|
||||||
|
// subsequent Reorder if they intend to push existing rows down.
|
||||||
|
func (s *SectionService) Create(ctx context.Context, in SectionCreateInput) (*SubmissionSection, error) {
|
||||||
|
in.SectionKey = strings.TrimSpace(in.SectionKey)
|
||||||
|
in.LabelDE = strings.TrimSpace(in.LabelDE)
|
||||||
|
in.LabelEN = strings.TrimSpace(in.LabelEN)
|
||||||
|
if in.SectionKey == "" || in.LabelDE == "" || in.LabelEN == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
switch in.Kind {
|
||||||
|
case "prose", "requests", "evidence":
|
||||||
|
default:
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.OrderIndex == 0 {
|
||||||
|
var maxOrder int
|
||||||
|
err := s.db.GetContext(ctx, &maxOrder,
|
||||||
|
`SELECT COALESCE(MAX(order_index), 0) FROM paliad.submission_sections WHERE draft_id = $1`,
|
||||||
|
in.DraftID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("max order_index: %w", err)
|
||||||
|
}
|
||||||
|
in.OrderIndex = maxOrder + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var sec SubmissionSection
|
||||||
|
err := s.db.GetContext(ctx, &sec,
|
||||||
|
`INSERT INTO paliad.submission_sections
|
||||||
|
(draft_id, section_key, order_index, kind,
|
||||||
|
label_de, label_en, included,
|
||||||
|
content_md_de, content_md_en)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING `+sectionColumns,
|
||||||
|
in.DraftID, in.SectionKey, in.OrderIndex, in.Kind,
|
||||||
|
in.LabelDE, in.LabelEN, in.Included,
|
||||||
|
in.ContentMDDE, in.ContentMDEN)
|
||||||
|
if err != nil {
|
||||||
|
// UNIQUE (draft_id, section_key) collision → invalid input.
|
||||||
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505") {
|
||||||
|
return nil, fmt.Errorf("%w: section_key already exists on this draft", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("create submission section: %w", err)
|
||||||
|
}
|
||||||
|
return &sec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes one section row by id. Owner-scope is the caller's
|
||||||
|
// responsibility (the handler runs SubmissionDraftService.Get first).
|
||||||
|
func (s *SectionService) Delete(ctx context.Context, sectionID uuid.UUID) error {
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM paliad.submission_sections WHERE id = $1`,
|
||||||
|
sectionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete submission section: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrSubmissionSectionNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder updates the order_index of every section row for the draft
|
||||||
|
// according to the supplied ID sequence. Transactional — partial
|
||||||
|
// failures roll back. Any section_id present on the draft but not in
|
||||||
|
// the sequence keeps its previous order_index, then sorts last by
|
||||||
|
// updated_at (so a partial reorder doesn't lose rows the caller
|
||||||
|
// forgot to mention).
|
||||||
|
func (s *SectionService) Reorder(ctx context.Context, draftID uuid.UUID, order []uuid.UUID) ([]SubmissionSection, error) {
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reorder tx: %w", err)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Each id in order gets order_index 10, 20, 30, ... (gaps so a
|
||||||
|
// future single-row insert doesn't trigger a full reflow). Ids
|
||||||
|
// not present on the draft are silently ignored.
|
||||||
|
for i, sectionID := range order {
|
||||||
|
idx := (i + 1) * 10
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE paliad.submission_sections
|
||||||
|
SET order_index = $1
|
||||||
|
WHERE id = $2 AND draft_id = $3`,
|
||||||
|
idx, sectionID, draftID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reorder update: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit reorder: %w", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
|
||||||
|
return s.ListForDraft(ctx, draftID)
|
||||||
|
}
|
||||||
|
|
||||||
// SeedFromSpec inserts one row per BaseSectionSpec.Default into
|
// SeedFromSpec inserts one row per BaseSectionSpec.Default into
|
||||||
// submission_sections for the given draft. Runs inside the caller's
|
// submission_sections for the given draft. Runs inside the caller's
|
||||||
// transaction (the SubmissionDraftService.Create path wraps the
|
// transaction (the SubmissionDraftService.Create path wraps the
|
||||||
|
|||||||
152
internal/services/submission_section_slice_f_test.go
Normal file
152
internal/services/submission_section_slice_f_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
// Live-DB tests for Slice F section service additions (Create + Delete
|
||||||
|
// + Reorder). Gated on TEST_DATABASE_URL, mirroring Slice A's pattern.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSectionService_SliceF(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||||
|
}
|
||||||
|
if err := db.ApplyMigrations(url); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := sqlx.Connect("postgres", url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bases := NewBaseService(pool)
|
||||||
|
sections := NewSectionService(pool)
|
||||||
|
|
||||||
|
// Seed user + draft so we have a draft_id to attach sections to.
|
||||||
|
userID := uuid.New()
|
||||||
|
cleanup := func() {
|
||||||
|
pool.ExecContext(ctx, `DELETE FROM paliad.submission_sections WHERE draft_id IN (SELECT id FROM paliad.submission_drafts WHERE user_id = $1)`, userID)
|
||||||
|
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||||
|
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||||
|
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
email := "slice-f-" + userID.String()[:8] + "@hlc.com"
|
||||||
|
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||||
|
t.Fatalf("seed auth.users: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.ExecContext(ctx,
|
||||||
|
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||||
|
VALUES ($1, $2, 'Slice F User', 'munich', 'standard', 'de')`,
|
||||||
|
userID, email); err != nil {
|
||||||
|
t.Fatalf("seed paliad.users: %v", err)
|
||||||
|
}
|
||||||
|
users := NewUserService(pool)
|
||||||
|
projects := NewProjectService(pool, users)
|
||||||
|
parties := NewPartyService(pool, projects)
|
||||||
|
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||||
|
renderer := NewSubmissionRenderer()
|
||||||
|
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||||
|
drafts.AttachComposer(bases, sections, "HLC")
|
||||||
|
|
||||||
|
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create draft: %v", err)
|
||||||
|
}
|
||||||
|
initial, err := sections.ListForDraft(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListForDraft initial: %v", err)
|
||||||
|
}
|
||||||
|
if len(initial) != 10 {
|
||||||
|
t.Fatalf("expected 10 seeded sections; got %d", len(initial))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Create custom section", func(t *testing.T) {
|
||||||
|
created, err := sections.Create(ctx, SectionCreateInput{
|
||||||
|
DraftID: d.ID,
|
||||||
|
SectionKey: "berufungsantraege",
|
||||||
|
Kind: "requests",
|
||||||
|
LabelDE: "Berufungsanträge",
|
||||||
|
LabelEN: "Appeal requests",
|
||||||
|
Included: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
if created.OrderIndex <= 10 {
|
||||||
|
t.Errorf("auto-assigned order_index should be > existing max; got %d", created.OrderIndex)
|
||||||
|
}
|
||||||
|
// Slug collision must surface as ErrInvalidInput.
|
||||||
|
_, err = sections.Create(ctx, SectionCreateInput{
|
||||||
|
DraftID: d.ID, SectionKey: "berufungsantraege",
|
||||||
|
Kind: "prose", LabelDE: "x", LabelEN: "x", Included: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected unique-key collision error; got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete section", func(t *testing.T) {
|
||||||
|
// Grab one of the seeded rows to delete.
|
||||||
|
current, _ := sections.ListForDraft(ctx, d.ID)
|
||||||
|
var victimID uuid.UUID
|
||||||
|
for _, s := range current {
|
||||||
|
if s.SectionKey == "exhibits" {
|
||||||
|
victimID = s.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if victimID == uuid.Nil {
|
||||||
|
t.Fatalf("expected exhibits section to exist")
|
||||||
|
}
|
||||||
|
if err := sections.Delete(ctx, victimID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
// Second delete returns not-found.
|
||||||
|
if err := sections.Delete(ctx, victimID); err == nil {
|
||||||
|
t.Errorf("expected ErrSubmissionSectionNotFound on second delete")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Reorder sections", func(t *testing.T) {
|
||||||
|
current, _ := sections.ListForDraft(ctx, d.ID)
|
||||||
|
if len(current) < 3 {
|
||||||
|
t.Skipf("need at least 3 sections to test reorder; got %d", len(current))
|
||||||
|
}
|
||||||
|
// Reverse the order list.
|
||||||
|
ids := make([]uuid.UUID, 0, len(current))
|
||||||
|
for i := len(current) - 1; i >= 0; i-- {
|
||||||
|
ids = append(ids, current[i].ID)
|
||||||
|
}
|
||||||
|
reordered, err := sections.Reorder(ctx, d.ID, ids)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reorder: %v", err)
|
||||||
|
}
|
||||||
|
// Verify the first ID in our list now has the lowest order_index.
|
||||||
|
if reordered[0].ID != ids[0] {
|
||||||
|
t.Errorf("first ID after reorder = %s; want %s", reordered[0].ID, ids[0])
|
||||||
|
}
|
||||||
|
// Order indices should be ascending.
|
||||||
|
prev := 0
|
||||||
|
for _, s := range reordered {
|
||||||
|
if s.OrderIndex <= prev {
|
||||||
|
t.Errorf("non-ascending order_index after reorder: %d (prev=%d) at %s", s.OrderIndex, prev, s.SectionKey)
|
||||||
|
}
|
||||||
|
prev = s.OrderIndex
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
256
scripts/gen-submission-base/main.go
Normal file
256
scripts/gen-submission-base/main.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// Composer Slice E base-template generator (t-paliad-317).
|
||||||
|
//
|
||||||
|
// Produces a minimal Composer-mode .docx whose <w:body> contains the
|
||||||
|
// 10 default section anchors and whose word/styles.xml declares a
|
||||||
|
// named style for each stylemap key the composer references. Each
|
||||||
|
// "preset" (lg-duesseldorf, upc-formal, …) hard-codes the typography
|
||||||
|
// (font, sizes, colour) so the lawyer can swap between them and see
|
||||||
|
// the chrome change while the section content carries through
|
||||||
|
// unchanged (the Q10 base-swap-content-survival contract).
|
||||||
|
//
|
||||||
|
// Run:
|
||||||
|
//
|
||||||
|
// go run ./scripts/gen-submission-base -preset lg-duesseldorf -out /tmp/lg-duesseldorf.docx
|
||||||
|
// go run ./scripts/gen-submission-base -preset upc-formal -out /tmp/upc-formal.docx
|
||||||
|
//
|
||||||
|
// Both outputs are byte-reproducible (zip mtimes pinned to a fixed
|
||||||
|
// UTC timestamp so a clean rebuild diff stays at zero bytes).
|
||||||
|
//
|
||||||
|
// Cross-firm: the bases this generator emits are firm-agnostic
|
||||||
|
// (firm = NULL on the catalog row). They contain no HLC branding
|
||||||
|
// content. Per-firm bases continue to use gen-hl-skeleton-template
|
||||||
|
// against the proprietary .dotm source.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
preset := flag.String("preset", "", "preset: lg-duesseldorf | upc-formal")
|
||||||
|
out := flag.String("out", "", "output .docx path (required)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *preset == "" || *out == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: gen-submission-base -preset NAME -out PATH")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := presets[*preset]
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown preset %q (available: ", *preset)
|
||||||
|
first := true
|
||||||
|
for k := range presets {
|
||||||
|
if !first {
|
||||||
|
fmt.Fprint(os.Stderr, ", ")
|
||||||
|
}
|
||||||
|
fmt.Fprint(os.Stderr, k)
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, ")")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
docx, err := buildDocx(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "gen-submission-base:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "gen-submission-base: write:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("wrote %s (%d bytes) for preset %s\n", *out, len(docx), *preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetConfig captures everything the generator needs to vary between
|
||||||
|
// bases: typography defaults (font + size + colour) and the style-name
|
||||||
|
// prefix that surfaces in the styles.xml.
|
||||||
|
type presetConfig struct {
|
||||||
|
StylePrefix string // e.g. "LG" / "UPC"
|
||||||
|
DefaultFont string // e.g. "Times New Roman" / "Calibri"
|
||||||
|
BodyHalfPoints int // w:sz value (half-points; 22 = 11pt)
|
||||||
|
Heading1Size int
|
||||||
|
Heading2Size int
|
||||||
|
Heading3Size int
|
||||||
|
Heading1Color string // hex without #
|
||||||
|
Heading2Color string
|
||||||
|
Heading3Color string
|
||||||
|
BlockquoteFont string // separate font for the quote style
|
||||||
|
}
|
||||||
|
|
||||||
|
// presets are the seeded base styles for Slice E. Both are intended
|
||||||
|
// as starting points the firm's admin can refine via the admin editor
|
||||||
|
// in a later slice — this is the floor, not the ceiling.
|
||||||
|
var presets = map[string]presetConfig{
|
||||||
|
"lg-duesseldorf": {
|
||||||
|
StylePrefix: "LG",
|
||||||
|
DefaultFont: "Times New Roman",
|
||||||
|
BodyHalfPoints: 22, // 11pt
|
||||||
|
Heading1Size: 28, // 14pt
|
||||||
|
Heading2Size: 26, // 13pt
|
||||||
|
Heading3Size: 24, // 12pt
|
||||||
|
Heading1Color: "000000",
|
||||||
|
Heading2Color: "000000",
|
||||||
|
Heading3Color: "000000",
|
||||||
|
BlockquoteFont: "Times New Roman",
|
||||||
|
},
|
||||||
|
"upc-formal": {
|
||||||
|
StylePrefix: "UPC",
|
||||||
|
DefaultFont: "Calibri",
|
||||||
|
BodyHalfPoints: 22, // 11pt
|
||||||
|
Heading1Size: 32, // 16pt
|
||||||
|
Heading2Size: 28, // 14pt
|
||||||
|
Heading3Size: 24, // 12pt
|
||||||
|
Heading1Color: "1F3864", // UPC dark blue
|
||||||
|
Heading2Color: "1F3864",
|
||||||
|
Heading3Color: "1F3864",
|
||||||
|
BlockquoteFont: "Cambria",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixedTime = time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func buildDocx(cfg presetConfig) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
|
||||||
|
add := func(name, body string) error {
|
||||||
|
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: fixedTime}
|
||||||
|
w, err := zw.CreateHeader(hdr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(body)); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := add("word/styles.xml", buildStylesXML(cfg)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||||
|
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||||
|
</Types>`
|
||||||
|
|
||||||
|
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||||
|
</Relationships>`
|
||||||
|
|
||||||
|
// documentRelsXML — empty relationships envelope. The composer's
|
||||||
|
// hyperlink patch slots fresh <Relationship Type="…/hyperlink"/>
|
||||||
|
// rows in here at compose time.
|
||||||
|
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||||
|
</Relationships>`
|
||||||
|
|
||||||
|
// buildStylesXML emits the stylemap-aligned named styles. Each style
|
||||||
|
// id matches what the catalog row's section_spec.stylemap declares
|
||||||
|
// for the corresponding key (paragraph / heading_1/2/3 / list_*
|
||||||
|
// / blockquote / Hyperlink).
|
||||||
|
//
|
||||||
|
// "Hyperlink" is the built-in Word style id the composer's MD walker
|
||||||
|
// emits on link-child runs (Slice D). Including it here makes the
|
||||||
|
// blue-underline-link rendering land out of the box.
|
||||||
|
func buildStylesXML(cfg presetConfig) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||||
|
b.WriteString(`<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||||
|
|
||||||
|
// Document defaults — sets the body font + size for every paragraph
|
||||||
|
// that doesn't override.
|
||||||
|
fmt.Fprintf(&b, `<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="%s" w:hAnsi="%s" w:cs="%s"/><w:sz w:val="%d"/></w:rPr></w:rPrDefault></w:docDefaults>`,
|
||||||
|
cfg.DefaultFont, cfg.DefaultFont, cfg.DefaultFont, cfg.BodyHalfPoints)
|
||||||
|
|
||||||
|
// Normal — Word's default paragraph style; nothing fancy.
|
||||||
|
b.WriteString(`<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>`)
|
||||||
|
|
||||||
|
// Body style — body0 alias for the composer's stylemap.paragraph.
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Body"><w:name w:val="%s body"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:after="120" w:line="276" w:lineRule="auto"/></w:pPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix)
|
||||||
|
|
||||||
|
// Headings — three levels with descending sizes + colours.
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading1"><w:name w:val="%s heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="320" w:after="160"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading1Size, cfg.Heading1Color)
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading2"><w:name w:val="%s heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading2Size, cfg.Heading2Color)
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading3"><w:name w:val="%s heading 3"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="200" w:after="80"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading3Size, cfg.Heading3Color)
|
||||||
|
|
||||||
|
// List paragraph styles — same indent as body but with hanging
|
||||||
|
// indent so the visible "• " / "N. " prefix from the MD walker
|
||||||
|
// aligns cleanly.
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-ListBullet"><w:name w:val="%s list bullet"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="360" w:hanging="360"/><w:spacing w:after="60"/></w:pPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix)
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-ListNumber"><w:name w:val="%s list number"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="360" w:hanging="360"/><w:spacing w:after="60"/></w:pPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix)
|
||||||
|
|
||||||
|
// Blockquote — italic, indented, optional alternative font.
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Quote"><w:name w:val="%s quote"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/><w:spacing w:before="120" w:after="120"/></w:pPr><w:rPr><w:i/><w:rFonts w:ascii="%s" w:hAnsi="%s"/></w:rPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix, cfg.BlockquoteFont, cfg.BlockquoteFont)
|
||||||
|
|
||||||
|
// Hyperlink — Word's built-in character-style id matches what the
|
||||||
|
// MD walker emits, so the link runs pick up the colour + underline
|
||||||
|
// automatically.
|
||||||
|
b.WriteString(`<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style>`)
|
||||||
|
|
||||||
|
b.WriteString(`</w:styles>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDocumentXML emits the composer-mode body — 10 default section
|
||||||
|
// anchors in the design's §6.1 order, nothing else.
|
||||||
|
func buildDocumentXML() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||||
|
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||||
|
b.WriteString(`<w:body>`)
|
||||||
|
for _, key := range []string{
|
||||||
|
"letterhead", "caption", "introduction", "requests",
|
||||||
|
"facts", "legal_argument", "evidence", "exhibits",
|
||||||
|
"closing", "signature",
|
||||||
|
} {
|
||||||
|
anchor(&b, "{{#section:"+key+"}}")
|
||||||
|
anchor(&b, "{{/section:"+key+"}}")
|
||||||
|
}
|
||||||
|
b.WriteString(`</w:body></w:document>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func anchor(b *strings.Builder, text string) {
|
||||||
|
b.WriteString(`<w:p><w:r><w:t xml:space="preserve">`)
|
||||||
|
b.WriteString(text)
|
||||||
|
b.WriteString(`</w:t></w:r></w:p>`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user