From f2923389194cf61225cb9cd8d60bba1977c0e561 Mon Sep 17 00:00:00 2001 From: mAi Date: Sun, 31 May 2026 15:28:54 +0200 Subject: [PATCH] feat(submissions): auto-name new drafts ./../. (m/paliad#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New project-bound submission drafts now default to a sortable, legal- convention title instead of the bare "Entwurf N" counter: ./. ./. - 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). --- internal/services/submission_autoname.go | 178 ++++++++++++++ internal/services/submission_autoname_test.go | 224 ++++++++++++++++++ .../submission_draft_autoname_live_test.go | 129 ++++++++++ internal/services/submission_draft_service.go | 143 +++++++++-- internal/services/submission_vars.go | 7 +- 5 files changed, 660 insertions(+), 21 deletions(-) create mode 100644 internal/services/submission_autoname.go create mode 100644 internal/services/submission_autoname_test.go create mode 100644 internal/services/submission_draft_autoname_live_test.go diff --git a/internal/services/submission_autoname.go b/internal/services/submission_autoname.go new file mode 100644 index 0000000..562e63e --- /dev/null +++ b/internal/services/submission_autoname.go @@ -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: +// +// ./. ./. +// +// 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" + } +} diff --git a/internal/services/submission_autoname_test.go b/internal/services/submission_autoname_test.go new file mode 100644 index 0000000..dd70910 --- /dev/null +++ b/internal/services/submission_autoname_test.go @@ -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) + } + }) + } +} diff --git a/internal/services/submission_draft_autoname_live_test.go b/internal/services/submission_draft_autoname_live_test.go new file mode 100644 index 0000000..e087849 --- /dev/null +++ b/internal/services/submission_draft_autoname_live_test.go @@ -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 " ./. ./. +// " 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") + } +} diff --git a/internal/services/submission_draft_service.go b/internal/services/submission_draft_service.go index 1022bc0..8146a3f 100644 --- a/internal/services/submission_draft_service.go +++ b/internal/services/submission_draft_service.go @@ -356,12 +356,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 +434,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) — +// " ./. ./. ", 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 +536,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 diff --git a/internal/services/submission_vars.go b/internal/services/submission_vars.go index c9fde89..f4987c6 100644 --- a/internal/services/submission_vars.go +++ b/internal/services/submission_vars.go @@ -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])