Merge: t-paliad-356 Slice 2 — non-project drafts get date-first name via nomen engine
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-06-01 12:06:43 +02:00
4 changed files with 238 additions and 14 deletions

View File

@@ -92,15 +92,25 @@ func NameArtifact(id string) (Artifact, bool) {
// Seed compositions (the two shipped schemes, as data — PRD §5).
// ---------------------------------------------------------------------------
// submissionDraftTitleComposition reproduces AutoSubmissionTitle (#155):
// submissionDraftTitleComposition reproduces AutoSubmissionTitle (#155) and
// carries the non-project degradation (Slice 2, PRD §6):
//
// <date> <client> ./. <forum> ./. <opponent>
// project draft: <date> <client> ./. <forum> ./. <opponent>
// non-project draft: <date> <keyword>
//
// Trailing separators: the date joins the identity block with a space, the
// Trailing separators: the date joins the next segment with a space, the
// identity segments join each other with " ./. ". Because separators are
// owned by the left segment, dropping any identity segment (or all of them)
// still yields the byte-exact original — e.g. client-absent renders
// "<date> <forum> ./. <opponent>" with a single space after the date.
//
// The identity trio and the keyword are mutually exclusive by construction:
// project drafts resolve client/forum/opponent and leave keyword empty;
// non-project drafts have no project so the trio omits and the keyword
// (document type, or an "Entwurf"/"Draft" fallback) carries the name. A
// project draft therefore renders identically to #155 (keyword omits), which
// is the Slice-2 regression guard. opponent.Sep is unused under this
// invariant (it would only fire if both opponent and keyword emitted).
func submissionDraftTitleComposition() nomen.Composition {
return nomen.Composition{
Version: nomen.Version,
@@ -108,7 +118,8 @@ func submissionDraftTitleComposition() nomen.Composition {
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "client", Sep: " ./. ", Missing: nomen.Omit()},
{Var: "forum", Sep: " ./. ", Missing: nomen.Omit()},
{Var: "opponent", Sep: "", Missing: nomen.Omit()},
{Var: "opponent", Sep: " ./. ", Missing: nomen.Omit()},
{Var: "keyword", Sep: "", Missing: nomen.Omit()},
},
}
}
@@ -142,6 +153,7 @@ func submissionTitleCatalog() nomen.VarCatalog {
"client": {Key: "client", Label: "Mandant", LabelEN: "Client", Group: "parties", Description: "Name des Mandanten (Wurzel der Akte)"},
"forum": {Key: "forum", Label: "Forum", LabelEN: "Forum", Group: "proceeding", Description: "Kurzbezeichnung des Forums (UPC, EPA, LG, …)"},
"opponent": {Key: "opponent", Label: "Gegner", LabelEN: "Opponent", Group: "parties", Description: "Name der Gegenseite"},
"keyword": {Key: "keyword", Label: "Stichwort", LabelEN: "Keyword", Group: "document", Description: "Dokumenttyp — trägt den Namen projektloser Entwürfe"},
}
}
@@ -170,8 +182,11 @@ func nomenDateBerlin(t time.Time) string {
// submissionTitleResolver yields the draft-title variables. now is injected
// (tests pin a fixed instant); the three identity segments resolve from the
// existing helpers and report absence so the composition's Omit rule drops
// them.
func submissionTitleResolver(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) nomen.VarResolver {
// them. keyword is empty for project drafts (the trio carries the name) and
// holds the document type — or an "Entwurf"/"Draft" fallback — for
// project-less drafts (Slice 2); the caller resolves it (it needs a DB hop)
// and passes the value in, keeping this resolver pure.
func submissionTitleResolver(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType, keyword string) nomen.VarResolver {
return func(key string) (string, bool) {
switch key {
case "date":
@@ -189,11 +204,24 @@ func submissionTitleResolver(now time.Time, clientName string, project *models.P
}
o := submissionOpponentName(parties, ourSide)
return o, o != ""
case "keyword":
k := strings.TrimSpace(keyword)
return k, k != ""
}
return "", false
}
}
// renderSubmissionDraftTitle is the single render path for the
// submission_draft_title artifact, shared by the project path
// (AutoSubmissionTitle, keyword="") and the non-project path
// (autoNameForNonProject, trio nil + keyword set).
func renderSubmissionDraftTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType, keyword string) string {
art := nameArtifacts[ArtifactSubmissionDraftTitle]
resolve := submissionTitleResolver(now, clientName, project, parties, pt, keyword)
return art.SystemDefault.Render(resolve, art.Target)
}
// submissionFilenameResolver yields the .docx-filename variables. The date is
// render-time "today" (the original used time.Now()); keyword applies the
// override -> lang-aware rule name precedence and reports absence so the

View File

@@ -46,9 +46,10 @@ import (
// identity segments are appended only when non-empty. Rendered through the
// submission_draft_title artifact (namegen.go).
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
art := nameArtifacts[ArtifactSubmissionDraftTitle]
resolve := submissionTitleResolver(now, clientName, project, parties, pt)
return art.SystemDefault.Render(resolve, art.Target)
// Project path: the identity trio carries the name, keyword stays empty
// (and its segment omits) — so a project draft renders identically to
// #155. Non-project drafts go through autoNameForNonProject instead.
return renderSubmissionDraftTitle(now, clientName, project, parties, pt, "")
}
// submissionForumShort maps a proceeding type to the short forum label

View File

@@ -0,0 +1,121 @@
package services
// Live-DB gate for the non-project date-first draft name
// (t-paliad-356 Slice 2, PRD §6). Skipped without TEST_DATABASE_URL.
//
// Exercises the real SubmissionDraftService.Create path for a project-less
// draft (projectID == nil): the title must lead with today's date and carry
// the document type resolved from the submission_code, degrade to an
// "Entwurf"/"Draft" fallback when the code has no published filing rule, and
// stay unique on collision. Project-draft titles are guarded byte-for-byte by
// the pure TestAutoSubmissionTitle matrix and are unchanged by this slice.
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 todayBerlinDate() string {
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
}
return day.Format("2006-01-02")
}
func TestSubmissionDraft_NonProjectName_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 := "np-" + 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, 'Non-Project 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)
date := todayBerlinDate()
// de.inf.lg.erwidg is a published filing rule → "Klageerwiderung" (DE) /
// "Statement of Defence" (EN). A project-less draft must lead with the
// date and carry that keyword.
d1, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("create draft 1: %v", err)
}
if want := date + " Klageerwiderung"; d1.Name != want {
t.Errorf("draft 1 name = %q, want %q", d1.Name, want)
}
// Same code again → collision → " (2)" via uniqueDraftName.
d2, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("create draft 2: %v", err)
}
if want := date + " Klageerwiderung (2)"; d2.Name != want {
t.Errorf("draft 2 name = %q, want %q", d2.Name, want)
}
// EN locale resolves the English document type.
dEN, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "en")
if err != nil {
t.Fatalf("create draft EN: %v", err)
}
if want := date + " Statement of Defence"; dEN.Name != want {
t.Errorf("draft EN name = %q, want %q", dEN.Name, want)
}
// A code with no published filing rule falls back to "<date> Entwurf".
dFallback, err := drafts.Create(ctx, userID, nil, "zzz.bogus.nope", "de")
if err != nil {
t.Fatalf("create fallback draft: %v", err)
}
if want := date + " Entwurf"; dFallback.Name != want {
t.Errorf("fallback draft name = %q, want %q", dFallback.Name, want)
}
// EN fallback word.
dFallbackEN, err := drafts.Create(ctx, userID, nil, "zzz.bogus.nope", "en")
if err != nil {
t.Fatalf("create EN fallback draft: %v", err)
}
if want := date + " Draft"; dFallbackEN.Name != want {
t.Errorf("EN fallback draft name = %q, want %q", dFallbackEN.Name, want)
}
}

