Compare commits
26 Commits
mai/curie/
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
| f963b0df34 | |||
| 6cd340300b | |||
| 557f9a4cce | |||
| 3af71e772b | |||
| e2969fc358 | |||
| 85d0cedd22 | |||
| 0e1691f00e | |||
| 05ad43aa46 | |||
| 43de8f9c7b | |||
| 635457474a | |||
| 235e68496b | |||
| 8125caf49a | |||
| 937ff13470 | |||
| b97f170c1d | |||
| 935ea23038 | |||
| f8e5be5f7a | |||
| ee0a9ea6cb | |||
| da464813b7 | |||
| 6d24fb8931 | |||
| 446c46e5c5 | |||
| d1aa0f72c0 | |||
| 94f2831f3f | |||
| 83be122b19 | |||
| b6c2df95cc | |||
| 367627af0d | |||
| 7d7b20651d |
@@ -160,6 +160,19 @@ func main() {
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-313 Composer Slice A — base catalog + section seeding.
|
||||
// AttachComposer wires both into the draft service so Create
|
||||
// seeds base_id + submission_sections rows on new drafts. v1
|
||||
// fallback path stays active for pre-Composer drafts (base_id
|
||||
// NULL, no section rows).
|
||||
submissionBaseSvc := services.NewBaseService(pool)
|
||||
submissionSectionSvc := services.NewSectionService(pool)
|
||||
submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name)
|
||||
// t-paliad-313 Slice B — render-pipeline assembler. Reuses the
|
||||
// existing SubmissionRenderer for the final placeholder pass so
|
||||
// the {{rule.X}} alias contract stays preserved inside the
|
||||
// composed body.
|
||||
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -171,7 +184,10 @@ func main() {
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionBase: submissionBaseSvc,
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
|
||||
1018
docs/design-submission-generator-v2-2026-05-26.md
Normal file
1018
docs/design-submission-generator-v2-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"changelog.tag.fix": "Fix",
|
||||
|
||||
// Footer
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 ein Werkzeug von",
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 by",
|
||||
|
||||
// Landing page
|
||||
"index.title": `Paliad \u2014 Patent Litigation f\u00fcr ${FIRM}`,
|
||||
@@ -1520,6 +1520,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Vorlagenbasis",
|
||||
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
|
||||
"submissions.draft.sections.title": "Abschnitte",
|
||||
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
|
||||
// t-paliad-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
@@ -3174,7 +3179,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"changelog.tag.fix": "Fix",
|
||||
|
||||
// Footer
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 a tool by",
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 by",
|
||||
|
||||
// Landing page
|
||||
"index.title": `Paliad \u2014 Patent Litigation for ${FIRM}`,
|
||||
@@ -4596,6 +4601,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
"submissions.draft.parties.title": "Parties",
|
||||
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Template base",
|
||||
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
|
||||
"submissions.draft.sections.title": "Sections",
|
||||
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
|
||||
@@ -28,10 +28,47 @@ interface SubmissionDraftJSON {
|
||||
last_exported_at?: string | null;
|
||||
last_exported_sha?: string | null;
|
||||
last_imported_at?: string | null;
|
||||
// t-paliad-313 Composer Slice A — base reference + Composer-side
|
||||
// metadata. base_id is null on pre-Composer drafts (the v1 render
|
||||
// path stays the fallback). composer_meta carries the seed-time
|
||||
// section order in later slices.
|
||||
base_id?: string | null;
|
||||
composer_meta?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// t-paliad-313 Composer Slice A — per-draft section row, surfaced
|
||||
// read-only in the editor body. Slice B adds inline edit + PATCH.
|
||||
interface SubmissionSectionJSON {
|
||||
id: string;
|
||||
section_key: string;
|
||||
order_index: number;
|
||||
kind: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
included: boolean;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
}
|
||||
|
||||
// t-paliad-313 Composer Slice A — base catalog row, surfaced in the
|
||||
// sidebar picker dropdown.
|
||||
interface SubmissionBaseRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
firm?: string | null;
|
||||
proceeding_family?: string | null;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description_de?: string | null;
|
||||
description_en?: string | null;
|
||||
gitea_path: string;
|
||||
is_default_for: string[];
|
||||
is_active: boolean;
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
interface AvailablePartyJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -64,6 +101,9 @@ interface SubmissionDraftView {
|
||||
// language has no per-firm language-matched template.
|
||||
template_tier?: string;
|
||||
language_fallback?: boolean;
|
||||
// t-paliad-313 Composer Slice A — per-draft section stack. Empty
|
||||
// for pre-Composer drafts where no rows have been seeded.
|
||||
sections: SubmissionSectionJSON[];
|
||||
}
|
||||
|
||||
interface SubmissionDraftListResponse {
|
||||
@@ -328,6 +368,11 @@ interface State {
|
||||
addPartyMode: "manual" | "search";
|
||||
addPartySearchHits: PartySearchHit[];
|
||||
addPartyBusy: boolean;
|
||||
// t-paliad-313 Composer Slice A — base catalog fetched once on boot.
|
||||
// Picker hidden until populated; empty array (after the fetch
|
||||
// completes) keeps the picker hidden permanently for this load.
|
||||
bases: SubmissionBaseRow[];
|
||||
basesLoaded: boolean;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
@@ -354,6 +399,8 @@ const state: State = {
|
||||
addPartyMode: "manual",
|
||||
addPartySearchHits: [],
|
||||
addPartyBusy: false,
|
||||
bases: [],
|
||||
basesLoaded: false,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -371,6 +418,14 @@ async function boot(): Promise<void> {
|
||||
}
|
||||
state.parsed = parsed;
|
||||
|
||||
// t-paliad-313 Composer Slice A — kick the base catalog fetch in
|
||||
// parallel with the view load. The picker hydrates when both land;
|
||||
// either failing leaves the picker hidden but the editor functional.
|
||||
loadBases().catch(err => {
|
||||
console.warn("submission-draft: base catalog fetch failed", err);
|
||||
state.basesLoaded = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||||
@@ -523,11 +578,13 @@ function paint(): void {
|
||||
paintNoProjectBanner();
|
||||
paintSwitcher();
|
||||
paintNameRow();
|
||||
paintBasePicker();
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintVariables();
|
||||
paintSectionList();
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
@@ -1143,6 +1200,354 @@ function paintPreview(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// t-paliad-313 Composer Slice A — base picker + section list
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadBases(): Promise<void> {
|
||||
const res = await fetch("/api/submission-bases", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
throw new Error("base list HTTP " + res.status);
|
||||
}
|
||||
const body = await res.json() as { bases?: SubmissionBaseRow[] };
|
||||
state.bases = body.bases ?? [];
|
||||
state.basesLoaded = true;
|
||||
// If the view has already painted, re-paint the picker so it
|
||||
// hydrates as soon as the catalog lands. paint() is idempotent.
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
function paintBasePicker(): void {
|
||||
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
|
||||
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
|
||||
if (!row || !sel || !state.view) return;
|
||||
|
||||
// Hide the picker until the catalog has loaded AND the catalog has
|
||||
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
|
||||
// keeps the picker hidden indefinitely so the editor stays usable.
|
||||
if (!state.basesLoaded || state.bases.length === 0) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
|
||||
// Rebuild the <option> list each paint so language toggles + base
|
||||
// catalog updates flow through.
|
||||
sel.innerHTML = "";
|
||||
const currentBaseID = state.view.draft.base_id ?? "";
|
||||
|
||||
// "Keine Vorlagenbasis" only listed when the draft is currently in
|
||||
// that state (pre-Composer / cleared). Avoids tempting the lawyer
|
||||
// to clear after they've already picked one.
|
||||
if (!currentBaseID) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
for (const b of state.bases) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = b.id;
|
||||
opt.textContent = isEN() ? b.label_en : b.label_de;
|
||||
if (b.id === currentBaseID) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
// Wire change handler once per paint. Removing then re-adding
|
||||
// keeps the binding consistent across repaints (e.g. after
|
||||
// language toggle re-renders the labels).
|
||||
sel.onchange = () => { onBaseChange(sel.value); };
|
||||
}
|
||||
|
||||
async function onBaseChange(newBaseID: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const payload: Record<string, unknown> = {
|
||||
// Empty string in the picker maps to null = clear.
|
||||
base_id: newBaseID === "" ? null : newBaseID,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("base swap PATCH failed", res.status);
|
||||
return;
|
||||
}
|
||||
const view = await res.json() as SubmissionDraftView;
|
||||
state.view = view;
|
||||
paint();
|
||||
} catch (err) {
|
||||
console.warn("base swap PATCH error", err);
|
||||
}
|
||||
}
|
||||
|
||||
// sectionAutosaveTimers — one debounce timer per section id so two
|
||||
// sections autosaving simultaneously don't trample each other. Reset
|
||||
// on each keystroke; 500ms after the last keystroke the patch fires.
|
||||
const sectionAutosaveTimers: Record<string, number> = {};
|
||||
const SECTION_AUTOSAVE_MS = 500;
|
||||
|
||||
function paintSectionList(): void {
|
||||
const wrap = document.getElementById("submission-draft-sections-wrap");
|
||||
const list = document.getElementById("submission-draft-sections-list") as HTMLOListElement | null;
|
||||
if (!wrap || !list || !state.view) return;
|
||||
|
||||
const sections = state.view.sections ?? [];
|
||||
if (sections.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
|
||||
// Don't blow away the editor if a section is currently focused —
|
||||
// would steal cursor + selection mid-type. The patch round-trip
|
||||
// returns the updated row, but paintSectionList only re-renders
|
||||
// when the focused section isn't being edited (or the new render
|
||||
// is being driven by something other than the active editor itself).
|
||||
const activeID = activeSectionEditorID();
|
||||
|
||||
list.innerHTML = "";
|
||||
const lang = state.view.draft.language || state.view.lang || "de";
|
||||
for (const sec of sections) {
|
||||
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "submission-draft-section";
|
||||
li.dataset.sectionId = sec.id;
|
||||
if (!sec.included) li.classList.add("submission-draft-section--excluded");
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "submission-draft-section-head";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "submission-draft-section-title";
|
||||
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
|
||||
head.appendChild(title);
|
||||
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "submission-draft-section-kind";
|
||||
kind.textContent = sec.kind;
|
||||
head.appendChild(kind);
|
||||
|
||||
if (!sec.included) {
|
||||
const muted = document.createElement("span");
|
||||
muted.className = "submission-draft-section-excluded-badge";
|
||||
muted.textContent = isEN() ? "excluded" : "ausgeblendet";
|
||||
head.appendChild(muted);
|
||||
}
|
||||
|
||||
// Per-section "Aufnehmen" / "Ausblenden" toggle in the head — flips
|
||||
// `included` via PATCH and re-paints.
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "btn-small btn-secondary submission-draft-section-toggle";
|
||||
toggle.textContent = sec.included
|
||||
? (isEN() ? "Hide" : "Ausblenden")
|
||||
: (isEN() ? "Include" : "Aufnehmen");
|
||||
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
|
||||
head.appendChild(toggle);
|
||||
|
||||
li.appendChild(head);
|
||||
|
||||
// Toolbar — shared B/I affordance per section. Slice D extends with
|
||||
// headings, lists, quote.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "submission-draft-section-toolbar";
|
||||
toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
|
||||
toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
|
||||
li.appendChild(toolbar);
|
||||
|
||||
const md = (lang === "en" ? sec.content_md_en : sec.content_md_de) || "";
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "submission-draft-section-editor";
|
||||
editor.contentEditable = "true";
|
||||
editor.spellcheck = true;
|
||||
editor.dataset.sectionId = sec.id;
|
||||
editor.dataset.lang = lang;
|
||||
editor.dataset.placeholder = isEN()
|
||||
? "Write section content…"
|
||||
: "Abschnittstext eingeben…";
|
||||
// Paint the Markdown as plain text on first render — the editor's
|
||||
// source of truth is Markdown, the DOM is the view. Lawyer types,
|
||||
// we serialise back to MD on autosave.
|
||||
editor.textContent = md;
|
||||
|
||||
editor.addEventListener("input", () => onSectionInput(editor));
|
||||
editor.addEventListener("focus", () => {
|
||||
li.classList.add("submission-draft-section--editing");
|
||||
});
|
||||
editor.addEventListener("blur", () => {
|
||||
li.classList.remove("submission-draft-section--editing");
|
||||
// Force-flush any pending autosave so we don't leave unsynced
|
||||
// edits hanging when the lawyer tabs out.
|
||||
flushSectionAutosave(sec.id);
|
||||
});
|
||||
|
||||
li.appendChild(editor);
|
||||
|
||||
if (isActive) {
|
||||
// The repaint happened while this section was focused — restore
|
||||
// focus to it. Cursor placement at the end is a fair default
|
||||
// (typing mid-content during a repaint is rare; the autosave path
|
||||
// typically doesn't repaint at all).
|
||||
queueMicrotask(() => {
|
||||
const fresh = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sec.id)}"]`) as HTMLDivElement | null;
|
||||
if (fresh) {
|
||||
fresh.focus();
|
||||
placeCaretAtEnd(fresh);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function makeToolbarButton(label: string, title: string, format: "bold" | "italic"): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
btn.textContent = label;
|
||||
btn.title = title;
|
||||
// Mousedown rather than click so the editor doesn't lose focus
|
||||
// mid-command — execCommand requires the editor to be the active
|
||||
// selection target.
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
document.execCommand(format, false);
|
||||
// Trigger the input handler so autosave fires.
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function activeSectionEditorID(): string | null {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
|
||||
return active.dataset.sectionId ?? null;
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(el: HTMLElement): void {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function onSectionInput(editor: HTMLDivElement): void {
|
||||
const id = editor.dataset.sectionId;
|
||||
if (!id) return;
|
||||
if (sectionAutosaveTimers[id]) clearTimeout(sectionAutosaveTimers[id]);
|
||||
sectionAutosaveTimers[id] = window.setTimeout(() => {
|
||||
sectionAutosaveTimers[id] = 0;
|
||||
flushSectionAutosave(id);
|
||||
}, SECTION_AUTOSAVE_MS);
|
||||
}
|
||||
|
||||
function flushSectionAutosave(sectionID: string): void {
|
||||
if (sectionAutosaveTimers[sectionID]) {
|
||||
clearTimeout(sectionAutosaveTimers[sectionID]);
|
||||
sectionAutosaveTimers[sectionID] = 0;
|
||||
}
|
||||
const editor = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sectionID)}"]`) as HTMLDivElement | null;
|
||||
if (!editor || !state.view) return;
|
||||
const lang = editor.dataset.lang || state.view.draft.language || "de";
|
||||
const md = domToMarkdown(editor);
|
||||
void patchSection(sectionID, lang === "en" ? { content_md_en: md } : { content_md_de: md });
|
||||
}
|
||||
|
||||
// domToMarkdown serialises a contentEditable's DOM tree back to
|
||||
// Markdown. Walks the tree: <b>/<strong> emit `**…**`, <i>/<em> emit
|
||||
// `*…*`, <br> emits a newline, block-level elements emit a blank line
|
||||
// between siblings. Slice B handles only B/I + paragraphs/line breaks
|
||||
// — Slice D's rich toolbar extends this to headings + lists + quote.
|
||||
function domToMarkdown(root: HTMLElement): string {
|
||||
return serializeNode(root).trim();
|
||||
}
|
||||
|
||||
function serializeNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent ?? "";
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
let inner = "";
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
inner += serializeNode(child);
|
||||
}
|
||||
switch (tag) {
|
||||
case "b":
|
||||
case "strong":
|
||||
return inner ? `**${inner}**` : "";
|
||||
case "i":
|
||||
case "em":
|
||||
return inner ? `*${inner}*` : "";
|
||||
case "br":
|
||||
return "\n";
|
||||
case "div":
|
||||
case "p":
|
||||
// execCommand and contentEditable insert <div> on Enter in some
|
||||
// browsers, <p> in others. Both are paragraph boundaries.
|
||||
return inner + "\n\n";
|
||||
default:
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void> {
|
||||
await patchSection(sec.id, { included: !sec.included });
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const draftID = state.view?.draft.id;
|
||||
if (!draftID) return;
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${draftID}/sections/${sectionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("section PATCH failed", res.status, sectionID);
|
||||
return;
|
||||
}
|
||||
const updated = await res.json() as SubmissionSectionJSON;
|
||||
// Splice the updated row into state.view.sections. Don't re-paint
|
||||
// unless we need to (avoid focus stealing during active typing).
|
||||
if (state.view && state.view.sections) {
|
||||
const idx = state.view.sections.findIndex(s => s.id === sectionID);
|
||||
if (idx >= 0) state.view.sections[idx] = updated;
|
||||
}
|
||||
// Only repaint when the change has visible UI knock-on (toggle,
|
||||
// label, order). content_md_* changes don't need a repaint —
|
||||
// the editor already shows the lawyer's keystrokes.
|
||||
if ("included" in payload || "label_de" in payload || "label_en" in payload || "order_index" in payload) {
|
||||
paintSectionList();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("section PATCH error", err);
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-261 (B) — click a substituted variable in the preview to
|
||||
// jump to the matching sidebar input. Re-wires on every paintPreview
|
||||
// since the preview HTML is replaced wholesale. The server side wraps
|
||||
|
||||
@@ -12,7 +12,6 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
@@ -24,10 +23,27 @@ import {
|
||||
import {
|
||||
attachEventCardChoices,
|
||||
reseedChips,
|
||||
currentChoices,
|
||||
type EventChoice,
|
||||
type ChoiceKind,
|
||||
} from "./views/event-card-choices";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
type AppealTarget,
|
||||
type Side,
|
||||
type StorageLike,
|
||||
applyFiltersToSearch,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./views/verfahrensablauf-state";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
@@ -61,8 +77,14 @@ let sidePrefilledFromProject = false;
|
||||
// chosen side's column). For first-instance proceedings (Inf, Rev,
|
||||
// …) the side picker still narrows columns but doesn't collapse
|
||||
// the "both" rows.
|
||||
//
|
||||
// upc.apl.unified is NOT in this set since t-paliad-307: appeal
|
||||
// timelines route via per-rule appealRole (engine-stamped under
|
||||
// appeal_target) instead of the page-level appellant axis collapse.
|
||||
// Adding upc.apl.unified here would short-circuit the appealAware
|
||||
// path and re-introduce the dead side selector on upc.apl.unified
|
||||
// (m/paliad#136 Bug 1).
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.unified",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
@@ -113,21 +135,13 @@ const ROLE_LABELS: Record<string, RoleLabels> = {
|
||||
// Proceedings that surface the appeal-target chip group. Currently
|
||||
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
|
||||
// can opt in by adding the code here.
|
||||
//
|
||||
// APPEAL_TARGETS itself lives in ./views/verfahrensablauf-state so the
|
||||
// pure URL parser and this page share the same canonical list.
|
||||
const APPEAL_TARGET_PROCEEDINGS = new Set([
|
||||
"upc.apl.unified",
|
||||
]);
|
||||
|
||||
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
|
||||
// in sync with pkg/litigationplanner/types.go AppealTargets).
|
||||
const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
function hasAppealTarget(proceedingType: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
@@ -136,16 +150,35 @@ function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
// Scenario storage — real localStorage in the browser, in-memory
|
||||
// fallback when localStorage throws (private mode, disabled storage,
|
||||
// etc.). All scenario writes go through this single handle so a
|
||||
// failure mode is isolated to one try/catch path.
|
||||
const scenarioStorage: StorageLike = makeScenarioStorage();
|
||||
|
||||
function makeScenarioStorage(): StorageLike {
|
||||
try {
|
||||
const probe = "__paliad_va_probe__";
|
||||
window.localStorage.setItem(probe, "1");
|
||||
window.localStorage.removeItem(probe);
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return makeMemoryStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
// URL writers — all four chip params route through this single helper
|
||||
// so the canonical query-string shape (no empty values, no trailing
|
||||
// `?`) is enforced in one place.
|
||||
function applyURLFilters(filters: {
|
||||
proceeding?: string;
|
||||
side?: Side;
|
||||
target?: AppealTarget;
|
||||
triggerDate?: string;
|
||||
}): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
const nextSearch = applyFiltersToSearch(url.search, filters);
|
||||
window.history.replaceState(null, "", url.pathname + nextSearch + url.hash);
|
||||
}
|
||||
|
||||
// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
|
||||
@@ -175,26 +208,6 @@ function applyRoleLabels(proceedingType: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B1 — appeal-target URL state. Empty string = no target picked
|
||||
// (the row is hidden because the proceeding isn't an appeal). Any
|
||||
// other value must be one of APPEAL_TARGETS; unknown values are
|
||||
// rejected by readAppealTargetFromURL so a stale link can't break
|
||||
// the engine filter.
|
||||
function readAppealTargetFromURL(): AppealTarget {
|
||||
const raw = new URLSearchParams(window.location.search).get("target") || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function writeAppealTargetToURL(t: AppealTarget) {
|
||||
const url = new URL(window.location.href);
|
||||
if (t === "") url.searchParams.delete("target");
|
||||
else url.searchParams.set("target", t);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Default target on first picker entry into upc.apl. m: Endentscheidung
|
||||
// is the most-common appeal target; the chip group also defaults
|
||||
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
|
||||
@@ -211,54 +224,18 @@ const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
// Per-event-card choices (t-paliad-265). Unbound on this page (no
|
||||
// project context), so persistence is URL-only via `?event_choices=`.
|
||||
// Format: comma-separated `submission_code:kind=value` tuples. Same
|
||||
// idiom as `?side=` + `?appellant=`.
|
||||
let perCardChoices: EventChoice[] = [];
|
||||
// project context). Persistence moved from URL → localStorage under
|
||||
// SCENARIO_KEYS.eventChoices (t-paliad-308 / m/paliad#137) — these
|
||||
// are per-user scenario tweaks, not the timeline kind, so a shared
|
||||
// link should NOT leak them into the recipient's view.
|
||||
let perCardChoices: EventChoice[] = readEventChoices(scenarioStorage);
|
||||
|
||||
function readChoicesFromURL(): EventChoice[] {
|
||||
const raw = new URLSearchParams(window.location.search).get("event_choices");
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeChoicesToURL(choices: EventChoice[]) {
|
||||
const url = new URL(window.location.href);
|
||||
if (choices.length === 0) {
|
||||
url.searchParams.delete("event_choices");
|
||||
} else {
|
||||
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
|
||||
url.searchParams.set("event_choices", enc);
|
||||
}
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
|
||||
// Show-hidden toggle (t-paliad-290 / m/paliad#122). When ON, the
|
||||
// calculator re-surfaces cards whose submission_code is in the active
|
||||
// skipRules set; they render faded with a "Wieder einblenden" chip.
|
||||
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
|
||||
// the visibility. Default OFF — m's not asking to see hidden by
|
||||
// default, just to be able to.
|
||||
function readShowHiddenFromURL(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
|
||||
}
|
||||
|
||||
function writeShowHiddenToURL(on: boolean) {
|
||||
const url = new URL(window.location.href);
|
||||
if (on) url.searchParams.set("show_hidden", "1");
|
||||
else url.searchParams.delete("show_hidden");
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
let showHidden = readShowHiddenFromURL();
|
||||
// Persistence moved from URL → localStorage (t-paliad-308) — it's a
|
||||
// per-user UX preference, not scenario state worth sharing in a link.
|
||||
let showHidden = readBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden);
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
@@ -505,6 +482,12 @@ function renderResults(data: DeadlineResponse) {
|
||||
// in the picked side's column). For non-role-swap proceedings,
|
||||
// the appellant axis is irrelevant — pass null.
|
||||
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
|
||||
// Appeal-target proceedings get per-rule appealRole routing
|
||||
// instead of the page-level appellant collapse, so the side
|
||||
// selector actually splits Berufungskläger vs Berufungs-
|
||||
// beklagter filings across columns. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1)
|
||||
appealAware: hasAppealTarget(selectedType),
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });
|
||||
|
||||
@@ -556,7 +539,7 @@ function syncInfAmendEnabled() {
|
||||
if (!ccr.checked) infAmend.checked = false;
|
||||
}
|
||||
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
function selectProceeding(btn: HTMLButtonElement, opts: { writeURL?: boolean } = {}) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const nextType = btn.dataset.code || "";
|
||||
@@ -566,20 +549,76 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
|
||||
// Persist the picked proceeding to ?proceeding= so a refresh / shared
|
||||
// link reproduces the same tile. writeURL=false on the load-time
|
||||
// hydration path so we don't churn history.replaceState when the
|
||||
// URL already carries the canonical value.
|
||||
if (opts.writeURL !== false) {
|
||||
applyURLFilters({ proceeding: selectedType });
|
||||
}
|
||||
|
||||
// Trigger-event label fires from the calc response (root rule).
|
||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||
lastResponse = null;
|
||||
syncTriggerEventLabel();
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppealTargetRowVisibility();
|
||||
applyRoleLabels(selectedType);
|
||||
// Restore flags from localStorage BEFORE the initial calc so the
|
||||
// first /api/tools/fristenrechner POST already carries the user's
|
||||
// stored flag state. Court_id is async (populateCourtPicker fetches
|
||||
// courts from the API) so it restores via the .then() below + a
|
||||
// follow-up recalc when the picker is ready.
|
||||
restoreFlagsForProceeding();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
showStep(2);
|
||||
scheduleCalc(0);
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType).then(() => {
|
||||
if (restoreCourtForProceeding()) scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// restoreFlagsForProceeding seeds the proceeding-specific flag
|
||||
// checkboxes from localStorage. Mirrors syncFlagRows in scope — only
|
||||
// flags currently visible for the active proceeding are meaningful
|
||||
// (the hidden checkboxes still write to localStorage if toggled, but
|
||||
// that's impossible because they're not in the DOM as visible
|
||||
// controls). syncInfAmendEnabled enforces the upc.inf.cfi inf-amend
|
||||
// gating after the restore.
|
||||
function restoreFlagsForProceeding(): void {
|
||||
const flagPairs: Array<[string, string]> = [
|
||||
["ccr-flag", SCENARIO_KEYS.ccr],
|
||||
["inf-amend-flag", SCENARIO_KEYS.infAmend],
|
||||
["rev-amend-flag", SCENARIO_KEYS.revAmend],
|
||||
["rev-cci-flag", SCENARIO_KEYS.revCci],
|
||||
];
|
||||
for (const [domId, storageKey] of flagPairs) {
|
||||
const cb = document.getElementById(domId) as HTMLInputElement | null;
|
||||
if (!cb) continue;
|
||||
cb.checked = readBoolFlag(scenarioStorage, storageKey);
|
||||
}
|
||||
syncInfAmendEnabled();
|
||||
}
|
||||
|
||||
// restoreCourtForProceeding tries to apply the localStorage court_id
|
||||
// to the picker after populateCourtPicker resolves. Returns true iff
|
||||
// a value actually changed (so the caller can schedule a follow-up
|
||||
// calc). Skips silently when the picker is hidden, the stored ID isn't
|
||||
// in the options list (court rotated since last visit), or the picker
|
||||
// already happens to be on the stored value.
|
||||
function restoreCourtForProceeding(): boolean {
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
const storedCourtId = readCourtId(scenarioStorage);
|
||||
if (!courtPicker || !storedCourtId) return false;
|
||||
const has = Array.from(courtPicker.options).some((o) => o.value === storedCourtId);
|
||||
if (!has) return false;
|
||||
if (courtPicker.value === storedCourtId) return false;
|
||||
courtPicker.value = storedCourtId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
@@ -594,7 +633,7 @@ function syncAppealTargetRowVisibility() {
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppealTarget !== "") {
|
||||
currentAppealTarget = "";
|
||||
writeAppealTargetToURL("");
|
||||
applyURLFilters({ target: "" });
|
||||
syncRadioGroup("appeal-target", "endentscheidung");
|
||||
}
|
||||
}
|
||||
@@ -708,11 +747,11 @@ function showSideRadioCluster() {
|
||||
// already chosen and we never overwrite. When we do prefill, write the
|
||||
// derived side to the URL so reload + back/forward round-trip cleanly.
|
||||
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
|
||||
if (readSideFromURL() !== null) return;
|
||||
if (parseSideFromSearch(window.location.search) !== null) return;
|
||||
const next = ourSideToSide(os);
|
||||
if (next === null) return;
|
||||
currentSide = next;
|
||||
writeSideToURL(next);
|
||||
applyURLFilters({ side: next });
|
||||
syncRadioGroup("side", next);
|
||||
sidePrefilledFromProject = true;
|
||||
renderSideChip(next);
|
||||
@@ -781,8 +820,8 @@ function initViewToggle() {
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppealTarget = readAppealTargetFromURL();
|
||||
currentSide = parseSideFromSearch(window.location.search);
|
||||
currentAppealTarget = parseAppealTargetFromSearch(window.location.search);
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
||||
syncSideHintVisibility();
|
||||
@@ -792,7 +831,7 @@ function initPerspectiveControls() {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
applyURLFilters({ side: currentSide });
|
||||
syncSideHintVisibility();
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
@@ -810,7 +849,7 @@ function initPerspectiveControls() {
|
||||
} else {
|
||||
currentAppealTarget = "";
|
||||
}
|
||||
writeAppealTargetToURL(currentAppealTarget);
|
||||
applyURLFilters({ target: currentAppealTarget });
|
||||
scheduleCalc(0);
|
||||
});
|
||||
});
|
||||
@@ -832,28 +871,57 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener("change", () => scheduleCalc());
|
||||
dateInput.addEventListener("input", () => scheduleCalc());
|
||||
// Hydrate trigger_date from URL on first paint so a refresh /
|
||||
// shared link reproduces the same dated timeline. URL wins over
|
||||
// the verfahrensablauf.tsx today-default that the <input> renders
|
||||
// with. parseTriggerDateFromSearch validates the shape so a
|
||||
// malformed link silently falls back to the today-default.
|
||||
const urlDate = parseTriggerDateFromSearch(window.location.search);
|
||||
if (urlDate) dateInput.value = urlDate;
|
||||
const persistDate = () => {
|
||||
applyURLFilters({ triggerDate: dateInput.value });
|
||||
};
|
||||
dateInput.addEventListener("change", () => { persistDate(); scheduleCalc(); });
|
||||
dateInput.addEventListener("input", () => { persistDate(); scheduleCalc(); });
|
||||
dateInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
|
||||
if ((e as KeyboardEvent).key === "Enter") { persistDate(); scheduleCalc(0); }
|
||||
});
|
||||
}
|
||||
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => {
|
||||
writeCourtId(scenarioStorage, courtPicker.value);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
|
||||
// Flag-checkbox listeners — each flip triggers a fresh calc so the
|
||||
// timeline re-projects with the new gating. ccr-flag additionally
|
||||
// enables/disables the nested inf-amend row.
|
||||
// enables/disables the nested inf-amend row. Each flip also writes
|
||||
// through to localStorage so the choice survives a reload (URL stays
|
||||
// clean; flags are scenario state, not filter chips — t-paliad-308).
|
||||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
if (ccrFlag) ccrFlag.addEventListener("change", () => {
|
||||
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.ccr, ccrFlag.checked);
|
||||
syncInfAmendEnabled();
|
||||
// Disabling ccr also unchecks inf-amend (see syncInfAmendEnabled).
|
||||
// Mirror that into storage so the next reload doesn't repopulate a
|
||||
// disabled checkbox as checked.
|
||||
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
|
||||
const flagStorageKeys: Record<string, string> = {
|
||||
"inf-amend-flag": SCENARIO_KEYS.infAmend,
|
||||
"rev-amend-flag": SCENARIO_KEYS.revAmend,
|
||||
"rev-cci-flag": SCENARIO_KEYS.revCci,
|
||||
};
|
||||
for (const [id, storageKey] of Object.entries(flagStorageKeys)) {
|
||||
const cb = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
|
||||
});
|
||||
if (cb) cb.addEventListener("change", () => {
|
||||
writeBoolFlag(scenarioStorage, storageKey, cb.checked);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
@@ -897,16 +965,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
|
||||
// to URL + recalc (the backend reshapes the response — we can't just
|
||||
// re-render lastResponse since the hidden rows aren't in it when the
|
||||
// toggle was OFF).
|
||||
// t-paliad-290 — show-hidden toggle. Hydrated from localStorage at
|
||||
// module load (showHidden); each flip writes back to localStorage
|
||||
// and triggers a recalc (the backend reshapes the response — we
|
||||
// can't just re-render lastResponse since the hidden rows aren't
|
||||
// in it when the toggle was OFF).
|
||||
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
|
||||
if (showHiddenCb) {
|
||||
showHiddenCb.checked = showHidden;
|
||||
showHiddenCb.addEventListener("change", () => {
|
||||
showHidden = showHiddenCb.checked;
|
||||
writeShowHiddenToURL(showHidden);
|
||||
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden, showHidden);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
@@ -914,11 +983,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
|
||||
// mutate the in-memory list + URL, then trigger a recalc. The
|
||||
// popover module owns the popover lifecycle; this page owns the
|
||||
// recalc + URL plumbing.
|
||||
perCardChoices = readChoicesFromURL();
|
||||
// t-paliad-265 — per-event-card choices. Unbound surface; persistence
|
||||
// is localStorage-only (t-paliad-308) so a shared link doesn't carry
|
||||
// the recipient's per-card tweaks. The popover module owns the
|
||||
// popover lifecycle; this page owns the recalc + storage plumbing.
|
||||
const timelineEl = document.getElementById("timeline-container");
|
||||
if (timelineEl) {
|
||||
attachEventCardChoices({
|
||||
@@ -929,14 +997,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
|
||||
);
|
||||
perCardChoices.push(choice);
|
||||
writeChoicesToURL(perCardChoices);
|
||||
writeEventChoices(scenarioStorage, perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
remove: (submissionCode, kind) => {
|
||||
perCardChoices = perCardChoices.filter(
|
||||
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
|
||||
);
|
||||
writeChoicesToURL(perCardChoices);
|
||||
writeEventChoices(scenarioStorage, perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
});
|
||||
@@ -972,8 +1040,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
syncTriggerEventLabel();
|
||||
});
|
||||
|
||||
// Pre-select the first proceeding tile so users see a timeline
|
||||
// immediately on landing — matches /tools/fristenrechner behaviour.
|
||||
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
if (firstBtn) selectProceeding(firstBtn);
|
||||
// Pre-select the proceeding tile. URL wins: if ?proceeding= is set
|
||||
// and points at a known tile, that tile is selected without rewriting
|
||||
// the URL. Otherwise fall back to the first tile so users see a
|
||||
// timeline immediately on landing — matches /tools/fristenrechner
|
||||
// behaviour. The auto-pick does NOT write the URL so the default
|
||||
// landing stays clean (`?proceeding=` only appears once the user
|
||||
// makes an explicit choice). (t-paliad-308 / m/paliad#137)
|
||||
const urlProceeding = parseProceedingFromSearch(window.location.search);
|
||||
let initialBtn: HTMLButtonElement | null = null;
|
||||
let urlHit = false;
|
||||
if (urlProceeding) {
|
||||
initialBtn = document.querySelector<HTMLButtonElement>(
|
||||
`.proceeding-btn[data-code="${urlProceeding.replace(/"/g, '\\"')}"]`,
|
||||
);
|
||||
urlHit = initialBtn !== null;
|
||||
}
|
||||
if (!initialBtn) {
|
||||
initialBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
}
|
||||
if (initialBtn) {
|
||||
// writeURL=false when the URL either already carries this code
|
||||
// (no churn) or has no proceeding (auto-default → don't pollute
|
||||
// the clean URL). Only an unknown / stale ?proceeding= triggers
|
||||
// a rewrite so the URL converges on the resolved tile.
|
||||
const writeURL = urlProceeding !== "" && !urlHit;
|
||||
selectProceeding(initialBtn, { writeURL });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
stripLeadingDurationFromNotes,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
@@ -487,3 +489,287 @@ describe("renderColumnsBody — side-aware column header labels (m/paliad#127)",
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing.
|
||||
// All appeal rules carry party='both' (either side could be the
|
||||
// appellant). With appealAware=true + dl.appealRole set, the bucketer
|
||||
// routes by (filer matches user) instead of collapsing every 'both'
|
||||
// row into the user's column. Without a side picked, the bucketer
|
||||
// keeps the legacy mirror so every appeal rule is visible.
|
||||
describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => {
|
||||
const appeal = (
|
||||
name: string,
|
||||
role: "appellant" | "appellee",
|
||||
due: string,
|
||||
): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
appealRole: role,
|
||||
});
|
||||
|
||||
const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26");
|
||||
const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26");
|
||||
const response = appeal("Berufungserwiderung", "appellee", "2026-12-26");
|
||||
|
||||
test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, grounds, response], {
|
||||
side: "claimant",
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.ours).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
});
|
||||
|
||||
test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], {
|
||||
side: "defendant",
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appealAware + side=null: mirror to both columns (every rule visible)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], {
|
||||
side: null,
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
});
|
||||
|
||||
test("appealAware off: appealRole is ignored and legacy bucketing applies", () => {
|
||||
// Regression guard: a stale frontend that drops `appealAware: true`
|
||||
// must not silently route via appealRole — the side selector
|
||||
// would visibly change behaviour without a UI control to opt in.
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" });
|
||||
// Legacy "side without appellant" collapse → both rows into ours.
|
||||
const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name));
|
||||
expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appealAware respects court party — court rows always route to court column", () => {
|
||||
const decision: CalculatedDeadline = {
|
||||
...notice,
|
||||
name: "Entscheidung",
|
||||
party: "court",
|
||||
appealRole: "", // court events deliberately stay empty
|
||||
dueDate: "",
|
||||
};
|
||||
const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true });
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appealAware + rule without appealRole falls back to legacy bucketing", () => {
|
||||
// A future appeal rule we forgot to map: appealRole='' falls
|
||||
// through the appealAware branch and lands in the legacy
|
||||
// side-collapse path → ours.
|
||||
const unmapped: CalculatedDeadline = { ...notice, appealRole: "" };
|
||||
const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true });
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the
|
||||
// parent rule name (or the proceeding's trigger event label for
|
||||
// root rules) so the chip reads "4 Monate nach Endentscheidung"
|
||||
// instead of the dangling "4 Monate nach".
|
||||
describe("formatDurationLabel — appends parent name (t-paliad-307)", () => {
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "x",
|
||||
name: "x",
|
||||
nameEN: "x",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "",
|
||||
originalDate: "",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 4,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("with parent label: appends to head", () => {
|
||||
expect(formatDurationLabel(dl(), "Endentscheidung (R.118)"))
|
||||
.toBe("4 Monate nach Endentscheidung (R.118)");
|
||||
});
|
||||
|
||||
test("without parent label: bare head — caller decides whether to render", () => {
|
||||
expect(formatDurationLabel(dl())).toBe("4 Monate nach");
|
||||
});
|
||||
|
||||
test("without timing: parent is not appended (degenerate phrasing)", () => {
|
||||
// No timing == we can't form "4 Monate <timing> <parent>" cleanly,
|
||||
// so the bare "4 Monate" head stays. Pinned to catch a future
|
||||
// edit that would emit "4 Monate Endentscheidung" without a
|
||||
// preposition.
|
||||
expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate");
|
||||
});
|
||||
|
||||
test("singular value: switches to .one unit key", () => {
|
||||
expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X");
|
||||
});
|
||||
|
||||
test("zero / missing duration: empty string", () => {
|
||||
expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe("");
|
||||
expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => {
|
||||
test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => {
|
||||
// upc.apl.merits.notice has no parent_id but a 2-month duration
|
||||
// off the trigger event (the appealed decision). The duration
|
||||
// tooltip must read the appeal-target label, not just "2 Monate
|
||||
// nach".
|
||||
const dl: CalculatedDeadline = {
|
||||
code: "upc.apl.merits.notice",
|
||||
name: "Berufungseinlegung",
|
||||
nameEN: "Notice of Appeal",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-26",
|
||||
originalDate: "2026-07-26",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 2,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
};
|
||||
const html = deadlineCardHtml(dl, {
|
||||
showParty: false,
|
||||
editable: true,
|
||||
triggerEventLabel: "Endentscheidung (R.118)",
|
||||
});
|
||||
expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\"");
|
||||
});
|
||||
|
||||
test("non-root rule prefers parent rule name over triggerEventLabel", () => {
|
||||
// merits.response chains off merits.grounds; the duration label
|
||||
// should read "3 Monate nach Berufungsbegründung", not the
|
||||
// appeal-target fallback.
|
||||
const dl: CalculatedDeadline = {
|
||||
code: "upc.apl.merits.response",
|
||||
name: "Berufungserwiderung",
|
||||
nameEN: "Response to Appeal",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-12-26",
|
||||
originalDate: "2026-12-26",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 3,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
parentRuleCode: "upc.apl.merits.grounds",
|
||||
parentRuleName: "Berufungsbegründung",
|
||||
parentRuleNameEN: "Statement of Grounds",
|
||||
};
|
||||
const html = deadlineCardHtml(dl, {
|
||||
showParty: false,
|
||||
editable: true,
|
||||
triggerEventLabel: "Endentscheidung (R.118)",
|
||||
});
|
||||
expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\"");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N <unit> …"
|
||||
// substring is stripped before deadline_notes renders so the new
|
||||
// duration affordance and the legacy free-text don't duplicate.
|
||||
describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => {
|
||||
test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("Antrag auf Simultanübersetzung.");
|
||||
});
|
||||
|
||||
test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist 15 Tage ab Zustellung der Kostenentscheidung",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("Spätestens 1 Jahr.");
|
||||
});
|
||||
|
||||
test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => {
|
||||
const composite =
|
||||
"Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme.";
|
||||
expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite);
|
||||
});
|
||||
|
||||
test("DE: 'Frist vom Gericht' (no number) is preserved", () => {
|
||||
const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de");
|
||||
expect(out).toBe("Frist vom Gericht bestimmt");
|
||||
});
|
||||
|
||||
test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("Request for simultaneous interpretation.");
|
||||
});
|
||||
|
||||
test("EN: strips '15-day period from …'", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"15-day period from service of the cost decision",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("EN: strips 'Period is N <unit> from …'", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("Latest 12 months.");
|
||||
});
|
||||
|
||||
test("EN: empty / non-matching notes pass through unchanged", () => {
|
||||
expect(stripLeadingDurationFromNotes("", "en")).toBe("");
|
||||
expect(stripLeadingDurationFromNotes("Time limit set by the court", "en"))
|
||||
.toBe("Time limit set by the court");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,19 +104,92 @@ export interface CalculatedDeadline {
|
||||
durationValue?: number;
|
||||
durationUnit?: string;
|
||||
timing?: string;
|
||||
// appealRole carries the rule's appeal-filer identity when the
|
||||
// server computed the timeline under an appeal_target filter:
|
||||
// "appellant" (Berufungskläger files this rule), "appellee"
|
||||
// (Berufungsbeklagter files this rule), or empty for court events
|
||||
// and non-appeal timelines. The column bucketer reads this in
|
||||
// preference to primary_party='both' so a user-perspective `?side=`
|
||||
// pick can split appeal filings into the user's column vs the
|
||||
// opponent's, instead of routing every "both" rule into the
|
||||
// user's column. (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealRole?: "appellant" | "appellee" | "";
|
||||
// isTriggerEvent marks the synthetic row the engine prepends to the
|
||||
// timeline when computing an appeal: a court-set decision dated to
|
||||
// the trigger date with the per-appeal-target label
|
||||
// (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
|
||||
// carries no real rule_id — it's a UI marker so the timeline reads
|
||||
// decision → appeal filings → next decision. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 2)
|
||||
isTriggerEvent?: boolean;
|
||||
}
|
||||
|
||||
// formatDurationLabel renders the per-rule duration ("2 Mo. nach") for
|
||||
// the Verfahrensablauf card affordance (m/paliad#133, t-paliad-302).
|
||||
// Returns empty string for rules without a usable duration so the
|
||||
// caller can skip the tooltip / inline span entirely.
|
||||
// stripLeadingDurationFromNotes drops the leading
|
||||
// "Frist N <unit> <preposition> <subject>." (DE) /
|
||||
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
|
||||
// deadline_notes so it doesn't duplicate the new duration affordance
|
||||
// added in m/paliad#133 (t-paliad-307 Bug 4).
|
||||
//
|
||||
// Pluralisation key naming mirrors the Fristenrechner event-mode
|
||||
// renderer (deadlines.event.unit.<unit>.{one,many}) — the unit and
|
||||
// timing translations already exist for /tools/fristenrechner's
|
||||
// "Was kommt nach…" mode and are reused here as the single
|
||||
// source of truth.
|
||||
export function formatDurationLabel(dl: CalculatedDeadline): string {
|
||||
// The duration affordance now renders the same prose as a badge on
|
||||
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
|
||||
// notes string that opens with the same prose reads as a verbatim
|
||||
// duplicate. Only the leading-prefix shape is stripped — anything
|
||||
// after the first sentence is preserved (the editorial commentary
|
||||
// the lawyers actually want to read).
|
||||
//
|
||||
// Conservative: composite-duration prefaces with "ODER" /
|
||||
// "whichever is the longer" don't match and stay untouched — those
|
||||
// are the follow-up editorial cleanup (option b in the issue brief).
|
||||
//
|
||||
// Examples:
|
||||
// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
|
||||
// → "Antrag …"
|
||||
// "Frist 15 Tage ab Zustellung der Kostenentscheidung"
|
||||
// → ""
|
||||
// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
|
||||
// → "Spätestens …"
|
||||
// "1-month period from service of the main decision"
|
||||
// → ""
|
||||
// "1 month BEFORE the oral hearing (R.109.1). Request for …"
|
||||
// → "Request for …"
|
||||
// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
|
||||
// → "Latest …"
|
||||
// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
|
||||
// → unchanged (composite — option b follow-up)
|
||||
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
|
||||
if (!notes) return notes;
|
||||
// Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
|
||||
// (period followed by whitespace) OR end of input. Embedded dots
|
||||
// inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
|
||||
// are skipped because the char right after them isn't whitespace.
|
||||
// `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
|
||||
// any character including newlines, non-greedy.
|
||||
const re = lang === "en"
|
||||
? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
|
||||
: /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
|
||||
return notes.replace(re, "");
|
||||
}
|
||||
|
||||
// formatDurationLabel renders the per-rule duration label for the
|
||||
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
|
||||
// "1 Monat vor Mündlicher Verhandlung", …
|
||||
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
|
||||
// m/paliad#136 Bug 3).
|
||||
//
|
||||
// Returns empty string for rules without a usable duration so the
|
||||
// caller can skip the tooltip / inline span entirely. Pluralisation
|
||||
// key naming mirrors the Fristenrechner event-mode renderer
|
||||
// (deadlines.event.unit.<unit>.{one,many}) — the unit and timing
|
||||
// translations already exist for /tools/fristenrechner's
|
||||
// "Was kommt nach…" mode and are reused here as the single source
|
||||
// of truth.
|
||||
//
|
||||
// `parentLabel` is the rule's anchor name (parent rule's name when
|
||||
// the rule has a parent_id; otherwise the proceeding's
|
||||
// triggerEventLabel from the wire). Empty falls back to bare
|
||||
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
|
||||
// remains the default for fixtures / tests that omit a parent.
|
||||
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string {
|
||||
const value = dl.durationValue ?? 0;
|
||||
const unit = dl.durationUnit || "";
|
||||
if (value <= 0 || !unit) return "";
|
||||
@@ -124,7 +197,9 @@ export function formatDurationLabel(dl: CalculatedDeadline): string {
|
||||
const unitStr = tDyn(unitKey);
|
||||
const timing = dl.timing || "";
|
||||
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
|
||||
return timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
|
||||
const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
|
||||
if (!timingStr || !parentLabel) return head;
|
||||
return `${head} ${parentLabel}`;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
@@ -363,16 +438,34 @@ export interface CardOpts {
|
||||
// flips this and re-renders; persisted via the localStorage key
|
||||
// `paliad.verfahrensablauf.durations-show`. Default false.
|
||||
showDurations?: boolean;
|
||||
// triggerEventLabel: per-language label of the proceeding's anchor
|
||||
// event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
|
||||
// "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
|
||||
// as the parent-name fallback when a rule is a root rule (no
|
||||
// parent_id) but carries a non-zero duration — e.g. the
|
||||
// Berufungseinlegung 2 months after Endentscheidung. Pages pass the
|
||||
// already-language-resolved string. (t-paliad-307 / m/paliad#136
|
||||
// Bug 3)
|
||||
triggerEventLabel?: string;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
const wantsEditable = !!opts.editable;
|
||||
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
// Parent name for the duration label (t-paliad-307 / m/paliad#136
|
||||
// Bug 3): use the rule's parent if set, else fall back to the
|
||||
// proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
|
||||
// for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
|
||||
// Empty for rules whose anchor isn't surface-able — the duration
|
||||
// label degrades to the bare "<n> <unit> <timing>" form in that case.
|
||||
const parentLabelForDuration = (getLang() === "en"
|
||||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||||
: (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
|
||||
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
|
||||
// both the date-span tooltip and the inline meta-row span pull from
|
||||
// the same string. Empty for rules without a usable duration.
|
||||
const durationLabel = formatDurationLabel(dl);
|
||||
const durationLabel = formatDurationLabel(dl, parentLabelForDuration);
|
||||
// Hover affordance on the date span: prefer the duration tooltip when
|
||||
// we have one, else fall back to the edit-hint when the cell is
|
||||
// click-to-edit. The edit affordance still works either way — the
|
||||
@@ -478,7 +571,14 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
|
||||
}
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
// Strip the leading-duration prefix so the new duration affordance
|
||||
// doesn't duplicate what the lawyer wrote verbatim into deadline_notes
|
||||
// for those legacy rule rows that still carry it.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 4)
|
||||
const noteText = rawNoteText
|
||||
? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
|
||||
: rawNoteText;
|
||||
const showNotes = opts.showNotes === true;
|
||||
const notesBlock = noteText && showNotes
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
@@ -608,7 +708,32 @@ export function wireDateEditClicks(
|
||||
});
|
||||
}
|
||||
|
||||
// pickTriggerEventLabel returns the per-language trigger event label
|
||||
// from a DeadlineResponse, used as the parent-fallback for root-rule
|
||||
// duration labels. Mirrors the precedence the page-level
|
||||
// triggerEventLabelFor uses (curated server label > proceedingName
|
||||
// fallback). Distinct from the page helper in that it stays language-
|
||||
// scoped to the current getLang() — root-rule duration labels render
|
||||
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
|
||||
export function pickTriggerEventLabel(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel || "")
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN || "");
|
||||
if (curated) return curated;
|
||||
return lang === "en"
|
||||
? (data.proceedingNameEN || data.proceedingName || "")
|
||||
: (data.proceedingName || data.proceedingNameEN || "");
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
// Resolve the trigger event label once so the duration affordance on
|
||||
// root rules (no parent) can read it as the anchor fallback. Caller-
|
||||
// provided value wins (lets the page override for sub-track flows).
|
||||
const cardOpts: CardOpts = {
|
||||
...opts,
|
||||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||||
};
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
const itemClasses = [
|
||||
@@ -630,7 +755,7 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, opts)}
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -689,6 +814,15 @@ export interface ColumnsBodyOpts {
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
// appealAware: forwarded to bucketDeadlinesIntoColumns when the
|
||||
// page is rendering an appeal_target-filtered timeline. Routes
|
||||
// each rule to its filer-perspective column via dl.appealRole
|
||||
// instead of the legacy primary_party='both' collapse.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealAware?: boolean;
|
||||
// triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 3)
|
||||
triggerEventLabel?: string;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
@@ -704,6 +838,15 @@ export interface ColumnsRow {
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
// appealAware: when true, rules carrying a `dl.appealRole` of
|
||||
// "appellant" / "appellee" route via the appeal role + user side
|
||||
// axis instead of the legacy primary_party='both' collapse. With
|
||||
// `side=null` the bucketer keeps the mirror semantic (both columns
|
||||
// render every appeal rule); with `side` set, "appellant" rules
|
||||
// land in the user's column when the user IS the appellant, in
|
||||
// the opponent's column otherwise — mirror for "appellee" rules.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealAware?: boolean;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
@@ -738,6 +881,8 @@ export function bucketDeadlinesIntoColumns(
|
||||
return r;
|
||||
};
|
||||
|
||||
const appealAware = opts.appealAware === true;
|
||||
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
@@ -760,6 +905,25 @@ export function bucketDeadlinesIntoColumns(
|
||||
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
|
||||
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
|
||||
row[perCardCol].push(dl);
|
||||
} else if (
|
||||
appealAware &&
|
||||
(dl.appealRole === "appellant" || dl.appealRole === "appellee")
|
||||
) {
|
||||
// Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
|
||||
// With no side picked, mirror to both columns so every rule
|
||||
// is visible regardless of which side the user is on. With
|
||||
// a side picked, route by (filer matches user) → ours
|
||||
// column, else opponent column. side=claimant maps the
|
||||
// user to "appellant" (Berufungskläger); side=defendant
|
||||
// maps the user to "appellee" (Berufungsbeklagter).
|
||||
if (userSide === null) {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
} else {
|
||||
const userIsAppellant = userSide === "claimant";
|
||||
const filerIsAppellant = dl.appealRole === "appellant";
|
||||
row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
|
||||
}
|
||||
} else if (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
@@ -798,7 +962,11 @@ export function bucketDeadlinesIntoColumns(
|
||||
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, {
|
||||
side: userSide,
|
||||
appellant: opts.appellant,
|
||||
appealAware: opts.appealAware,
|
||||
});
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = {
|
||||
@@ -806,6 +974,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
editable: opts.editable,
|
||||
showNotes: opts.showNotes,
|
||||
showDurations: opts.showDurations,
|
||||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||||
};
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
|
||||
309
frontend/src/client/views/verfahrensablauf-state.test.ts
Normal file
309
frontend/src/client/views/verfahrensablauf-state.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
|
||||
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
|
||||
//
|
||||
// The contract:
|
||||
// 1. URL params (proceeding, side, target, trigger_date) define which
|
||||
// timeline kind the user is looking at — paste-able, shareable,
|
||||
// refresh-resistant.
|
||||
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
|
||||
// per-user scenario tweaks (event_choices, court_id, flags,
|
||||
// show_hidden) — these never leak into a shared link.
|
||||
// 3. On hydrate, URL wins. localStorage fills the rest.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
SCENARIO_PREFIX,
|
||||
URL_KEYS,
|
||||
applyFiltersToSearch,
|
||||
hydrate,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
readScenario,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./verfahrensablauf-state";
|
||||
|
||||
describe("URL parsers — filter chips", () => {
|
||||
test("parseProceedingFromSearch returns empty string when absent", () => {
|
||||
expect(parseProceedingFromSearch("")).toBe("");
|
||||
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
|
||||
});
|
||||
|
||||
test("parseProceedingFromSearch echoes the raw value", () => {
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
|
||||
});
|
||||
|
||||
test("parseSideFromSearch validates the enum", () => {
|
||||
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
|
||||
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
|
||||
expect(parseSideFromSearch("?side=neither")).toBe(null);
|
||||
expect(parseSideFromSearch("")).toBe(null);
|
||||
});
|
||||
|
||||
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
|
||||
for (const t of APPEAL_TARGETS) {
|
||||
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
|
||||
}
|
||||
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
|
||||
expect(parseAppealTargetFromSearch("")).toBe("");
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
|
||||
expect(parseTriggerDateFromSearch("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL encoder — applyFiltersToSearch", () => {
|
||||
test("empty filters preserve the existing query string", () => {
|
||||
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
|
||||
});
|
||||
|
||||
test("setting a filter writes the canonical key", () => {
|
||||
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
|
||||
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
|
||||
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
|
||||
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("setting null / empty / undefined deletes the key", () => {
|
||||
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
|
||||
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
|
||||
});
|
||||
|
||||
test("invalid trigger_date is deleted (never written as-is)", () => {
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
|
||||
});
|
||||
|
||||
test("setting all four filters together emits all four keys", () => {
|
||||
const out = applyFiltersToSearch("", {
|
||||
proceeding: "upc.apl.unified",
|
||||
side: "defendant",
|
||||
target: "endentscheidung",
|
||||
triggerDate: "2026-05-26",
|
||||
});
|
||||
expect(out).toContain("proceeding=upc.apl.unified");
|
||||
expect(out).toContain("side=defendant");
|
||||
expect(out).toContain("target=endentscheidung");
|
||||
expect(out).toContain("trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("other params (project, view) are preserved", () => {
|
||||
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
|
||||
expect(out).toContain("project=abc");
|
||||
expect(out).toContain("view=timeline");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
|
||||
test("absent keys in the filter object don't touch existing URL values", () => {
|
||||
// Only updating side — proceeding should be untouched.
|
||||
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
|
||||
expect(out).toContain("proceeding=upc.inf.cfi");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL round-trip — encode then parse yields the same value", () => {
|
||||
test("proceeding", () => {
|
||||
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
|
||||
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
|
||||
});
|
||||
|
||||
test("side", () => {
|
||||
const enc = applyFiltersToSearch("", { side: "defendant" });
|
||||
expect(parseSideFromSearch(enc)).toBe("defendant");
|
||||
});
|
||||
|
||||
test("target", () => {
|
||||
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
|
||||
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
|
||||
});
|
||||
|
||||
test("trigger_date", () => {
|
||||
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
|
||||
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scenario localStorage helpers", () => {
|
||||
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
|
||||
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
|
||||
for (const key of Object.values(SCENARIO_KEYS)) {
|
||||
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("readEventChoices returns [] on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readEventChoices(s)).toEqual([]);
|
||||
});
|
||||
|
||||
test("writeEventChoices + readEventChoices round-trip", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const choices = [
|
||||
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
|
||||
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
|
||||
];
|
||||
writeEventChoices(s, choices);
|
||||
expect(readEventChoices(s)).toEqual(choices);
|
||||
});
|
||||
|
||||
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
|
||||
writeEventChoices(s, []);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
|
||||
});
|
||||
|
||||
test("readEventChoices ignores unknown choice_kind values", () => {
|
||||
const s = makeMemoryStorage();
|
||||
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
|
||||
expect(readEventChoices(s)).toEqual([
|
||||
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
|
||||
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readCourtId(s)).toBe("");
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(readCourtId(s)).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("writeCourtId('') removes the key", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
|
||||
writeCourtId(s, "");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
|
||||
});
|
||||
|
||||
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
|
||||
});
|
||||
|
||||
test("readScenario returns all fields defaulted on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readScenario(s)).toEqual({
|
||||
eventChoices: [],
|
||||
courtId: "",
|
||||
ccr: false,
|
||||
infAmend: false,
|
||||
revAmend: false,
|
||||
revCci: false,
|
||||
showHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hydration order — URL wins, localStorage fills the rest", () => {
|
||||
test("URL fills filter chips, localStorage fills scenario state", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
|
||||
s,
|
||||
);
|
||||
// URL-sourced
|
||||
expect(out.proceeding).toBe("upc.inf.cfi");
|
||||
expect(out.side).toBe("defendant");
|
||||
expect(out.target).toBe("endentscheidung");
|
||||
expect(out.triggerDate).toBe("2026-05-26");
|
||||
// localStorage-sourced
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
expect(out.showHidden).toBe(true);
|
||||
expect(out.ccr).toBe(true);
|
||||
});
|
||||
|
||||
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
const out = hydrate("", s);
|
||||
expect(out.proceeding).toBe("");
|
||||
expect(out.side).toBe(null);
|
||||
expect(out.target).toBe("");
|
||||
expect(out.triggerDate).toBe("");
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
|
||||
s,
|
||||
);
|
||||
expect(out.proceeding).toBe("upc.apl.unified");
|
||||
expect(out.side).toBe("claimant");
|
||||
expect(out.target).toBe("anordnung");
|
||||
expect(out.triggerDate).toBe("2026-07-01");
|
||||
expect(out.courtId).toBe("");
|
||||
expect(out.eventChoices).toEqual([]);
|
||||
expect(out.showHidden).toBe(false);
|
||||
});
|
||||
|
||||
test("a shared link doesn't leak the recipient's scenario state in", () => {
|
||||
// Two storages: m's (loaded with court + flags) and a recipient's
|
||||
// (empty). The same URL should reproduce filter chips identically
|
||||
// but leave each user's scenario state untouched.
|
||||
const mStorage = makeMemoryStorage();
|
||||
writeCourtId(mStorage, "UPC-LD-MUC");
|
||||
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
|
||||
const recipientStorage = makeMemoryStorage();
|
||||
|
||||
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
|
||||
|
||||
const mView = hydrate(sharedURL, mStorage);
|
||||
const recipientView = hydrate(sharedURL, recipientStorage);
|
||||
|
||||
// Filter chips identical
|
||||
expect(mView.proceeding).toBe(recipientView.proceeding);
|
||||
expect(mView.side).toBe(recipientView.side);
|
||||
expect(mView.triggerDate).toBe(recipientView.triggerDate);
|
||||
|
||||
// Scenario state diverges — recipient sees defaults
|
||||
expect(mView.courtId).toBe("UPC-LD-MUC");
|
||||
expect(recipientView.courtId).toBe("");
|
||||
expect(mView.ccr).toBe(true);
|
||||
expect(recipientView.ccr).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL key constants match the documented contract", () => {
|
||||
test("URL_KEYS uses the spec'd snake_case names", () => {
|
||||
expect(URL_KEYS.proceeding).toBe("proceeding");
|
||||
expect(URL_KEYS.side).toBe("side");
|
||||
expect(URL_KEYS.target).toBe("target");
|
||||
expect(URL_KEYS.triggerDate).toBe("trigger_date");
|
||||
});
|
||||
});
|
||||
263
frontend/src/client/views/verfahrensablauf-state.ts
Normal file
263
frontend/src/client/views/verfahrensablauf-state.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// /tools/verfahrensablauf URL + scenario-localStorage state contract
|
||||
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
|
||||
// two namespaces:
|
||||
//
|
||||
// URL params (filter chips — the timeline kind the user is looking
|
||||
// at; paste-able, shareable, refresh-resistant):
|
||||
// proceeding, side, target, trigger_date
|
||||
//
|
||||
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
|
||||
// scenario inputs — the noisy parts that don't belong in a URL):
|
||||
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
|
||||
// show_hidden
|
||||
//
|
||||
// Hydration order: URL wins. On page load, URL fills the filter chips;
|
||||
// localStorage fills the rest. Filter-chip changes write to URL only.
|
||||
// Scenario changes write to localStorage only. A shared link from a
|
||||
// colleague reproduces the timeline kind (proceeding + side + target +
|
||||
// trigger_date) but never leaks the recipient's court / flag /
|
||||
// event_choices state in.
|
||||
//
|
||||
// All helpers in this module are pure: they take a search string (or a
|
||||
// StorageLike) and return values, no DOM. The wiring in
|
||||
// ../verfahrensablauf.ts mounts them onto window.location +
|
||||
// window.localStorage at runtime.
|
||||
|
||||
import type { EventChoice, ChoiceKind } from "./event-card-choices";
|
||||
|
||||
// ----- URL params (filter chips) ----------------------------------
|
||||
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
export const URL_KEYS = {
|
||||
proceeding: "proceeding",
|
||||
side: "side",
|
||||
target: "target",
|
||||
triggerDate: "trigger_date",
|
||||
} as const;
|
||||
|
||||
// parseProceedingFromSearch extracts the proceeding code. Returns ""
|
||||
// if absent. No validation against the proceeding registry — that's
|
||||
// the caller's job (an unknown code from a stale link should leave
|
||||
// the first-tile auto-select fallback running).
|
||||
export function parseProceedingFromSearch(search: string): string {
|
||||
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
|
||||
return v ?? "";
|
||||
}
|
||||
|
||||
export function parseSideFromSearch(search: string): Side {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.side);
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
export function parseAppealTargetFromSearch(search: string): AppealTarget {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// parseTriggerDateFromSearch validates the ISO-date shape so a
|
||||
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
|
||||
// only. Round-tripped against Date to reject 2026-02-30 etc.
|
||||
export function parseTriggerDateFromSearch(search: string): string {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
|
||||
const d = new Date(raw + "T00:00:00Z");
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
if (d.toISOString().slice(0, 10) !== raw) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
// applyFiltersToSearch produces the canonical query string for the
|
||||
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
|
||||
// preserved verbatim. Empty values are deleted, never written as
|
||||
// empty string, so the URL stays clean on the default.
|
||||
export function applyFiltersToSearch(
|
||||
search: string,
|
||||
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if ("proceeding" in filters) {
|
||||
if (filters.proceeding && filters.proceeding !== "") {
|
||||
params.set(URL_KEYS.proceeding, filters.proceeding);
|
||||
} else {
|
||||
params.delete(URL_KEYS.proceeding);
|
||||
}
|
||||
}
|
||||
if ("side" in filters) {
|
||||
if (filters.side === "claimant" || filters.side === "defendant") {
|
||||
params.set(URL_KEYS.side, filters.side);
|
||||
} else {
|
||||
params.delete(URL_KEYS.side);
|
||||
}
|
||||
}
|
||||
if ("target" in filters) {
|
||||
if (filters.target && filters.target !== "") {
|
||||
params.set(URL_KEYS.target, filters.target);
|
||||
} else {
|
||||
params.delete(URL_KEYS.target);
|
||||
}
|
||||
}
|
||||
if ("triggerDate" in filters) {
|
||||
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
|
||||
params.set(URL_KEYS.triggerDate, filters.triggerDate);
|
||||
} else {
|
||||
params.delete(URL_KEYS.triggerDate);
|
||||
}
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ----- localStorage (scenario state) ------------------------------
|
||||
|
||||
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
|
||||
export const SCENARIO_KEYS = {
|
||||
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
|
||||
courtId: `${SCENARIO_PREFIX}.court_id`,
|
||||
ccr: `${SCENARIO_PREFIX}.ccr`,
|
||||
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
|
||||
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
|
||||
revCci: `${SCENARIO_PREFIX}.rev_cci`,
|
||||
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
|
||||
} as const;
|
||||
|
||||
// StorageLike is the tiny subset of the Web Storage API the scenario
|
||||
// helpers actually use. Lets the tests pass a Map-backed fake without
|
||||
// pulling in a full localStorage polyfill.
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
// readEventChoices is forgiving: malformed tuples or unknown
|
||||
// choice_kinds are dropped silently. Same shape as the legacy URL
|
||||
// codec (comma-separated `submission_code:kind=value`).
|
||||
export function readEventChoices(storage: StorageLike): EventChoice[] {
|
||||
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
|
||||
if (choices.length === 0) {
|
||||
storage.removeItem(SCENARIO_KEYS.eventChoices);
|
||||
return;
|
||||
}
|
||||
const enc = choices
|
||||
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
|
||||
.join(",");
|
||||
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
|
||||
}
|
||||
|
||||
// readCourtId / writeCourtId — empty string == no court picked. The
|
||||
// "" value is stored as a removed key, not an empty string entry, so
|
||||
// reading it back yields null rather than "".
|
||||
export function readCourtId(storage: StorageLike): string {
|
||||
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
|
||||
}
|
||||
|
||||
export function writeCourtId(storage: StorageLike, courtId: string): void {
|
||||
if (courtId === "") {
|
||||
storage.removeItem(SCENARIO_KEYS.courtId);
|
||||
return;
|
||||
}
|
||||
storage.setItem(SCENARIO_KEYS.courtId, courtId);
|
||||
}
|
||||
|
||||
// Boolean flags — "1" / "0" string encoding, removeItem on default
|
||||
// (false for flags, also false for show_hidden) so the storage stays
|
||||
// uncluttered on a fresh page.
|
||||
export function readBoolFlag(storage: StorageLike, key: string): boolean {
|
||||
return storage.getItem(key) === "1";
|
||||
}
|
||||
|
||||
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
|
||||
if (on) storage.setItem(key, "1");
|
||||
else storage.removeItem(key);
|
||||
}
|
||||
|
||||
// Read all scenario state in one call — convenience for the page's
|
||||
// load-time hydration. Caller decides whether to apply each field
|
||||
// (e.g. court_id is proceeding-specific; the page may discard the
|
||||
// stored value if the active proceeding doesn't expose a court row).
|
||||
export interface ScenarioState {
|
||||
eventChoices: EventChoice[];
|
||||
courtId: string;
|
||||
ccr: boolean;
|
||||
infAmend: boolean;
|
||||
revAmend: boolean;
|
||||
revCci: boolean;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function readScenario(storage: StorageLike): ScenarioState {
|
||||
return {
|
||||
eventChoices: readEventChoices(storage),
|
||||
courtId: readCourtId(storage),
|
||||
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
|
||||
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
|
||||
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
|
||||
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
|
||||
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
|
||||
};
|
||||
}
|
||||
|
||||
// ----- URL → localStorage hydration order -------------------------
|
||||
|
||||
// The page's load-time contract: read URL filters, then read
|
||||
// scenario state from localStorage. URL wins on conflict — but the
|
||||
// only field that can conflict is none of them today (URL owns
|
||||
// proceeding/side/target/trigger_date; localStorage owns the rest).
|
||||
// The order matters for one edge case: if a future field migrates
|
||||
// from URL → localStorage with overlap, the URL value MUST be honored.
|
||||
|
||||
export interface HydratedState extends ScenarioState {
|
||||
proceeding: string;
|
||||
side: Side;
|
||||
target: AppealTarget;
|
||||
triggerDate: string;
|
||||
}
|
||||
|
||||
export function hydrate(search: string, storage: StorageLike): HydratedState {
|
||||
const scenario = readScenario(storage);
|
||||
return {
|
||||
proceeding: parseProceedingFromSearch(search),
|
||||
side: parseSideFromSearch(search),
|
||||
target: parseAppealTargetFromSearch(search),
|
||||
triggerDate: parseTriggerDateFromSearch(search),
|
||||
...scenario,
|
||||
};
|
||||
}
|
||||
|
||||
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
|
||||
// Not used by the runtime page (which mounts real localStorage), but
|
||||
// kept here so test files have one well-known import.
|
||||
export function makeMemoryStorage(): StorageLike {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k, v) => { store.set(k, v); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function Footer(): string {
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<p>
|
||||
<span data-i18n="footer.text">{"© 2026 Paliad — ein Werkzeug von"}</span>{" "}
|
||||
<span data-i18n="footer.text">{"© 2026 Paliad — by"}</span>{" "}
|
||||
<a href="https://flexsiebels.de" target="_blank" rel="noopener">flexsiebels.de</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2615,6 +2615,8 @@ export type I18nKey =
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.import.button"
|
||||
| "submissions.draft.language"
|
||||
| "submissions.draft.language.de"
|
||||
@@ -2627,6 +2629,8 @@ export type I18nKey =
|
||||
| "submissions.draft.parties.title"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.sections.hint"
|
||||
| "submissions.draft.sections.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
|
||||
@@ -6124,6 +6124,176 @@ dialog.modal::backdrop {
|
||||
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
|
||||
as the rest of the sidebar mini-controls; muted label + inline radios
|
||||
so it doesn't compete with the editor's primary inputs. */
|
||||
/* t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list. */
|
||||
.submission-draft-base-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-base-row label {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.submission-draft-base-row select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.submission-draft-base-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-sections-wrap {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
}
|
||||
|
||||
.submission-draft-sections-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.submission-draft-sections-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.submission-draft-sections-hint {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-sections-list {
|
||||
list-style: decimal inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.submission-draft-section {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--color-bg-elev-2, var(--color-bg));
|
||||
}
|
||||
|
||||
.submission-draft-section--excluded {
|
||||
opacity: 0.55;
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
}
|
||||
|
||||
.submission-draft-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submission-draft-section-title {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.submission-draft-section-kind {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.submission-draft-section-excluded-badge {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submission-draft-section-body {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 0.88em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* t-paliad-313 Slice B — inline editor per section. */
|
||||
.submission-draft-section-toggle {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin: 0.4rem 0 0.3rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar-btn {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar-btn:hover {
|
||||
background: var(--color-bg-subtle, var(--color-bg-elev-2));
|
||||
}
|
||||
|
||||
.submission-draft-section-editor {
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-family: inherit;
|
||||
font-size: 0.92em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.submission-draft-section-editor:focus {
|
||||
border-color: var(--color-accent-fg, var(--color-text));
|
||||
box-shadow: 0 0 0 2px var(--color-bg-lime-tint, transparent);
|
||||
}
|
||||
|
||||
.submission-draft-section-editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.submission-draft-section--editing {
|
||||
background: var(--color-bg-elev-2, var(--color-bg-elev-1));
|
||||
}
|
||||
|
||||
.submission-draft-language-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6230,7 +6400,7 @@ dialog.modal::backdrop {
|
||||
align-items: baseline;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
background: var(--color-surface-2);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -6388,7 +6558,7 @@ dialog.modal::backdrop {
|
||||
}
|
||||
|
||||
.submissions-new-chip:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.submissions-new-chip--active {
|
||||
@@ -6426,7 +6596,7 @@ dialog.modal::backdrop {
|
||||
}
|
||||
|
||||
.submissions-new-project-item:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.submissions-new-project-title {
|
||||
@@ -6441,7 +6611,7 @@ dialog.modal::backdrop {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-accent, #c6f41c);
|
||||
border-radius: 6px;
|
||||
@@ -6464,7 +6634,7 @@ dialog.modal::backdrop {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -6592,7 +6762,7 @@ dialog.modal::backdrop {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
@@ -6715,7 +6885,7 @@ dialog.modal::backdrop {
|
||||
margin-left: 0.3rem;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -7922,7 +8092,7 @@ dialog.modal::backdrop {
|
||||
.collab-invite-hint {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface-alt, var(--color-bg-lime-tint));
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
@@ -16582,7 +16752,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -16636,7 +16806,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -16658,7 +16828,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
|
||||
.smart-timeline-kind-chip--projected {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -16725,7 +16895,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
|
||||
.smart-timeline-add-choice:hover:not(:disabled) {
|
||||
border-color: var(--color-accent-fg);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.smart-timeline-add-choice--primary {
|
||||
|
||||
@@ -109,6 +109,27 @@ export function renderSubmissionDraft(): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
base picker. Hydrated by client/submission-draft.ts
|
||||
once /api/submission-bases returns. Disabled
|
||||
for pre-Composer drafts (base_id NULL); switching
|
||||
autosaves the draft. */}
|
||||
<div
|
||||
className="submission-draft-base-row"
|
||||
id="submission-draft-base-row"
|
||||
style="display:none">
|
||||
<label htmlFor="submission-draft-base" data-i18n="submissions.draft.base.label">
|
||||
Vorlagenbasis
|
||||
</label>
|
||||
<select id="submission-draft-base" />
|
||||
<p
|
||||
className="submission-draft-base-hint"
|
||||
id="submission-draft-base-hint"
|
||||
data-i18n="submissions.draft.base.hint">
|
||||
Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-276 — output language toggle (DE/EN).
|
||||
Hydrated by client/submission-draft.ts; switching
|
||||
autosaves the draft and re-renders the preview. */}
|
||||
@@ -202,6 +223,29 @@ export function renderSubmissionDraft(): string {
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
read-only section list. Painted from
|
||||
view.sections. Empty/hidden for pre-Composer
|
||||
drafts where no rows have been seeded. Slice B
|
||||
turns these into in-place editable prose blocks. */}
|
||||
<section
|
||||
className="submission-draft-sections-wrap"
|
||||
id="submission-draft-sections-wrap"
|
||||
style="display:none">
|
||||
<header className="submission-draft-sections-header">
|
||||
<h2 data-i18n="submissions.draft.sections.title">Abschnitte</h2>
|
||||
<span
|
||||
className="submission-draft-sections-hint"
|
||||
data-i18n="submissions.draft.sections.hint">
|
||||
Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.
|
||||
</span>
|
||||
</header>
|
||||
<ol
|
||||
className="submission-draft-sections-list"
|
||||
id="submission-draft-sections-list"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
|
||||
3
internal/db/migrations/146_submission_bases.down.sql
Normal file
3
internal/db/migrations/146_submission_bases.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-313: revert submission_bases catalog.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_bases;
|
||||
173
internal/db/migrations/146_submission_bases.up.sql
Normal file
173
internal/db/migrations/146_submission_bases.up.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — submission base catalog.
|
||||
--
|
||||
-- paliad.submission_bases is a thin pointer table — each row maps a
|
||||
-- short, stable slug ("hlc-letterhead", "neutral", …) onto a Gitea path
|
||||
-- that holds the actual .docx body, plus a JSON section-spec describing
|
||||
-- the base's default section set, stylemap, and per-section seed
|
||||
-- Markdown. The .docx in Gitea stays the source of truth for the
|
||||
-- chrome, fonts, paragraph styles, and (in later slices) the
|
||||
-- {{#section:KEY}} anchors. The DB row carries the listable metadata
|
||||
-- the picker needs.
|
||||
--
|
||||
-- Visibility: every authenticated user SELECTs (the catalog is shared
|
||||
-- firm-wide). Mutations are admin-only and enforced in Go at the
|
||||
-- handler layer — RLS only gates reads.
|
||||
--
|
||||
-- Slice A seeds two rows:
|
||||
-- 1. hlc-letterhead — points at the existing HLC firm skeleton
|
||||
-- (_firm-skeleton.docx with HL Patents Style typography).
|
||||
-- 2. neutral — points at the universal _skeleton.docx.
|
||||
-- Specialist bases (lg-duesseldorf, upc-formal) land in Slice E with
|
||||
-- their own .docx authoring task.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_bases (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
firm text,
|
||||
proceeding_family text,
|
||||
label_de text NOT NULL,
|
||||
label_en text NOT NULL,
|
||||
description_de text,
|
||||
description_en text,
|
||||
gitea_path text NOT NULL,
|
||||
section_spec jsonb NOT NULL,
|
||||
is_default_for text[] NOT NULL DEFAULT '{}'::text[],
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_bases_firm_family_idx
|
||||
ON paliad.submission_bases (firm, proceeding_family) WHERE is_active;
|
||||
|
||||
ALTER TABLE paliad.submission_bases ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_bases_select ON paliad.submission_bases;
|
||||
CREATE POLICY submission_bases_select
|
||||
ON paliad.submission_bases FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- INSERT / UPDATE / DELETE intentionally absent — admin-only mutations
|
||||
-- happen via the handler layer with explicit role checks. No RLS path
|
||||
-- for mutations means RLS denies them by default.
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_bases_set_updated_at ON paliad.submission_bases;
|
||||
CREATE TRIGGER submission_bases_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_bases
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_bases IS
|
||||
't-paliad-313: Composer base catalog. One row per base template (HLC letterhead, neutral, …) pointing at a .docx in Gitea + a JSON section spec.';
|
||||
|
||||
-- Seed: HLC letterhead + neutral skeleton. The section_spec carries the
|
||||
-- 10 default sections (letterhead, caption, introduction, requests,
|
||||
-- facts, legal_argument, evidence, exhibits, closing, signature) with
|
||||
-- their kinds, default order, and bilingual labels. seed_md_de /
|
||||
-- seed_md_en are populated for the bag-driven sections (letterhead,
|
||||
-- caption, signature); the remaining sections seed empty.
|
||||
--
|
||||
-- exhibits.included=false by default (lawyer opts in when an attachment
|
||||
-- list applies). Every other section ships included=true.
|
||||
|
||||
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
|
||||
('hlc-letterhead', 'HLC', NULL,
|
||||
'HLC-Briefkopf', 'HLC letterhead',
|
||||
'Mit HL Patents Style — Firmen-Header, Schriftarten, Absatzformaten.',
|
||||
'With HL Patents Style — firm header, fonts, paragraph styles.',
|
||||
'6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'HLpat-Body-B0',
|
||||
'heading_1', 'HLpat-Heading-H1',
|
||||
'heading_2', 'HLpat-Heading-H2',
|
||||
'heading_3', 'HLpat-Heading-H3',
|
||||
'list_bullet', 'HLpat-Body-B0',
|
||||
'list_numbered', 'HLpat-Body-B0',
|
||||
'blockquote', 'HLpat-Body-B1'
|
||||
),
|
||||
'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}}, {{user.office}}',
|
||||
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}, {{user.office}}'),
|
||||
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}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\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}}\n{{user.office}}',
|
||||
'seed_md_en', E'{{user.display_name}}\n{{user.office}}')
|
||||
)
|
||||
),
|
||||
'{}'::text[]
|
||||
),
|
||||
('neutral', NULL, NULL,
|
||||
'Neutraler Schriftsatz', 'Neutral skeleton',
|
||||
'Universelle Vorlage ohne firmenspezifisches Branding.',
|
||||
'Universal template with no firm-specific branding.',
|
||||
'6 - material/Templates/Word/Paliad/HLC/_skeleton.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'Normal',
|
||||
'heading_1', 'Heading 1',
|
||||
'heading_2', 'Heading 2',
|
||||
'heading_3', 'Heading 3',
|
||||
'list_bullet', 'Normal',
|
||||
'list_numbered', 'Normal',
|
||||
'blockquote', '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}}',
|
||||
'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}}'),
|
||||
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,5 @@
|
||||
-- t-paliad-313: revert Composer columns on submission_drafts.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS composer_meta,
|
||||
DROP COLUMN IF EXISTS base_id;
|
||||
31
internal/db/migrations/147_submission_drafts_composer.up.sql
Normal file
31
internal/db/migrations/147_submission_drafts_composer.up.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — point submission_drafts at a base.
|
||||
--
|
||||
-- Two purely-additive columns on paliad.submission_drafts:
|
||||
--
|
||||
-- base_id uuid — FK to paliad.submission_bases. NULL on existing
|
||||
-- drafts (Slice A explicitly does NOT auto-upgrade pre-Composer
|
||||
-- rows — that's Slice C). NEW drafts created post-Composer get
|
||||
-- base_id seeded by SubmissionDraftService.Create from the firm
|
||||
-- default for the proceeding family. ON DELETE SET NULL keeps a
|
||||
-- draft renderable via the v1 fallback chain even if its base is
|
||||
-- removed; the lawyer picks a new base via the sidebar.
|
||||
--
|
||||
-- composer_meta jsonb — Composer-specific metadata. For Slice A this
|
||||
-- carries the seed-time section order so the editor paints without
|
||||
-- a join. Future slices may add hidden_sections, active_locale,
|
||||
-- etc.
|
||||
--
|
||||
-- No data backfill, no auto-upgrade — pre-Composer drafts keep base_id
|
||||
-- NULL and render via the existing v1 path. The Go side has the
|
||||
-- corresponding gate (base_id IS NULL OR no submission_sections rows →
|
||||
-- v1 path).
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS base_id uuid REFERENCES paliad.submission_bases(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS composer_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.base_id IS
|
||||
't-paliad-313: Composer base reference. NULL = pre-Composer draft, renders via v1 fallback chain. ON DELETE SET NULL.';
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.composer_meta IS
|
||||
't-paliad-313: Composer-side metadata (section_order, hidden_sections, …). jsonb, default {}.';
|
||||
3
internal/db/migrations/148_submission_sections.down.sql
Normal file
3
internal/db/migrations/148_submission_sections.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-313: revert submission_sections table.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_sections;
|
||||
116
internal/db/migrations/148_submission_sections.up.sql
Normal file
116
internal/db/migrations/148_submission_sections.up.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — per-draft section rows.
|
||||
--
|
||||
-- paliad.submission_sections holds one row per (draft, section_key) for
|
||||
-- Composer-mode drafts. Slice A seeds rows on draft create from the
|
||||
-- base's section_spec.defaults; the editor renders them read-only. Slice
|
||||
-- B turns them editable, Slice F adds reorder/hide/add-custom.
|
||||
--
|
||||
-- kind values per the design (Q10 ratification — no *_auto kind):
|
||||
-- 'prose' — free Markdown content (default).
|
||||
-- 'requests' — Anträge-style content (editor may add auto-numbering
|
||||
-- later; Slice A treats identical to 'prose').
|
||||
-- 'evidence' — Beweisangebote (editor may prefix lines with
|
||||
-- 'Beweis: '; Slice A treats identical to 'prose').
|
||||
--
|
||||
-- Visibility flows through draft_id → submission_drafts → can_see_project
|
||||
-- + owner-scoped. RLS policies mirror the four-policy shape on
|
||||
-- submission_drafts so seeding from the Go service stays inside the
|
||||
-- same RLS envelope.
|
||||
--
|
||||
-- content_md_de + content_md_en both NOT NULL DEFAULT '' so neither
|
||||
-- side blocks the bilingual-by-construction render path. Empty content
|
||||
-- renders as the missing-content marker per the editor's contract.
|
||||
--
|
||||
-- Per the brief (head's instruction msg #2392) Slice A does NOT auto-
|
||||
-- upgrade the 11 pre-Composer drafts — those remain base_id=NULL with
|
||||
-- no section rows. The v1 fallback render path stays compiled in to
|
||||
-- keep them working.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_sections (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
draft_id uuid NOT NULL REFERENCES paliad.submission_drafts(id) ON DELETE CASCADE,
|
||||
section_key text NOT NULL,
|
||||
order_index int NOT NULL,
|
||||
kind text NOT NULL,
|
||||
label_de text NOT NULL,
|
||||
label_en text NOT NULL,
|
||||
included bool NOT NULL DEFAULT true,
|
||||
content_md_de text NOT NULL DEFAULT '',
|
||||
content_md_en text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_sections_kind_check
|
||||
CHECK (kind IN ('prose', 'requests', 'evidence')),
|
||||
CONSTRAINT submission_sections_unique_per_draft
|
||||
UNIQUE (draft_id, section_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_sections_draft_idx
|
||||
ON paliad.submission_sections (draft_id, order_index);
|
||||
|
||||
ALTER TABLE paliad.submission_sections ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_select ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_select
|
||||
ON paliad.submission_sections FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_insert ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_insert
|
||||
ON paliad.submission_sections FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_update ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_update
|
||||
ON paliad.submission_sections FOR UPDATE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_delete ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_delete
|
||||
ON paliad.submission_sections FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_sections_set_updated_at ON paliad.submission_sections;
|
||||
CREATE TRIGGER submission_sections_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_sections
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_sections IS
|
||||
't-paliad-313: per-draft Composer section rows. Slice A: seeded on draft create from base.section_spec.defaults, rendered read-only. Slice B: editable. RLS mirrors submission_drafts (owner-scoped + can_see_project).';
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -402,6 +404,35 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
|
||||
}
|
||||
|
||||
// composerBaseSlugMap routes a Composer base.slug to the existing
|
||||
// fileRegistry slug whose Gitea object backs it (t-paliad-313 Slice B).
|
||||
// Slice A seeded two bases that already share .docx files with the v1
|
||||
// fallback chain — no new Gitea uploads needed for those. Future bases
|
||||
// (e.g. lg-duesseldorf, upc-formal in Slice E) register their own
|
||||
// fileRegistry entries via the same shape and add a row here.
|
||||
var composerBaseSlugMap = map[string]string{
|
||||
"hlc-letterhead": firmSkeletonSubmissionSlug,
|
||||
"neutral": skeletonSubmissionSlug,
|
||||
}
|
||||
|
||||
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
|
||||
// pulled from the shared Gitea proxy cache. ErrComposerBaseNotProxied
|
||||
// when the slug has no registered fileRegistry entry — a base authored
|
||||
// without a file-registry mapping (rare; admin oversight) renders as
|
||||
// "Vorlagenbasis nicht erreichbar" upstream of this call.
|
||||
var ErrComposerBaseNotProxied = errors.New("composer base: Gitea slug not registered")
|
||||
|
||||
func fetchComposerBaseBytes(ctx context.Context, base *services.SubmissionBase) ([]byte, string, error) {
|
||||
if base == nil {
|
||||
return nil, "", fmt.Errorf("composer base: nil base")
|
||||
}
|
||||
slug, ok := composerBaseSlugMap[base.Slug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("%w: base slug %q", ErrComposerBaseNotProxied, base.Slug)
|
||||
}
|
||||
return fetchSubmissionTemplateSlug(ctx, slug)
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
|
||||
// the firm-skeleton and universal-skeleton accessors. Factored out so
|
||||
// the two paths can't drift apart on caching semantics.
|
||||
|
||||
@@ -116,6 +116,14 @@ type Services struct {
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A + B — base catalog,
|
||||
// per-draft section rows, render-pipeline assembler. All three
|
||||
// nil in DATABASE_URL-less deploys (the Composer surfaces return
|
||||
// 503 / hide the picker).
|
||||
SubmissionBase *services.BaseService
|
||||
SubmissionSection *services.SectionService
|
||||
SubmissionComposer *services.SubmissionComposer
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
@@ -187,9 +195,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
submissionBase: svc.SubmissionBase,
|
||||
submissionSection: svc.SubmissionSection,
|
||||
submissionComposer: svc.SubmissionComposer,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +419,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base catalog for
|
||||
// the sidebar picker. Wide-open SELECT (any authenticated user);
|
||||
// admin mutations are not exposed yet (Slice C).
|
||||
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
|
||||
// for inline editor autosave. URL keyed on draft_id + section_id;
|
||||
// owner-scoped via SubmissionDraftService.Get.
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}/sections/{section_id}", handlePatchSubmissionSection)
|
||||
// t-paliad-277 / m/paliad#109 — refresh project-derived variables on
|
||||
// the draft. Strips overrides for project.* / parties.* / deadline.*
|
||||
// / procedural_event.* / rule.* prefixes and bumps last_imported_at.
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -360,6 +361,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
log.Printf("paliadin: stream error turn=%s code=%s retryable=%v message=%q",
|
||||
turnID, ev.Code, ev.Retryable, ev.Message)
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
@@ -372,6 +375,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
case <-silenceTicker.C:
|
||||
elapsed := time.Since(lastEventAt)
|
||||
if elapsed >= silenceTimeout {
|
||||
log.Printf("paliadin: silence timeout turn=%s elapsed=%s (silenceTimeout=%s)",
|
||||
turnID, elapsed, silenceTimeout)
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
@@ -419,6 +424,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
}
|
||||
}
|
||||
if res.err != nil {
|
||||
log.Printf("paliadin: backend returned error turn=%s err=%v errorEmittedAlready=%v",
|
||||
turnID, res.err, errorEmitted)
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
@@ -432,6 +439,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
}
|
||||
result := res.result
|
||||
if result == nil {
|
||||
log.Printf("paliadin: backend returned nil result without error turn=%s errorEmittedAlready=%v",
|
||||
turnID, errorEmitted)
|
||||
// Shouldn't happen — backend contract returns either err
|
||||
// or a result. Defensive bail.
|
||||
if !errorEmitted {
|
||||
|
||||
@@ -69,6 +69,13 @@ type dbServices struct {
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-313 — Composer base catalog + per-draft sections +
|
||||
// (Slice B) the render pipeline assembling base + sections into a
|
||||
// final .docx.
|
||||
submissionBase *services.BaseService
|
||||
submissionSection *services.SectionService
|
||||
submissionComposer *services.SubmissionComposer
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
|
||||
|
||||
96
internal/handlers/submission_bases.go
Normal file
96
internal/handlers/submission_bases.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
// Submission base catalog handler — Composer Slice A (t-paliad-313,
|
||||
// m/paliad#141, design doc docs/design-submission-generator-v2-2026-05-26.md
|
||||
// §5.1 / Slice A acceptance).
|
||||
//
|
||||
// Endpoint: GET /api/submission-bases → list of active bases visible
|
||||
// to the requesting firm. The sidebar picker on the draft editor reads
|
||||
// this once on page load and caches in-memory; the response shape is
|
||||
// stable across the picker's lifetime.
|
||||
//
|
||||
// Visibility: the catalog is shared firm-wide (per the design + mig
|
||||
// 146's wide-open RLS SELECT policy). The handler still requires
|
||||
// authentication; anonymous users 401.
|
||||
//
|
||||
// Filtering: the response includes the firm's own bases AND the
|
||||
// firm-agnostic ones (firm IS NULL). The Go service-side filter passes
|
||||
// branding.Name as the firm hint; cross-firm cases (e.g. a future
|
||||
// non-HLC deployment) get their own filtered slice naturally.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionBaseRow is the on-the-wire shape returned by the list
|
||||
// endpoint. Mirrors services.SubmissionBase but drops the raw bytes
|
||||
// and exposes the parsed section spec inline so the picker can show a
|
||||
// preview of the default section count without an extra round-trip.
|
||||
type submissionBaseRow struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
GiteaPath string `json:"gitea_path"`
|
||||
IsDefaultFor []string `json:"is_default_for"`
|
||||
IsActive bool `json:"is_active"`
|
||||
SectionCount int `json:"section_count"`
|
||||
}
|
||||
|
||||
type submissionBaseListResponse struct {
|
||||
Bases []submissionBaseRow `json:"bases"`
|
||||
}
|
||||
|
||||
// handleListSubmissionBases backs GET /api/submission-bases.
|
||||
func handleListSubmissionBases(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBase == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submission bases not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := dbSvc.submissionBase.List(r.Context(), branding.Name)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]submissionBaseRow, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, baseRowFromService(&rows[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, submissionBaseListResponse{Bases: out})
|
||||
}
|
||||
|
||||
// baseRowFromService projects a services.SubmissionBase into the
|
||||
// on-the-wire row shape.
|
||||
func baseRowFromService(b *services.SubmissionBase) submissionBaseRow {
|
||||
return submissionBaseRow{
|
||||
ID: b.ID.String(),
|
||||
Slug: b.Slug,
|
||||
Firm: b.Firm,
|
||||
ProceedingFamily: b.ProceedingFamily,
|
||||
LabelDE: b.LabelDE,
|
||||
LabelEN: b.LabelEN,
|
||||
DescriptionDE: b.DescriptionDE,
|
||||
DescriptionEN: b.DescriptionEN,
|
||||
GiteaPath: b.GiteaPath,
|
||||
IsDefaultFor: b.IsDefaultFor,
|
||||
IsActive: b.IsActive,
|
||||
SectionCount: len(b.SectionSpec.Defaults),
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,11 @@ type submissionDraftView struct {
|
||||
// so the frontend can render the multi-select picker in one round-
|
||||
// trip. Empty when the draft has no project attached.
|
||||
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
|
||||
// Sections is the per-draft section stack (t-paliad-313 Slice A).
|
||||
// Slice A renders these read-only; the lawyer sees what the
|
||||
// Composer seeded but can't yet edit prose. nil for pre-Composer
|
||||
// drafts (base_id NULL, no submission_sections rows).
|
||||
Sections []submissionSectionJSON `json:"sections"`
|
||||
}
|
||||
|
||||
// submissionDraftPartyJSON is the minimal party row the editor sidebar
|
||||
@@ -106,8 +111,30 @@ type submissionDraftJSON struct {
|
||||
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
|
||||
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// BaseID — Composer base reference (t-paliad-313). NULL on
|
||||
// pre-Composer drafts; the editor sidebar surfaces this in the
|
||||
// base picker. PATCH accepts {"base_id": "<uuid>"} or
|
||||
// {"base_id": null} to set or clear.
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// submissionSectionJSON is the on-the-wire row for each per-draft
|
||||
// section. Slice A renders these read-only — the lawyer sees the
|
||||
// section stack but doesn't yet edit prose. Slice B makes content_md_*
|
||||
// editable + adds the PATCH endpoint.
|
||||
type submissionSectionJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
}
|
||||
|
||||
type submissionRuleSummary struct {
|
||||
@@ -132,6 +159,41 @@ type submissionDraftPatchInput struct {
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
// BaseID accepts three states per the JSON contract:
|
||||
// field absent → no change (json:"-")
|
||||
// {"base_id": "<uuid>"} → set to picked base
|
||||
// {"base_id": null} → clear (return to v1 fallback)
|
||||
// We model this with a **uuid.UUID inside a custom UnmarshalJSON
|
||||
// in case extends; for now the simpler `*uuid.UUID` + presence
|
||||
// flag covers Slice A's set-base flow. Clearing is exposed but
|
||||
// rarely used (the editor always picks a base; clearing is for
|
||||
// admin-recovery flows).
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
BaseIDSet bool `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
|
||||
// the "base_id" key appears in the payload (regardless of whether
|
||||
// the value is null or a uuid string). Lets the handler distinguish
|
||||
// "field absent" (no change) from "field set to null" (clear).
|
||||
func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
// Phase 1: decode into a raw map to detect key presence.
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// Phase 2: decode the typed fields. Use an alias to skip this
|
||||
// custom UnmarshalJSON during the re-parse.
|
||||
type alias submissionDraftPatchInput
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = submissionDraftPatchInput(a)
|
||||
if _, ok := raw["base_id"]; ok {
|
||||
p.BaseIDSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -372,6 +434,9 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
}
|
||||
if input.BaseIDSet {
|
||||
patch.BaseID = &input.BaseID
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -501,16 +566,10 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
||||
writeSubmissionExportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -523,7 +582,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
|
||||
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
|
||||
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
|
||||
@@ -538,6 +597,82 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// exportSubmissionDraft is the shared render entry point used by both
|
||||
// the project-scoped and global export handlers (t-paliad-313 Slice B).
|
||||
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
|
||||
// Composer pipeline assembles the document; otherwise the v1
|
||||
// template-only path stays the fallback. composerUsed = true means the
|
||||
// metadata jsonb on the audit row carries "composer": true so admins
|
||||
// can tell the two paths apart in the feed.
|
||||
//
|
||||
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
|
||||
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
||||
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
|
||||
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
|
||||
switch {
|
||||
case err == nil:
|
||||
baseBytes, baseSHA, err := fetchComposerBaseBytes(ctx, base)
|
||||
if err == nil {
|
||||
sections, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("list sections: %w", err)
|
||||
}
|
||||
bag, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, err
|
||||
}
|
||||
docx, err := dbSvc.submissionComposer.Compose(ctx, services.ComposeOptions{
|
||||
Sections: sections,
|
||||
Base: base,
|
||||
BaseBytes: baseBytes,
|
||||
Lang: resolved.Lang,
|
||||
Vars: bag,
|
||||
Missing: services.DefaultMissingMarker(resolved.Lang),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("composer: %w", err)
|
||||
}
|
||||
return docx, resolved, baseSHA, true, nil
|
||||
}
|
||||
log.Printf("submission_drafts: composer base bytes fetch failed (draft=%s base=%s): %v — falling back to v1 path", d.ID, base.Slug, err)
|
||||
case errors.Is(err, services.ErrBaseNotFound):
|
||||
log.Printf("submission_drafts: composer base missing (draft=%s base_id=%s) — falling back to v1 path", d.ID, *d.BaseID)
|
||||
default:
|
||||
return nil, nil, "", false, fmt.Errorf("composer base lookup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// v1 fallback: template-only render via resolveSubmissionTemplate +
|
||||
// SubmissionDraftService.Export. Unchanged behaviour for
|
||||
// pre-Composer drafts.
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("template upstream: %w", err)
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("render: %w", err)
|
||||
}
|
||||
return docx, resolved, tplSHA, false, nil
|
||||
}
|
||||
|
||||
// writeSubmissionExportError maps a render-time error to an HTTP
|
||||
// response. The shape mirrors what the handlers used to inline.
|
||||
func writeSubmissionExportError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "template upstream"):
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
case strings.Contains(msg, "composer:") || strings.Contains(msg, "render:") || strings.Contains(msg, "list sections"):
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleSubmissionDraftPage serves dist/submission-draft.html for the
|
||||
// dedicated draft editor at /projects/{id}/submissions/{code}/draft
|
||||
// (and …/draft/{draft_id}). Project visibility is enforced server-side
|
||||
@@ -713,6 +848,11 @@ type globalDraftPatchInput struct {
|
||||
// SelectedParties: present-but-empty array resets to "all parties",
|
||||
// present non-empty array restricts to subset, absent = no change.
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
// BaseID + baseIDProvided mirror the ProjectID pattern — present
|
||||
// (regardless of value) means "set"; absent means "no change". Set
|
||||
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
baseIDProvided bool
|
||||
}
|
||||
|
||||
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
@@ -722,6 +862,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -732,12 +873,15 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
g.Language = a.Language
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
// Detect whether "project_id" was present in the JSON object.
|
||||
g.BaseID = a.BaseID
|
||||
// Detect whether "project_id" / "base_id" were present in the JSON
|
||||
// object.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, g.projectIDProvided = raw["project_id"]
|
||||
_, g.baseIDProvided = raw["base_id"]
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -778,6 +922,10 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
patch.ProjectID = &pid
|
||||
}
|
||||
if in.baseIDProvided {
|
||||
bid := in.BaseID // may be nil → clear
|
||||
patch.BaseID = &bid
|
||||
}
|
||||
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
@@ -890,16 +1038,10 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
||||
writeSubmissionExportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -910,7 +1052,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
|
||||
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
|
||||
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
|
||||
@@ -952,6 +1094,30 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
Lang: lang,
|
||||
HasTemplate: true,
|
||||
AvailableParties: []submissionDraftPartyJSON{},
|
||||
Sections: []submissionSectionJSON{},
|
||||
}
|
||||
|
||||
// Composer Slice A — surface seeded sections (read-only). Empty
|
||||
// when the draft has no base + no section rows (pre-Composer
|
||||
// drafts that haven't been auto-upgraded — that's Slice C).
|
||||
if dbSvc.submissionSection != nil {
|
||||
secs, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sec := range secs {
|
||||
view.Sections = append(view.Sections, submissionSectionJSON{
|
||||
ID: sec.ID,
|
||||
SectionKey: sec.SectionKey,
|
||||
OrderIndex: sec.OrderIndex,
|
||||
Kind: sec.Kind,
|
||||
LabelDE: sec.LabelDE,
|
||||
LabelEN: sec.LabelEN,
|
||||
Included: sec.Included,
|
||||
ContentMDDE: sec.ContentMDDE,
|
||||
ContentMDEN: sec.ContentMDEN,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
||||
@@ -1135,6 +1301,10 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
meta := d.ComposerMeta
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
return submissionDraftJSON{
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
@@ -1147,6 +1317,8 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
@@ -1160,7 +1332,7 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
// 'user' with scope_root = draft.user_id; the audit feed therefore
|
||||
// surfaces these exports on the user's row rather than against a
|
||||
// (non-existent) project.
|
||||
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string) error {
|
||||
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string, composerUsed bool) error {
|
||||
meta := map[string]any{
|
||||
"submission_code": d.SubmissionCode,
|
||||
"draft_id": d.ID.String(),
|
||||
@@ -1168,6 +1340,15 @@ func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *ser
|
||||
"filename": filename,
|
||||
"template_sha": templateSHA,
|
||||
}
|
||||
// t-paliad-313 Slice B — composer flag in metadata so admins can
|
||||
// tell the two render paths apart in the audit feed without
|
||||
// adding a new event_type.
|
||||
if composerUsed {
|
||||
meta["composer"] = true
|
||||
if d.BaseID != nil {
|
||||
meta["base_id"] = d.BaseID.String()
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
var (
|
||||
actorID any
|
||||
|
||||
148
internal/handlers/submission_sections.go
Normal file
148
internal/handlers/submission_sections.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package handlers
|
||||
|
||||
// Submission section handlers — Composer Slice B (t-paliad-313). Backs
|
||||
// the inline editor on /projects/{id}/submissions/{code}/draft/{draft_id}
|
||||
// where the lawyer types prose into each section.
|
||||
//
|
||||
// Endpoint:
|
||||
//
|
||||
// PATCH /api/submission-drafts/{draft_id}/sections/{section_id}
|
||||
//
|
||||
// Body shape (all fields optional — absent = no change):
|
||||
//
|
||||
// {
|
||||
// "content_md_de": "...",
|
||||
// "content_md_en": "...",
|
||||
// "included": true|false,
|
||||
// "label_de": "...",
|
||||
// "label_en": "...",
|
||||
// "order_index": 3
|
||||
// }
|
||||
//
|
||||
// Visibility: ownership of the draft is checked via
|
||||
// SubmissionDraftService.Get (404 on no-access), then the section is
|
||||
// fetched + verified to belong to that draft. The DB-side RLS policy
|
||||
// (mig 148) enforces the same gate independently.
|
||||
//
|
||||
// Returns 200 + the refreshed section row on success.
|
||||
//
|
||||
// This is global-scoped (no /projects/{id}/ prefix) because the
|
||||
// section's owning draft already carries the project_id; routing on
|
||||
// section_id alone keeps the URL shape stable across project-scoped
|
||||
// and project-less drafts.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionSectionPatchInput is the JSON shape accepted by PATCH.
|
||||
type submissionSectionPatchInput struct {
|
||||
ContentMDDE *string `json:"content_md_de,omitempty"`
|
||||
ContentMDEN *string `json:"content_md_en,omitempty"`
|
||||
Included *bool `json:"included,omitempty"`
|
||||
LabelDE *string `json:"label_de,omitempty"`
|
||||
LabelEN *string `json:"label_en,omitempty"`
|
||||
OrderIndex *int `json:"order_index,omitempty"`
|
||||
}
|
||||
|
||||
// submissionSectionPatchTimeout caps the round-trip.
|
||||
const submissionSectionPatchTimeout = 10 * time.Second
|
||||
|
||||
func handlePatchSubmissionSection(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()
|
||||
|
||||
// Owner-scope on the draft (RLS mirror; this gives us the typed
|
||||
// 404 + the path for the "section belongs to a different draft"
|
||||
// case below).
|
||||
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
existing, 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 existing.DraftID != draft.ID {
|
||||
// Section exists but doesn't belong to this draft — surface as
|
||||
// 404 to keep the "no fishing for foreign drafts" property.
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var input submissionSectionPatchInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
patch := services.SectionPatch{
|
||||
ContentMDDE: input.ContentMDDE,
|
||||
ContentMDEN: input.ContentMDEN,
|
||||
Included: input.Included,
|
||||
LabelDE: input.LabelDE,
|
||||
LabelEN: input.LabelEN,
|
||||
OrderIndex: input.OrderIndex,
|
||||
}
|
||||
updated, err := dbSvc.submissionSection.Update(ctx, sectionID, patch)
|
||||
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
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
||||
}
|
||||
|
||||
// sectionJSONFromService projects a services.SubmissionSection into the
|
||||
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
|
||||
// emits under .sections[].
|
||||
func sectionJSONFromService(sec *services.SubmissionSection) submissionSectionJSON {
|
||||
return submissionSectionJSON{
|
||||
ID: sec.ID,
|
||||
SectionKey: sec.SectionKey,
|
||||
OrderIndex: sec.OrderIndex,
|
||||
Kind: sec.Kind,
|
||||
LabelDE: sec.LabelDE,
|
||||
LabelEN: sec.LabelEN,
|
||||
Included: sec.Included,
|
||||
ContentMDDE: sec.ContentMDDE,
|
||||
ContentMDEN: sec.ContentMDEN,
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
|
||||
AND dr.submission_code IS NOT NULL
|
||||
AND dr.submission_code <> ''
|
||||
AND pt.is_active = true
|
||||
ORDER BY pt.code ASC, dr.submission_code ASC`)
|
||||
ORDER BY pt.code ASC, dr.sequence_order ASC, dr.submission_code ASC`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -220,6 +220,14 @@ func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnReque
|
||||
}
|
||||
|
||||
if streamErr != nil {
|
||||
// Aichat persona without streaming support — graceful fallback to
|
||||
// the one-shot /chat/turn endpoint. Same body shape; we adapt the
|
||||
// non-streaming response into a single StreamChunk so the caller
|
||||
// sees identical event ordering.
|
||||
if strings.Contains(streamErr.Error(), "unsupported_streaming") {
|
||||
log.Printf("paliadin: persona %q lacks streaming support — falling back to one-shot turn %s", s.cfg.Persona, turnID)
|
||||
return s.fallbackOneShotFromStream(ctx, turnID, body, events, startedAt, session)
|
||||
}
|
||||
// Don't overwrite an existing error_code we may have set above.
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(streamErr))
|
||||
return nil, streamErr
|
||||
@@ -255,6 +263,80 @@ func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnReque
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fallbackOneShotFromStream runs the same `body` against aichat's
|
||||
// non-streaming /chat/turn endpoint and adapts the response into the
|
||||
// StreamingPaliadin contract — a single StreamChunk + StreamMeta +
|
||||
// StreamConversation, followed by `events` being closed by the
|
||||
// outer RunTurnStream's defer. Used when the configured persona doesn't
|
||||
// support streaming (aichat returns HTTP 400 unsupported_streaming).
|
||||
//
|
||||
// Identical persistence shape as the one-shot RunTurn: completeTurn +
|
||||
// markPrimed/clearPrimed. No new turn row (already inserted by
|
||||
// RunTurnStream). No primer rebuild (already in body).
|
||||
func (s *AichatPaliadinService) fallbackOneShotFromStream(
|
||||
ctx context.Context,
|
||||
turnID uuid.UUID,
|
||||
body aichatTurnRequest,
|
||||
events chan<- StreamEvent,
|
||||
startedAt time.Time,
|
||||
session string,
|
||||
) (*TurnResult, error) {
|
||||
var resp aichatTurnResponse
|
||||
if err := s.callHTTP(ctx, http.MethodPost, "/chat/turn", body, &resp); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(err))
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: classifyAichatError(err),
|
||||
Message: err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.PaneSpawned {
|
||||
s.clearPrimed(session)
|
||||
} else {
|
||||
s.markPrimed(session)
|
||||
}
|
||||
|
||||
cleanBody := resp.Response
|
||||
tokens := approxTokenCount(cleanBody)
|
||||
chipCount := countChips(cleanBody)
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
|
||||
|
||||
tmeta := trailerMeta{
|
||||
UsedTools: resp.Meta.UsedTools,
|
||||
ClassifierTag: resp.Meta.ClassifierTag,
|
||||
RowsSeen: coerceAichatRowsSeen(resp.Meta.RowsSeen),
|
||||
}
|
||||
|
||||
// Emit the response as a single chunk so the frontend renders it.
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamChunk,
|
||||
Content: cleanBody,
|
||||
})
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamMeta,
|
||||
UsedTools: tmeta.UsedTools,
|
||||
ClassifierTag: tmeta.ClassifierTag,
|
||||
RowsSeen: tmeta.RowsSeen,
|
||||
})
|
||||
|
||||
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, tmeta, chipCount); err != nil {
|
||||
log.Printf("paliadin: complete turn %s (fallback one-shot): %v", turnID, err)
|
||||
}
|
||||
|
||||
return &TurnResult{
|
||||
TurnID: turnID,
|
||||
Response: cleanBody,
|
||||
UsedTools: tmeta.UsedTools,
|
||||
RowsSeen: tmeta.RowsSeen,
|
||||
ChipCount: chipCount,
|
||||
ClassifierTag: tmeta.ClassifierTag,
|
||||
DurationMS: durationMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// streamFrame is one decoded SSE event.
|
||||
type streamFrame struct {
|
||||
event string // "" → default (data:) event
|
||||
|
||||
99
internal/services/backup_service_live_test.go
Normal file
99
internal/services/backup_service_live_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestResolveOrgSheets_LiveSchemaSnapshot probes the live paliad schema
|
||||
// the way the backup runner does at the start of every run, then asserts
|
||||
// that every spec the registry declares either keeps all its ORDER BY
|
||||
// columns or — if any are missing — composes a fallback SELECT that the
|
||||
// DB can still execute. Catches the m/paliad#140 class of bug
|
||||
// (hardcoded ORDER BY against a renamed column) before deploy.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset. Read-only: opens a
|
||||
// REPEATABLE READ tx, never writes.
|
||||
func TestResolveOrgSheets_LiveSchemaSnapshot(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
specs := orgSheetSpecs()
|
||||
sheets, err := resolveOrgSheets(ctx, pool, specs)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveOrgSheets: %v", err)
|
||||
}
|
||||
if len(sheets) != len(specs) {
|
||||
t.Fatalf("resolved %d sheets, want %d", len(sheets), len(specs))
|
||||
}
|
||||
|
||||
// Each resolved SELECT must run cleanly against the live schema.
|
||||
// We LIMIT 1 inside a sub-SELECT so we don't materialise the full
|
||||
// table (some are large) but still exercise the ORDER BY clause.
|
||||
for _, sq := range sheets {
|
||||
wrapped := `SELECT * FROM (` + sq.SQL + `) _wrap LIMIT 1`
|
||||
if _, err := pool.QueryxContext(ctx, wrapped, sq.Args...); err != nil {
|
||||
t.Errorf("sheet %q SQL failed: %v\nSQL: %s", sq.SheetName, err, sq.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteOrg_LiveSmoke runs the full ExportService.WriteOrg pipeline
|
||||
// against a real DB: schema probe, REPEATABLE READ tx, every sheet
|
||||
// query, xlsx + json + per-sheet CSV assembly, outer zip framing.
|
||||
// Discards the bytes — this is a "does it crash" smoke, the bug class
|
||||
// it catches is exactly the one from m/paliad#140 (hardcoded ORDER BY
|
||||
// against a missing column).
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestWriteOrg_LiveSmoke(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
svc := NewExportService(pool, "test-firm")
|
||||
var buf bytes.Buffer
|
||||
meta, err := svc.WriteOrg(context.Background(), &buf, ExportSpec{
|
||||
ActorID: uuid.New(),
|
||||
ActorEmail: "backup-smoke@test.local",
|
||||
ActorLabel: "Backup Smoke",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteOrg: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Fatalf("WriteOrg wrote no bytes")
|
||||
}
|
||||
// Spot-check meta fills.
|
||||
if meta.Scope != ExportScopeOrg {
|
||||
t.Errorf("meta.Scope = %q, want %q", meta.Scope, ExportScopeOrg)
|
||||
}
|
||||
if len(meta.RowCounts) != len(orgSheetSpecs()) {
|
||||
t.Errorf("meta.RowCounts has %d entries, want %d (one per sheet)", len(meta.RowCounts), len(orgSheetSpecs()))
|
||||
}
|
||||
// The bytes are a zip; the first 4 bytes are PK\x03\x04 for a non-empty zip.
|
||||
if buf.Len() >= 4 && !strings.HasPrefix(buf.String()[:4], "PK\x03\x04") {
|
||||
t.Errorf("bundle bytes don't look like a zip (first bytes: %x)", buf.Bytes()[:4])
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ package services
|
||||
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
|
||||
// This file covers the bits that don't need a database:
|
||||
//
|
||||
// - orgSheetQueries registry shape: no duplicates, no excluded
|
||||
// - orgSheetSpecs registry shape: no duplicates, no excluded
|
||||
// paliadin sheets, predictable prefix split between entity and ref.
|
||||
// - composeOrgSheetSQL drift-resistance: missing ORDER BY cols drop,
|
||||
// SQL override path bypasses the builder, all-missing → no clause.
|
||||
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
|
||||
// URI traversal rejection.
|
||||
|
||||
@@ -22,60 +24,216 @@ import (
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// orgSheetQueries registry
|
||||
// orgSheetSpecs registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
|
||||
func TestOrgSheetSpecs_NoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if seen[sq.SheetName] {
|
||||
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if seen[sp.SheetName] {
|
||||
t.Fatalf("duplicate sheet name in orgSheetSpecs: %q", sp.SheetName)
|
||||
}
|
||||
seen[sq.SheetName] = true
|
||||
seen[sp.SheetName] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
|
||||
func TestOrgSheetSpecs_ExcludesPaliadinTables(t *testing.T) {
|
||||
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
|
||||
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
|
||||
// from the registry (structural exclusion, not just column-drop).
|
||||
for _, sq := range orgSheetQueries() {
|
||||
name := sq.SheetName
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
name := sp.SheetName
|
||||
if strings.Contains(name, "paliadin") {
|
||||
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||
t.Fatalf("orgSheetSpecs leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||
}
|
||||
// Belt-and-braces: SQL bodies should not reference the tables
|
||||
// either (no UNION joins, no subqueries pulling them in).
|
||||
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
|
||||
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
|
||||
if strings.Contains(sp.Table, "paliadin") {
|
||||
t.Fatalf("orgSheetSpecs[%q].Table references a paliadin table: %s", name, sp.Table)
|
||||
}
|
||||
// Belt-and-braces: SQL override bodies (the few sheets that
|
||||
// bypass the Table+OrderBy builder) also can't pull paliadin
|
||||
// tables in through UNION/subquery.
|
||||
if strings.Contains(sp.SQL, "paliadin_turns") || strings.Contains(sp.SQL, "paliadin_aichat_conversation") {
|
||||
t.Fatalf("orgSheetSpecs[%q] SQL references a paliadin table: %s", name, sp.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
|
||||
func TestOrgSheetSpecs_RefSheetsPrefixed(t *testing.T) {
|
||||
// Every sheet whose data is read-only reference material is
|
||||
// expected to use the `ref__` prefix. The writer's downstream
|
||||
// consumers rely on this convention to group reference data
|
||||
// visually in the workbook.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.HasPrefix(sq.SheetName, "ref__") {
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if !strings.HasPrefix(sp.SheetName, "ref__") {
|
||||
continue
|
||||
}
|
||||
// Reference sheets shouldn't carry per-row WHERE clauses (they
|
||||
// dump the whole reference table for portability).
|
||||
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
|
||||
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
|
||||
// dump the whole reference table for portability). Only
|
||||
// applies to the SQL-override path; the Table+OrderBy builder
|
||||
// never emits a WHERE.
|
||||
if sp.SQL != "" && strings.Contains(strings.ToUpper(sp.SQL), "WHERE") {
|
||||
t.Fatalf("orgSheetSpecs[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sp.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
|
||||
// Every sheet must specify an ORDER BY so the byte-deterministic
|
||||
// contract from t-paliad-214 §3 holds across runs.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
|
||||
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
|
||||
func TestOrgSheetSpecs_OrderByForDeterminism(t *testing.T) {
|
||||
// Every sheet must declare a stable sort: either OrderBy on the
|
||||
// Table+OrderBy path, or ORDER BY in the SQL override. Keeps the
|
||||
// byte-deterministic contract from t-paliad-214 §3 across runs.
|
||||
//
|
||||
// (Drift removes ORDER BY columns at runtime, but only ones that
|
||||
// no longer exist in the schema — the spec-level declaration is
|
||||
// still required so we know what *should* be ordered.)
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if sp.SQL != "" {
|
||||
if !strings.Contains(strings.ToUpper(sp.SQL), "ORDER BY") {
|
||||
t.Fatalf("orgSheetSpecs[%q] SQL override missing ORDER BY (determinism contract): %s", sp.SheetName, sp.SQL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(sp.OrderBy) == 0 {
|
||||
t.Fatalf("orgSheetSpecs[%q] has no OrderBy and no SQL override (determinism contract)", sp.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composeOrgSheetSQL — drift-resistant SQL builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestComposeOrgSheetSQL_AllColumnsPresent(t *testing.T) {
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "appointments",
|
||||
Table: "paliad.appointments",
|
||||
OrderBy: []string{"id"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"appointments": {"id": {}, "project_id": {}},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.appointments ORDER BY id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 0 {
|
||||
t.Fatalf("expected no dropped columns, got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_DropsMissingOrderByColumn(t *testing.T) {
|
||||
// The original bug from m/paliad#138 reproduced in unit form:
|
||||
// orderBy references a column the table doesn't have.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "appointment_caldav_targets",
|
||||
Table: "paliad.appointment_caldav_targets",
|
||||
OrderBy: []string{"appointment_id", "calendar_binding_id"}, // wrong: real col is binding_id
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"appointment_caldav_targets": {
|
||||
"appointment_id": {},
|
||||
"binding_id": {},
|
||||
},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "calendar_binding_id" {
|
||||
t.Fatalf("expected dropped=[calendar_binding_id], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_AllOrderByMissing_NoClause(t *testing.T) {
|
||||
// If every declared ORDER BY column is gone, the builder still
|
||||
// produces a runnable SELECT — without ORDER BY. The export
|
||||
// succeeds; the order across runs is no longer deterministic for
|
||||
// this sheet until the spec is updated. WARN log alerts the
|
||||
// operator (verified in TestResolveOrgSheets_LogsWarnings).
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "ghost",
|
||||
Table: "paliad.ghost",
|
||||
OrderBy: []string{"missing_a", "missing_b"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"ghost": {"unrelated": {}},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.ghost"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 2 {
|
||||
t.Fatalf("expected 2 dropped columns, got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_SQLOverride_BypassesBuilder(t *testing.T) {
|
||||
// When a sheet declares SQL, the builder MUST NOT touch it — even
|
||||
// if the column knowledge would suggest a change. Custom
|
||||
// projections (documents drops ai_extracted) and special-case
|
||||
// joins both rely on this.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "documents",
|
||||
Table: "paliad.documents", // should be ignored
|
||||
OrderBy: []string{"id"}, // should be ignored
|
||||
SQL: "SELECT id, title FROM paliad.documents ORDER BY id",
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"documents": {}, // empty → would drop everything if builder ran
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
if got != spec.SQL {
|
||||
t.Fatalf("SQL override mutated: got %q, want %q", got, spec.SQL)
|
||||
}
|
||||
if len(dropped) != 0 {
|
||||
t.Fatalf("override path should never report drops; got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_UnknownTable_DropsAllOrderBy(t *testing.T) {
|
||||
// A table missing entirely from the schema snapshot is treated as
|
||||
// "no columns known" — every ORDER BY column gets dropped, but
|
||||
// the SELECT still emits (so a stale registry doesn't crash the
|
||||
// backup; the operator gets WARNs to fix it).
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "renamed_table",
|
||||
Table: "paliad.renamed_table",
|
||||
OrderBy: []string{"id"},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, map[string]map[string]struct{}{})
|
||||
want := "SELECT * FROM paliad.renamed_table"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "id" {
|
||||
t.Fatalf("expected dropped=[id], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_PreservesOrderByOrder(t *testing.T) {
|
||||
// Multi-column OrderBy must keep its declared order, with kept
|
||||
// columns concatenated in the same sequence. Determinism contract
|
||||
// from t-paliad-214 §3 depends on this.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "partner_unit_members",
|
||||
Table: "paliad.partner_unit_members",
|
||||
OrderBy: []string{"partner_unit_id", "missing_middle", "user_id"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"partner_unit_members": {
|
||||
"partner_unit_id": {},
|
||||
"user_id": {},
|
||||
},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "missing_middle" {
|
||||
t.Fatalf("expected dropped=[missing_middle], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -297,7 +298,10 @@ func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSp
|
||||
// is just bookkeeping that releases the snapshot.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sheets := orgSheetQueries()
|
||||
sheets, err := resolveOrgSheets(ctx, tx, orgSheetSpecs())
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
@@ -1560,73 +1564,249 @@ SELECT 'partner_unit_default'::text AS source,
|
||||
// secret|token|password|api_key|private_key on every sheet as a
|
||||
// belt-and-braces filter. user_caldav_config.password_encrypted is
|
||||
// explicitly named in DropColumns too.
|
||||
func orgSheetQueries() []sheetQuery {
|
||||
return []sheetQuery{
|
||||
//
|
||||
// Drift-resistance (m/paliad#140): each spec declares its desired
|
||||
// ORDER BY columns as a list. At backup time the exporter probes
|
||||
// information_schema.columns for the live schema; any ORDER BY column
|
||||
// that no longer exists is dropped (logged WARN). This way a column
|
||||
// rename or removal never breaks a backup — the worst case is a sheet
|
||||
// that loses sort stability until the spec is updated. A sheet whose
|
||||
// ORDER BY columns are all gone still exports, just in pg's natural
|
||||
// (unspecified) order.
|
||||
//
|
||||
// Custom column projections (e.g. documents drops ai_extracted) live
|
||||
// in the SQL override field; if set, it bypasses the Table+OrderBy
|
||||
// builder entirely. Use it sparingly — every override re-introduces
|
||||
// drift risk for that sheet.
|
||||
|
||||
// orgSheetSpec declares one org-scope sheet for the drift-resistant
|
||||
// builder. Either set SQL (free-form override) or set Table+OrderBy
|
||||
// (let the builder compose `SELECT * FROM <Table> ORDER BY <existing>`).
|
||||
type orgSheetSpec struct {
|
||||
// SheetName lands in the workbook sheet and the JSON top-level key.
|
||||
SheetName string
|
||||
// Table is schema-qualified (e.g. "paliad.appointments"). Used only
|
||||
// when SQL is empty. The schema/table form must be valid SQL
|
||||
// identifiers — the builder splits on the dot, no quoting.
|
||||
Table string
|
||||
// OrderBy is the *desired* sort columns. Missing columns are
|
||||
// dropped silently-with-a-WARN at build time; remaining columns
|
||||
// keep their declared order. Empty/all-missing → no ORDER BY (still
|
||||
// deterministic-within-a-snapshot under the REPEATABLE READ tx, but
|
||||
// the order across runs may differ).
|
||||
OrderBy []string
|
||||
// SQL is an explicit override; if non-empty, Table+OrderBy are
|
||||
// ignored entirely. Use only when the projection cannot be
|
||||
// expressed as SELECT * (e.g. documents drops the ai_extracted
|
||||
// jsonb column).
|
||||
SQL string
|
||||
// Args are positional arguments. Only meaningful with SQL override;
|
||||
// the Table+OrderBy path takes no args.
|
||||
Args []any
|
||||
// DropColumns is an explicit list of column names to drop from the
|
||||
// result regardless of the PII deny-regex.
|
||||
DropColumns []string
|
||||
}
|
||||
|
||||
func orgSheetSpecs() []orgSheetSpec {
|
||||
return []orgSheetSpec{
|
||||
// --- entity sheets (alphabetical) ---
|
||||
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
|
||||
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
|
||||
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
|
||||
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
|
||||
{SheetName: "appointment_caldav_targets", Table: "paliad.appointment_caldav_targets", OrderBy: []string{"appointment_id", "binding_id"}},
|
||||
{SheetName: "appointments", Table: "paliad.appointments", OrderBy: []string{"id"}},
|
||||
{SheetName: "approval_policies", Table: "paliad.approval_policies", OrderBy: []string{"id"}},
|
||||
{SheetName: "approval_requests", Table: "paliad.approval_requests", OrderBy: []string{"id"}},
|
||||
// backups is self-reflexive — including it makes "what backups
|
||||
// have we taken" recoverable from any prior backup. Tiny table.
|
||||
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
|
||||
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
|
||||
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
|
||||
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
|
||||
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
|
||||
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
|
||||
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
|
||||
{SheetName: "backups", Table: "paliad.backups", OrderBy: []string{"started_at", "id"}},
|
||||
{SheetName: "caldav_sync_log", Table: "paliad.caldav_sync_log", OrderBy: []string{"occurred_at", "id"}},
|
||||
{SheetName: "checklist_instances", Table: "paliad.checklist_instances", OrderBy: []string{"id"}},
|
||||
{SheetName: "checklist_shares", Table: "paliad.checklist_shares", OrderBy: []string{"id"}},
|
||||
{SheetName: "checklists", Table: "paliad.checklists", OrderBy: []string{"id"}},
|
||||
{SheetName: "deadline_rule_audit", Table: "paliad.deadline_rule_audit", OrderBy: []string{"changed_at", "id"}},
|
||||
{SheetName: "deadlines", Table: "paliad.deadlines", OrderBy: []string{"id"}},
|
||||
// documents: ai_extracted jsonb dropped (verbose AI prompts;
|
||||
// matches the personal/project precedent). Binaries are not in
|
||||
// the export — only metadata.
|
||||
// the export — only metadata. Uses SQL override because the
|
||||
// projection isn't SELECT *.
|
||||
{
|
||||
SheetName: "documents",
|
||||
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
|
||||
FROM paliad.documents
|
||||
ORDER BY id`,
|
||||
},
|
||||
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
|
||||
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
|
||||
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
|
||||
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
|
||||
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
|
||||
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
|
||||
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
|
||||
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
|
||||
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
|
||||
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
|
||||
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
|
||||
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
|
||||
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
|
||||
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
|
||||
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
|
||||
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
|
||||
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
|
||||
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
|
||||
{SheetName: "email_broadcasts", Table: "paliad.email_broadcasts", OrderBy: []string{"id"}},
|
||||
{SheetName: "email_template_versions", Table: "paliad.email_template_versions", OrderBy: []string{"id"}},
|
||||
{SheetName: "email_templates", Table: "paliad.email_templates", OrderBy: []string{"key", "lang"}},
|
||||
{SheetName: "firm_dashboard_default", Table: "paliad.firm_dashboard_default", OrderBy: []string{"id"}},
|
||||
{SheetName: "invitations", Table: "paliad.invitations", OrderBy: []string{"sent_at", "id"}},
|
||||
{SheetName: "notes", Table: "paliad.notes", OrderBy: []string{"id"}},
|
||||
{SheetName: "parties", Table: "paliad.parties", OrderBy: []string{"id"}},
|
||||
{SheetName: "partner_unit_events", Table: "paliad.partner_unit_events", OrderBy: []string{"id"}},
|
||||
{SheetName: "partner_unit_members", Table: "paliad.partner_unit_members", OrderBy: []string{"partner_unit_id", "user_id"}},
|
||||
{SheetName: "partner_units", Table: "paliad.partner_units", OrderBy: []string{"id"}},
|
||||
{SheetName: "policy_audit_log", Table: "paliad.policy_audit_log", OrderBy: []string{"created_at", "id"}},
|
||||
{SheetName: "project_events", Table: "paliad.project_events", OrderBy: []string{"id"}},
|
||||
{SheetName: "project_partner_units", Table: "paliad.project_partner_units", OrderBy: []string{"project_id", "partner_unit_id"}},
|
||||
{SheetName: "project_teams", Table: "paliad.project_teams", OrderBy: []string{"project_id", "user_id"}},
|
||||
{SheetName: "projects", Table: "paliad.projects", OrderBy: []string{"id"}},
|
||||
{SheetName: "reminder_log", Table: "paliad.reminder_log", OrderBy: []string{"sent_at", "id"}},
|
||||
{SheetName: "submission_drafts", Table: "paliad.submission_drafts", OrderBy: []string{"id"}},
|
||||
{SheetName: "system_audit_log", Table: "paliad.system_audit_log", OrderBy: []string{"created_at", "id"}},
|
||||
{
|
||||
SheetName: "user_caldav_config",
|
||||
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
|
||||
Table: "paliad.user_caldav_config",
|
||||
OrderBy: []string{"user_id"},
|
||||
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
|
||||
},
|
||||
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
|
||||
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
|
||||
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
|
||||
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
|
||||
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
|
||||
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
|
||||
{SheetName: "user_calendar_bindings", Table: "paliad.user_calendar_bindings", OrderBy: []string{"user_id", "calendar_path"}},
|
||||
{SheetName: "user_card_layouts", Table: "paliad.user_card_layouts", OrderBy: []string{"id"}},
|
||||
{SheetName: "user_dashboard_layouts", Table: "paliad.user_dashboard_layouts", OrderBy: []string{"user_id"}},
|
||||
{SheetName: "user_pinned_projects", Table: "paliad.user_pinned_projects", OrderBy: []string{"user_id", "project_id"}},
|
||||
{SheetName: "user_views", Table: "paliad.user_views", OrderBy: []string{"id"}},
|
||||
{SheetName: "users", Table: "paliad.users", OrderBy: []string{"id"}},
|
||||
|
||||
// --- reference data (alphabetical, prefixed ref__) ---
|
||||
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`},
|
||||
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
|
||||
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
|
||||
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
|
||||
{SheetName: "ref__countries", Table: "paliad.countries", OrderBy: []string{"code"}},
|
||||
{SheetName: "ref__courts", Table: "paliad.courts", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__deadline_concept_event_types", Table: "paliad.deadline_concept_event_types", OrderBy: []string{"concept_id", "event_type_id"}},
|
||||
{SheetName: "ref__deadline_concepts", Table: "paliad.deadline_concepts", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__deadline_event_types", Table: "paliad.deadline_event_types", OrderBy: []string{"deadline_id", "event_type_id"}},
|
||||
{SheetName: "ref__deadline_rules", Table: "paliad.deadline_rules_unified", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__event_categories", Table: "paliad.event_categories", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__event_category_concepts", Table: "paliad.event_category_concepts", OrderBy: []string{"event_category_id", "concept_id"}},
|
||||
{SheetName: "ref__event_types", Table: "paliad.event_types", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__holidays", Table: "paliad.holidays", OrderBy: []string{"date", "country"}},
|
||||
{SheetName: "ref__proceeding_types", Table: "paliad.proceeding_types", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__trigger_events", Table: "paliad.trigger_events", OrderBy: []string{"id"}},
|
||||
}
|
||||
}
|
||||
|
||||
// composeOrgSheetSQL turns one orgSheetSpec into the final SQL string,
|
||||
// using a per-table column set (typically loaded once per backup run
|
||||
// from information_schema.columns). Returns the SQL and the list of
|
||||
// ORDER BY columns that were dropped because they don't exist in the
|
||||
// live schema.
|
||||
//
|
||||
// Pure function — no DB access — so the missing-column behaviour is
|
||||
// unit-testable without a fixture database.
|
||||
//
|
||||
// Rules:
|
||||
// - If spec.SQL is non-empty, return it unchanged (override path).
|
||||
// - Otherwise build `SELECT * FROM <Table> [ORDER BY <kept-cols>]`.
|
||||
// - Columns are kept in their declared order; missing ones recorded
|
||||
// in `dropped` and omitted from ORDER BY.
|
||||
// - If no ORDER BY columns survive, the ORDER BY clause is omitted.
|
||||
//
|
||||
// knownCols maps unqualified table names (e.g. "appointments") to the
|
||||
// set of columns they have. A table missing from knownCols is treated
|
||||
// as "no columns known" — every declared ORDER BY column gets dropped.
|
||||
func composeOrgSheetSQL(spec orgSheetSpec, knownCols map[string]map[string]struct{}) (sqlText string, dropped []string) {
|
||||
if spec.SQL != "" {
|
||||
return spec.SQL, nil
|
||||
}
|
||||
unqualified := spec.Table
|
||||
if i := strings.IndexByte(unqualified, '.'); i >= 0 {
|
||||
unqualified = unqualified[i+1:]
|
||||
}
|
||||
cols := knownCols[unqualified]
|
||||
kept := make([]string, 0, len(spec.OrderBy))
|
||||
for _, c := range spec.OrderBy {
|
||||
if _, ok := cols[c]; ok {
|
||||
kept = append(kept, c)
|
||||
} else {
|
||||
dropped = append(dropped, c)
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("SELECT * FROM ")
|
||||
b.WriteString(spec.Table)
|
||||
if len(kept) > 0 {
|
||||
b.WriteString(" ORDER BY ")
|
||||
b.WriteString(strings.Join(kept, ", "))
|
||||
}
|
||||
return b.String(), dropped
|
||||
}
|
||||
|
||||
// loadOrgSheetColumns probes information_schema.columns once for every
|
||||
// table referenced by Table+OrderBy specs. Returns a lookup
|
||||
// {table_name → {column_name → {}}} restricted to the paliad schema.
|
||||
//
|
||||
// The queryer is whatever runs the backup's read snapshot — typically
|
||||
// the REPEATABLE READ tx opened in WriteOrg, so the schema snapshot
|
||||
// matches the row snapshot.
|
||||
func loadOrgSheetColumns(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) (map[string]map[string]struct{}, error) {
|
||||
tableSet := map[string]struct{}{}
|
||||
for _, sp := range specs {
|
||||
if sp.Table == "" {
|
||||
continue // SQL-override sheets carry their own column refs
|
||||
}
|
||||
t := sp.Table
|
||||
if i := strings.IndexByte(t, '.'); i >= 0 {
|
||||
t = t[i+1:]
|
||||
}
|
||||
tableSet[t] = struct{}{}
|
||||
}
|
||||
if len(tableSet) == 0 {
|
||||
return map[string]map[string]struct{}{}, nil
|
||||
}
|
||||
tables := make([]string, 0, len(tableSet))
|
||||
for t := range tableSet {
|
||||
tables = append(tables, t)
|
||||
}
|
||||
rows, err := queryer.QueryxContext(ctx, `
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = ANY($1)
|
||||
`, tables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probe paliad columns: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]map[string]struct{}, len(tableSet))
|
||||
for rows.Next() {
|
||||
var table, column string
|
||||
if err := rows.Scan(&table, &column); err != nil {
|
||||
return nil, fmt.Errorf("scan paliad columns: %w", err)
|
||||
}
|
||||
set, ok := out[table]
|
||||
if !ok {
|
||||
set = map[string]struct{}{}
|
||||
out[table] = set
|
||||
}
|
||||
set[column] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate paliad columns: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveOrgSheets materialises an org-scope spec list into the
|
||||
// concrete []sheetQuery that writeBundle expects. Composes each
|
||||
// spec's SQL via composeOrgSheetSQL using a schema snapshot loaded
|
||||
// from the same queryer. Logs WARN per dropped ORDER BY column.
|
||||
func resolveOrgSheets(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) ([]sheetQuery, error) {
|
||||
knownCols, err := loadOrgSheetColumns(ctx, queryer, specs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]sheetQuery, 0, len(specs))
|
||||
for _, sp := range specs {
|
||||
sqlText, dropped := composeOrgSheetSQL(sp, knownCols)
|
||||
for _, c := range dropped {
|
||||
slog.Warn("backup: ORDER BY column dropped (not in schema)",
|
||||
"sheet", sp.SheetName,
|
||||
"table", sp.Table,
|
||||
"column", c,
|
||||
)
|
||||
}
|
||||
out = append(out, sheetQuery{
|
||||
SheetName: sp.SheetName,
|
||||
SQL: sqlText,
|
||||
Args: sp.Args,
|
||||
DropColumns: sp.DropColumns,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
274
internal/services/submission_base_service.go
Normal file
274
internal/services/submission_base_service.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package services
|
||||
|
||||
// Submission base catalog service — Composer Slice A (t-paliad-313,
|
||||
// design doc docs/design-submission-generator-v2-2026-05-26.md §4.2 +
|
||||
// §5.1).
|
||||
//
|
||||
// Each row in paliad.submission_bases maps a stable slug onto a Gitea
|
||||
// path (the .docx body) plus a JSON section spec that drives the
|
||||
// editor's default section seeding. Slice A surfaces this catalog via
|
||||
// a sidebar picker and uses GetDefaultForCode to pre-fill base_id on
|
||||
// new drafts.
|
||||
//
|
||||
// Read-only — admin mutations land in Slice C's /admin/submission-bases
|
||||
// editor. Visibility is wide-open SELECT (the catalog is shared
|
||||
// firm-wide); RLS denies mutations by default.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// SubmissionBase mirrors a row in paliad.submission_bases.
|
||||
type SubmissionBase struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Slug string `db:"slug" json:"slug"`
|
||||
Firm *string `db:"firm" json:"firm,omitempty"`
|
||||
ProceedingFamily *string `db:"proceeding_family" json:"proceeding_family,omitempty"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
DescriptionDE *string `db:"description_de" json:"description_de,omitempty"`
|
||||
DescriptionEN *string `db:"description_en" json:"description_en,omitempty"`
|
||||
GiteaPath string `db:"gitea_path" json:"gitea_path"`
|
||||
SectionSpecRaw []byte `db:"section_spec" json:"-"`
|
||||
IsDefaultForRaw pq.StringArray `db:"is_default_for" json:"-"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
|
||||
// SectionSpec is the parsed section spec; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
SectionSpec BaseSectionSpec `json:"section_spec"`
|
||||
|
||||
// IsDefaultFor is the parsed string-slice form of the
|
||||
// is_default_for column.
|
||||
IsDefaultFor []string `json:"is_default_for"`
|
||||
}
|
||||
|
||||
// BaseSectionSpec is the parsed shape of submission_bases.section_spec.
|
||||
// Slice A consumes Defaults to seed submission_sections rows on draft
|
||||
// create; later slices consume Stylemap (Slice B's MD→OOXML walker) and
|
||||
// Version (forward compat).
|
||||
type BaseSectionSpec struct {
|
||||
Version int `json:"version"`
|
||||
Stylemap map[string]string `json:"stylemap"`
|
||||
Defaults []BaseSectionSpecDefault `json:"defaults"`
|
||||
}
|
||||
|
||||
// BaseSectionSpecDefault declares one default section per base. SeedMD*
|
||||
// is the Markdown copied into submission_sections.content_md_* on draft
|
||||
// create. Empty seed = blank prose section.
|
||||
type BaseSectionSpecDefault struct {
|
||||
SectionKey string `json:"section_key"`
|
||||
Kind string `json:"kind"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
SeedMDDE string `json:"seed_md_de"`
|
||||
SeedMDEN string `json:"seed_md_en"`
|
||||
}
|
||||
|
||||
// BaseService reads the catalog. No mutations in Slice A.
|
||||
type BaseService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewBaseService wires the service.
|
||||
func NewBaseService(db *sqlx.DB) *BaseService {
|
||||
return &BaseService{db: db}
|
||||
}
|
||||
|
||||
// ErrBaseNotFound is the sentinel for "no base with that id/slug".
|
||||
var ErrBaseNotFound = errors.New("submission base: not found")
|
||||
|
||||
const baseColumns = `id, slug, firm, proceeding_family, label_de, label_en,
|
||||
description_de, description_en, gitea_path,
|
||||
section_spec, is_default_for, is_active`
|
||||
|
||||
// List returns every active base ordered by firm-then-label.
|
||||
// firmFilter (when non-empty) restricts to rows where firm matches OR
|
||||
// firm IS NULL — the picker shows the firm's own bases plus the
|
||||
// firm-agnostic ones.
|
||||
func (s *BaseService) List(ctx context.Context, firmFilter string) ([]SubmissionBase, error) {
|
||||
var rows []SubmissionBase
|
||||
var err error
|
||||
if firmFilter == "" {
|
||||
err = s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active
|
||||
ORDER BY COALESCE(firm, ''), label_de`)
|
||||
} else {
|
||||
err = s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND (firm = $1 OR firm IS NULL)
|
||||
ORDER BY (firm IS NULL), label_de`,
|
||||
firmFilter)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission bases: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID fetches one base by uuid.
|
||||
func (s *BaseService) GetByID(ctx context.Context, id uuid.UUID) (*SubmissionBase, error) {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE id = $1 AND is_active`,
|
||||
id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission base by id: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// GetBySlug fetches one base by stable slug ("hlc-letterhead", …).
|
||||
func (s *BaseService) GetBySlug(ctx context.Context, slug string) (*SubmissionBase, error) {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE slug = $1 AND is_active`,
|
||||
slug)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission base by slug: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// GetDefaultForCode picks the base SubmissionDraftService.Create should
|
||||
// seed for a new draft, given the requesting firm and the draft's
|
||||
// submission_code. Priority:
|
||||
//
|
||||
// 1. firm-matched base whose is_default_for[] contains the exact code.
|
||||
// 2. firm-matched base whose proceeding_family matches the code's
|
||||
// family (first three dot-segments, e.g. "de.inf.lg" from
|
||||
// "de.inf.lg.erwidg").
|
||||
// 3. firm-matched base with NULL proceeding_family (firm-agnostic
|
||||
// fallback within the firm).
|
||||
// 4. firm-NULL (cross-firm) base by family match.
|
||||
// 5. firm-NULL base with NULL family — the universal neutral fallback.
|
||||
// 6. first active row (deterministic ordering on (firm IS NULL,
|
||||
// label_de)).
|
||||
//
|
||||
// Returns ErrBaseNotFound if the table is empty.
|
||||
func (s *BaseService) GetDefaultForCode(ctx context.Context, firm, submissionCode string) (*SubmissionBase, error) {
|
||||
family := familyOfCode(submissionCode)
|
||||
|
||||
tryQueries := []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND $2 = ANY(is_default_for)
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm, submissionCode},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND proceeding_family = $2
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm, family},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND proceeding_family IS NULL
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm IS NULL AND proceeding_family = $1
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{family},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm IS NULL AND proceeding_family IS NULL
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active
|
||||
ORDER BY (firm IS NULL), label_de LIMIT 1`,
|
||||
[]any{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, q := range tryQueries {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b, q.sql, q.args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get default base: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
|
||||
// familyOfCode returns the first three dot-segments of a submission_code.
|
||||
// "de.inf.lg.erwidg" → "de.inf.lg". Codes with fewer than three segments
|
||||
// pass through unchanged (none in the corpus today, but safe).
|
||||
func familyOfCode(code string) string {
|
||||
parts := strings.SplitN(code, ".", 4)
|
||||
if len(parts) <= 3 {
|
||||
return code
|
||||
}
|
||||
return strings.Join(parts[:3], ".")
|
||||
}
|
||||
|
||||
// decode fills the parsed views from the raw scan fields.
|
||||
func (b *SubmissionBase) decode() error {
|
||||
if len(b.SectionSpecRaw) > 0 {
|
||||
if err := json.Unmarshal(b.SectionSpecRaw, &b.SectionSpec); err != nil {
|
||||
return fmt.Errorf("decode submission base section_spec: %w", err)
|
||||
}
|
||||
}
|
||||
b.IsDefaultFor = []string(b.IsDefaultForRaw)
|
||||
if b.IsDefaultFor == nil {
|
||||
b.IsDefaultFor = []string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
99
internal/services/submission_base_service_test.go
Normal file
99
internal/services/submission_base_service_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
// Unit tests for Composer base helpers — pure functions, no DB
|
||||
// dependency (t-paliad-313 Slice A).
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFamilyOfCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// canonical four-segment codes → first three segments
|
||||
{"de.inf.lg.erwidg", "de.inf.lg"},
|
||||
{"de.inf.lg.klage", "de.inf.lg"},
|
||||
{"de.inf.olg.berufung", "de.inf.olg"},
|
||||
{"upc.inf.cfi.soc", "upc.inf.cfi"},
|
||||
{"upc.inf.cfi.sod", "upc.inf.cfi"},
|
||||
{"upc.apl.cost.leave_app", "upc.apl.cost"},
|
||||
{"epa.opp.opd.einspruch", "epa.opp.opd"},
|
||||
// five-segment codes (rarely used in the corpus today) → still
|
||||
// truncate to three
|
||||
{"upc.inf.cfi.appeal_spawn.followup", "upc.inf.cfi"},
|
||||
// shorter codes pass through unchanged
|
||||
{"de.inf.lg", "de.inf.lg"},
|
||||
{"de.inf", "de.inf"},
|
||||
{"de", "de"},
|
||||
// empty stays empty
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := familyOfCode(tc.in); got != tc.want {
|
||||
t.Errorf("familyOfCode(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSectionSpec_DecodeShape(t *testing.T) {
|
||||
// The default seed in mig 146 emits a JSON document the service
|
||||
// must decode round-trip; this golden pins the exact field shape
|
||||
// the editor expects.
|
||||
raw := []byte(`{
|
||||
"version": 1,
|
||||
"stylemap": {
|
||||
"paragraph": "HLpat-Body-B0",
|
||||
"heading_1": "HLpat-Heading-H1",
|
||||
"heading_2": "HLpat-Heading-H2",
|
||||
"heading_3": "HLpat-Heading-H3",
|
||||
"list_bullet": "HLpat-Body-B0",
|
||||
"list_numbered": "HLpat-Body-B0",
|
||||
"blockquote": "HLpat-Body-B1"
|
||||
},
|
||||
"defaults": [
|
||||
{"section_key":"letterhead","kind":"prose","order_index":1,"label_de":"Briefkopf","label_en":"Letterhead","included":true,"seed_md_de":"hi","seed_md_en":"hi"},
|
||||
{"section_key":"requests","kind":"requests","order_index":4,"label_de":"Anträge","label_en":"Requests","included":true,"seed_md_de":"","seed_md_en":""}
|
||||
]
|
||||
}`)
|
||||
b := SubmissionBase{SectionSpecRaw: raw}
|
||||
if err := b.decode(); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if b.SectionSpec.Version != 1 {
|
||||
t.Errorf("Version = %d; want 1", b.SectionSpec.Version)
|
||||
}
|
||||
if got := b.SectionSpec.Stylemap["heading_1"]; got != "HLpat-Heading-H1" {
|
||||
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", got)
|
||||
}
|
||||
if len(b.SectionSpec.Defaults) != 2 {
|
||||
t.Fatalf("Defaults len = %d; want 2", len(b.SectionSpec.Defaults))
|
||||
}
|
||||
first := b.SectionSpec.Defaults[0]
|
||||
if first.SectionKey != "letterhead" || first.Kind != "prose" || first.OrderIndex != 1 {
|
||||
t.Errorf("Defaults[0] = %+v; want letterhead/prose/1", first)
|
||||
}
|
||||
if first.SeedMDDE != "hi" || first.SeedMDEN != "hi" {
|
||||
t.Errorf("Defaults[0] seed_md_* = %q/%q; want hi/hi", first.SeedMDDE, first.SeedMDEN)
|
||||
}
|
||||
second := b.SectionSpec.Defaults[1]
|
||||
if second.SectionKey != "requests" || second.Kind != "requests" || second.OrderIndex != 4 {
|
||||
t.Errorf("Defaults[1] = %+v; want requests/requests/4", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSectionSpec_EmptyDecode(t *testing.T) {
|
||||
// A bare row (SectionSpecRaw == nil) decodes cleanly into the
|
||||
// zero value — no panic, no garbage.
|
||||
b := SubmissionBase{}
|
||||
if err := b.decode(); err != nil {
|
||||
t.Fatalf("decode empty: %v", err)
|
||||
}
|
||||
if b.SectionSpec.Version != 0 || len(b.SectionSpec.Defaults) != 0 {
|
||||
t.Errorf("expected zero SectionSpec on empty raw; got %+v", b.SectionSpec)
|
||||
}
|
||||
if b.IsDefaultFor == nil {
|
||||
t.Errorf("IsDefaultFor must be non-nil (empty slice) after decode; got nil")
|
||||
}
|
||||
}
|
||||
469
internal/services/submission_compose.go
Normal file
469
internal/services/submission_compose.go
Normal file
@@ -0,0 +1,469 @@
|
||||
package services
|
||||
|
||||
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
|
||||
// §9.2). Assembles a base .docx and a draft's section rows into a
|
||||
// merged .docx ready for export.
|
||||
//
|
||||
// Pipeline (high-level):
|
||||
//
|
||||
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
|
||||
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
|
||||
// 3. For each section in the draft (order_index ASC, included=true):
|
||||
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
|
||||
// base.section_spec.stylemap.paragraph.
|
||||
// 4. Splice the rendered OOXML into the base body. Two splice modes:
|
||||
// - Anchor mode: when the body carries `{{#section:KEY}}` /
|
||||
// `{{/section:KEY}}` marker pairs, replace the slot's content
|
||||
// (including the anchor paragraphs themselves) with the rendered
|
||||
// section.
|
||||
// - Append mode: when no anchor pair is found for a section, the
|
||||
// rendered OOXML appends at the end of the body, just before any
|
||||
// `<w:sectPr>` element. Sections with `included=false` are
|
||||
// dropped silently.
|
||||
// 5. Strip any leftover unmatched anchor paragraphs.
|
||||
// 6. Re-pack the document.xml into the zip, leaving every other part
|
||||
// untouched.
|
||||
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
|
||||
// so `{{path}}` placeholders inside section content (and inside
|
||||
// the base's untouched chrome) get substituted by the merged bag.
|
||||
// Cross-run merge in pass 2 handles autocorrect-fragmented
|
||||
// placeholders the same as v1.
|
||||
//
|
||||
// Result: a fully-merged .docx. No new third-party Go dep — reuses
|
||||
// archive/zip + the existing SubmissionRenderer.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SubmissionComposer assembles base + sections into a final .docx.
|
||||
// Stateless; safe for concurrent use.
|
||||
type SubmissionComposer struct {
|
||||
renderer *SubmissionRenderer
|
||||
}
|
||||
|
||||
// NewSubmissionComposer wires the composer. The renderer is required —
|
||||
// a nil renderer is a programmer error and the composer panics at
|
||||
// construction.
|
||||
func NewSubmissionComposer(renderer *SubmissionRenderer) *SubmissionComposer {
|
||||
if renderer == nil {
|
||||
panic("submission composer: renderer required")
|
||||
}
|
||||
return &SubmissionComposer{renderer: renderer}
|
||||
}
|
||||
|
||||
// ComposeOptions carries the per-call composition inputs.
|
||||
type ComposeOptions struct {
|
||||
// Sections are the draft's section rows in display order. The
|
||||
// composer renders included sections; excluded rows are dropped.
|
||||
// Caller is responsible for visibility — by the time the composer
|
||||
// runs, the section rows have already been gated through
|
||||
// SubmissionDraftService.Get + can_see_project.
|
||||
Sections []SubmissionSection
|
||||
|
||||
// Base supplies the document chrome (.docx body host) plus the
|
||||
// stylemap for the MD walker. Must not be nil.
|
||||
Base *SubmissionBase
|
||||
|
||||
// BaseBytes is the raw .docx bytes for the base. Typically fetched
|
||||
// from Gitea via the existing template cache.
|
||||
BaseBytes []byte
|
||||
|
||||
// Lang ('de' or 'en') selects which content_md_* column the
|
||||
// composer reads per section. Defaults to 'de' if empty.
|
||||
Lang string
|
||||
|
||||
// Vars is the merged placeholder bag the v1 renderer pass
|
||||
// substitutes after the composer assembly. Passed straight through
|
||||
// to SubmissionRenderer.Render.
|
||||
Vars PlaceholderMap
|
||||
|
||||
// Missing translates an unbound placeholder key into the marker
|
||||
// the lawyer sees in Word. Passed straight to the renderer.
|
||||
Missing MissingPlaceholderFn
|
||||
}
|
||||
|
||||
// Compose runs the full pipeline and returns the merged .docx bytes.
|
||||
func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) ([]byte, error) {
|
||||
if opts.Base == nil {
|
||||
return nil, fmt.Errorf("submission compose: base required")
|
||||
}
|
||||
_ = ctx // reserved for cancellation propagation in later slices
|
||||
sections := opts.Sections
|
||||
|
||||
// Pre-pass: strip macros so the base reads as a plain .docx zip.
|
||||
cleanBytes, err := ConvertDotmToDocx(opts.BaseBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: convert base: %w", err)
|
||||
}
|
||||
|
||||
// Locate + extract word/document.xml so we can splice in-place.
|
||||
documentXML, otherParts, err := splitBaseZip(cleanBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build the rendered-section map: section_key → OOXML span.
|
||||
style := opts.Base.SectionSpec.Stylemap["paragraph"]
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]SubmissionSection, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
if !sec.Included {
|
||||
continue
|
||||
}
|
||||
md := sec.ContentMDDE
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXML(md, style)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
// belt-and-braces in case the caller swaps the ordering policy
|
||||
// later.
|
||||
sort.SliceStable(keptSections, func(i, j int) bool {
|
||||
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
|
||||
})
|
||||
|
||||
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
||||
|
||||
// Re-pack into a zip with the assembled document.xml. All other
|
||||
// parts (styles, fonts, headers, footers, theme, settings) pass
|
||||
// through bit-for-bit at their original mtime + compression.
|
||||
repacked, err := repackBaseZip(otherParts, assembledBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Final pass: substitute placeholders against the merged bag. The
|
||||
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
|
||||
// alias contract, and the missing-marker emission. Reusing it
|
||||
// guarantees v1's placeholder grammar stays intact inside section
|
||||
// content + base chrome.
|
||||
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Section splicing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Anchor markers as they appear inside a <w:t> text node. We don't
|
||||
// need a full XML parse — finding the marker text inside the body is
|
||||
// sufficient because:
|
||||
// - {{ and }} are never legitimate document content (placeholders
|
||||
// follow the same convention everywhere else in paliad).
|
||||
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
|
||||
// special characters.
|
||||
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
|
||||
// exactly one <w:r>...</w:r>, which lives in exactly one
|
||||
// <w:p>...</w:p>. We expand from the marker outward to find the
|
||||
// enclosing <w:p> span and drop the entire paragraph as part of
|
||||
// the splice.
|
||||
//
|
||||
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
|
||||
// implemented as manual byte-index search around the marker hit
|
||||
// (anchorParagraphSpan below) rather than a single regex pattern.
|
||||
|
||||
const (
|
||||
anchorOpenPrefix = "{{#section:"
|
||||
anchorClosePrefix = "{{/section:"
|
||||
anchorSuffix = "}}"
|
||||
)
|
||||
|
||||
// anchorKeyRegex validates that the captured anchor key is a clean
|
||||
// identifier. Keys that include other characters (which can't actually
|
||||
// appear in our authored .docx) are treated as no match.
|
||||
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
|
||||
// anchorPair records the byte span of one matched anchor pair inside
|
||||
// the body — from the start of the opening anchor's <w:p> element
|
||||
// through the end of the closing anchor's </w:p>.
|
||||
type anchorPair struct {
|
||||
key string
|
||||
openStart int // start of <w:p> for the opening anchor
|
||||
closeEnd int // index just past </w:p> for the closing anchor
|
||||
}
|
||||
|
||||
// findAllAnchorPairs scans the body for matched open/close anchor
|
||||
// pairs. Unbalanced markers (open without close, or vice versa) are
|
||||
// dropped from the result. Returns pairs in body-order; each pair's
|
||||
// span is non-overlapping.
|
||||
func findAllAnchorPairs(body string) []anchorPair {
|
||||
type marker struct {
|
||||
key string
|
||||
paraStart int
|
||||
paraEnd int
|
||||
isOpen bool
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
collect := func(prefix string, isOpen bool) {
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(body[offset:], prefix)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
start := offset + idx
|
||||
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
|
||||
if suffixIdx < 0 {
|
||||
return
|
||||
}
|
||||
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
|
||||
if !anchorKeyRegex.MatchString(key) {
|
||||
offset = start + len(prefix)
|
||||
continue
|
||||
}
|
||||
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
|
||||
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
|
||||
if !ok {
|
||||
offset = markerEnd
|
||||
continue
|
||||
}
|
||||
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
|
||||
offset = pEnd
|
||||
}
|
||||
}
|
||||
collect(anchorOpenPrefix, true)
|
||||
collect(anchorClosePrefix, false)
|
||||
|
||||
// Walk markers in body-order, matching each open with the next
|
||||
// close that carries the same key.
|
||||
sort.SliceStable(markers, func(i, j int) bool {
|
||||
return markers[i].paraStart < markers[j].paraStart
|
||||
})
|
||||
var pairs []anchorPair
|
||||
openStack := map[string]marker{}
|
||||
for _, m := range markers {
|
||||
if m.isOpen {
|
||||
openStack[m.key] = m
|
||||
continue
|
||||
}
|
||||
o, ok := openStack[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, anchorPair{
|
||||
key: m.key,
|
||||
openStart: o.paraStart,
|
||||
closeEnd: m.paraEnd,
|
||||
})
|
||||
delete(openStack, m.key)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
|
||||
// element that fully contains the byte range [markerStart, markerEnd).
|
||||
// Returns false when the byte range doesn't sit inside a single
|
||||
// paragraph (which would mean the marker survived a cross-paragraph
|
||||
// edit — defensive guard, shouldn't happen in well-formed input).
|
||||
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
|
||||
// Walk backwards to find the nearest unclosed <w:p ... > opening.
|
||||
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
|
||||
// the enclosing paragraph's opening tag.
|
||||
pStart := -1
|
||||
cursor := markerStart
|
||||
for cursor > 0 {
|
||||
idx := strings.LastIndex(body[:cursor], "<w:p")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
// Confirm this is a paragraph open, not a different
|
||||
// w:p-prefixed tag (e.g. <w:pPr>).
|
||||
if idx+4 <= len(body) {
|
||||
after := body[idx+4]
|
||||
if after == ' ' || after == '>' || after == '/' {
|
||||
// <w:p ...> or <w:p>; not <w:pPr>.
|
||||
close := strings.Index(body[idx:], ">")
|
||||
if close < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pStart = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
cursor = idx
|
||||
}
|
||||
if pStart < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
|
||||
// the next </w:p> after the marker is the close.
|
||||
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
|
||||
if pEndIdx < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pEnd := markerEnd + pEndIdx + len("</w:p>")
|
||||
return pStart, pEnd, true
|
||||
}
|
||||
|
||||
// spliceSections replaces anchor slots with rendered sections and
|
||||
// appends any unanchored sections before sectPr. Returns the assembled
|
||||
// document.xml body.
|
||||
func spliceSections(documentXML []byte, rendered map[string]string, kept []SubmissionSection, all []SubmissionSection) []byte {
|
||||
body := string(documentXML)
|
||||
pairs := findAllAnchorPairs(body)
|
||||
|
||||
// Build a lookup of kept section keys for quick membership tests.
|
||||
keptByKey := map[string]int{}
|
||||
for i, sec := range kept {
|
||||
keptByKey[sec.SectionKey] = i
|
||||
}
|
||||
allByKey := map[string]int{}
|
||||
for i, sec := range all {
|
||||
allByKey[sec.SectionKey] = i
|
||||
}
|
||||
|
||||
matchedKeys := map[string]bool{}
|
||||
|
||||
// Walk pairs in REVERSE body-order so slice mutations don't shift
|
||||
// later offsets.
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
return pairs[i].openStart > pairs[j].openStart
|
||||
})
|
||||
for _, p := range pairs {
|
||||
replacement := ""
|
||||
if idx, ok := keptByKey[p.key]; ok {
|
||||
replacement = rendered[p.key]
|
||||
matchedKeys[p.key] = true
|
||||
_ = idx
|
||||
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
|
||||
// Anchor matches an excluded section on the draft — drop
|
||||
// the entire slot.
|
||||
replacement = ""
|
||||
} else {
|
||||
// Anchor doesn't match any section on this draft — drop
|
||||
// to leave the base's chrome unbroken.
|
||||
replacement = ""
|
||||
}
|
||||
body = body[:p.openStart] + replacement + body[p.closeEnd:]
|
||||
}
|
||||
|
||||
// Append unanchored sections before sectPr in order_index ASC.
|
||||
var unanchored strings.Builder
|
||||
for _, sec := range kept {
|
||||
if matchedKeys[sec.SectionKey] {
|
||||
continue
|
||||
}
|
||||
unanchored.WriteString(rendered[sec.SectionKey])
|
||||
}
|
||||
if unanchored.Len() > 0 {
|
||||
body = appendBeforeSectPr(body, unanchored.String())
|
||||
}
|
||||
|
||||
return []byte(body)
|
||||
}
|
||||
|
||||
// appendBeforeSectPr inserts content immediately before the first
|
||||
// `<w:sectPr` element in the body, or at the end of the body if there
|
||||
// is none. Word documents conventionally close the body with a sectPr
|
||||
// describing page setup; we want to land sections before that element
|
||||
// so they show up on the actual pages.
|
||||
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
|
||||
|
||||
func appendBeforeSectPr(body, content string) string {
|
||||
loc := sectPrRegex.FindStringIndex(body)
|
||||
if loc == nil {
|
||||
// No sectPr → append before `</w:body>` if present, else at
|
||||
// the very end.
|
||||
idx := strings.LastIndex(body, "</w:body>")
|
||||
if idx < 0 {
|
||||
return body + content
|
||||
}
|
||||
return body[:idx] + content + body[idx:]
|
||||
}
|
||||
return body[:loc[0]] + content + body[loc[0]:]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zip plumbing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// baseZipPart captures one zip entry we kept aside while extracting
|
||||
// document.xml.
|
||||
type baseZipPart struct {
|
||||
name string
|
||||
method uint16
|
||||
modTime int64 // wall seconds; converted back to time.Time on repack
|
||||
body []byte
|
||||
}
|
||||
|
||||
// splitBaseZip extracts document.xml and returns it alongside every
|
||||
// other zip entry, ready for repacking.
|
||||
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
|
||||
}
|
||||
var documentXML []byte
|
||||
parts := make([]baseZipPart, 0, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
documentXML = body
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
|
||||
continue
|
||||
}
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
|
||||
}
|
||||
if documentXML == nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
|
||||
}
|
||||
return documentXML, parts, nil
|
||||
}
|
||||
|
||||
// repackBaseZip rebuilds the zip, swapping document.xml for the
|
||||
// assembled body and leaving every other part untouched.
|
||||
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, p := range parts {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: p.name,
|
||||
Method: p.method,
|
||||
}
|
||||
if p.modTime > 0 {
|
||||
hdr.Modified = time.Unix(p.modTime, 0)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
|
||||
}
|
||||
body := p.body
|
||||
if p.name == "word/document.xml" {
|
||||
body = assembledBody
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
276
internal/services/submission_compose_test.go
Normal file
276
internal/services/submission_compose_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package services
|
||||
|
||||
// Unit tests for SubmissionComposer's pure splice logic — no DB
|
||||
// dependency. The end-to-end Compose path is exercised by the live
|
||||
// integration test in submission_section_service_live_test.go (Slice
|
||||
// A) once anchors land in the seeded .docx; this file covers the
|
||||
// anchor-splicing primitives and the section rendering glue.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// minimalBaseBytes builds a tiny .docx zip with one document.xml body
|
||||
// for the composer tests. The body content is provided by the caller
|
||||
// so different splice scenarios can be exercised in-process.
|
||||
func minimalBaseBytes(t *testing.T, body string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
parts := map[string]string{
|
||||
"[Content_Types].xml": `<?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"/>
|
||||
</Types>`,
|
||||
"_rels/.rels": `<?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>`,
|
||||
"word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>` + body + `</w:body>
|
||||
</w:document>`,
|
||||
}
|
||||
for name, contents := range parts {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(contents)); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// extractDocumentXML pulls word/document.xml out of a .docx zip for
|
||||
// assertions.
|
||||
func extractDocumentXML(t *testing.T, data []byte) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(rc); err != nil {
|
||||
t.Fatalf("read document.xml: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
t.Fatal("document.xml not found in zip")
|
||||
return ""
|
||||
}
|
||||
|
||||
// composerBase returns a SubmissionBase wired with the neutral
|
||||
// stylemap for composer tests.
|
||||
func composerBase() *SubmissionBase {
|
||||
return &SubmissionBase{
|
||||
ID: uuid.New(),
|
||||
Slug: "test-base",
|
||||
SectionSpec: BaseSectionSpec{
|
||||
Version: 1,
|
||||
Stylemap: map[string]string{
|
||||
"paragraph": "Normal",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_AppendMode_NoAnchors(t *testing.T) {
|
||||
// Base has no anchors → composer appends sections before sectPr.
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>Static chrome</w:t></w:r></w:p><w:sectPr/>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Section text"},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections,
|
||||
Base: base,
|
||||
BaseBytes: baseBytes,
|
||||
Lang: "de",
|
||||
Vars: PlaceholderMap{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if !strings.Contains(docXML, "Static chrome") {
|
||||
t.Errorf("base chrome dropped: %q", docXML)
|
||||
}
|
||||
if !strings.Contains(docXML, "Section text") {
|
||||
t.Errorf("section content missing: %q", docXML)
|
||||
}
|
||||
// Section must land before sectPr (rule of thumb: it's an end-of-body element).
|
||||
staticIdx := strings.Index(docXML, "Section text")
|
||||
sectPrIdx := strings.Index(docXML, "<w:sectPr")
|
||||
if staticIdx < 0 || sectPrIdx < 0 || staticIdx > sectPrIdx {
|
||||
t.Errorf("section landed after sectPr: section=%d sectPr=%d", staticIdx, sectPrIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_AnchorMode_SpliceContent(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>(seed)</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Real prose"},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if !strings.Contains(docXML, "Header") || !strings.Contains(docXML, "Footer") {
|
||||
t.Errorf("base chrome dropped: %q", docXML)
|
||||
}
|
||||
if !strings.Contains(docXML, "Real prose") {
|
||||
t.Errorf("section content missing: %q", docXML)
|
||||
}
|
||||
// Anchor paragraphs themselves must be gone.
|
||||
if strings.Contains(docXML, "{{#section:facts}}") || strings.Contains(docXML, "{{/section:facts}}") {
|
||||
t.Errorf("anchor markers survived: %q", docXML)
|
||||
}
|
||||
// Seed content between anchors must be gone (replaced by the
|
||||
// composed section).
|
||||
if strings.Contains(docXML, "(seed)") {
|
||||
t.Errorf("anchor-spanned seed survived: %q", docXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_ExcludedSection_DropsAnchorPair(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{#section:exhibits}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>(default)</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{/section:exhibits}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "exhibits", OrderIndex: 8, Kind: "prose", Included: false, ContentMDDE: "ignored"},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if strings.Contains(docXML, "{{#section:exhibits}}") || strings.Contains(docXML, "{{/section:exhibits}}") {
|
||||
t.Errorf("anchors for excluded section survived: %q", docXML)
|
||||
}
|
||||
if strings.Contains(docXML, "ignored") {
|
||||
t.Errorf("excluded section content rendered: %q", docXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_PlaceholdersResolve(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>{{#section:greeting}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{/section:greeting}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "greeting", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Hallo {{user.name}}"},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
Vars: PlaceholderMap{"user.name": "Maria Schmidt"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if !strings.Contains(docXML, "Hallo") || !strings.Contains(docXML, "Maria Schmidt") {
|
||||
t.Errorf("placeholder not substituted: %q", docXML)
|
||||
}
|
||||
if strings.Contains(docXML, "{{user.name}}") {
|
||||
t.Errorf("placeholder survived: %q", docXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_LangPicksColumn(t *testing.T) {
|
||||
base := composerBase()
|
||||
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)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
|
||||
ContentMDDE: "deutscher text", ContentMDEN: "english text"},
|
||||
}
|
||||
deOut, _ := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
enOut, _ := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "en",
|
||||
})
|
||||
deXML := extractDocumentXML(t, deOut)
|
||||
enXML := extractDocumentXML(t, enOut)
|
||||
if !strings.Contains(deXML, "deutscher text") || strings.Contains(deXML, "english text") {
|
||||
t.Errorf("DE pick failed: %q", deXML)
|
||||
}
|
||||
if !strings.Contains(enXML, "english text") || strings.Contains(enXML, "deutscher text") {
|
||||
t.Errorf("EN pick failed: %q", enXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_OrderIndexAscending(t *testing.T) {
|
||||
base := composerBase()
|
||||
// No anchors → both sections append in order_index ASC order
|
||||
// before sectPr.
|
||||
body := `<w:sectPr/>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "second", OrderIndex: 2, Kind: "prose", Included: true, ContentMDDE: "ZWEITER"},
|
||||
{ID: uuid.New(), SectionKey: "first", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "ERSTER"},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
firstIdx := strings.Index(docXML, "ERSTER")
|
||||
secondIdx := strings.Index(docXML, "ZWEITER")
|
||||
if firstIdx < 0 || secondIdx < 0 || firstIdx > secondIdx {
|
||||
t.Errorf("order_index ASC not honoured: ERSTER=%d ZWEITER=%d", firstIdx, secondIdx)
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,17 @@ type SubmissionDraft struct {
|
||||
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
|
||||
LastImportedAt *time.Time `db:"last_imported_at" json:"last_imported_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// BaseID is the Composer base reference (t-paliad-313). NULL on
|
||||
// pre-Composer drafts — the v1 render path stays the fallback.
|
||||
// ON DELETE SET NULL keeps a draft renderable if its base is
|
||||
// removed; the lawyer picks a new one via the sidebar.
|
||||
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
|
||||
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
|
||||
// Slice A: empty default. Future slices populate section_order,
|
||||
// hidden_sections, etc.
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
@@ -70,15 +79,36 @@ type SubmissionDraft struct {
|
||||
// the backward-compat "include every party" behaviour; a non-empty
|
||||
// slice restricts the variable bag to the listed paliad.parties rows.
|
||||
SelectedParties []uuid.UUID `json:"selected_parties"`
|
||||
|
||||
// ComposerMeta is the parsed Composer-side metadata (t-paliad-313).
|
||||
// Slice A: typically empty. Populated on read by decodeComposerMeta().
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
}
|
||||
|
||||
// SubmissionDraftService handles CRUD on submission_drafts and exposes
|
||||
// the render/preview/export entry points the handler layer calls.
|
||||
//
|
||||
// The Composer wiring (t-paliad-313, Slice A): bases + sections are
|
||||
// optional — when nil the service stays back-compat with the v1 shape
|
||||
// (drafts created without a base_id, no section rows). When wired, new
|
||||
// drafts created via Create get base_id seeded from the firm default
|
||||
// and submission_sections rows inserted from the base's section spec.
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
vars *SubmissionVarsService
|
||||
renderer *SubmissionRenderer
|
||||
|
||||
// bases + sections are optional Composer wiring (t-paliad-313).
|
||||
// Nil means "stay back-compat with the v1 shape" — new drafts
|
||||
// keep base_id NULL and no submission_sections rows get seeded.
|
||||
bases *BaseService
|
||||
sections *SectionService
|
||||
|
||||
// firmName captures branding.Name at construction time. Used to
|
||||
// resolve the firm-default base in Create. Empty string is
|
||||
// allowed (treated as "no firm filter" at base-lookup time).
|
||||
firmName string
|
||||
}
|
||||
|
||||
// NewSubmissionDraftService wires the service.
|
||||
@@ -91,6 +121,19 @@ func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *Subm
|
||||
}
|
||||
}
|
||||
|
||||
// AttachComposer wires the Composer-side services. Called by
|
||||
// cmd/server/main.go after constructing the base + section services.
|
||||
// firm is branding.Name (typically "HLC"); empty string disables the
|
||||
// firm filter at default-base lookup.
|
||||
//
|
||||
// Calling AttachComposer is purely additive — drafts created before the
|
||||
// call (or with bases==nil) keep the v1 behaviour. Idempotent.
|
||||
func (s *SubmissionDraftService) AttachComposer(bases *BaseService, sections *SectionService, firm string) {
|
||||
s.bases = bases
|
||||
s.sections = sections
|
||||
s.firmName = firm
|
||||
}
|
||||
|
||||
// DraftPatch carries optional fields for Update. nil pointer = "no
|
||||
// change"; non-nil = "set to this". Variables is replace-semantics —
|
||||
// the lawyer's sidebar sends the full map every save.
|
||||
@@ -117,6 +160,16 @@ type DraftPatch struct {
|
||||
// Language sets the output language. Valid values: "de", "en".
|
||||
// Anything else returns ErrInvalidInput. t-paliad-276.
|
||||
Language *string
|
||||
|
||||
// BaseID swaps the Composer base. Two-level pointer mirrors the
|
||||
// ProjectID shape so callers can encode the three operations:
|
||||
// nil → no change
|
||||
// *p == nil → clear (set base_id NULL, return to v1 fallback)
|
||||
// **p → set to the picked base
|
||||
// Slice A: lawyer flips this from the sidebar picker. Section
|
||||
// content is unaffected — the base swap is render-side only.
|
||||
// t-paliad-313.
|
||||
BaseID **uuid.UUID
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
@@ -133,6 +186,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
|
||||
variables, selected_parties,
|
||||
last_exported_at, last_exported_sha,
|
||||
last_imported_at,
|
||||
base_id, composer_meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
@@ -185,6 +239,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
|
||||
d.variables, d.selected_parties,
|
||||
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
|
||||
d.base_id, d.composer_meta,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
@@ -279,6 +334,14 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
|
||||
// A nil projectID creates a project-less draft (t-paliad-243); the
|
||||
// visibility check is skipped — the caller is the owner and the row is
|
||||
// private to them.
|
||||
//
|
||||
// Composer wiring (t-paliad-313, Slice A): when AttachComposer has
|
||||
// been called and a base resolves for the submission_code, the INSERT
|
||||
// runs in a transaction alongside SectionService.SeedFromSpec so the
|
||||
// new draft and its seeded sections land atomically. If the base
|
||||
// lookup fails (catalog empty, no firm match, etc.) the draft still
|
||||
// creates with base_id=NULL — Composer is additive, the v1 fallback
|
||||
// path remains valid.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
@@ -294,16 +357,61 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
|
||||
// Anything other than "en" normalizes to "de" — matches the DB CHECK
|
||||
// constraint and the project's primary-language default.
|
||||
draftLang := normalizeDraftLanguage(lang)
|
||||
|
||||
// Resolve the Composer base for this draft. nil result keeps the
|
||||
// draft v1-shaped (base_id NULL, no sections rows).
|
||||
var baseToSeed *SubmissionBase
|
||||
if s.bases != nil {
|
||||
base, err := s.bases.GetDefaultForCode(ctx, s.firmName, submissionCode)
|
||||
switch {
|
||||
case err == nil:
|
||||
baseToSeed = base
|
||||
case errors.Is(err, ErrBaseNotFound):
|
||||
// Catalog empty / no match — fall through to v1 shape.
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin create submission draft tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var baseID *uuid.UUID
|
||||
if baseToSeed != nil {
|
||||
id := baseToSeed.ID
|
||||
baseID = &id
|
||||
}
|
||||
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d,
|
||||
err = tx.GetContext(ctx, &d,
|
||||
`INSERT INTO paliad.submission_drafts
|
||||
(project_id, submission_code, user_id, name, language)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
(project_id, submission_code, user_id, name, language, base_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING `+draftColumns,
|
||||
projectID, submissionCode, userID, name, draftLang)
|
||||
projectID, submissionCode, userID, name, draftLang, baseID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create submission draft: %w", err)
|
||||
}
|
||||
|
||||
if baseToSeed != nil && s.sections != nil {
|
||||
if err := s.sections.SeedFromSpec(ctx, tx, d.ID, baseToSeed.SectionSpec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create submission draft tx: %w", err)
|
||||
}
|
||||
committed = true
|
||||
|
||||
if err := d.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -446,6 +554,18 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.BaseID != nil {
|
||||
newBID := *patch.BaseID // *uuid.UUID — nil means clear
|
||||
if newBID != nil && s.bases != nil {
|
||||
// Validate the picked base exists + is active.
|
||||
if _, err := s.bases.GetByID(ctx, *newBID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("base_id = $%d", idx))
|
||||
args = append(args, newBID)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
@@ -682,14 +802,32 @@ func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, us
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// decode fills the parsed views (Variables, SelectedParties) from the
|
||||
// raw scan fields. Called by every fetch path so the caller sees both
|
||||
// populated together.
|
||||
// decode fills the parsed views (Variables, SelectedParties,
|
||||
// ComposerMeta) from the raw scan fields. Called by every fetch path
|
||||
// so the caller sees them populated together.
|
||||
func (d *SubmissionDraft) decode() error {
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.decodeSelectedParties()
|
||||
if err := d.decodeSelectedParties(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.decodeComposerMeta()
|
||||
}
|
||||
|
||||
// decodeComposerMeta turns the raw composer_meta jsonb into a
|
||||
// map[string]any. NULL or empty payload yields an empty map.
|
||||
func (d *SubmissionDraft) decodeComposerMeta() error {
|
||||
if len(d.ComposerMetaRaw) == 0 {
|
||||
d.ComposerMeta = map[string]any{}
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal(d.ComposerMetaRaw, &out); err != nil {
|
||||
return fmt.Errorf("decode submission draft composer_meta: %w", err)
|
||||
}
|
||||
d.ComposerMeta = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
|
||||
|
||||
239
internal/services/submission_md.go
Normal file
239
internal/services/submission_md.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package services
|
||||
|
||||
// Markdown → OOXML walker for Composer section content (t-paliad-313
|
||||
// Slice B, design doc §9.2).
|
||||
//
|
||||
// Scope per the head's Slice B brief: paragraphs + inline bold/italic
|
||||
// only. Headings, lists, blockquote, links land in Slice D's rich-prose
|
||||
// pass. This walker is intentionally minimal — every Markdown construct
|
||||
// it doesn't recognise is rendered as a plain paragraph so the lawyer's
|
||||
// prose round-trips losslessly even when they hit Markdown the walker
|
||||
// doesn't yet understand.
|
||||
//
|
||||
// The output uses the base's stylemap.paragraph entry for the
|
||||
// <w:pStyle> on each paragraph so the styling matches the base's
|
||||
// typography (HLpat-Body-B0 on the HLC base, Normal on the neutral
|
||||
// base, etc.).
|
||||
//
|
||||
// Placeholders ({{path.dot.notation}}) are preserved verbatim — they
|
||||
// pass through the walker untouched and get substituted by the v1
|
||||
// SubmissionRenderer's placeholder pass after the composer assembly.
|
||||
//
|
||||
// Grammar supported:
|
||||
//
|
||||
// - Blank line → paragraph break
|
||||
// - `**bold**` → <w:r><w:rPr><w:b/></w:rPr><w:t>…</w:t></w:r>
|
||||
// - `*italic*` or `_italic_` → <w:r><w:rPr><w:i/></w:rPr>…</w:r>
|
||||
// - Otherwise → plain text run
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
|
||||
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
|
||||
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
|
||||
// when paragraphStyle is non-empty.
|
||||
//
|
||||
// Empty input renders one empty paragraph so the splice site is
|
||||
// well-formed even when the lawyer hasn't typed anything in this
|
||||
// section.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
if md == "" {
|
||||
return emptyParagraph(paragraphStyle)
|
||||
}
|
||||
paragraphs := splitMarkdownParagraphs(md)
|
||||
if len(paragraphs) == 0 {
|
||||
return emptyParagraph(paragraphStyle)
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, para := range paragraphs {
|
||||
b.WriteString(renderParagraph(para, paragraphStyle))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// splitMarkdownParagraphs splits the source into paragraphs. A
|
||||
// "paragraph" is a maximal run of non-blank lines. N consecutive blank
|
||||
// lines between two paragraphs produce (N-1) empty paragraphs in the
|
||||
// output so the lawyer's intentional vertical spacing survives.
|
||||
//
|
||||
// CRLF line endings normalise to LF before splitting.
|
||||
func splitMarkdownParagraphs(md string) []string {
|
||||
normalised := strings.ReplaceAll(md, "\r\n", "\n")
|
||||
lines := strings.Split(normalised, "\n")
|
||||
var paragraphs []string
|
||||
var current []string
|
||||
blankRun := 0
|
||||
flushParagraph := func() {
|
||||
if len(current) > 0 {
|
||||
paragraphs = append(paragraphs, strings.Join(current, "\n"))
|
||||
current = nil
|
||||
}
|
||||
}
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(current) > 0 {
|
||||
// End of a paragraph; the blank-counting starts now.
|
||||
flushParagraph()
|
||||
blankRun = 1
|
||||
continue
|
||||
}
|
||||
// Already inside a blank run (or before the first paragraph).
|
||||
blankRun++
|
||||
continue
|
||||
}
|
||||
// Starting a new paragraph — emit (blankRun-1) empty paragraphs
|
||||
// in between if the lawyer used multiple blank lines as
|
||||
// vertical spacing.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
paragraphs = append(paragraphs, "")
|
||||
}
|
||||
blankRun = 0
|
||||
current = append(current, line)
|
||||
}
|
||||
flushParagraph()
|
||||
return paragraphs
|
||||
}
|
||||
|
||||
// renderParagraph emits one `<w:p>` element for the given paragraph
|
||||
// text. Inline bold/italic spans become `<w:r>` runs with the
|
||||
// corresponding `<w:rPr>`.
|
||||
func renderParagraph(text, paragraphStyle string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
if text == "" {
|
||||
// Empty paragraph — emit a single empty run so Word renders the
|
||||
// paragraph as a blank line. Without the run, some Word
|
||||
// versions collapse the paragraph entirely.
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
for _, span := range parseInlineSpans(text) {
|
||||
b.WriteString(renderRun(span))
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// inlineSpan is one piece of inline content: a text payload plus
|
||||
// formatting flags. Bold and italic are independent — `***both***`
|
||||
// produces one span with both flags set.
|
||||
type inlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
}
|
||||
|
||||
// parseInlineSpans tokenises Markdown inline formatting into runs of
|
||||
// (text, bold, italic). The grammar is intentionally narrow:
|
||||
//
|
||||
// - `**…**` → bold
|
||||
// - `__…__` → bold (Markdown alternate)
|
||||
// - `*…*` → italic
|
||||
// - `_…_` → italic (Markdown alternate)
|
||||
// - Anything else flows through as plain text.
|
||||
//
|
||||
// Unbalanced delimiters fall through as literal characters — the
|
||||
// walker never errors on malformed Markdown. Nested formatting (e.g.
|
||||
// `**bold *bold-italic* bold**`) toggles flags as it walks.
|
||||
func parseInlineSpans(text string) []inlineSpan {
|
||||
var out []inlineSpan
|
||||
var cur strings.Builder
|
||||
bold := false
|
||||
italic := false
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, inlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
|
||||
cur.Reset()
|
||||
}
|
||||
i := 0
|
||||
n := len(text)
|
||||
for i < n {
|
||||
// Bold delimiters first (longer match wins over italic).
|
||||
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
|
||||
flush()
|
||||
bold = !bold
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if text[i] == '*' || text[i] == '_' {
|
||||
flush()
|
||||
italic = !italic
|
||||
i++
|
||||
continue
|
||||
}
|
||||
cur.WriteByte(text[i])
|
||||
i++
|
||||
}
|
||||
flush()
|
||||
if len(out) == 0 {
|
||||
out = append(out, inlineSpan{Text: ""})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderRun emits one `<w:r>` element for an inline span. Empty text
|
||||
// spans render as empty runs (Word accepts them; they're harmless).
|
||||
func renderRun(span inlineSpan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:r>`)
|
||||
if span.Bold || span.Italic {
|
||||
b.WriteString(`<w:rPr>`)
|
||||
if span.Bold {
|
||||
b.WriteString(`<w:b/>`)
|
||||
}
|
||||
if span.Italic {
|
||||
b.WriteString(`<w:i/>`)
|
||||
}
|
||||
b.WriteString(`</w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlTextEscape(span.Text))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// emptyParagraph returns one empty `<w:p>` with the given style. Used
|
||||
// when a section's content_md is empty so the splice site stays
|
||||
// well-formed.
|
||||
func emptyParagraph(paragraphStyle string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// xmlTextEscape escapes the five XML-significant characters for safe
|
||||
// insertion into <w:t> content. & first to avoid double-encoding.
|
||||
func xmlTextEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
// Quotes and apostrophes are legal inside element text content;
|
||||
// no need to escape them here.
|
||||
return s
|
||||
}
|
||||
|
||||
// xmlAttrEscape escapes for safe insertion into an attribute value
|
||||
// (e.g. `<w:pStyle w:val="…"/>`).
|
||||
func xmlAttrEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
146
internal/services/submission_md_test.go
Normal file
146
internal/services/submission_md_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package services
|
||||
|
||||
// Unit tests for the Composer's Markdown → OOXML walker (t-paliad-313
|
||||
// Slice B). Pure function; no DB dependency.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMarkdownToOOXML_EmptyInput(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("", "Normal")
|
||||
if !strings.Contains(out, `<w:p>`) {
|
||||
t.Errorf("empty input must still emit one <w:p>; got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `<w:pStyle w:val="Normal"/>`) {
|
||||
t.Errorf("empty input must carry the paragraph style; got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_SingleParagraph(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("Hello world", "HLpat-Body-B0")
|
||||
if !strings.Contains(out, `<w:pStyle w:val="HLpat-Body-B0"/>`) {
|
||||
t.Errorf("paragraph missing stylemap entry: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "Hello world") {
|
||||
t.Errorf("paragraph text missing: %q", out)
|
||||
}
|
||||
// Exactly one <w:p>.
|
||||
if got := strings.Count(out, "<w:p>"); got != 1 {
|
||||
t.Errorf("expected 1 <w:p>; got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_TwoParagraphs(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("first\n\nsecond", "Normal")
|
||||
if got := strings.Count(out, "<w:p>"); got != 2 {
|
||||
t.Errorf("expected 2 <w:p>; got %d, out=%q", got, out)
|
||||
}
|
||||
if !strings.Contains(out, "first") || !strings.Contains(out, "second") {
|
||||
t.Errorf("paragraph text missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_BoldInline(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("hello **bold** world", "")
|
||||
if !strings.Contains(out, `<w:rPr><w:b/></w:rPr>`) {
|
||||
t.Errorf("bold rPr missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, ">bold<") {
|
||||
t.Errorf("bold text payload missing: %q", out)
|
||||
}
|
||||
// The surrounding "hello " and " world" pieces are separate runs;
|
||||
// the bold rPr should appear exactly once in this output.
|
||||
if got := strings.Count(out, "<w:b/>"); got != 1 {
|
||||
t.Errorf("expected exactly one <w:b/> tag; got %d in %q", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_ItalicInline(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("see *italic* here", "")
|
||||
if !strings.Contains(out, `<w:rPr><w:i/></w:rPr>`) {
|
||||
t.Errorf("italic rPr missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, ">italic<") {
|
||||
t.Errorf("italic text payload missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_BoldItalicCombo(t *testing.T) {
|
||||
// Nested: ***both*** → entering both flags. The walker toggles each
|
||||
// delimiter independently, so the resulting run carries both <w:b/>
|
||||
// and <w:i/>.
|
||||
out := RenderMarkdownToOOXML("***both***", "")
|
||||
if !strings.Contains(out, `<w:b/>`) || !strings.Contains(out, `<w:i/>`) {
|
||||
t.Errorf("expected both <w:b/> and <w:i/>; got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_PlaceholdersPassThrough(t *testing.T) {
|
||||
// Placeholders are sacred — the walker must preserve them verbatim
|
||||
// so the v1 placeholder pass can substitute them later.
|
||||
out := RenderMarkdownToOOXML("Sehr geehrter {{parties.claimant.0.name}}", "Normal")
|
||||
if !strings.Contains(out, "{{parties.claimant.0.name}}") {
|
||||
t.Errorf("placeholder corrupted: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_XMLEscape(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("a & b < c > d", "")
|
||||
if strings.Contains(out, " & ") {
|
||||
t.Errorf("unescaped & survived: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "&") || !strings.Contains(out, "<") || !strings.Contains(out, ">") {
|
||||
t.Errorf("expected escaped entities; got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_BlankLinesPreserveSpacing(t *testing.T) {
|
||||
// Two blank lines between paragraphs → one empty paragraph in
|
||||
// between, preserving the lawyer's intentional whitespace.
|
||||
out := RenderMarkdownToOOXML("first\n\n\nsecond", "Normal")
|
||||
if got := strings.Count(out, "<w:p>"); got != 3 {
|
||||
t.Errorf("expected 3 <w:p> (first + blank + second); got %d in %q", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_CRLFNormalisation(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("first\r\n\r\nsecond", "")
|
||||
if got := strings.Count(out, "<w:p>"); got != 2 {
|
||||
t.Errorf("CRLF input should produce 2 paragraphs; got %d in %q", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_Plain(t *testing.T) {
|
||||
spans := parseInlineSpans("hello world")
|
||||
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
|
||||
t.Errorf("expected single plain span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_UnderscoreItalic(t *testing.T) {
|
||||
spans := parseInlineSpans("_emph_")
|
||||
var italicHits int
|
||||
for _, s := range spans {
|
||||
if s.Italic && s.Text == "emph" {
|
||||
italicHits++
|
||||
}
|
||||
}
|
||||
if italicHits != 1 {
|
||||
t.Errorf("expected one italic 'emph' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
|
||||
spans := parseInlineSpans("__strong__")
|
||||
var boldHits int
|
||||
for _, s := range spans {
|
||||
if s.Bold && s.Text == "strong" {
|
||||
boldHits++
|
||||
}
|
||||
}
|
||||
if boldHits != 1 {
|
||||
t.Errorf("expected one bold 'strong' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
213
internal/services/submission_section_service.go
Normal file
213
internal/services/submission_section_service.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package services
|
||||
|
||||
// Submission section service — Composer Slice A (t-paliad-313, design
|
||||
// doc docs/design-submission-generator-v2-2026-05-26.md §4.3 + §6).
|
||||
//
|
||||
// Each row in paliad.submission_sections is one ordered, named block
|
||||
// inside a Composer draft. Slice A seeds rows on draft create from the
|
||||
// base's section_spec.defaults and exposes them read-only for the
|
||||
// editor's section-list pane. Slice B turns them editable, Slice F
|
||||
// adds reorder/hide/add-custom.
|
||||
//
|
||||
// Visibility flows through draft_id → submission_drafts → owner-scoped
|
||||
// + can_see_project (RLS in mig 148 mirrors the four-policy shape on
|
||||
// submission_drafts). Service calls go through SubmissionDraftService
|
||||
// for the visibility gate before touching this table.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// SubmissionSection mirrors a row in paliad.submission_sections.
|
||||
type SubmissionSection struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
DraftID uuid.UUID `db:"draft_id" json:"draft_id"`
|
||||
SectionKey string `db:"section_key" json:"section_key"`
|
||||
OrderIndex int `db:"order_index" json:"order_index"`
|
||||
Kind string `db:"kind" json:"kind"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
Included bool `db:"included" json:"included"`
|
||||
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
|
||||
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// SectionService handles per-draft section rows. Slice A: read + seed
|
||||
// only. Editable mutations land in Slice B's brief.
|
||||
type SectionService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewSectionService wires the service.
|
||||
func NewSectionService(db *sqlx.DB) *SectionService {
|
||||
return &SectionService{db: db}
|
||||
}
|
||||
|
||||
// ErrSubmissionSectionNotFound is the sentinel for "no section with
|
||||
// that id visible to this user".
|
||||
var ErrSubmissionSectionNotFound = errors.New("submission section: not found")
|
||||
|
||||
const sectionColumns = `id, draft_id, section_key, order_index, kind,
|
||||
label_de, label_en, included,
|
||||
content_md_de, content_md_en,
|
||||
created_at, updated_at`
|
||||
|
||||
// ListForDraft returns every section row for a draft, ordered by
|
||||
// order_index ASC. Caller is responsible for the visibility gate
|
||||
// (SubmissionDraftService.Get returns ErrSubmissionDraftNotFound for
|
||||
// un-visible drafts, which the handler maps to 404). RLS in mig 148
|
||||
// additionally enforces owner-scope at the DB layer.
|
||||
func (s *SectionService) ListForDraft(ctx context.Context, draftID uuid.UUID) ([]SubmissionSection, error) {
|
||||
var rows []SubmissionSection
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+sectionColumns+`
|
||||
FROM paliad.submission_sections
|
||||
WHERE draft_id = $1
|
||||
ORDER BY order_index ASC`,
|
||||
draftID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission sections: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns one section by id. Visibility gate is the caller's
|
||||
// responsibility — Slice A handlers wrap this with a SubmissionDraftService.Get
|
||||
// to enforce owner+can_see_project before exposing the section.
|
||||
func (s *SectionService) Get(ctx context.Context, sectionID uuid.UUID) (*SubmissionSection, error) {
|
||||
var sec SubmissionSection
|
||||
err := s.db.GetContext(ctx, &sec,
|
||||
`SELECT `+sectionColumns+`
|
||||
FROM paliad.submission_sections
|
||||
WHERE id = $1`,
|
||||
sectionID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionSectionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission section: %w", err)
|
||||
}
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
// SectionPatch carries optional fields for an Update call. nil pointer
|
||||
// = "no change"; non-nil = "set to this".
|
||||
type SectionPatch struct {
|
||||
ContentMDDE *string
|
||||
ContentMDEN *string
|
||||
Included *bool
|
||||
LabelDE *string
|
||||
LabelEN *string
|
||||
OrderIndex *int
|
||||
}
|
||||
|
||||
// Update applies a patch to one section row. Visibility is the caller's
|
||||
// responsibility — handlers wrap with SubmissionDraftService.Get for
|
||||
// owner-scoped checks. The DB-level RLS policy mirrors that check.
|
||||
//
|
||||
// Returns the refreshed row. ErrSubmissionSectionNotFound when the
|
||||
// section doesn't exist or the calling owner can't see it (RLS
|
||||
// filters at the SELECT step).
|
||||
func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch SectionPatch) (*SubmissionSection, error) {
|
||||
setParts := []string{}
|
||||
args := []any{}
|
||||
idx := 1
|
||||
|
||||
if patch.ContentMDDE != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("content_md_de = $%d", idx))
|
||||
args = append(args, *patch.ContentMDDE)
|
||||
idx++
|
||||
}
|
||||
if patch.ContentMDEN != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("content_md_en = $%d", idx))
|
||||
args = append(args, *patch.ContentMDEN)
|
||||
idx++
|
||||
}
|
||||
if patch.Included != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("included = $%d", idx))
|
||||
args = append(args, *patch.Included)
|
||||
idx++
|
||||
}
|
||||
if patch.LabelDE != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("label_de = $%d", idx))
|
||||
args = append(args, *patch.LabelDE)
|
||||
idx++
|
||||
}
|
||||
if patch.LabelEN != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("label_en = $%d", idx))
|
||||
args = append(args, *patch.LabelEN)
|
||||
idx++
|
||||
}
|
||||
if patch.OrderIndex != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("order_index = $%d", idx))
|
||||
args = append(args, *patch.OrderIndex)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return s.Get(ctx, sectionID)
|
||||
}
|
||||
|
||||
args = append(args, sectionID)
|
||||
q := fmt.Sprintf(
|
||||
`UPDATE paliad.submission_sections
|
||||
SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING `+sectionColumns,
|
||||
strings.Join(setParts, ", "), idx,
|
||||
)
|
||||
|
||||
var sec SubmissionSection
|
||||
err := s.db.GetContext(ctx, &sec, q, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionSectionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update submission section: %w", err)
|
||||
}
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
// SeedFromSpec inserts one row per BaseSectionSpec.Default into
|
||||
// submission_sections for the given draft. Runs inside the caller's
|
||||
// transaction (the SubmissionDraftService.Create path wraps the
|
||||
// draft INSERT + section seed in one tx so a failed seed rolls back
|
||||
// the draft too).
|
||||
//
|
||||
// Idempotent at the row level — UNIQUE (draft_id, section_key) returns
|
||||
// an error if the seed runs twice for the same draft, which is the
|
||||
// desired safety net (we never want to silently double-seed).
|
||||
//
|
||||
// Per the Q10 ratification: every kind is one of prose | requests |
|
||||
// evidence — there is no *_auto kind. Caption/letterhead/signature
|
||||
// sections are regular prose rows seeded with bag-driven Markdown.
|
||||
func (s *SectionService) SeedFromSpec(ctx context.Context, tx *sqlx.Tx, draftID uuid.UUID, spec BaseSectionSpec) error {
|
||||
if len(spec.Defaults) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, d := range spec.Defaults {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`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)`,
|
||||
draftID, d.SectionKey, d.OrderIndex, d.Kind,
|
||||
d.LabelDE, d.LabelEN, d.Included,
|
||||
d.SeedMDDE, d.SeedMDEN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed submission section %s: %w", d.SectionKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
178
internal/services/submission_section_service_live_test.go
Normal file
178
internal/services/submission_section_service_live_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration tests for the Composer seeding flow (t-paliad-313
|
||||
// Slice A). Skipped when TEST_DATABASE_URL is unset, mirroring the
|
||||
// other live-DB tests (see cansee_test.go for the bootstrap pattern).
|
||||
//
|
||||
// Covers:
|
||||
// 1. Mig 146 seeded the catalog: hlc-letterhead + neutral both
|
||||
// resolve via GetBySlug and carry 10 section defaults each.
|
||||
// 2. BaseService.GetDefaultForCode picks the firm-matched base for a
|
||||
// canonical submission_code (e.g. de.inf.lg.erwidg) — Slice A
|
||||
// contract that drives new-draft seeding.
|
||||
// 3. SubmissionDraftService.Create on a fresh draft seeds base_id +
|
||||
// 10 submission_sections rows in one transaction, with order_index
|
||||
// ascending and bilingual labels populated.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestComposerSeedFlow(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)
|
||||
|
||||
t.Run("seed catalog: hlc-letterhead has 10 default sections", func(t *testing.T) {
|
||||
b, err := bases.GetBySlug(ctx, "hlc-letterhead")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySlug(hlc-letterhead): %v", err)
|
||||
}
|
||||
if got := len(b.SectionSpec.Defaults); got != 10 {
|
||||
t.Errorf("len(Defaults) = %d; want 10", got)
|
||||
}
|
||||
if b.SectionSpec.Stylemap["heading_1"] != "HLpat-Heading-H1" {
|
||||
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", b.SectionSpec.Stylemap["heading_1"])
|
||||
}
|
||||
// Verify the section order is strictly ascending.
|
||||
prev := 0
|
||||
for _, d := range b.SectionSpec.Defaults {
|
||||
if d.OrderIndex <= prev {
|
||||
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", d.OrderIndex, prev, d.SectionKey)
|
||||
}
|
||||
prev = d.OrderIndex
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("seed catalog: neutral exists with universal stylemap", func(t *testing.T) {
|
||||
b, err := bases.GetBySlug(ctx, "neutral")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySlug(neutral): %v", err)
|
||||
}
|
||||
if b.SectionSpec.Stylemap["heading_1"] != "Heading 1" {
|
||||
t.Errorf("neutral Stylemap[heading_1] = %q; want \"Heading 1\"", b.SectionSpec.Stylemap["heading_1"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetDefaultForCode firm match", func(t *testing.T) {
|
||||
// HLC + de.inf.lg.erwidg → hlc-letterhead (firm-matched).
|
||||
b, err := bases.GetDefaultForCode(ctx, "HLC", "de.inf.lg.erwidg")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultForCode HLC: %v", err)
|
||||
}
|
||||
if b.Slug != "hlc-letterhead" {
|
||||
t.Errorf("Slug = %q; want hlc-letterhead", b.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetDefaultForCode falls back to neutral when no firm hint", func(t *testing.T) {
|
||||
b, err := bases.GetDefaultForCode(ctx, "", "de.inf.lg.erwidg")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultForCode no-firm: %v", err)
|
||||
}
|
||||
// Without a firm hint, the fallback chain skips firm-matched
|
||||
// queries and lands on the firm-NULL neutral base.
|
||||
if b.Slug != "neutral" {
|
||||
t.Errorf("Slug = %q; want neutral (firm-NULL fallback)", b.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
// Section seeding via SubmissionDraftService.Create — exercises the
|
||||
// transactional INSERT path. Requires a real auth.users + paliad.users
|
||||
// row because submission_drafts.user_id is FK-constrained.
|
||||
t.Run("SubmissionDraftService.Create seeds 10 section rows", func(t *testing.T) {
|
||||
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 := "composer-seed-" + 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, 'Composer Seed', '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)
|
||||
sections := NewSectionService(pool)
|
||||
drafts.AttachComposer(bases, sections, "HLC")
|
||||
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if d.BaseID == nil {
|
||||
t.Fatalf("BaseID = nil; want seeded base reference")
|
||||
}
|
||||
// hlc-letterhead is the firm default for HLC.
|
||||
base, _ := bases.GetByID(ctx, *d.BaseID)
|
||||
if base == nil || base.Slug != "hlc-letterhead" {
|
||||
t.Errorf("seeded base slug = %v; want hlc-letterhead", base)
|
||||
}
|
||||
|
||||
secs, err := sections.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForDraft: %v", err)
|
||||
}
|
||||
if len(secs) != 10 {
|
||||
t.Errorf("section count = %d; want 10", len(secs))
|
||||
}
|
||||
// Verify section_key set + bilingual labels populated.
|
||||
wantKeys := map[string]bool{
|
||||
"letterhead": false, "caption": false, "introduction": false,
|
||||
"requests": false, "facts": false, "legal_argument": false,
|
||||
"evidence": false, "exhibits": false, "closing": false, "signature": false,
|
||||
}
|
||||
prev := 0
|
||||
for _, sec := range secs {
|
||||
wantKeys[sec.SectionKey] = true
|
||||
if sec.OrderIndex <= prev {
|
||||
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", sec.OrderIndex, prev, sec.SectionKey)
|
||||
}
|
||||
prev = sec.OrderIndex
|
||||
if sec.LabelDE == "" || sec.LabelEN == "" {
|
||||
t.Errorf("section %s missing bilingual label: de=%q en=%q", sec.SectionKey, sec.LabelDE, sec.LabelEN)
|
||||
}
|
||||
}
|
||||
for k, seen := range wantKeys {
|
||||
if !seen {
|
||||
t.Errorf("missing seeded section_key: %s", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
58
pkg/litigationplanner/appeal_role.go
Normal file
58
pkg/litigationplanner/appeal_role.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package litigationplanner
|
||||
|
||||
// AppealRole* are the canonical filer-role slugs used by the unified
|
||||
// upc.apl Berufung proceeding (t-paliad-307 / m/paliad#136 Bug 1).
|
||||
//
|
||||
// Every appeal filing rule carries primary_party='both' in the catalog
|
||||
// (either party could be the appellant, depending on which side lost
|
||||
// downstream), so the static primary_party column can't drive
|
||||
// column-bucketing under a user-perspective `?side=` pick. The
|
||||
// per-rule appeal role fills that gap: "appellant" rules are filed by
|
||||
// the Berufungskläger (the party who lost in the lower instance and
|
||||
// is now appealing); "appellee" rules are filed by the
|
||||
// Berufungsbeklagter (the party defending the lower-instance
|
||||
// decision). The mapping is rule-semantic, not data-driven — we know
|
||||
// from R.224/235 which submission belongs to which side.
|
||||
const (
|
||||
AppealRoleAppellant = "appellant"
|
||||
AppealRoleAppellee = "appellee"
|
||||
)
|
||||
|
||||
// AppealFilerRole returns the appeal-filer role for a submission code
|
||||
// in the unified upc.apl proceeding. Empty string for codes whose role
|
||||
// is not statically known (court-issued events, unmapped codes, or
|
||||
// non-appeal proceedings).
|
||||
//
|
||||
// The engine stamps TimelineEntry.AppealRole with this value when
|
||||
// CalcOptions.AppealTarget is set so the frontend column-bucketer can
|
||||
// route each "both"-party rule into the correct user-perspective
|
||||
// column (Berufungskläger vs Berufungsbeklagter) once the user picks
|
||||
// a side.
|
||||
//
|
||||
// Adding a new appeal rule? Add its submission_code to the matching
|
||||
// branch below. Court-issued events (cost.decision, order.order,
|
||||
// merits.oral, merits.decision) deliberately stay empty — they route
|
||||
// to the court column on primary_party='court'.
|
||||
func AppealFilerRole(submissionCode string) string {
|
||||
switch submissionCode {
|
||||
// Appellant filings — Berufungskläger initiates the appeal +
|
||||
// replies to the cross-appeal.
|
||||
case "upc.apl.merits.notice",
|
||||
"upc.apl.merits.grounds",
|
||||
"upc.apl.merits.cross_a_reply",
|
||||
"upc.apl.cost.leave_app",
|
||||
"upc.apl.order.with_leave",
|
||||
"upc.apl.order.grounds_orders",
|
||||
"upc.apl.order.discretion",
|
||||
"upc.apl.order.cross_reply":
|
||||
return AppealRoleAppellant
|
||||
// Appellee filings — Berufungsbeklagter responds to the appeal +
|
||||
// files the cross-appeal.
|
||||
case "upc.apl.merits.response",
|
||||
"upc.apl.merits.cross_a",
|
||||
"upc.apl.order.response_orders",
|
||||
"upc.apl.order.cross":
|
||||
return AppealRoleAppellee
|
||||
}
|
||||
return ""
|
||||
}
|
||||
192
pkg/litigationplanner/appeal_role_test.go
Normal file
192
pkg/litigationplanner/appeal_role_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestAppealFilerRole pins the rule-semantic mapping that drives
|
||||
// column-bucketing on the unified upc.apl Berufung timeline
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has
|
||||
// primary_party='both' in the catalog so the bucketer can't decide
|
||||
// between Berufungskläger and Berufungsbeklagter columns from
|
||||
// primary_party alone — the appeal role fills that gap.
|
||||
func TestAppealFilerRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
code string
|
||||
want string
|
||||
}{
|
||||
// Appellant filings (Berufungskläger initiates / replies to cross).
|
||||
{"upc.apl.merits.notice", AppealRoleAppellant},
|
||||
{"upc.apl.merits.grounds", AppealRoleAppellant},
|
||||
{"upc.apl.merits.cross_a_reply", AppealRoleAppellant},
|
||||
{"upc.apl.cost.leave_app", AppealRoleAppellant},
|
||||
{"upc.apl.order.with_leave", AppealRoleAppellant},
|
||||
{"upc.apl.order.grounds_orders", AppealRoleAppellant},
|
||||
{"upc.apl.order.discretion", AppealRoleAppellant},
|
||||
{"upc.apl.order.cross_reply", AppealRoleAppellant},
|
||||
// Appellee filings (Berufungsbeklagter responds + cross-appeals).
|
||||
{"upc.apl.merits.response", AppealRoleAppellee},
|
||||
{"upc.apl.merits.cross_a", AppealRoleAppellee},
|
||||
{"upc.apl.order.response_orders", AppealRoleAppellee},
|
||||
{"upc.apl.order.cross", AppealRoleAppellee},
|
||||
// Court-issued events stay empty — they route on party='court'.
|
||||
{"upc.apl.merits.decision", ""},
|
||||
{"upc.apl.merits.oral", ""},
|
||||
{"upc.apl.cost.decision", ""},
|
||||
{"upc.apl.order.order", ""},
|
||||
// Unmapped codes are empty (defensive — never silently picks a
|
||||
// side for a new appeal rule we forgot to map).
|
||||
{"upc.inf.cfi.soc", ""},
|
||||
{"", ""},
|
||||
{"foo.bar", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := AppealFilerRole(c.code); got != c.want {
|
||||
t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root
|
||||
// row the engine prepends when CalcOptions.AppealTarget is set
|
||||
// (t-paliad-307 / m/paliad#136 Bug 2). The row carries the
|
||||
// per-appeal-target label, the trigger date as DueDate, IsRootEvent=
|
||||
// IsTriggerEvent=true, and party=court. Without the appeal_target
|
||||
// filter, no synthetic row is emitted (regression guard).
|
||||
func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.apl.unified",
|
||||
Name: "Berufung",
|
||||
NameEN: "Appeal",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
mkID := func() uuid.UUID {
|
||||
id, _ := uuid.NewRandom()
|
||||
return id
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &procID
|
||||
|
||||
noticeCode := "upc.apl.merits.notice"
|
||||
groundsCode := "upc.apl.merits.grounds"
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: mkID(),
|
||||
ProceedingTypeID: procIDPtr,
|
||||
SubmissionCode: ¬iceCode,
|
||||
Name: "Berufungseinlegung",
|
||||
NameEN: "Notice of Appeal",
|
||||
PrimaryParty: str(PrimaryPartyBoth),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
|
||||
},
|
||||
{
|
||||
ID: mkID(),
|
||||
ProceedingTypeID: procIDPtr,
|
||||
SubmissionCode: &groundsCode,
|
||||
Name: "Berufungsbegründung",
|
||||
NameEN: "Statement of Grounds",
|
||||
PrimaryParty: str(PrimaryPartyBoth),
|
||||
DurationValue: 4,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 1,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
|
||||
},
|
||||
}
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) {
|
||||
opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
if len(timeline.Deadlines) < 3 {
|
||||
t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines))
|
||||
}
|
||||
// Synthetic row first.
|
||||
first := timeline.Deadlines[0]
|
||||
if !first.IsTriggerEvent {
|
||||
t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent)
|
||||
}
|
||||
if !first.IsRootEvent {
|
||||
t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent)
|
||||
}
|
||||
if first.Name != "Endentscheidung (R.118)" {
|
||||
t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)")
|
||||
}
|
||||
if first.NameEN != "Final decision (R.118)" {
|
||||
t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)")
|
||||
}
|
||||
if first.DueDate != "2026-05-26" {
|
||||
t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate)
|
||||
}
|
||||
if first.Party != PrimaryPartyCourt {
|
||||
t.Errorf("first row Party=%q, want court", first.Party)
|
||||
}
|
||||
// Real rules should carry AppealRole.
|
||||
byCode := map[string]TimelineEntry{}
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant {
|
||||
t.Errorf("notice AppealRole=%q, want appellant", got)
|
||||
}
|
||||
if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant {
|
||||
t.Errorf("grounds AppealRole=%q, want appellant", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) {
|
||||
opts := CalcOptions{}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.IsTriggerEvent {
|
||||
t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d)
|
||||
}
|
||||
if d.AppealRole != "" {
|
||||
t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) {
|
||||
opts := CalcOptions{AppealTarget: "bogus"}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
// IsValidAppealTarget("bogus") = false, so the engine skips
|
||||
// both the rule filter AND the synthetic trigger emission.
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.IsTriggerEvent {
|
||||
t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -572,6 +572,21 @@ func Calculate(
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
// Stamp AppealRole on every entry when an appeal-target filter is
|
||||
// active so the frontend column-bucketer can route primary_party=
|
||||
// 'both' rules into the user-perspective columns
|
||||
// (Berufungskläger vs Berufungsbeklagter). Court events stay empty
|
||||
// — they route on Party='court' regardless. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1)
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
for i := range deadlines {
|
||||
if deadlines[i].Code == "" {
|
||||
continue
|
||||
}
|
||||
deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore sequence_order on the output slice. The compute walk
|
||||
// re-ordered rules topologically (parent-first) so the parent-state
|
||||
// checks resolved correctly; the wire shape and the linear timeline
|
||||
@@ -594,6 +609,31 @@ func Calculate(
|
||||
// same-group rows. Court-set / conditional rows sort LAST.
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Synthetic trigger-event row for appeal timelines (t-paliad-307 /
|
||||
// m/paliad#136 Bug 2). The decision being appealed (Endentscheidung
|
||||
// R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the
|
||||
// upc.apl catalog — it's the anchor the user picked. Lawyers expect
|
||||
// it to surface as the first row of the timeline so the chain reads
|
||||
// decision → appeal filings → next decision. Emitted only when an
|
||||
// appeal_target is in play and the helper returns a non-empty label.
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de")
|
||||
nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en")
|
||||
if nameDE != "" || nameEN != "" {
|
||||
trig := TimelineEntry{
|
||||
Name: nameDE,
|
||||
NameEN: nameEN,
|
||||
Party: PrimaryPartyCourt,
|
||||
Priority: "informational",
|
||||
DueDate: triggerDateStr,
|
||||
OriginalDate: triggerDateStr,
|
||||
IsRootEvent: true,
|
||||
IsTriggerEvent: true,
|
||||
}
|
||||
deadlines = append([]TimelineEntry{trig}, deadlines...)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
|
||||
@@ -441,6 +441,22 @@ type TimelineEntry struct {
|
||||
DurationValue int `json:"durationValue,omitempty"`
|
||||
DurationUnit string `json:"durationUnit,omitempty"`
|
||||
Timing string `json:"timing,omitempty"`
|
||||
|
||||
// AppealRole carries the rule's appeal-filer role (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1) when the timeline was computed under an
|
||||
// appeal_target filter. One of AppealRoleAppellant /
|
||||
// AppealRoleAppellee, or empty for court events / non-appeal
|
||||
// timelines. The frontend column-bucketer reads this to route
|
||||
// primary_party='both' rules to Berufungskläger vs
|
||||
// Berufungsbeklagter columns once the user picks a side.
|
||||
AppealRole string `json:"appealRole,omitempty"`
|
||||
|
||||
// IsTriggerEvent marks the synthetic root row that represents the
|
||||
// decision being appealed (t-paliad-307 / m/paliad#136 Bug 2).
|
||||
// Distinct from IsRootEvent in that the row carries no real rule
|
||||
// id — it's a UI marker dated to the trigger date with the
|
||||
// per-appeal-target label from TriggerEventLabelForAppealTarget.
|
||||
IsTriggerEvent bool `json:"isTriggerEvent,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculation is the single-rule calc response that backs the
|
||||
|
||||
@@ -39,9 +39,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// anchorsOnly switches the body emitter from the legacy variable-bag
|
||||
// banner template to the Composer Slice B anchor-only body. Toggled
|
||||
// via the -anchors flag; default true so the Slice B regen produces
|
||||
// the composer-ready file.
|
||||
var anchorsOnly = true
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "_skeleton.docx", "output .docx path")
|
||||
anchors := flag.Bool("anchors", true, "emit Composer-mode body with section anchors only (t-paliad-313 Slice B); false = legacy variable-bag banner body")
|
||||
flag.Parse()
|
||||
anchorsOnly = *anchors
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
@@ -156,6 +164,45 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
// DEMO/SKELETON banner makes it obvious this is a starter template and
|
||||
// not approved firm content.
|
||||
func buildDocumentXML() string {
|
||||
if anchorsOnly {
|
||||
return buildAnchoredDocumentXML()
|
||||
}
|
||||
return buildLegacyDocumentXML()
|
||||
}
|
||||
|
||||
// buildAnchoredDocumentXML emits the Composer-mode body: just section
|
||||
// anchors. The composer pipeline (services/submission_compose.go)
|
||||
// replaces each {{#section:KEY}}...{{/section:KEY}} paragraph pair
|
||||
// with the rendered section content from submission_sections.
|
||||
// Pre-Composer drafts continue to use the legacy body (run with
|
||||
// -anchors=false).
|
||||
//
|
||||
// Order matches the default section spec in mig 146:
|
||||
// letterhead, caption, introduction, requests, facts,
|
||||
// legal_argument, evidence, exhibits, closing, signature.
|
||||
func buildAnchoredDocumentXML() 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>`)
|
||||
|
||||
anchorPair := func(key string) {
|
||||
plain(&b, "{{#section:"+key+"}}")
|
||||
plain(&b, "{{/section:"+key+"}}")
|
||||
}
|
||||
for _, key := range []string{
|
||||
"letterhead", "caption", "introduction", "requests",
|
||||
"facts", "legal_argument", "evidence", "exhibits",
|
||||
"closing", "signature",
|
||||
} {
|
||||
anchorPair(key)
|
||||
}
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildLegacyDocumentXML() 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">`)
|
||||
|
||||
Reference in New Issue
Block a user