Compare commits

..

6 Commits

Author SHA1 Message Date
mAi
d834b36313 test(submissions): live-DB round-trip for filename_keyword composer_meta merge/clear (t-paliad-354)
Some checks are pending
Paliad CI gate / build (push) Waiting to run
Paliad CI gate / test-go (push) Waiting to run
Paliad CI gate / deploy (push) Blocked by required conditions
2026-06-01 10:40:55 +02:00
mAi
4092c889c4 feat(submissions): generated-doc filename <date> <keyword> (<case>) + user-replaceable keyword
Generated documents now download as "YYYY-MM-DD keyword (case number).docx"
(date first/sortable, case number bracketed) instead of the old
"rule-case-date.docx" shape.

- submissionFileName: date-led frame; keyword = user override > lang-aware
  rule name > "submission"; case number always bracketed, placeholder
  "Az. folgt" (named const) when the project has no Aktenzeichen.
- SanitiseSubmissionFileName hardened to fold the full Windows-reserved
  set (colon star question angle pipe) on top of slash/backslash, while
  preserving spaces + parentheses so the assembled frame stays
  human-facing yet filesystem-safe.
- User-replaceable keyword stored in the draft's composer_meta jsonb
  (filename_keyword, no migration). Editor gains a "Stichwort (Dateiname)"
  input that placeholders the auto rule name and persists via the draft
  PATCH path. One-click /generate has no draft row -> keeps auto keyword.

Tests: submissionFileName (full / no-AZ / override / EN / slash case-no /
blank override / empty rule), submissionFilenameKeyword, extended
sanitiser cases.

