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).
This commit is contained in:
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -356,12 +356,15 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
|
|||||||
// creates with base_id=NULL — Composer is additive, the v1 fallback
|
// creates with base_id=NULL — Composer is additive, the v1 fallback
|
||||||
// path remains valid.
|
// path remains valid.
|
||||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
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 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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -431,20 +434,94 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
|
|||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
// newDraftName picks the title for a freshly-created draft. Project-
|
||||||
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
|
||||||
// suffix if two callers race; the unique constraint on the table is
|
// "<date> <client> ./. <forum> ./. <opponent>", de-duplicated against
|
||||||
// the final guard.
|
// 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
|
// Only Create calls this — existing drafts are never renamed (the
|
||||||
// for this submission_code — matches the row-uniqueness contract on
|
// scheme is create-time only, per #155). A lawyer's later manual rename
|
||||||
// the DB side (project_id, submission_code, user_id, name) where
|
// flows through Update and is left untouched.
|
||||||
// project_id IS NULL is its own equivalence class.
|
func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.UUID, project *models.Project, projectID *uuid.UUID, submissionCode, lang string) (string, error) {
|
||||||
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
|
existing, err := s.existingDraftNames(ctx, projectID, submissionCode, userID)
|
||||||
prefix := "Entwurf"
|
if err != nil {
|
||||||
if strings.EqualFold(lang, "en") {
|
return "", err
|
||||||
prefix = "Draft"
|
|
||||||
}
|
}
|
||||||
|
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 names []string
|
||||||
var err error
|
var err error
|
||||||
if projectID == nil {
|
if projectID == nil {
|
||||||
@@ -459,16 +536,48 @@ func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *u
|
|||||||
*projectID, submissionCode, userID)
|
*projectID, submissionCode, userID)
|
||||||
}
|
}
|
||||||
if err != nil {
|
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
|
highest := 0
|
||||||
for _, n := range names {
|
for _, n := range existing {
|
||||||
var idx int
|
var idx int
|
||||||
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
|
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
|
||||||
highest = idx
|
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
|
// Update patches the draft. Variables is replace-semantics — pass the
|
||||||
|
|||||||
@@ -412,11 +412,10 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
|
|||||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||||
var claimants, defendants, others []models.Party
|
var claimants, defendants, others []models.Party
|
||||||
for i := range parties {
|
for i := range parties {
|
||||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
switch partyRoleBucket(parties[i].Role) {
|
||||||
switch role {
|
case "claimant":
|
||||||
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
|
||||||
claimants = append(claimants, parties[i])
|
claimants = append(claimants, parties[i])
|
||||||
case "defendant", "beklagter", "beklagte":
|
case "defendant":
|
||||||
defendants = append(defendants, parties[i])
|
defendants = append(defendants, parties[i])
|
||||||
default:
|
default:
|
||||||
others = append(others, parties[i])
|
others = append(others, parties[i])
|
||||||
|
|||||||
Reference in New Issue
Block a user