View File

@@ -466,8 +466,75 @@ func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.U
if strings.TrimSpace(auto) != "" {
return uniqueDraftName(auto, existing), nil
}
// A project draft whose auto-name resolved to nothing (date always
// renders, so this is unreachable in practice) keeps the legacy
// counter as a defensive fallback.
return nextDraftName(existing, lang), nil
}
return nextDraftName(existing, lang), nil
// Project-less draft (t-paliad-243): date-first name as well
// (t-paliad-356 Slice 2, PRD §6) — "<date> <keyword>", keyword being the
// document type resolved from submission_code, or an "Entwurf"/"Draft"
// fallback when the code has no published filing rule.
auto, err := s.autoNameForNonProject(ctx, time.Now(), submissionCode, lang)
if err != nil {
return "", err
}
return uniqueDraftName(auto, existing), nil
}
// autoNameForNonProject builds the date-first title for a project-less draft.
// It resolves the keyword (document type) from the submission_code via the
// catalog — which is project-independent because submission_code → name is a
// function across the published filing rules — and falls back to the
// localized "Entwurf"/"Draft" word when the code has no matching rule. The
// identity trio is absent (no project), so the title degrades to
// "<date> <keyword>".
func (s *SubmissionDraftService) autoNameForNonProject(ctx context.Context, now time.Time, submissionCode, lang string) (string, error) {
keyword, err := s.keywordForSubmissionCode(ctx, submissionCode, lang)
if err != nil {
return "", err
}
if strings.TrimSpace(keyword) == "" {
keyword = draftWord(lang)
}
return renderSubmissionDraftTitle(now, "", nil, nil, nil, keyword), nil
}
// keywordForSubmissionCode resolves the document-type label for a
// submission_code, lang-aware, without needing a project. submission_code is
// a globally-unique key for a published filing rule (the code encodes the
// proceeding, e.g. de.inf.lg.erwidg → "Klageerwiderung"), so a project-free
// LIMIT 1 lookup is deterministic. Returns "" (no error) when the code has no
// active published filing rule — the caller then uses the "Entwurf"/"Draft"
// fallback.
func (s *SubmissionDraftService) keywordForSubmissionCode(ctx context.Context, submissionCode, lang string) (string, error) {
code := strings.TrimSpace(submissionCode)
if code == "" {
return "", nil
}
var row struct {
Name string `db:"name"`
NameEN string `db:"name_en"`
}
err := s.db.GetContext(ctx, &row,
`SELECT dr.name AS name, COALESCE(dr.name_en, '') AS name_en
FROM paliad.deadline_rules_unified dr
WHERE dr.submission_code = $1
AND dr.is_active = true
AND dr.lifecycle_state = 'published'
AND dr.event_type = 'filing'
ORDER BY dr.sequence_order ASC
LIMIT 1`, code)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("auto-name: resolve keyword for %q: %w", code, err)
}
if strings.EqualFold(lang, "en") && strings.TrimSpace(row.NameEN) != "" {
return strings.TrimSpace(row.NameEN), nil
}
return strings.TrimSpace(row.Name), nil
}
// autoNameForProject resolves the three identity segments for a
@@ -554,10 +621,7 @@ func (s *SubmissionDraftService) existingDraftNames(ctx context.Context, project
// 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"
}
prefix := draftWord(lang)
highest := 0
for _, n := range existing {
var idx int
@@ -568,6 +632,16 @@ func nextDraftName(existing []string, lang string) string {
return fmt.Sprintf("%s %d", prefix, highest+1)
}
// draftWord is the localized noun for an unnamed draft: "Draft" for English,
// "Entwurf" otherwise. Shared by nextDraftName (the legacy counter) and the
// non-project date-first fallback (Slice 2).
func draftWord(lang string) string {
if strings.EqualFold(lang, "en") {
return "Draft"
}
return "Entwurf"
}
// 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