Merge: t-paliad-356 Slice 2 — non-project drafts get date-first name via nomen engine
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
121
internal/services/submission_draft_nonproject_name_live_test.go
Normal file
121
internal/services/submission_draft_nonproject_name_live_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user