t-paliad-354
2026-06-01 10:35:23 +02:00
mAi
db1040968f Merge: t-paliad-352 submission draft auto-naming (m/paliad#155)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-31 15:29:32 +02:00
mAi
f292338919 feat(submissions): auto-name new drafts <date> <client>./.<forum>./.<opponent> (m/paliad#155)
New project-bound submission drafts now default to a sortable, legal-
convention title instead of the bare "Entwurf N" counter:

    <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>

- Date leads (ISO, Europe/Berlin) so drafts list chronologically; " ./. "
  is the German legal "gegen" separator.
- Client = root 'client' ancestor of the project tree.
- Forum = proceeding-type jurisdiction (UPC/EPA/DPMA); German proceedings
  resolve to the deciding court (LG/OLG/BGH/BPatG) from the code tail.
- Opponent = primary opposing party, picked by our_side posture
  (active → defendant bucket, reactive → claimant bucket).
- Any segment that resolves empty is omitted with its leading separator;
  a project-less draft keeps the legacy "Entwurf N" scheme entirely.
- Create-time only: existing drafts are never renamed, and a lawyer's
  later manual rename via Update is untouched. Same-slot collisions
  de-duplicate with a " (N)" suffix.

Customization scope (per-user / firm / template, issue #155 Q4) is v1.1 —
the template is hardcoded in submission_autoname.go for now; the override
string is documented as the single extension point on AutoSubmissionTitle.

Example output:
  full:        2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma
  no opponent: 2026-05-31 Bayer AG ./. BPatG
  no forum:    2026-05-31 Bayer AG ./. Novartis Pharma
  date only:   2026-05-31

AutoSubmissionTitle + segment resolvers are pure and table-tested
(submission_autoname_test.go); the Create flow is covered end-to-end
against real Postgres in submission_draft_autoname_live_test.go (gated
on TEST_DATABASE_URL).
2026-05-31 15:28:54 +02:00
mAi
2b240e7dd0 Merge: docs PRD schema corrections (planck feedback)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-31 15:16:55 +02:00
mAi
c945cbd330 docs(prd): fix 3 schema inaccuracies in litigation-planner PRD
planck flagged via mai report feedback (id 12301) after the B5+B6
verification round caught them:

- §5.4 'INSERT into paliad.project_parties' → real table is paliad.parties
- §5.4 'status=open' → real CHECK constraint allows pending/completed/cancelled/waived
- §7.4 listed verfahrensablauf-detail-mode.ts as dead code, but builder
  imports filterByDetailMode from it; struck through with KEEP note.

Code shipped (B5+B6) used the correct values throughout; this aligns
the historical PRD with reality so a future reader doesn't repeat the
verification time planck spent.
2026-05-31 15:16:55 +02:00
16 changed files with 1107 additions and 49 deletions

View File

@@ -509,14 +509,14 @@ Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `pal
Transaction:
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
SET origin_scenario_id = <scenario.id>
2. INSERT into paliad.project_parties from step-2 payload
2. INSERT into paliad.parties from step-2 payload
3. For each scenario_proceeding (depth-first, parent before child):
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
children become sub-projects via parent_project_id)
b. For each filed scenario_event: INSERT paliad.deadlines row with
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
c. For each planned scenario_event: INSERT paliad.deadlines row with
status='open', due_date=computed (or actual_date override)
status='pending', due_date=computed (or actual_date override)
d. Skipped events: not inserted (no deadline row)
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
5. Navigate to /projects/<new>
@@ -636,7 +636,7 @@ Dead code to delete (verify with grep before deletion):
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
- `frontend/src/client/verfahrensablauf-detail-mode.ts` (replaced by per-triplet Detailgrad)
- ~~`frontend/src/client/verfahrensablauf-detail-mode.ts`~~ KEEP. Builder imports `filterByDetailMode` from it; per-triplet Detailgrad reuses this module.
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
**Kept**:

View File

@@ -1745,6 +1745,10 @@ 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-354 — Dateiname-Stichwort (führt den Namen des exportierten Dokuments an).
"submissions.draft.keyword.label": "Stichwort (Dateiname)",
"submissions.draft.keyword.placeholder": "Automatisch aus dem Schriftsatztyp",
"submissions.draft.keyword.hint": "Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).",
// 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.",
@@ -5070,6 +5074,10 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
// t-paliad-354 — filename keyword (leads the exported document name).
"submissions.draft.keyword.label": "Keyword (filename)",
"submissions.draft.keyword.placeholder": "Auto-derived from the submission type",
"submissions.draft.keyword.hint": "Leads the filename: <date> <keyword> (<case number>).",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",

View File

@@ -503,7 +503,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string; filename_keyword?: string }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -558,6 +558,7 @@ function paint(): void {
paintPartyPicker();
paintLanguageRow();
paintLanguageFallback();
paintKeywordRow();
paintVariables();
paintSectionList();
paintPreview();
@@ -1034,6 +1035,53 @@ function paintLanguageFallback(): void {
el.style.display = fallback ? "" : "none";
}
// autoKeyword returns the lang-aware rule name that leads the exported
// filename when the user sets no override — shown as the keyword input's
// placeholder so the lawyer sees the default without it being forced.
// t-paliad-354.
function autoKeyword(): string {
const view = state.view;
if (!view?.rule) return "";
const en = (view.draft.language || view.lang || "de").toLowerCase() === "en";
const name = en && view.rule.name_en ? view.rule.name_en : view.rule.name;
return (name || "").trim();
}
// paintKeywordRow syncs the "Stichwort (Dateiname)" input with the
// draft's stored override (composer_meta.filename_keyword) and shows the
// auto-derived rule name as the placeholder. Editing PATCHes the draft on
// blur (change), persisting under composer_meta.filename_keyword.
// t-paliad-354.
function paintKeywordRow(): void {
const input = document.getElementById("submission-draft-keyword") as HTMLInputElement | null;
if (!input || !state.view) return;
const stored = state.view.draft.composer_meta?.["filename_keyword"];
input.value = typeof stored === "string" ? stored : "";
const auto = autoKeyword();
if (auto) input.placeholder = auto;
input.onchange = () => { void onKeywordChange(input.value.trim()); };
}
async function onKeywordChange(keyword: string): Promise<void> {
if (!state.view) return;
const stored = state.view.draft.composer_meta?.["filename_keyword"];
const current = typeof stored === "string" ? stored.trim() : "";
if (keyword === current) return;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ filename_keyword: keyword });
state.view = view;
paintKeywordRow();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft keyword save:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
// Revert to the persisted value so the field doesn't lie.
paintKeywordRow();
}
}
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
if (!state.view) return;
if ((state.view.draft.language || "de").toLowerCase() === lang) return;

View File

@@ -2842,6 +2842,9 @@ export type I18nKey =
| "submissions.draft.base.hint"
| "submissions.draft.base.label"
| "submissions.draft.import.button"
| "submissions.draft.keyword.hint"
| "submissions.draft.keyword.label"
| "submissions.draft.keyword.placeholder"
| "submissions.draft.language"
| "submissions.draft.language.de"
| "submissions.draft.language.en"

View File

@@ -171,6 +171,33 @@ export function renderSubmissionDraft(): string {
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
</p>
{/* t-paliad-354 — keyword that leads the exported
document name "<date> <keyword> (<case>)". Empty
falls back to the auto-derived rule name; the
placeholder shows that default. Persisted to
composer_meta.filename_keyword via the draft-save
path on change. */}
<div className="submission-draft-keyword-row">
<label
htmlFor="submission-draft-keyword"
data-i18n="submissions.draft.keyword.label">
Stichwort (Dateiname)
</label>
<input
type="text"
id="submission-draft-keyword"
className="entity-form-input"
data-i18n-placeholder="submissions.draft.keyword.placeholder"
placeholder="Automatisch aus dem Schriftsatztyp"
/>
<p
className="submission-draft-keyword-hint"
id="submission-draft-keyword-hint"
data-i18n="submissions.draft.keyword.hint">
Führt den Dateinamen an: &lt;Datum&gt; &lt;Stichwort&gt; (&lt;Aktenzeichen&gt;).
</p>
</div>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
{/* t-paliad-277: "Aus Projekt importieren" + last-

View File

@@ -180,6 +180,11 @@ type submissionDraftPatchInput struct {
// base_id: absent = no change, uuid = pin, null = clear.
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
TemplateVersionIDSet bool `json:"-"`
// FilenameKeyword overrides the leading keyword of the exported
// document name (t-paliad-354). Absent = no change; "" = clear back
// to the auto-derived rule name; "x" = set. Persisted in
// composer_meta.filename_keyword.
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
@@ -446,6 +451,7 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
Variables: input.Variables,
SelectedParties: input.SelectedParties,
Language: input.Language,
FilenameKeyword: input.FilenameKeyword,
}
if input.BaseIDSet {
patch.BaseID = &input.BaseID
@@ -592,7 +598,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
// Audit + provenance updates are best-effort on a background
// context so the download still succeeds if the DB races.
@@ -939,6 +945,10 @@ type globalDraftPatchInput struct {
// (t-paliad-349 slice 7), same present/absent contract as base_id.
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
templateVersionIDProvided bool
// FilenameKeyword overrides the leading keyword of the exported
// document name (t-paliad-354). Absent = no change; "" = clear; "x" =
// set. Persisted in composer_meta.filename_keyword.
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
@@ -950,6 +960,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
BaseID *uuid.UUID `json:"base_id,omitempty"`
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
@@ -962,6 +973,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
g.SelectedParties = a.SelectedParties
g.BaseID = a.BaseID
g.TemplateVersionID = a.TemplateVersionID
g.FilenameKeyword = a.FilenameKeyword
// Detect whether "project_id" / "base_id" / "template_version_id" were
// present in the JSON object.
var raw map[string]json.RawMessage
@@ -1006,6 +1018,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
Variables: in.Variables,
SelectedParties: in.SelectedParties,
Language: in.Language,
FilenameKeyword: in.FilenameKeyword,
}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
@@ -1141,7 +1154,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()

View File

@@ -0,0 +1,144 @@
package handlers
// Regression tests for the generated-document download name
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
// The date segment is environment-dependent (Europe/Berlin "today"),
// so the assertions pin the keyword + bracketed case-number frame and
// the .docx suffix rather than the literal date.
import (
"strings"
"testing"
"time"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
func strptr(s string) *string { return &s }
func todayBerlin() string {
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
}
return day.Format("2006-01-02")
}
func TestSubmissionFileName(t *testing.T) {
t.Parallel()
rule := &models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of defence"}
date := todayBerlin()
cases := []struct {
name string
rule *models.DeadlineRule
project *models.Project
lang string
keyword string
want string
}{
{
name: "full data — rule name + case number",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "missing case number falls back to placeholder",
rule: rule,
project: &models.Project{CaseNumber: nil},
lang: "de",
want: date + " Klageerwiderung (Az. folgt).docx",
},
{
name: "user override keyword wins over rule name",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
keyword: "Replik Hauptantrag",
want: date + " Replik Hauptantrag (UPC_CFI_123_2026).docx",
},
{
name: "EN lang uses NameEN when no override",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "en",
want: date + " Statement of defence (UPC_CFI_123_2026).docx",
},
{
name: "case number containing slash is sanitised inside brackets",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123/2026")},
lang: "de",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "blank override falls back to rule name",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
keyword: " ",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "empty rule name + no override falls back to submission",
rule: &models.DeadlineRule{Name: "", NameEN: ""},
project: &models.Project{CaseNumber: nil},
lang: "de",
want: date + " submission (Az. folgt).docx",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := submissionFileName(tc.rule, tc.project, tc.lang, tc.keyword)
if got != tc.want {
t.Errorf("submissionFileName() = %q, want %q", got, tc.want)
}
if !strings.HasSuffix(got, ".docx") {
t.Errorf("filename %q missing .docx suffix", got)
}
})
}
}
func TestSubmissionFilenameKeyword(t *testing.T) {
t.Parallel()
cases := []struct {
name string
draft *services.SubmissionDraft
want string
}{
{"nil draft", nil, ""},
{"nil meta", &services.SubmissionDraft{}, ""},
{
"key absent",
&services.SubmissionDraft{ComposerMeta: map[string]any{"other": "x"}},
"",
},
{
"key set",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": "Replik"}},
"Replik",
},
{
"key set with surrounding whitespace is trimmed",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": " Replik "}},
"Replik",
},
{
"non-string value ignored",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": 42}},
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := submissionFilenameKeyword(tc.draft); got != tc.want {
t.Errorf("submissionFilenameKeyword() = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -336,7 +336,9 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
// One-click /generate has no saved draft row → no override store, so
// the keyword stays the auto-derived rule name (t-paliad-354).
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, "")
// Audit write is best-effort with a background context so the
// download still succeeds if the DB races. Audit failure here only
@@ -355,34 +357,66 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
}
}
// submissionFileName produces the user-facing download name per
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
// Empty case_number drops the segment entirely (no fallback hash —
// the lawyer can rename if the project lacks an Aktenzeichen).
// Umlauts in the rule name are ASCII-folded by SanitiseSubmissionFileName
// so the file lands cleanly on legacy SMB shares.
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang string) string {
// submissionNoCaseNumberPlaceholder fills the bracketed case-number slot
// when the project has no Aktenzeichen yet. Kept as a named const so the
// wording is one-line changeable (m left the exact text open, t-paliad-354).
const submissionNoCaseNumberPlaceholder = "Az. folgt"
// submissionFileName produces the user-facing download name
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
//
// - Date first (Europe/Berlin) so the files sort chronologically.
// - keyword is the user override when set, else the lang-aware rule
// name, else "submission".
// - The case number is always rendered in parentheses; when the project
// has no Aktenzeichen it falls back to submissionNoCaseNumberPlaceholder.
//
// Each segment is run through SanitiseSubmissionFileName (umlaut-folds for
// legacy SMB shares, strips the Windows-reserved set so a case number like
// "UPC_CFI_123/2026" stays safe) while the assembled "<date> <kw> (<case>)"
// frame keeps its spaces and brackets — the sanitiser preserves both.
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
}
ruleName := strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
ruleName = strings.TrimSpace(rule.NameEN)
kw := strings.TrimSpace(keyword)
if kw == "" {
kw = strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
kw = strings.TrimSpace(rule.NameEN)
}
}
if ruleName == "" {
ruleName = "submission"
if kw == "" {
kw = "submission"
}
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
caseNo := ""
if project != nil && project.CaseNumber != nil {
caseNo = strings.TrimSpace(*project.CaseNumber)
}
if caseNo != "" {
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
if caseNo == "" {
caseNo = submissionNoCaseNumberPlaceholder
}
parts = append(parts, day.Format("2006-01-02"))
return strings.Join(parts, "-") + ".docx"
return fmt.Sprintf("%s %s (%s).docx",
day.Format("2006-01-02"),
services.SanitiseSubmissionFileName(kw),
services.SanitiseSubmissionFileName(caseNo),
)
}
// submissionFilenameKeyword pulls the user's filename keyword override
// from a saved draft's composer_meta jsonb (t-paliad-354). Empty when the
// key is absent or blank — callers then fall back to the auto-derived rule
// name inside submissionFileName. The one-click /generate path has no draft
// row and always passes "".
func submissionFilenameKeyword(d *services.SubmissionDraft) string {
if d == nil || d.ComposerMeta == nil {
return ""
}
if v, ok := d.ComposerMeta["filename_keyword"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
// writeSubmissionAuditRow files one row in paliad.system_audit_log per

View File

@@ -0,0 +1,178 @@
package services
// Auto-naming for freshly-created submission drafts (t-paliad-352 /
// m/paliad#155). A new project-bound draft gets a sortable, legal-
// convention default title instead of the bare "Entwurf N" counter:
//
// <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>
//
// The date leads so drafts sort chronologically; " ./. " is the German
// legal shorthand for "gegen". The three identity segments are the
// client we act for, the forum the proceeding runs in, and the opposing
// party — exactly the trio m named ("CLIENTNAME / UPC / OPPONENTNAME").
//
// Missing-segment rule: any segment that resolves empty is dropped
// together with its leading separator, so a project without an opponent
// yet renders "2026-05-31 Bayer AG ./. UPC" (no trailing separator) and
// a project-less draft never reaches this path at all (it keeps the
// "Entwurf N" counter — see SubmissionDraftService.Create).
//
// v1.1 customization hook: the template is hardcoded here in v1. When m
// promotes naming to a per-user / per-firm / per-base setting (issue
// #155 Q4), the override string lands as an extra parameter on
// AutoSubmissionTitle (or a small template struct) and the segment
// resolvers below stay as the value source. Nothing else needs to move.
import (
"strings"
"time"
"mgit.msbls.de/m/paliad/internal/models"
)
// submissionTitleSep is the separator between identity segments —
// " ./. " is the German legal convention for "gegen" / "versus".
const submissionTitleSep = " ./. "
// AutoSubmissionTitle assembles the auto-generated draft title from the
// resolved identity pieces. Pure and table-testable — every DB hop
// happens in the caller (SubmissionDraftService.autoNameForProject).
//
// clientName is passed separately because the client we act for is the
// root ancestor of the project tree, not a field on the draft's own
// project node; the caller walks the path to resolve it. ourSide and
// the proceeding type both come off the draft's project node, the
// parties hang directly off it.
//
// The date is always present (formatted in Europe/Berlin to match the
// today.* render vars); the three identity segments are appended only
// when non-empty.
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
loc, _ := time.LoadLocation("Europe/Berlin")
if loc != nil {
now = now.In(loc)
}
date := now.Format("2006-01-02")
segments := make([]string, 0, 3)
if c := strings.TrimSpace(clientName); c != "" {
segments = append(segments, c)
}
if f := submissionForumShort(pt); f != "" {
segments = append(segments, f)
}
ourSide := ""
if project != nil {
ourSide = derefString(project.OurSide)
}
if o := submissionOpponentName(parties, ourSide); o != "" {
segments = append(segments, o)
}
if len(segments) == 0 {
return date
}
return date + " " + strings.Join(segments, submissionTitleSep)
}
// submissionForumShort maps a proceeding type to the short forum label
// used in the auto-name. The jurisdiction is the forum for the
// supranational / office tracks (UPC, EPA, DPMA); German court
// proceedings disambiguate by the court that hears them (LG / OLG /
// BGH / BPatG), which is the tail segment of the proceeding code
// (de.inf.lg → LG, de.null.bpatg → BPatG). nil / unknown → "".
func submissionForumShort(pt *models.ProceedingType) string {
if pt == nil {
return ""
}
switch j := strings.ToUpper(strings.TrimSpace(derefString(pt.Jurisdiction))); j {
case "":
return ""
case "DE":
return germanCourtShort(pt.Code)
default:
// UPC / EPA / DPMA and any future jurisdiction are their own
// forum label.
return j
}
}
// germanCourtShort returns the court abbreviation from the tail segment
// of a German proceeding code (the part after the last "."). Known
// courts get their canonical casing; anything else falls back to the
// uppercased tail so a new German proceeding still yields a label.
func germanCourtShort(code string) string {
parts := strings.Split(code, ".")
tail := strings.ToLower(strings.TrimSpace(parts[len(parts)-1]))
switch tail {
case "":
return ""
case "lg":
return "LG"
case "olg":
return "OLG"
case "bgh":
return "BGH"
case "bpatg":
return "BPatG"
default:
return strings.ToUpper(tail)
}
}
// submissionOpponentName picks the name of the primary opposing party
// given the side we act for. We act actively (claimant / applicant /
// appellant) → the opponent is on the defendant bucket; we act
// reactively (defendant / respondent) → the opponent is the claimant.
// An unknown / unset side (third_party, other, NULL) can't fix a
// posture, so no opponent is derived (the segment is omitted). The
// first party of the opposing bucket wins — PartyService.ListForProject
// orders by name, so the pick is deterministic for a given project.
func submissionOpponentName(parties []models.Party, ourSide string) string {
var want string
switch sidePosture(ourSide) {
case "active":
want = "defendant"
case "reactive":
want = "claimant"
default:
return ""
}
for i := range parties {
if partyRoleBucket(parties[i].Role) == want {
if n := strings.TrimSpace(parties[i].Name); n != "" {
return n
}
}
}
return ""
}
// sidePosture folds the our_side sub-role vocabulary (t-paliad-222)
// down to the active / reactive axis. Returns "" for sides that have no
// clear posture (third_party, other) or an unset value.
func sidePosture(ourSide string) string {
switch strings.ToLower(strings.TrimSpace(ourSide)) {
case "claimant", "applicant", "appellant":
return "active"
case "defendant", "respondent":
return "reactive"
default:
return ""
}
}
// partyRoleBucket folds a party's free-text role into the
// claimant / defendant / other buckets. German and English spellings
// both fold in; everything else (Streithelfer, Patentinhaberin, …) is
// "other". Shared with addPartyVars so the two paths can't drift.
func partyRoleBucket(role *string) string {
switch strings.ToLower(strings.TrimSpace(derefString(role))) {
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
return "claimant"
case "defendant", "beklagter", "beklagte":
return "defendant"
default:
return "other"
}
}

View File

@@ -0,0 +1,224 @@
package services
import (
"testing"
"time"
"mgit.msbls.de/m/paliad/internal/models"
)
func party(name, role string) models.Party {
return models.Party{Name: name, Role: strPtr(role)}
}
func proceeding(jurisdiction, code string) *models.ProceedingType {
return &models.ProceedingType{Jurisdiction: strPtr(jurisdiction), Code: code}
}
func projectSide(side string) *models.Project {
if side == "" {
return &models.Project{}
}
return &models.Project{OurSide: strPtr(side)}
}
// noon UTC on 2026-05-31 → 14:00 Europe/Berlin (CEST), same calendar day.
var fixedNow = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
func TestAutoSubmissionTitle(t *testing.T) {
cases := []struct {
name string
clientName string
project *models.Project
parties []models.Party
pt *models.ProceedingType
want string
}{
{
name: "full data — UPC, we are claimant",
clientName: "Bayer AG",
project: projectSide("claimant"),
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
pt: proceeding("UPC", "upc.inf.cfi"),
want: "2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma",
},
{
name: "full data — German court, we are respondent",
clientName: "Bayer AG",
project: projectSide("respondent"),
parties: []models.Party{party("Acme Generics", "Klägerin")},
pt: proceeding("DE", "de.null.bpatg"),
want: "2026-05-31 Bayer AG ./. BPatG ./. Acme Generics",
},
{
name: "no opponent — opposing bucket empty",
clientName: "Bayer AG",
project: projectSide("claimant"),
parties: []models.Party{party("Bayer AG", "Klägerin")}, // only our own side
pt: proceeding("UPC", "upc.inf.cfi"),
want: "2026-05-31 Bayer AG ./. UPC",
},
{
name: "no forum — proceeding type missing",
clientName: "Bayer AG",
project: projectSide("respondent"),
parties: []models.Party{party("Acme Generics", "Klägerin")},
pt: nil,
want: "2026-05-31 Bayer AG ./. Acme Generics",
},
{
name: "no client — client segment omitted",
clientName: "",
project: projectSide("claimant"),
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
pt: proceeding("UPC", "upc.inf.cfi"),
want: "2026-05-31 UPC ./. Novartis Pharma",
},
{
name: "all identity segments missing — date only",
clientName: "",
project: projectSide(""), // no our_side → no opponent posture
parties: nil,
pt: nil,
want: "2026-05-31",
},
{
name: "unknown side — opponent omitted even with parties",
clientName: "Bayer AG",
project: projectSide("third_party"),
parties: []models.Party{party("Acme Generics", "Klägerin")},
pt: proceeding("EPA", "epa.opp.opd"),
want: "2026-05-31 Bayer AG ./. EPA",
},
{
name: "nil project — opponent omitted, client + forum stand",
clientName: "Bayer AG",
project: nil,
parties: []models.Party{party("Acme Generics", "Klägerin")},
pt: proceeding("DPMA", "dpma.opp.dpma"),
want: "2026-05-31 Bayer AG ./. DPMA",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := AutoSubmissionTitle(fixedNow, c.clientName, c.project, c.parties, c.pt)
if got != c.want {
t.Errorf("AutoSubmissionTitle = %q, want %q", got, c.want)
}
})
}
}
// TestAutoSubmissionTitleBerlinDate locks the Europe/Berlin localisation:
// 22:30 UTC on 2026-05-31 is already 00:30 on 2026-06-01 in CEST, so the
// date segment must roll over.
func TestAutoSubmissionTitleBerlinDate(t *testing.T) {
lateUTC := time.Date(2026, 5, 31, 22, 30, 0, 0, time.UTC)
got := AutoSubmissionTitle(lateUTC, "Bayer AG", projectSide("claimant"),
[]models.Party{party("Novartis", "Beklagte")}, proceeding("UPC", "upc.inf.cfi"))
want := "2026-06-01 Bayer AG ./. UPC ./. Novartis"
if got != want {
t.Errorf("AutoSubmissionTitle (late UTC) = %q, want %q", got, want)
}
}
func TestSubmissionForumShort(t *testing.T) {
cases := []struct {
pt *models.ProceedingType
want string
}{
{nil, ""},
{proceeding("UPC", "upc.inf.cfi"), "UPC"},
{proceeding("EPA", "epa.opp.opd"), "EPA"},
{proceeding("DPMA", "dpma.opp.dpma"), "DPMA"},
{proceeding("DE", "de.inf.lg"), "LG"},
{proceeding("DE", "de.inf.olg"), "OLG"},
{proceeding("DE", "de.inf.bgh"), "BGH"},
{proceeding("DE", "de.null.bpatg"), "BPatG"},
{proceeding("DE", "de.null.bgh"), "BGH"},
{proceeding("DE", "de.foo.amtsgericht"), "AMTSGERICHT"}, // unknown court → uppercased tail
{proceeding("de", "de.inf.lg"), "LG"}, // lowercase jurisdiction folds
{proceeding("", ""), ""}, // no jurisdiction
}
for _, c := range cases {
if got := submissionForumShort(c.pt); got != c.want {
t.Errorf("submissionForumShort(%+v) = %q, want %q", c.pt, got, c.want)
}
}
}
func TestSubmissionOpponentName(t *testing.T) {
claimantA := party("Acme", "Klägerin")
defendantB := party("Novartis", "Beklagte")
other := party("Streithelfer X", "Streithelfer")
cases := []struct {
name string
parties []models.Party
ourSide string
want string
}{
{"active → first defendant", []models.Party{claimantA, defendantB}, "claimant", "Novartis"},
{"reactive → first claimant", []models.Party{claimantA, defendantB}, "respondent", "Acme"},
{"applicant (active) → defendant", []models.Party{defendantB}, "applicant", "Novartis"},
{"appellant (active) → defendant", []models.Party{defendantB}, "appellant", "Novartis"},
{"defendant (reactive) → claimant", []models.Party{claimantA}, "defendant", "Acme"},
{"unknown side → none", []models.Party{claimantA, defendantB}, "third_party", ""},
{"empty side → none", []models.Party{claimantA, defendantB}, "", ""},
{"no opposing party → none", []models.Party{claimantA, other}, "claimant", ""},
{"opposing bucket only 'other' → none", []models.Party{other}, "respondent", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := submissionOpponentName(c.parties, c.ourSide); got != c.want {
t.Errorf("submissionOpponentName = %q, want %q", got, c.want)
}
})
}
}
func TestUniqueDraftName(t *testing.T) {
cases := []struct {
name string
base string
existing []string
want string
}{
{"free", "2026-05-31 Bayer AG ./. UPC", nil, "2026-05-31 Bayer AG ./. UPC"},
{"first clash → (2)", "2026-05-31 Bayer AG ./. UPC",
[]string{"2026-05-31 Bayer AG ./. UPC"}, "2026-05-31 Bayer AG ./. UPC (2)"},
{"two clash → (3)", "2026-05-31 Bayer AG ./. UPC",
[]string{"2026-05-31 Bayer AG ./. UPC", "2026-05-31 Bayer AG ./. UPC (2)"},
"2026-05-31 Bayer AG ./. UPC (3)"},
{"gap reused → (2)", "X",
[]string{"X", "X (3)"}, "X (2)"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := uniqueDraftName(c.base, c.existing); got != c.want {
t.Errorf("uniqueDraftName = %q, want %q", got, c.want)
}
})
}
}
func TestNextDraftName(t *testing.T) {
cases := []struct {
name string
existing []string
lang string
want string
}{
{"empty de", nil, "de", "Entwurf 1"},
{"empty en", nil, "en", "Draft 1"},
{"highest+1", []string{"Entwurf 1", "Entwurf 3"}, "de", "Entwurf 4"},
{"ignores foreign names", []string{"2026-05-31 Bayer AG"}, "de", "Entwurf 1"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := nextDraftName(c.existing, c.lang); got != c.want {
t.Errorf("nextDraftName = %q, want %q", got, c.want)
}
})
}
}

View File

@@ -0,0 +1,129 @@
package services
// Live-DB test for the submission-draft auto-naming scheme
// (t-paliad-352 / m/paliad#155). Skipped without TEST_DATABASE_URL.
//
// Verifies the shipped Create flow end-to-end against real Postgres:
// a project-bound draft is auto-named "<date> <client> ./. <forum> ./.
// <opponent>" rather than "Entwurf N", the segments resolve from the
// real project tree (client = root ancestor, forum = proceeding-type
// jurisdiction, opponent = opposing party by our_side), and a second
// draft on the same slot de-duplicates with a " (2)" suffix.
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestSubmissionDraft_AutoName_Live(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()
userID := uuid.New()
email := "autoname-" + userID.String()[:8] + "@hlc.com"
var clientID, caseID uuid.UUID
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.parties WHERE project_id = $1`, caseID)
// Children first (FK), then root.
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, clientID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
defer cleanup()
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, 'Auto Name', '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)
// Client root → case child. The case carries the proceeding type
// (UPC) and our_side (claimant), the party is the opponent.
client, err := projects.Create(ctx, userID, CreateProjectInput{
Type: "client", Title: "Bayer AG",
})
if err != nil {
t.Fatalf("create client project: %v", err)
}
clientID = client.ID
ptID := 8 // upc.inf.cfi → jurisdiction UPC
side := "claimant"
caseProj, err := projects.Create(ctx, userID, CreateProjectInput{
Type: "case", Title: "Streitsache", ParentID: &client.ID,
ProceedingTypeID: &ptID, OurSide: &side,
})
if err != nil {
t.Fatalf("create case project: %v", err)
}
caseID = caseProj.ID
beklagte := "Beklagte"
if _, err := parties.Create(ctx, userID, caseProj.ID, CreatePartyInput{
Name: "Novartis Pharma", Role: &beklagte,
}); err != nil {
t.Fatalf("create party: %v", err)
}
loc, _ := time.LoadLocation("Europe/Berlin")
today := time.Now().In(loc).Format("2006-01-02")
wantBase := today + " Bayer AG ./. UPC ./. Novartis Pharma"
d1, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create draft 1: %v", err)
}
if d1.Name != wantBase {
t.Fatalf("draft 1 name = %q, want %q", d1.Name, wantBase)
}
// Second draft on the same (project, code) slot must de-duplicate.
d2, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create draft 2: %v", err)
}
want2 := wantBase + " (2)"
if d2.Name != want2 {
t.Fatalf("draft 2 name = %q, want %q", d2.Name, want2)
}
// A project-less draft keeps the legacy Entwurf-N counter.
dless, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create project-less draft: %v", err)
}
if dless.Name != "Entwurf 1" {
t.Fatalf("project-less draft name = %q, want %q", dless.Name, "Entwurf 1")
}
}

