Adds the dedicated Submissions/Schriftsätze editor at
/projects/{id}/submissions/{code}/draft (and …/draft/{draft_id}) per
docs/design-submission-page-2026-05-22.md.
Lawyer picks (or creates) a named draft, edits placeholder variables
in a sticky sidebar, sees a read-only HTML preview of the merged
document body, and exports a .docx with project state + lawyer
overrides resolved. Drafts persist in paliad.submission_drafts
keyed on (project_id, submission_code, user_id, name) with RLS via
can_see_project; updates and deletes additionally gated on owner-only
(Q-E4 owner-scoped pick, m-confirmed).
Resurrected from git history per the design's "no rewrite" plan:
SubmissionVarsService ← commit 1765d5e (Slice 2 with patent_number_upc)
SubmissionRenderer ← commit 8ea3509 (in-house merge engine — the
lukasjarosch/go-docx library refuses sibling
placeholders in one run, which patent submissions
use routinely)
ConvertDotmToDocx ← existing format-only convert (kept; reused as
pre-pass so .dotm inputs strip macros before
merge)
New code:
paliad.submission_drafts migration 119 (idempotent — DROP POLICY IF EXISTS
+ CREATE; CREATE OR REPLACE for the shared trigger
function). Applied to live DB.
SubmissionDraftService CRUD + autosave-friendly Update + Export/RenderPreview
entry points
RenderHTML method new on the renderer; walks the same merged
document.xml as Render but emits HTML for the
preview pane (Q-E3 server-side pick)
7 API handlers list / create / get / patch / delete / preview / export
2 page routes /draft and /draft/{draft_id}
submission-draft.tsx stand-alone editor page (header / sidebar /
preview / export button); served via
dist/submission-draft.html
submission-draft.ts client bundle — autosave (500ms debounce),
draft switcher, rename, delete, export with
blob download
Tab integration: existing /projects/{id}/#tab-submissions rows get
[Bearbeiten] alongside the existing [Generieren] one-click format-only
path — additive, no removal.
Slice A template: universal HL Patents Style .dotm (same path
t-paliad-230 uses). resolveSubmissionTemplate carries the
submission_code parameter so Slice B's TemplateRegistry wiring (per-
code .docx fallback chain) is a one-function swap.
Audit trail: paliad.system_audit_log row per export
(event_type='submission.exported') + paliad.project_events row
(event_type='submission_exported', timeline_kind='custom_milestone')
so the export surfaces on the project's Verlauf / SmartTimeline. No
paliad.documents write (Q-E2 inventor pick, head-ratified).
Tests: TestRender_* / TestPlaceholderRegex_* / TestRenderHTML_* +
TestLegalSourcePretty / TestOurSide* / TestPatentNumberUPC — all
green. go build / go vet / go test ./internal/... / bun run build all
clean.
Migration slot taken: 119.
308 lines
9.9 KiB
Go
308 lines
9.9 KiB
Go
package services
|
|
|
|
// Submission merge-engine tests — resurrected from the original
|
|
// t-paliad-215 Slice 1 (commit 8ea3509) + Slice 2 (commit 1765d5e).
|
|
// Adapted: helper names suffixed with "Merge" so they don't collide
|
|
// with the convert tests in submission_render_test.go (minimalDOTM,
|
|
// unzipEntries) that test the format-only ConvertDotmToDocx path.
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
|
|
// contains the given body. Just enough to exercise the merge engine.
|
|
func minimalMergeDOCX(t *testing.T, documentBody string) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
w, err := zw.Create("word/document.xml")
|
|
if err != nil {
|
|
t.Fatalf("create document.xml: %v", err)
|
|
}
|
|
if _, err := io.WriteString(w, documentBody); err != nil {
|
|
t.Fatalf("write document.xml: %v", err)
|
|
}
|
|
w2, err := zw.Create("[Content_Types].xml")
|
|
if err != nil {
|
|
t.Fatalf("create content types: %v", err)
|
|
}
|
|
// Use a docx-compatible content type so the convert pre-pass treats
|
|
// the input as already-clean (no .dotm rewrites needed).
|
|
body := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
|
`<Override PartName="/word/document.xml" ContentType="` + docxMainContentType + `"/></Types>`
|
|
if _, err := io.WriteString(w2, body); err != nil {
|
|
t.Fatalf("write content types: %v", err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close zip: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// readMergeDocumentXML pulls word/document.xml out of a rendered .docx.
|
|
func readMergeDocumentXML(t *testing.T, b []byte) string {
|
|
t.Helper()
|
|
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
|
if err != nil {
|
|
t.Fatalf("open rendered 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()
|
|
body, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("read document.xml: %v", err)
|
|
}
|
|
return string(body)
|
|
}
|
|
t.Fatal("rendered .docx had no word/document.xml")
|
|
return ""
|
|
}
|
|
|
|
func TestRender_SingleRunPlaceholder(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalMergeDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readMergeDocumentXML(t, out)
|
|
if !strings.Contains(body, ">HLC<") {
|
|
t.Errorf("expected HLC in body, got %q", body)
|
|
}
|
|
if strings.Contains(body, "{{") {
|
|
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalMergeDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{
|
|
"parties.claimant.name": "Acme Inc.",
|
|
"parties.claimant.representative": "Kanzlei Müller",
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readMergeDocumentXML(t, out)
|
|
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
|
|
t.Errorf("expected both party values, got %q", body)
|
|
}
|
|
if strings.Contains(body, "{{") {
|
|
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestRender_MissingMarker(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalMergeDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readMergeDocumentXML(t, out)
|
|
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
|
t.Errorf("expected KEIN WERT marker, got %q", body)
|
|
}
|
|
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
|
if err != nil {
|
|
t.Fatalf("render en: %v", err)
|
|
}
|
|
bodyEN := readMergeDocumentXML(t, outEN)
|
|
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
|
|
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
|
|
}
|
|
}
|
|
|
|
func TestRender_CrossRunPlaceholder(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalMergeDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readMergeDocumentXML(t, out)
|
|
if !strings.Contains(body, "7 O 1234/26") {
|
|
t.Errorf("expected case number after cross-run merge, got %q", body)
|
|
}
|
|
if strings.Contains(body, "{{") {
|
|
t.Errorf("orphan placeholder marker remained: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestRender_XMLEscaping(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalMergeDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{
|
|
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readMergeDocumentXML(t, out)
|
|
if !strings.Contains(body, "Müller & Söhne <GmbH> "Special"") {
|
|
t.Errorf("expected escaped value, got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestPlaceholderRegex_Boundaries(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
matches []string
|
|
}{
|
|
{"plain text", nil},
|
|
{"{{foo}}", []string{"{{foo}}"}},
|
|
{"{{ foo }}", []string{"{{ foo }}"}},
|
|
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
|
|
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
|
|
{"{{1bad}}", nil},
|
|
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
got := placeholderRegex.FindAllString(tc.in, -1)
|
|
if len(got) != len(tc.matches) {
|
|
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.matches[i] {
|
|
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLegalSourcePretty(t *testing.T) {
|
|
tests := []struct {
|
|
src, lang, want string
|
|
}{
|
|
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
|
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
|
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
|
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
|
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
|
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
|
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
|
{"DE.PatG.83", "de", "§ 83 PatG"},
|
|
{"EPC.123", "de", "Art. 123 EPÜ"},
|
|
{"EPC.123", "en", "Art. 123 EPC"},
|
|
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
|
{"", "de", ""},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
|
got := legalSourcePretty(tc.src, tc.lang)
|
|
if got != tc.want {
|
|
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOurSideTranslations(t *testing.T) {
|
|
cases := []struct {
|
|
in, wantDE, wantEN string
|
|
}{
|
|
{"claimant", "Klägerin", "Claimant"},
|
|
{"defendant", "Beklagte", "Defendant"},
|
|
{"court", "Gericht", "Court"},
|
|
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
|
|
{"", "", ""},
|
|
{"unknown", "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
if got := ourSideDE(tc.in); got != tc.wantDE {
|
|
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
|
}
|
|
if got := ourSideEN(tc.in); got != tc.wantEN {
|
|
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPatentNumberUPC(t *testing.T) {
|
|
tests := []struct {
|
|
in, want string
|
|
}{
|
|
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
|
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
|
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
|
{"EP 1 234 567", "EP 1 234 567"},
|
|
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
|
{"", ""},
|
|
{"WO/2023/123456", "WO/2023/123456"},
|
|
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
got := patentNumberUPC(tc.in)
|
|
if got != tc.want {
|
|
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
|
|
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
|
|
// bold/italic through to <strong>/<em>.
|
|
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
|
|
doc := `<w:document><w:body>` +
|
|
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
|
|
`<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Bold line</w:t></w:r></w:p>` +
|
|
`<w:p><w:r><w:rPr><w:i/></w:rPr><w:t>Italic line</w:t></w:r></w:p>` +
|
|
`</w:body></w:document>`
|
|
tmpl := minimalMergeDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render html: %v", err)
|
|
}
|
|
if !strings.Contains(html, "<p>Hello HLC</p>") {
|
|
t.Errorf("expected merged paragraph, got %q", html)
|
|
}
|
|
if !strings.Contains(html, "<strong>Bold line</strong>") {
|
|
t.Errorf("expected bold span, got %q", html)
|
|
}
|
|
if !strings.Contains(html, "<em>Italic line</em>") {
|
|
t.Errorf("expected italic span, got %q", html)
|
|
}
|
|
}
|
|
|
|
// TestRenderHTML_EscapesContent confirms the preview emitter HTML-escapes
|
|
// special characters in placeholder values.
|
|
func TestRenderHTML_EscapesContent(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalMergeDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
|
"user.display_name": `M&S <Inc> "X"`,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render html: %v", err)
|
|
}
|
|
if !strings.Contains(html, "M&S <Inc> "X"") {
|
|
t.Errorf("expected escaped value in HTML, got %q", html)
|
|
}
|
|
}
|