Compare commits
6 Commits
639ff4f672
...
mai/farada
| Author | SHA1 | Date | |
|---|---|---|---|
| d834b36313 | |||
| 4092c889c4 | |||
| db1040968f | |||
| f292338919 | |||
| 2b240e7dd0 | |||
| c945cbd330 |
@@ -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**:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: <Datum> <Stichwort> (<Aktenzeichen>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
{/* t-paliad-277: "Aus Projekt importieren" + last-
|
||||
|
||||
@@ -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()
|
||||
|
||||
144
internal/handlers/submission_filename_test.go
Normal file
144
internal/handlers/submission_filename_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
178
internal/services/submission_autoname.go
Normal file
178
internal/services/submission_autoname.go
Normal 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"
|
||||
}
|
||||
}
|
||||
224
internal/services/submission_autoname_test.go
Normal file
224
internal/services/submission_autoname_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
129
internal/services/submission_draft_autoname_live_test.go
Normal file
129
internal/services/submission_draft_autoname_live_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
111
internal/services/submission_draft_keyword_live_test.go
Normal file
111
internal/services/submission_draft_keyword_live_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user