View File

@@ -0,0 +1,111 @@
package services
// Live-DB test for the user-replaceable filename keyword
// (t-paliad-354). Skipped without TEST_DATABASE_URL.
//
// Exercises the real Update → Get code path against Postgres: setting the
// override merges into composer_meta.filename_keyword without clobbering
// other composer keys, clearing it removes only that key, and the value
// reads back through the same jsonb decode the export handler relies on.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestSubmissionDraft_FilenameKeyword_Live(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()
userID := uuid.New()
email := "kw-" + userID.String()[:8] + "@hlc.com"
cleanup := func() {
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)
}
defer cleanup()
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, 'Keyword Tester', '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)
// A project-less draft is the simplest fixture — no project tree
// needed to exercise composer_meta persistence.
d, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create draft: %v", err)
}
// Pre-seed an unrelated composer_meta key to prove the merge/delete
// only touches filename_keyword.
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.submission_drafts SET composer_meta = '{"other":"keep-me"}'::jsonb WHERE id = $1`,
d.ID); err != nil {
t.Fatalf("seed composer_meta: %v", err)
}
// Set the override.
kw := "Replik Hauptantrag"
got, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &kw})
if err != nil {
t.Fatalf("update set keyword: %v", err)
}
if v, _ := got.ComposerMeta["filename_keyword"].(string); v != kw {
t.Fatalf("after set: filename_keyword = %q, want %q", v, kw)
}
if v, _ := got.ComposerMeta["other"].(string); v != "keep-me" {
t.Fatalf("after set: unrelated key 'other' = %q, want %q (merge clobbered it)", v, "keep-me")
}
// Read back through Get (the path the export handler uses).
reload, err := drafts.Get(ctx, userID, d.ID)
if err != nil {
t.Fatalf("get after set: %v", err)
}
if v, _ := reload.ComposerMeta["filename_keyword"].(string); v != kw {
t.Fatalf("reload: filename_keyword = %q, want %q", v, kw)
}
// Clear the override (empty string) — only filename_keyword should go.
empty := ""
cleared, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &empty})
if err != nil {
t.Fatalf("update clear keyword: %v", err)
}
if _, present := cleared.ComposerMeta["filename_keyword"]; present {
t.Fatalf("after clear: filename_keyword still present: %v", cleared.ComposerMeta)
}
if v, _ := cleared.ComposerMeta["other"].(string); v != "keep-me" {
t.Fatalf("after clear: unrelated key 'other' = %q, want %q (delete removed too much)", v, "keep-me")
}
}

View File

@@ -183,6 +183,14 @@ type DraftPatch struct {
// **p → pin the version (validated via TemplateStore.GetVersion)
// t-paliad-349 slice 7.
TemplateVersionID **uuid.UUID
// FilenameKeyword sets (or clears) the user override that leads the
// exported document name "<date> <keyword> (<case>)" (t-paliad-354).
// Stored under composer_meta.filename_keyword — no dedicated column:
// nil → no change
// *p == "" → clear the key (back to the auto-derived rule name)
// *p == "x" → set the override
FilenameKeyword *string
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -356,12 +364,15 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
// 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) {
var project *models.Project
if projectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
p, err := s.projects.GetByID(ctx, userID, *projectID)
if err != nil {
return nil, err
}
project = p
}
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
name, err := s.newDraftName(ctx, userID, project, projectID, submissionCode, lang)
if err != nil {
return nil, err
}
@@ -431,20 +442,94 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
return &d, nil
}
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
// suffix if two callers race; the unique constraint on the table is
// the final guard.
// newDraftName picks the title for a freshly-created draft. Project-
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
// "<date> <client> ./. <forum> ./. <opponent>", de-duplicated against
// the user's existing drafts for the same (project, submission_code).
// Project-less drafts (and any project-bound draft whose auto-name
// resolves to nothing) fall back to the "Entwurf N" / "Draft N"
// counter.
//
// A nil projectID scopes the search to the user's project-less drafts
// for this submission_code — matches the row-uniqueness contract on
// the DB side (project_id, submission_code, user_id, name) where
// project_id IS NULL is its own equivalence class.
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
prefix := "Entwurf"
if strings.EqualFold(lang, "en") {
prefix = "Draft"
// Only Create calls this — existing drafts are never renamed (the
// scheme is create-time only, per #155). A lawyer's later manual rename
// flows through Update and is left untouched.
func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.UUID, project *models.Project, projectID *uuid.UUID, submissionCode, lang string) (string, error) {
existing, err := s.existingDraftNames(ctx, projectID, submissionCode, userID)
if err != nil {
return "", err
}
if project != nil {
auto, err := s.autoNameForProject(ctx, time.Now(), project)
if err != nil {
return "", err
}
if strings.TrimSpace(auto) != "" {
return uniqueDraftName(auto, existing), nil
}
}
return nextDraftName(existing, lang), nil
}
// autoNameForProject resolves the three identity segments for a
// project-bound draft and hands them to the pure AutoSubmissionTitle
// assembler. The client is the root ancestor of the project tree (the
// 'client' node), the proceeding type and our_side come off the draft's
// own project node, and the parties hang directly off it.
//
// A failure to resolve the client / proceeding type is not fatal —
// AutoSubmissionTitle just omits the empty segment — so the only errors
// returned here are genuine DB faults.
func (s *SubmissionDraftService) autoNameForProject(ctx context.Context, now time.Time, project *models.Project) (string, error) {
clientName, err := s.clientNameForProject(ctx, project.ID)
if err != nil {
return "", err
}
pt, err := s.vars.loadProceedingType(ctx, project.ProceedingTypeID)
if err != nil {
return "", err
}
var parties []models.Party
if err := s.db.SelectContext(ctx, &parties,
`SELECT id, project_id, name, role, representative, contact_info,
created_at, updated_at
FROM paliad.parties
WHERE project_id = $1
ORDER BY name`, project.ID); err != nil {
return "", fmt.Errorf("auto-name: load parties: %w", err)
}
return AutoSubmissionTitle(now, clientName, project, parties, pt), nil
}
// clientNameForProject returns the title of the 'client' ancestor in
// the project's path (the firm's mandant). Empty string when the tree
// has no client node — the auto-name then omits the client segment.
func (s *SubmissionDraftService) clientNameForProject(ctx context.Context, projectID uuid.UUID) (string, error) {
var title string
err := s.db.GetContext(ctx, &title,
`SELECT p.title
FROM paliad.projects target
JOIN paliad.projects p
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = $1 AND p.type = 'client'
LIMIT 1`, projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("auto-name: resolve client name: %w", err)
}
return title, nil
}
// existingDraftNames returns the names already in use for the
// (project, submission_code, user) slot. A nil projectID scopes to the
// user's project-less drafts for this submission_code — matching the
// DB unique contract (project_id, submission_code, user_id, name) where
// project_id IS NULL is its own equivalence class.
func (s *SubmissionDraftService) existingDraftNames(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID) ([]string, error) {
var names []string
var err error
if projectID == nil {
@@ -459,16 +544,48 @@ func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *u
*projectID, submissionCode, userID)
}
if err != nil {
return "", fmt.Errorf("scan existing draft names: %w", err)
return nil, fmt.Errorf("scan existing draft names: %w", err)
}
return names, nil
}
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
// suffix if two callers race; the unique constraint on the table is
// the final guard. Pure over the supplied name list.
func nextDraftName(existing []string, lang string) string {
prefix := "Entwurf"
if strings.EqualFold(lang, "en") {
prefix = "Draft"
}
highest := 0
for _, n := range names {
for _, n := range existing {
var idx int
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
highest = idx
}
}
return fmt.Sprintf("%s %d", prefix, highest+1), nil
return fmt.Sprintf("%s %d", prefix, highest+1)
}
// uniqueDraftName returns base unchanged when it's free, otherwise
// appends " (N)" with the lowest N≥2 that isn't taken. Mirrors the
// "race → unique constraint is the final guard" contract of
// nextDraftName; pure over the supplied name list.
func uniqueDraftName(base string, existing []string) string {
taken := make(map[string]struct{}, len(existing))
for _, n := range existing {
taken[n] = struct{}{}
}
if _, clash := taken[base]; !clash {
return base
}
for i := 2; ; i++ {
cand := fmt.Sprintf("%s (%d)", base, i)
if _, clash := taken[cand]; !clash {
return cand
}
}
}
// Update patches the draft. Variables is replace-semantics — pass the
@@ -589,6 +706,21 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.FilenameKeyword != nil {
// Targeted jsonb merge so other composer_meta keys survive. An
// empty override removes the key entirely, restoring the
// auto-derived rule name as the filename keyword (t-paliad-354).
kw := strings.TrimSpace(*patch.FilenameKeyword)
if kw == "" {
setParts = append(setParts, "composer_meta = composer_meta - 'filename_keyword'")
} else {
setParts = append(setParts,
fmt.Sprintf("composer_meta = composer_meta || jsonb_build_object('filename_keyword', $%d::text)", idx))
args = append(args, kw)
idx++
}
}
if len(setParts) == 0 {
return existing, nil
}

View File

@@ -412,11 +412,10 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
var claimants, defendants, others []models.Party
for i := range parties {
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
switch role {
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
switch partyRoleBucket(parties[i].Role) {
case "claimant":
claimants = append(claimants, parties[i])
case "defendant", "beklagter", "beklagte":
case "defendant":
defendants = append(defendants, parties[i])
default:
others = append(others, parties[i])

View File

@@ -185,7 +185,12 @@ func SanitiseSubmissionFileName(s string) string {
s = umlautFolder.Replace(s)
s = strings.Map(func(r rune) rune {
switch r {
case '/', '\\':
// Path separators and the rest of the Windows-reserved set —
// fold to underscore so a case number like "UPC_CFI_123/2026"
// stays one filesystem-safe segment. Spaces and parentheses are
// intentionally preserved: the human-facing download name
// "<date> <keyword> (<case>)" relies on them (t-paliad-354).
case '/', '\\', ':', '*', '?', '<', '>', '|':
return '_'
case '"', '\'':
return -1

View File

@@ -241,9 +241,12 @@ func TestSanitiseSubmissionFileName(t *testing.T) {
"Klageerwiderung": "Klageerwiderung",
"Berufungsbegründung": "Berufungsbegruendung",
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
`Statement of "Defence"`: "Statement of Defence",
` Klage `: "Klage",
"Größe": "Groesse",
`Statement of "Defence"`: "Statement of Defence",
` Klage `: "Klage",
"Größe": "Groesse",
"UPC_CFI_123/2026": "UPC_CFI_123_2026",
"a:b*c?d<e>f|g": "a_b_c_d_e_f_g",
"Klageerwiderung (Frist)": "Klageerwiderung (Frist)",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {