Compare commits

...

40 Commits

Author SHA1 Message Date
mAi
1882468780 feat(caption): apply m's caption-wording decisions — Respondent + Streitpatent line + DE appeal/nullity role labels
Some checks are pending
Paliad CI gate / build (push) Waiting to run
Paliad CI gate / test-go (push) Waiting to run
Paliad CI gate / deploy (push) Blocked by required conditions
t-paliad-361, follow-up to t-paliad-358 A-S2. m ruled on the 7 lexy-wording
flags (AskUserQuestion 2026-06-01 14:30). Most flags CONFIRMED the live
wording; three changes land here, all caption (Rubrum) wording, all in one
reversible migration 163.

Change 1 — UPC appeal responding party (EN): 'Appellee' → 'Respondent'.
  Fixed at the data source: the mig-137 role-label override on
  upc.apl.unified (the only place 'Appellee' was stored). The caption
  resolver's instance-derived EN fallback already said 'Respondent', so no
  code change. DE side (Berufungsbeklagter) untouched per m.

Change 2 — restore the standalone 'Streitpatent: {{project.patent_number_upc}}'
  (DE) / 'Patent in suit:' (EN) line in the upc-formal Composer caption seed,
  dropped in A-S2 (mig 161). Keeps the parametric 'In der Sache' heading (m did
  not revert that). Only the upc-formal base is touched; grouped with the case
  number ahead of {{project.court}}.

Change 3 — backfill lexy-confirmed role-label overrides for the four DE
  appeal/nullity proceedings that carried none, so designations are correct
  even when project.instance_level is unset (statute-grounded: §§ 511/542/544
  ZPO, §§ 81/110 PatG; bracketed-inclusive gender style):
    de.inf.olg    Berufungskläger(in) / Berufungsbeklagte(r)              // Appellant / Respondent
    de.inf.bgh    Revisionskläger(in) / Revisionsbeklagte(r)              // Appellant / Respondent
    de.null.bpatg Nichtigkeitskläger(in) / Beklagte(r) (Patentinhaber(in)) // Nullity claimant / Defendant (patent proprietor)
    de.null.bgh   Berufungskläger(in) / Berufungsbeklagte(r)              // Appellant / Respondent

Updates submission_vars_caption_test.go: adds EN assertions + cases pinning the
Respondent change and the four backfilled designations (each with instance_level
unset, proving the override path). go vet + go test ./... + bun build clean.
2026-06-01 15:21:37 +02:00
mAi
c303c01652 chore(patentstyle): publish HLC Patents Style v0.260601
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
sha256 DE6B6A17AC603FF4A9B3893CD2A7EF8263C9E2D4224A0A5E28E2FABF5E27A798
Source: HL/mWorkRepo#37 (Build.ps1 -> publish.sh pipeline).
2026-06-01 14:52:35 +02:00
mAi
97a2742f10 Merge: patentstyle landing page — English + HLC rebrand (work/paliad coordination)
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
2026-06-01 13:28:04 +02:00
mAi
b26360111a docs(patentstyle): English + HLC rebrand of landing page (work/paliad coordination) 2026-06-01 13:27:57 +02:00
mAi
e914bac79a chore(patentstyle): publish HL Patents Style v0.260601
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
sha256 D4D0BDD31CC2C4F9A2362363FEC2A7D86B8BE8A4EA7B0F2CEF5F1944A15B3A4A
Source: HL/mWorkRepo#37 (Build.ps1 -> publish.sh pipeline).
2026-06-01 13:17:15 +02:00
mAi
713a4d4206 Merge: t-paliad-358 A-S3 — firm-agnostic merge-fallback + firm-skeleton letterhead (firm.name placeholderised) — COMPLETES Rubrum Option A
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
2026-06-01 13:11:16 +02:00
mAi
cd3cd0230c Merge remote-tracking branch 'origin/main' into mai/bohr/coder-rubrum-letterhead 2026-06-01 13:11:05 +02:00
mAi
cd793b1d98 feat(submissions): firm-agnostic merge-fallback letterhead (t-paliad-358 A-S3)
A-S3 part 1 (merge-fallback letterhead, in-repo): the fallback skeleton
(docx.BuildFallbackSkeleton) gains a minimal Word page-header letterhead
(word/header1.xml) carrying only {{firm.name}}, filled from branding by the
variable bag. A generated fallback-path document now repeats a correct,
firm-agnostic firm identity on every page (the firm name moved out of the body
into the header; no hard-coded firm name anywhere). Wired the header part:
Content_Types override + document.xml.rels relationship (rId2) + sectPr
headerReference; document gains xmlns:r. Deliberately minimal — the fallback is
a starter, not full firm chrome.

A-S3 part 2 (firm-skeleton .docx, mWorkRepo — separate commit): _firm-skeleton.docx
footer1's "Firm name" SDT content control hard-coded "Hogan Lovells" → now
{{firm.name}}, filled by the Composer's final renderer pass / merge.go (both run
SubmissionRenderer over header/footer parts). Surgical <w:t> edit + repack (all
38 parts preserved, verified renders + fills cleanly, integrity OK). Pushed to
HL/mWorkRepo as mAi (commit 5a3a1722).

Path taken: option (a) for the firm NAME (cleanly placeholderised). NOT option
(a) for footer2's HL legal-entity boilerplate (registered no. OC323639, LLP
structure, 40+ office cities) — token-swapping the name into it would assert
false legal facts for a non-HLC firm; left intact and flagged (needs per-firm
legal-footer config, not templating). No corrupt .docx shipped.

Completes the Rubrum + letterhead auto-fill Option A train (A-S1 signature_block
+ generate-fallback fix; A-S2 parametric caption.*; A-S3 firm-agnostic letterhead).

Tests: fallback header asserts {{firm.name}} present + renders firm-agnostically
(de+en); patched firm-skeleton verified to render + fill (transient check).
go vet ./... + bun build clean; touched packages green.
2026-06-01 13:10:04 +02:00
mAi
a50ddc3927 Merge: t-paliad-356 Slice 5 — firm-wide default name compositions (mig 162) — COMPLETES nomen train
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
2026-06-01 13:05:50 +02:00
mAi
c639c5695c Merge remote-tracking branch 'origin/main' into mai/fermi/coder-implement-nomen 2026-06-01 13:05:30 +02:00
mAi
a05ae1f2ae feat(settings): firm-wide default name compositions (t-paliad-356 Slice 5)
Completes the nomen train (S1–S5). Adds the FIRM tier of the name-composition
precedence chain — per-document → user → FIRM → system (PRD §3.1/§3.2) —
mirroring firm_dashboard_default exactly.

Storage + service:
- Migration 162: paliad.firm_name_compositions singleton (id=1, CHECK id=1,
  RLS read-all + service-role writes) — same shape as firm_dashboard_default
  (mig 117), holding a validated { artifact_id: Composition } jsonb map.
- FirmNameCompositionService (Get/Set/Clear) + getFirmNameCompositions /
  setFirmNameCompositions / clearFirmNameCompositions singleton helpers in
  name_composition_spec.go.

Resolution:
- resolveComposition is now variadic over ordered specs (user, firm); first
  valid wins, else system default. Existing single-spec callers unchanged.
- Render path threads the firm tier: renderSubmissionDraftTitle /
  RenderSubmissionFilenameFor gain a firm param; newDraftName +
  submissionDownloadFilename load it (nil-safe). A firm default thus changes
  the effective name for every user without a personal override.

Admin surface (mirrors firm_dashboard_default):
- GET/PUT/DELETE /api/admin/name-compositions{/artifact_id} (adminGate) read
  back / set / clear the firm default per artifact.
- /settings Namensschemata cards gain an admin-only "Firmenstandard" block
  (set from the current template field / clear) revealed via is_admin, plus a
  "Firmenstandard" badge for non-admin users whose effective name comes from
  the firm tier. SettingsNameArtifact now resolves user→firm→system and
  exposes firm_is_set/firm_template.

Tests: pure precedence (user>firm>system) + firm-tier view + live firm
round-trip/Validate-rejection (via db.ApplyMigrations). go vet, go test ./...,
bun build all clean; gated live tests green against TEST_DATABASE_URL.

NOTE (merge ordering): golang-migrate is forward-only. Migration 162 must not
reach a DB before bohr's 161 (Rubrum Composer seed) exists, or 161 will be
skipped (current>161 → never applied). Merge 161 before/with 162.

Browser Playwright of the admin firm controls deferred to post-deploy
mai-tester — shared Supabase login wall blocks pre-merge browser login (same
ceiling as t-paliad-354).
2026-06-01 13:04:11 +02:00
mAi
7fe37bb550 Merge: t-paliad-358 A-S2 — parametric caption.* keys unify Rubrum across all render paths (mig 161)
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
2026-06-01 13:01:21 +02:00
mAi
57310ab3a4 Merge remote-tracking branch 'origin/main' into mai/bohr/coder-rubrum-letterhead 2026-06-01 13:00:54 +02:00
mAi
b99b6d6fb5 feat(submissions): parametric caption.* keys — unify Rubrum across all render paths (t-paliad-358 A-S2)
Promotes the case caption (Rubrum) to ONE parametric set of resolver keys
(caption.*) consumed identically by every render path, so the wording no
longer diverges per path and reflects the forum.

New caption.* keys (addCaptionVars, submission_vars.go), each in bare +
_de + _en forms (bare resolves to the draft language):
  caption.heading · caption.claimant_designation · caption.defendant_designation
  caption.versus · caption.subject

Parametrised from data already in the bag — NO new schema:
  - designations reuse the proceeding-type role-label overrides (mig 137:
    upc.apl.unified→Berufungskläger, upc.rev.cfi→Antragsteller (Nichtigkeit),
    epa.opp.*→Einsprechende(r)/Patentinhaber(in)); else instance-derived
    appeal/cassation (project.instance_level); else civil default Klägerin/
    Beklagte // Claimant/Defendant.
  - heading + subject from jurisdiction + the dotted code's nature segment
    (inf→"In dem Rechtsstreit"/"Patentverletzung", null→"In der
    Patentnichtigkeitssache", UPC→"In der Sache"/"In the matter",
    opp→"Im Einspruchsverfahren").
Also exposes project.proceeding.jurisdiction.

All three render paths now reference the SAME keys:
  1. docx.BuildFallbackSkeleton (merge fallback) — heading/designations/versus/
     wegen-subject are {{caption.*}} placeholders.
  2. demo per-code template de.inf.lg.erwidg.docx (mWorkRepo) — regenerated via
     scripts/gen-demo-submission-template; caption wired to caption.*; closing
     drops the duplicate {{firm.name}} line (now carried by signature_block).
     Pushed to HL/mWorkRepo as mAi (commit 3682299).
  3. Composer caption seeds — mig 161 rewrites the caption section seed_md of
     all 4 bases (hlc-letterhead, neutral, lg-duesseldorf, upc-formal) to the
     parametric form (position-independent jsonb_agg patch; reversible down).

our_side is intentionally NOT a caption driver — the caption designates both
parties by procedural role regardless of which side we act for.

Tests: resolveCaption forum matrix (DE-LG/BPatG/UPC/role-label/instance-appeal/
EPA-opp), bare-resolves-to-lang, fallback skeleton renders caption.* keys.
mig 161 passes TestMigrations_DryRun. go vet ./... + bun build clean; all
touched packages green (the live approval/migration_136 failures are
pre-existing shared-DB env issues, unrelated).

LEXY-REVIEW FLAGS in the report — DE caption conventions are practitioner
convention, not in the youpc corpus; specific wordings flagged for sign-off.
2026-06-01 12:59:29 +02:00
mAi
5468a7259d Merge: t-paliad-356 Slice 4 — name-composition token-template editor on /settings
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
2026-06-01 12:47:21 +02:00
mAi
230306518d Merge remote-tracking branch 'origin/main' into mai/fermi/coder-implement-nomen 2026-06-01 12:46:58 +02:00
mAi
6e56b9d51f feat(settings): name-composition token-template editor (t-paliad-356 Slice 4)
Adds the /settings "Namensschemata" tab so users can customise the two wired
name artifacts (submission_draft_title, submission_docx_filename) via a
single-line {token} template, with a clickable palette, live preview, and
reset-to-default — PRD §7.

Engine (pure, pkg/nomen):
- Composition.Template() serialises a composition to "{var}" shorthand;
  ParseTemplate() is its inverse — tokens + literal separators (trailing,
  owned by the left segment) + paren Wrap. Missing-rules are NOT in the
  shorthand (PRD §7); the parser leaves every segment KindOmit. Leading /
  trailing literals are rejected (the trailing-separator model can't carry
  them) so a save never silently drops characters. Table + round-trip tests.

Paliad glue (internal/services/name_template.go):
- ParseNameTemplate overlays each segment's missing-rule from the artifact's
  system default and validates against the catalog.
- PreviewNameComposition renders against the fixed PRD sample (Bayer AG / UPC
  / Sandoz / UPC_CFI_123/2026 / today) and an empties resolver so the
  missing-rule behaviour is visible. The frontend never parses templates —
  the nomen engine stays the single source of truth.
- SettingsNameArtifacts / SettingsNameArtifact build the per-artifact cards
  (current template, system default, override flag, ordered palette, previews).

API (internal/handlers/name_compositions.go):
- GET    /api/me/name-compositions               — cards
- POST   /api/me/name-compositions/preview        — live preview + validation
- PUT    /api/me/name-compositions/{artifact_id}   — store override
- DELETE /api/me/name-compositions/{artifact_id}   — reset to system default
Storage reuses the Slice-3 service surface (UserNameCompositions /
SetUserNameCompositions) via read-modify-write; no new column, no migration.

Frontend: new tab + JS-built cards (palette insert-at-cursor, 250ms-debounced
preview, save/reset, DE/EN labels), CSS, and i18n keys (de + en).

Gates: go vet, go test ./..., bun build all clean. Browser verification of the
settings UX is deferred to post-deploy mai-tester — the shared Supabase login
wall blocks pre-merge browser login (same ceiling as t-paliad-354).
2026-06-01 12:46:07 +02:00
mAi
375d631f1b Merge: t-paliad-358 A-S1 — fill firm.signature_block + fix generate-fallback junk (merge-safe Rubrum fallback)
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
2026-06-01 12:40:53 +02:00
mAi
e3a604b4c4 Merge remote-tracking branch 'origin/main' into mai/bohr/coder-rubrum-letterhead 2026-06-01 12:40:40 +02:00
mAi
0763b7daa2 feat(submissions): fill firm.signature_block + fix generate-fallback junk (t-paliad-358 A-S1)
Two letterhead/Rubrum auto-fill fixes (Option A, no schema change):

1. firm.signature_block: was hardcoded "" ("reserved for Phase 2"), so every
   template referencing {{firm.signature_block}} rendered blank. Now filled
   from branding.Name — the firm identity line of a submission's signature
   block (the signature section seeds with signature_block + user.display_name).
   Firm-agnostic: a FIRM_NAME redeploy signs with the right firm.

2. Generate-fallback junk (kepler audit §1 Path 3): resolveSubmissionTemplate is
   the merge-path resolver (every caller feeds merge.go), but its lower tiers
   fetched _firm-skeleton.docx / _skeleton.docx — which were repurposed into
   anchors-only Composer bases (t-paliad-313 Slice B). Their bodies hold only
   {{#section:KEY}} markers, which placeholderRegex ignores, so merge.go emitted
   them verbatim as literal "{{#section:letterhead}}…" junk for every code
   without a per-code template (i.e. everything except de.inf.lg.erwidg).

   Fix:
   - docx.BuildFallbackSkeleton(lang): in-process, lang-aware, merge-safe basic
     Schriftsatz with a data-driven basic Rubrum (real {{key}} placeholders the
     var bag fills). Always available, no Gitea round-trip.
   - docx.HasMergePlaceholders guards tiers 3/4/5: a fetched skeleton is used
     only if it carries real placeholders, else we fall through to the embedded
     fallback. Today's anchors-only/placeholder-free files are skipped; a future
     merge-safe firm-skeleton (with letterhead) is preferred again automatically.
   - merge.go strips stray {{#section:…}}/{{/section:…}} markers defensively so
     no anchors-only carrier can ever leak Composer junk into a merged document.

Verified: confirmed live that deployed _firm-skeleton.docx + _skeleton.docx are
anchors-only (fetch+unzip); unit tests cover BuildFallbackSkeleton rendering a
real Rubrum (de+en), HasMergePlaceholders classification, marker stripping, and
the signature_block fill. go build / vet ./... / test ./... + bun build clean.

Out of scope (flagged for next slices): demo template's closing prints
{{firm.name}} then {{firm.signature_block}} (=firm.name) → A-S2 dedups the demo
wording. Restoring firm letterhead chrome to the merge fallback → A-S3.
2026-06-01 12:39:53 +02:00
mAi
c4e3a74e35 Merge: t-paliad-359 regroup draft-editor sidebar — naming (name+keyword) then template (base+language)
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
2026-06-01 12:38:08 +02:00
mAi
9d234f275f fix(submissions): regroup draft-editor sidebar — naming (name+keyword) then template (base+language) 2026-06-01 12:37:26 +02:00
mAi
83d5ed27e0 Merge: t-paliad-356 Slice 3 — per-user name-composition overrides (system→user precedence, mig 160)
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
2026-06-01 12:21:27 +02:00
mAi
73f379d305 feat(submissions): per-user name-composition overrides, system→user precedence (t-paliad-356 Slice 3)
PRD §3. A user can now override the system-default name composition for an
artifact; rendering prefers a valid user override over the system default.
No UI yet (Slice 4) — overrides are settable via the service API + tests.

- Migration 160: users.name_compositions jsonb NOT NULL DEFAULT '{}' — a
  { artifact_id: Composition } map.
- NameCompositionSpec (internal/services/name_composition_spec.go): Validate
  (write: known artifact + segments reference the artifact's catalog vars)
  and SanitizeForRead (read: drop unknown artifacts, drop segments with
  unknown vars, clamp version) — mirrors DashboardLayoutSpec. Plus
  nomen.Composition.SanitizeForRead and lowercase JSON tags on the nomen
  types so the stored shape is stable.
- Resolution: resolveComposition(artifact, overrides) returns the user
  override when present + valid, else the system default. The firm slot
  (PRD §3.1) is reserved for Slice 5 — system is the fallback below user
  here. Wired into BOTH artifacts: the draft-title via the create path
  (newDraftName → autoNameFor{Project,NonProject}) and the .docx filename
  via RenderSubmissionFilenameFor in the three download handlers.
  AutoSubmissionTitle / RenderSubmissionFilename stay as the nil-override
  (system-default) references the #155/354 matrices pin.
- Per-document keyword override generalised: writes now land at
  composer_meta.name_overrides.keyword (the general {var:value} shape);
  reads honour both that and the legacy composer_meta.filename_keyword
  (back-compat). The read moved to services.SubmissionFilenameKeyword
  (handlers delegate) so it is live-testable.

BACK-COMPAT FINDING: the one shipped filename_keyword row needs no data
migration — the read resolves legacy composer_meta.filename_keyword as
name_overrides.keyword. Verified by a live round-trip (seed legacy jsonb →
Get → SubmissionFilenameKeyword == the legacy value).

Verified (TEST_DATABASE_URL): (a) name_compositions Set/Get round-trip +
write-time Validate rejection of an out-of-catalog variable; (b) a user
override beats the system default for the title (through Create:
'Klageerwiderung <date>' vs default '<date> Klageerwiderung') and the
filename (through RenderSubmissionFilenameFor), with the nil-override
default unchanged; (c) legacy filename_keyword back-compat read. Plus unit
tests for nomen.SanitizeForRead, NameCompositionSpec Validate/Sanitize/
resolve, and the keyword back-compat read. go vet + go test ./... (15
pkgs) + bun build clean. No Playwright (no UI this slice).
2026-06-01 12:20:19 +02:00
mAi
9a5ee93f2e Merge: t-paliad-357 Rubrum+letterhead auto-fill audit (gap map)
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
2026-06-01 12:13:51 +02:00
mAi
213be10ada docs(submissions): gap map — Rubrum + letterhead auto-fill from project data (t-paliad-357)
Read-only audit. Maps the 3 doc-generation fill paths (merge.go fills
header/footer placeholders; compose.go passes headers byte-for-byte;
skeleton-as-merge-fallback latent bug), three gap tables (template /
var-bag / data-model), forum-dependence grounded on UPC RoP r.13, and a
tracer-bullet-first wiring proposal. States the A/B fork for m: wire what
current data supports (basic caption, no schema) vs. forum-correct Rubrum
(structured party address/Rechtsform/Sitz + court details + capture UI).
2026-06-01 12:12:10 +02:00
mAi
6dd9befba3 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
2026-06-01 12:06:43 +02:00
mAi
e10b5e6546 feat(submissions): non-project drafts get a date-first name (t-paliad-356 Slice 2)
PRD §6. Project-less drafts no longer fall back to the bare 'Entwurf N'
counter — they render the submission_draft_title artifact through the
nomen engine, leading with the date like project drafts do (m's ask).

newDraftName's non-project branch now resolves a keyword (document type)
from the draft's submission_code and renders '<date> <keyword>', e.g.
'2026-06-01 Klageerwiderung'. When the code has no published filing rule
the keyword degrades to the localized 'Entwurf'/'Draft' word, yielding
'<date> Entwurf'. Collisions get the usual ' (N)' suffix via
uniqueDraftName.

FLAG resolved: project-less drafts DO carry a required submission_code
(the global-create handler rejects an empty code), and submission_code ->
rule name is a function across the published filing rules (the code
encodes the proceeding, e.g. de.inf.lg.erwidg -> Klageerwiderung), so the
keyword resolves project-independently via a LIMIT 1 catalog lookup. Both
branches (rule-name / Entwurf fallback) flow through the same composition.

The keyword is a new, normally-omitted segment on the title composition;
project drafts leave it empty so they render identically to #155 — the
regression guard. The identity trio and the keyword are mutually exclusive
by construction (project => trio, no project => keyword).

Verification: PRIMARY gate is a service-level live test (TEST_DATABASE_URL)
asserting Create(projectID=nil) yields '<date> <keyword>', the ' (2)'
collision suffix, EN document type, and the '<date> Entwurf'/'Draft'
fallback — all green against the real DB. Existing project-title matrix
(TestAutoSubmissionTitle) unchanged. go vet + go test ./... + bun build
clean. Browser (Playwright) verification not meaningful pre-merge: S2 is
not yet deployed (only S1/cd3f784 is) and the naming is server-side in
Create behind the shared-auth login wall.
2026-06-01 12:05:57 +02:00
mAi
cd3f7843a7 Merge: t-paliad-356 Slice 1 — nomen name-composition engine + fold in #155/354 schemes (byte-equal refactor) + PRD
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
2026-06-01 11:57:13 +02:00
mAi
4920328b09 feat(nomen): name-composition engine + fold in the two shipped schemes (t-paliad-356 Slice 1)
Slice 1 of the filename-generator train (PRD 2026-06-01 §8). Pure refactor
behind byte-equality — no user-visible change.

pkg/nomen: the reusable engine. A Composition (ordered Segments, each with
a trailing separator, optional wrap, and an omit/placeholder/literal
missing-rule) renders against a VarResolver and a RenderTarget. Targets
split into SanitiseValue (per-variable) + Finalise (whole-string + suffix)
so a human title and a sanitised filename are two targets of one
composition. VarCatalog + Validate guard stored compositions.

internal/services/namegen.go: paliad-side wiring — the two seed system-
default compositions that reproduce AutoSubmissionTitle (#155) and
submissionFileName (354) as DATA, their variable catalogs, the resolvers
(built from the existing submission_autoname helpers), and the artifact
registry binding artifact -> catalog -> target -> default.

Repointed call-sites: AutoSubmissionTitle and handlers.submissionFileName
are now thin wrappers rendering through the registry; the assembly logic
lives in the engine. Removed the hardcoded title/filename assembly and the
handler's Az.-folgt const (now the case_number segment's placeholder).

FLAG resolved (separators): the PRD sketched LEADING separators; that can't
reproduce #155's client-absent case (date must join forum with a space
while forum->opponent stays ' ./. '). Switched to TRAILING separators
(owned by the left segment) — the minimal faithful fix. PRD §2.1 annotated.

FLAG resolved (back-compat): the shipped composer_meta.filename_keyword
override still flows through the engine — live round-trip test green.

Acceptance: all existing #155/354 test matrices pass UNCHANGED (the
byte-equality gate); new pkg/nomen unit tests cover trailing-sep, the three
missing-rules, targets, and Validate; namegen_test validates the seeds
against their catalogs. go vet + go test ./... + bun build all clean.
2026-06-01 11:56:27 +02:00
mAi
385abc7a98 docs(prd): composable name/filename generator engine (t-paliad-355)
Engine (pkg/nomen) renders a name from (composition, var-bag, render-
target). Structured segments + string shorthand; omit/placeholder/literal
missing-rules; title vs filename targets. Both shipped schemes (#155
draft title, 354 export filename) fold in as data-driven seed defaults
with byte-equality as the acceptance gate. Non-project drafts get a
date-first <date> <keyword> name (m's immediate ask).

Settings built ON the dashboard-layout-spec precedent: system -> firm ->
user -> per-document precedence, validated jsonb spec. Project-level
deferred to v1.1 (storage path reserved on projects.metadata).

5-slice train: engine+faithful-refactor -> non-project fix -> user
override -> settings UX -> firm default. All 8 grilling questions
answered (matched recommendations), captured in the decisions section.
2026-06-01 11:42:50 +02:00
mAi
94adeeb8cb Merge: t-paliad-354 generated-doc filename <date> <keyword> (<case>) + user-replaceable keyword
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
2026-06-01 10:42:25 +02:00
mAi
d834b36313 test(submissions): live-DB round-trip for filename_keyword composer_meta merge/clear (t-paliad-354)
Some checks are pending
Paliad CI gate / build (push) Waiting to run
Paliad CI gate / test-go (push) Waiting to run
Paliad CI gate / deploy (push) Blocked by required conditions
2026-06-01 10:40:55 +02:00
mAi
4092c889c4 feat(submissions): generated-doc filename <date> <keyword> (<case>) + user-replaceable keyword
Generated documents now download as "YYYY-MM-DD keyword (case number).docx"
(date first/sortable, case number bracketed) instead of the old
"rule-case-date.docx" shape.

- submissionFileName: date-led frame; keyword = user override > lang-aware
  rule name > "submission"; case number always bracketed, placeholder
  "Az. folgt" (named const) when the project has no Aktenzeichen.
- SanitiseSubmissionFileName hardened to fold the full Windows-reserved
  set (colon star question angle pipe) on top of slash/backslash, while
  preserving spaces + parentheses so the assembled frame stays
  human-facing yet filesystem-safe.
- User-replaceable keyword stored in the draft's composer_meta jsonb
  (filename_keyword, no migration). Editor gains a "Stichwort (Dateiname)"
  input that placeholders the auto rule name and persists via the draft
  PATCH path. One-click /generate has no draft row -> keeps auto keyword.

Tests: submissionFileName (full / no-AZ / override / EN / slash case-no /
blank override / empty rule), submissionFilenameKeyword, extended
sanitiser cases.

t-paliad-354
2026-06-01 10:35:23 +02:00
mAi
db1040968f Merge: t-paliad-352 submission draft auto-naming (m/paliad#155)
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
2026-05-31 15:29:32 +02:00
mAi
f292338919 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).
2026-05-31 15:28:54 +02:00
mAi
2b240e7dd0 Merge: docs PRD schema corrections (planck feedback)
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
2026-05-31 15:16:55 +02:00
mAi
c945cbd330 docs(prd): fix 3 schema inaccuracies in litigation-planner PRD
planck flagged via mai report feedback (id 12301) after the B5+B6
verification round caught them:

- §5.4 'INSERT into paliad.project_parties' → real table is paliad.parties
- §5.4 'status=open' → real CHECK constraint allows pending/completed/cancelled/waived
- §7.4 listed verfahrensablauf-detail-mode.ts as dead code, but builder
  imports filterByDetailMode from it; struck through with KEEP note.

Code shipped (B5+B6) used the correct values throughout; this aligns
the historical PRD with reality so a future reader doesn't repeat the
verification time planck spent.
2026-05-31 15:16:55 +02:00
mAi
639ff4f672 Merge: t-paliad-350 B6 — mobile basic-read + dead U0-U4 cleanup + i18n finalise (m/paliad#153)
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
2026-05-29 20:52:06 +02:00
mAi
28d860a07d Merge: t-paliad-350 B5 — share + promote-to-project wizard (m/paliad#153)
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
2026-05-29 20:37:37 +02:00
57 changed files with 6471 additions and 173 deletions

View File

@@ -237,6 +237,7 @@ func main() {
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(pool),
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
FirmNameComposition: services.NewFirmNameCompositionService(pool),
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
// t-paliad-214 Slice 1 — personal-scope data export. firm name
// is captured into __meta of every export and printed in the

View File

@@ -0,0 +1,306 @@
# Rubrum + Briefkopf auto-fill — gap map (templates ↔ var bag ↔ data model)
**Task:** t-paliad-357 · **Author:** kepler (researcher) · **Date:** 2026-06-01
**Status:** AUDIT + GAP-MAP ONLY. No code or template edits made. Head reviews
before any wiring.
m's ask (2026-06-01 12:01): the **current** document templates should fill the
**letterhead (Briefkopf)** and **recitals/Rubrum (case caption)** from project
data on generation — "filled depending on project". This is the
content-correctness layer, downstream of the (code-complete) docforge engine and
parallel to leibniz's nomen naming train.
---
## TL;DR — the one fork for m
**The basic Rubrum is *already* wired and works today** (party names,
representatives, role designations, case number, court, patent number — all
data-driven, in both the demo per-code template and the Composer caption seed).
**The letterhead is *not* data-driven at all** (the real HL letterhead is
hardcoded inside the firm-skeleton's Word header/footer parts; `firm.signature_block`
is empty). And the Rubrum we have is only a *basic* caption — a forum-correct one
needs structured data paliad does not capture.
So the decision is **how complete a Rubrum we target**:
| | **Option A — wire what data already supports** | **Option B — forum-correct Rubrum** |
|---|---|---|
| Rubrum content | name · representative · role · case no. · court · patent | + structured address · Rechtsform · Sitz (registered office) · gesetzl. Vertreter · service addresses · court chamber/address |
| Data model | **no new columns** — uses existing `parties.*` + `project.*` | **new structured fields** on `parties` (+ maybe `projects`) + capture UI |
| Letterhead | tidy the existing path (firm.name/signature_block) | same as A (letterhead is orthogonal to the A/B choice) |
| Effort | small — mostly template-seed wording + plug `firm.signature_block` | a proper feature — schema migration + party-form rework + Composer reseed |
| Forum-correctness | a *workable* caption, not a *filing-correct* one | meets UPC RoP r.13 / ZPO §253 party-designation requirements |
Everything in Slice 12 below is Option A and is independent of the decision.
Option B is Slice 3+ and is the part that needs m's go/no-go.
---
## 1. Architecture — there are THREE fill realities, not one
The audit's biggest correction to the starting mental model: "the templates" are
not one thing, and the letterhead does **not** live where the Rubrum lives.
### Path 1 — legacy one-click `/generate` → `merge.go` (`SubmissionRenderer.Render`)
- Handler `submissions.go:316``resolveSubmissionTemplate``RenderProjectSubmission`
`renderer.Render` (`pkg/docforge/docx/merge.go`).
- **Substitutes `{{key}}` tokens in `word/document.xml` *and* in `word/header*.xml`
/ `word/footer*.xml`** (`isWordXMLEntry`, merge.go:189). So this path *can* fill a
letterhead in a Word header — **if the header contains `{{placeholders}}`. None
of the shipped headers do** (see §2).
- Template chosen by a 6-tier fallback (`submission_drafts.go:1341`): per-(code,lang)
→ per-code → EN-skeleton → firm-skeleton → universal-skeleton → HL-Patents-Style.
### Path 2 — Composer → `compose.go` (`Composer.Compose`)
- Draft editor with a `base_id` set (t-paliad-313/315/317). Handler
`submission_drafts.go:712``submissionComposer.Compose`.
- Assembles `word/document.xml` from the draft's **`paliad.submission_sections`
rows** (one per section: letterhead, caption, …), splicing each into the
carrier's `{{#section:KEY}}` anchor, then substitutes `{{placeholder}}` inside the
section bodies.
- **Headers/footers pass through byte-for-byte UNTOUCHED** (compose.go:68, :188).
So a Composer doc keeps the base .docx's letterhead chrome verbatim — it is
never data-driven on this path.
- Section bodies are seeded on draft-create from the base's
`section_spec.defaults[*].seed_md_{de,en}` (migrations 146 / 150).
### Path 3 — skeleton as a direct merge fallback (a latent bug)
- For any submission_code **without** a per-code template, `/generate` (Path 1)
falls through to tiers 4/5 and renders the **firm/universal skeleton through
merge.go**. But those skeletons contain only `{{#section:letterhead}}`-style
*block markers*, which `placeholderRegex` (`[A-Za-z]…`) does **not** match (they
start with `#`). **Result: the output Word doc shows literal
`{{#section:letterhead}}` … text.** Only `de.inf.lg.erwidg` has a real per-code
template today, so every other code's one-click `/generate` is exposed to this.
⚠️ **Flag to verify with head** — may be masked if `/generate` is only surfaced
for codes that have a per-code template.
> **Implication for m's ask:** "fill the letterhead from project data" means
> different work on each path. On Path 1 it means *putting `{{firm.*}}` placeholders
> into a header part*. On Path 2 it means *the letterhead is a body section already*
> (and the chrome stays hardcoded in the base). These should be reconciled, not
> both wired blindly — see Slice 2.
---
## 2. Gap table — TEMPLATE side
Fetched live from mgit (`m/mWorkRepo`, `6 - material/Templates/Word/…`), unzipped,
inspected `document.xml` + every `header*.xml`/`footer*.xml`.
| Template | Has header/footer? | Letterhead | Rubrum / caption | Verdict |
|---|---|---|---|---|
| **`HLC/de.inf.lg.erwidg.docx`** (per-code, the only wired code) | no | *pseudo*-letterhead inline in body: `{{firm.name}} — Patentstreitsachen`, Bearbeiter `{{user.display_name}}`, `{{user.email}}`, `{{user.office}}`, `{{today.long_de}}`. `{{firm.signature_block}}` in closing (renders empty). | **full inline Rubrum, all data-driven**: `{{parties.claimant.name}}` / `.representative`, `— Klägerin —`, `gegen`, `{{parties.defendant.name}}` / `.representative`, `— Beklagte —`, `Weitere Beteiligte: {{parties.other.name}}`, `{{project.court}}`, `Aktenzeichen: {{project.case_number}}`, `{{project.patent_number}}`. | Works — but body-banner is **labelled "DEMO — interne Vorlage (nicht freigegeben)"**, not a real letterhead. |
| **`HLC/_firm-skeleton.docx`** (Composer base `hlc-letterhead`) | **yes** — header1/2, footer1/2 | **Real HL letterhead, fully HARDCODED**: footer firm name is a Word SDT content-control literal "Hogan Lovells"; footer2 = static HL entity boilerplate (registered office, 50+ office cities); header2 = logo image only. **Zero `{{placeholders}}` in any header/footer.** | body `document.xml` has only `{{#section:KEY}}` markers (empty). Caption content comes from the section seed (§Composer). | Letterhead present but **not data-driven & not firm-agnostic** (contradicts `branding.Name` goal). |
| **`HLC/_skeleton.docx`** (Composer base `neutral`) | no | none | `{{#section:KEY}}` markers only | Composer scaffold; unusable via merge.go (Path 3 bug). |
| **`Composer/lg-duesseldorf.docx`** (base `lg-duesseldorf`, de.inf.lg) | no | none | `{{#section:KEY}}` markers only | Composer scaffold; letterhead must come from a header it doesn't have, or the body section. |
| **`Composer/upc-formal.docx`** (base `upc-formal`, upc.inf.cfi) | no | none | `{{#section:KEY}}` markers only | same. |
| **`HL Patents Style.dotm`** (last-ditch tier 6) | yes (same HL header/footer as firm-skeleton) | hardcoded HL letterhead | no placeholders | letterhead-only fallback. |
| `HLC/_skeleton.en.docx` | **404 — does not exist** | — | — | EN drafts silently fall back to the DE skeleton (matches code comment at files.go:104). |
**Template-side takeaways**
1. The Rubrum is template-complete on the demo per-code path and is a DB seed (not
a template file) on the Composer path.
2. The real letterhead exists only in the firm-skeleton/`.dotm` headers and is
**100% hardcoded** — no placeholder, no `branding.Name`. A firm rename or a
non-HLC deployment ships the wrong letterhead.
3. The Composer caption/letterhead are **DB seeds (migrations 146/150)**, so
"adjusting the template" for the Composer path means editing the
`section_spec` seed Markdown, *not* the .docx.
---
## 3. Gap table — VAR-BAG side
For every placeholder a correct letterhead + Rubrum needs, is there a bag key?
Bag built in `internal/services/submission_vars.go`.
| Need (letterhead + Rubrum) | Bag key | Status |
|---|---|---|
| Firm name | `firm.name` (← `branding.Name`) | ✅ wired |
| Firm signature block | `firm.signature_block` | ⚠️ **key exists but emits `""`** (reserved "Phase 2", submission_vars.go:324). Template references it → renders blank. |
| Author name / email / office | `user.display_name` / `.email` / `.office` | ✅ wired |
| Date (today, long DE/EN, ISO) | `today` / `.long_de` / `.long_en` / `.iso` | ✅ wired |
| Claimant name / rep (first + indexed + joined) | `parties.claimant.name`, `parties.claimant.0.name` / `.representative`, `parties.claimants` / `.representatives` | ✅ wired (3 forms, addPartyVars) |
| Defendant name / rep | `parties.defendant.*` (same 3 forms) | ✅ wired |
| Other parties (Streithelfer, Patentinhaberin…) | `parties.other.*` / `parties.others` | ✅ wired |
| Case number | `project.case_number` | ✅ wired |
| Court (name) | `project.court` | ✅ wired (free-text string) |
| Patent number (DE + UPC forms) | `project.patent_number` / `.patent_number_upc` | ✅ wired |
| Proceeding type / instance | `project.proceeding.name(_de/_en/.code)`, `project.instance_level` | ✅ wired |
| Our side (DE/EN prose) | `project.our_side_de` / `_en` / raw | ✅ wired |
| Client / matter / internal ref | `project.client_number` / `.matter_number` / `.reference` | ✅ wired |
| **Party postal address** | — | ❌ **NO key** (needs data model) |
| **Party legal form (Rechtsform)** | — | ❌ **NO key** |
| **Party registered office / Sitz** | — | ❌ **NO key** (UPC r.13.1(a)/(b)) |
| **Statutory representative (gesetzl. Vertreter, e.g. Geschäftsführer)** | — | ❌ **NO key** |
| **Address/person for service (Zustellungsbevollmächtigter)** | — | ❌ **NO key** (UPC r.13.1(c)/(d)) |
| **Court full address / chamber / Spruchkörper** | — | ❌ **NO key** (only the court *name* string exists) |
| **Firm letterhead address / contact block** | — | ❌ **NO key** (hardcoded in .docx header) |
**Var-bag takeaways:** every placeholder the *current* templates use is wired,
with one dud: **`firm.signature_block` always renders empty** — the single cheapest
letterhead/closing win. Everything a *forum-correct* Rubrum additionally needs has
**no key, because the data isn't captured** (§4).
---
## 4. Gap table — DATA-MODEL side
`models.Party` (models.go:539) carries **only**: `Name`, `Role`, `Representative`,
`ContactInfo json.RawMessage`. `models.Project` carries `Court *string` (free text),
`CaseNumber`, `PatentNumber`, dates, `OurSide`, `InstanceLevel`, client/matter.
- **`parties.contact_info` is a dormant jsonb column**: `PartyService.Create`
defaults it to `{}` and **no UI ever writes it** (party form captures only
Name / Role / Representative — `frontend/src/projects-detail.tsx:436460`). It is
a ready-made parking spot, but it is structurally empty today.
- **No court registry / court-address table exists.** `project.court` is a plain
string a user types.
| Forum-correct Rubrum needs | Derivable from existing fields? | Park in `contact_info` jsonb? | Needs new column + capture UI? | Cost |
|---|---|---|---|---|
| Party **postal address** | ❌ | ✅ feasible (`{address:{street,zip,city,country}}`) | UI: add fields to party form | **LowMed** — jsonb, no migration; party-form + bag resolver |
| Party **Rechtsform** (GmbH, LLP…) | ❌ (sometimes inside Name string, unreliable) | ✅ | UI field | **Low** |
| Party **Sitz / registered office** (UPC r.13.1(a/b)) | ❌ | ✅ | UI field | **LowMed** |
| Party **statutory representative** (Geschäftsführer / vertreten durch …) | ⚠️ partial — `Representative` today means the *lawyer/Prozessbevollmächtigter*, not the *organ*; conflating them is wrong | ✅ (`{statutory_rep:…}`) | UI field + relabel existing `representative` | **Med** — semantic untangle |
| **Address for service / Zustellungsbevollmächtigter** (UPC r.13.1(c/d)) | ❌ | ✅ | UI field | **LowMed** |
| **Court full address** | ❌ | n/a (project-level) | new `projects.court_address` col **or** a courts lookup table | **Med** (col) / **High** (registry) |
| **Court chamber / Spruchkörper / panel** | ❌ | n/a | new `projects.court_chamber` col | **LowMed** |
| Firm letterhead address block | ❌ | n/a | `branding`-level config (env or table) | **Med** — touches firm-agnostic story |
**Recommendation on storage:** structured party attributes belong in **typed jsonb
under `contact_info`** with a small Go struct (`models.PartyContact`) decoding it —
not a column-per-attribute migration. It keeps the party table stable, is
forum-shape-agnostic, and the bag resolver can emit `parties.claimant.0.address`,
`.sitz`, `.rechtsform`, etc. Court chamber/address are project-level and small
enough for two nullable columns; a full court **registry** is a separate, larger
feature (nice for autofill + validation, not required for a correct caption).
---
## 5. Forum-dependence — does one parametric Rubrum cover UPC / LG / OLG / BPatG?
Grounded sources: **UPC RoP Rule 13** ("Contents of the Statement of claim") pulled
verbatim from the house laws corpus (`data.laws`, `UPCRoP.013.*`). German ZPO/PatG
caption conventions below are **standard German civil-procedure practice — these are
NOT in the youpc corpus** (which is UPC/EPC-only), so they are flagged as
practitioner-convention, to be confirmed by a DE-litigation reviewer (lexy) before
wording is finalised.
**What UPC RoP r.13.1 demands (verified):**
- (a) claimant name; if corporate, **location of registered office**; + claimant's representative
- (b) defendant name; if corporate, **location of registered office**
- (c) **postal + electronic addresses for service** on claimant + persons authorised to accept service
- (d) postal/electronic service addresses on defendant + persons authorised, if known
- (e) proprietor service addresses where claimant ≠ (sole) proprietor
- (g) details of the patent including the **number**
- (k) nature of the claim / order / remedy sought
→ paliad today supplies only **name** (a/b) and **patent number** (g). It captures
**none** of: registered office/Sitz, postal/electronic service address, persons
authorised. So a *filing-correct* UPC caption is firmly **Option B** territory.
**How the caption shape differs across forums (convention):**
| Forum | Heading | Party designations | "wegen" / subject | Court line |
|---|---|---|---|---|
| **DE LG** (Patentstreitkammer) | "In dem Rechtsstreit" / "In der Patentstreitsache" | Kläger(in) / Beklagte(r); parties need **Name, Anschrift, Rechtsform, ges. Vertreter** (ZPO §253 Abs. 2 Nr. 1, §130 Nr. 1 — *convention*) | "**wegen** Patentverletzung" | "an das Landgericht … , … Kammer" — court **name + chamber** |
| **DE OLG** (Berufung) | "In dem Rechtsstreit" | **Berufungskläger / Berufungsbeklagte** (roles flip vs. first instance) | "wegen …" | "an das Oberlandesgericht …, … Senat" |
| **BPatG** (Nichtigkeit/Beschwerde) | "In der Patentnichtigkeitssache" / "Beschwerdesache" | **Kläger/Beklagte** (nullity) or **Anmelder/Einsprechende**; patent-centric | patent + nullity ground | "an das Bundespatentgericht, … Senat" |
| **UPC CFI** | "In the matter / In der Sache" | **Claimant / Defendant (Kläger/Beklagte)**; name + **registered office** + service address (r.13) | claim nature (r.13.1(k)) | division + "Aktenzeichen" (UPC case-number format `ACT_xxxxx/2026`) |
**Answer:** one *parametric* Rubrum block covers the **basic** caption across forums
(swap designation labels + heading + court line from `our_side`/`instance_level`/
`proceeding.code` — values the bag already has). It does **not** cover the
forum-specific *content requirements* (UPC service addresses vs. ZPO Anschrift/
Rechtsform vs. BPatG patent-centric framing). For Option B, the cleanest design is
**one caption section whose seed Markdown is chosen per `proceeding_family`** (the
Composer already keys bases by `proceeding_family``de.inf.lg`, `upc.inf.cfi`),
i.e. **forum-specific caption seeds, shared resolver keys** — not a single
universal block, and not N hand-maintained .docx files.
---
## 6. Sliced wiring proposal (tracer-bullet first)
Ordered so each slice ships value alone; the A/B fork only bites at Slice 3.
**Slice 1 — plug the empty letterhead key (pure win, no schema, no fork).**
- Fill `firm.signature_block` in `addFirmVars` from `branding` (firm name + office /
a configured block) instead of hardcoding `""`. Today every template that
references it renders blank.
- Decide letterhead source of truth: either (a) inject `{{firm.name}}` /
`{{firm.address}}` placeholders into the firm-skeleton **header** parts (Path 1
fills them; Composer leaves them — acceptable since chrome is firm-fixed), or
(b) keep chrome hardcoded but make it firm-agnostic via `branding`. **Recommend
(a)** so a firm rename / non-HLC deploy doesn't ship "Hogan Lovells".
- Template edits: firm-skeleton `header1/footer1` get `{{firm.*}}` tokens. (mWorkRepo,
authored as mAi — not this repo.)
**Slice 2 — reconcile the letterhead duplication + kill the Path-3 junk.**
- The Composer seeds a body "letterhead" section *and* the base has a header
letterhead → a Composer doc can show both. Decide: drop the body letterhead
section for letterhead-bearing bases, or keep it only for `neutral`.
- Fix Path 3: either give the universal/firm skeleton a **merge-safe** variant
(real `{{key}}` Rubrum like the demo template) for non-Composer `/generate`, or
gate `/generate` to codes that have a per-code template. (Verify with head which
codes expose `/generate`.)
**Slice 3 — Option A "good basic Rubrum" (no new data).**
- Promote the demo per-code Rubrum wording into a **published, forum-labelled
caption** and align the Composer caption seeds (146/150) to the same wording.
Parametrise designation labels + heading + "wegen" + court line off
`our_side` / `instance_level` / `proceeding.code`. **No migration.**
- This is the natural stopping point if m picks **A**.
**Slice 4 — Option B data model (the feature; needs m's go).**
- Add `models.PartyContact` decoding typed `contact_info` jsonb:
`{address, rechtsform, sitz, statutory_rep, service_address, service_agent}`.
- Extend the party form (`projects-detail.tsx`) with those inputs; `PartyService`
writes them.
- Add `projects.court_address` + `projects.court_chamber` (nullable cols).
- New bag keys in `addPartyVars` / `addProjectVars`:
`parties.<role>.<i>.address|sitz|rechtsform|statutory_rep|service_address`,
`project.court_address|court_chamber`.
**Slice 5 — Option B forum-correct caption seeds.**
- Per-`proceeding_family` caption seed Markdown (UPC r.13 shape, DE-LG ZPO shape,
OLG appeal-role shape, BPatG nullity shape), consuming the Slice-4 keys.
- Reviewer (lexy) signs off DE conventions before publish.
**Slice 6 (optional) — court registry** for autofill/validation of court
name+address+chamber. Larger; not required for a correct caption.
---
## 7. Key files (for the wiring worker)
- Var bag: `internal/services/submission_vars.go` (addFirmVars:319, addPartyVars:412,
addProjectVars:349).
- Render (Path 1, fills headers): `pkg/docforge/docx/merge.go` (isWordXMLEntry:189).
- Compose (Path 2, headers pass-through): `pkg/docforge/docx/compose.go` (:68,:188);
`internal/services/submission_compose.go`.
- Template resolution: `internal/handlers/submission_drafts.go:1341`
(`resolveSubmissionTemplate`, 6 tiers); paths in `internal/handlers/files.go`.
- Composer base seeds (caption/letterhead Markdown): migrations
`internal/db/migrations/146_submission_bases.up.sql`,
`150_submission_bases_specialist.up.sql`.
- Data model: `internal/models/models.go` (Party:539, Project:80);
party form `frontend/src/projects-detail.tsx:436`.
- Live templates: `m/mWorkRepo` `6 - material/Templates/Word/Paliad/{HLC,Composer}/`.
---
## 8. Open questions for m / head
1. **A or B?** (the §TL;DR fork). A = ship a good basic caption now, no data work.
B = capture structured party/court data for a filing-correct Rubrum.
2. **Letterhead source of truth:** placeholderise the firm-skeleton header (firm-agnostic)
vs. keep hardcoded HL chrome? (Slice 1 recommends placeholderise.)
3. **Path-3 junk:** is one-click `/generate` exposed for codes lacking a per-code
template? If yes, the literal `{{#section:…}}` output is a live bug.
4. **`representative` semantics:** today it's the lawyer (Prozessbevollmächtigter).
A forum Rubrum also needs the party's *statutory* representative (Geschäftsführer).
Keep them as two distinct fields under Option B.

View File

@@ -0,0 +1,354 @@
# PRD — Composable Name/Filename Generator Engine
**Task:** t-paliad-355 · **Author:** leibniz (inventor) · **Date:** 2026-06-01
**Status:** DESIGN — awaiting head go/no-go on coder shift
**Builds on:** t-paliad-352 / m/paliad#155 (draft title), t-paliad-354 (export filename, merged `94adeeb`)
**Related:** `docs/plans/prd-docforge-2026-05-29.md` (doc-generation engine — a future naming consumer)
---
## § m's decisions (2026-06-01)
All eight grilling questions answered; every pick matched the inventor recommendation.
**Batch 1 — model:**
- Q1 (Composition model): **Structured segments + string shorthand.** Canonical model is an ordered segment list with per-segment missing-rules; a token-template string is an optional authoring shorthand that compiles to segments.
- Q2 (v1 precedence): **System → Firm → User → per-document.** Mirrors the existing dashboard-layout chain exactly. Project-level deferred to v1.1.
- Q3 (Engine depth): **Reusable engine, wire 3 known consumers.** Real engine now; only draft-title, submission-.docx, and the non-project fix are wired. Other surfaces register as known artifacts but keep current code.
- Q4 (Non-project name): **`<date> <keyword>`**, falling back to `Entwurf N` only when no type context exists.
**Batch 2 — concrete:**
- Q5 (Missing-rule set): **omit + placeholder + literal**, per segment.
- Q6 (Date semantics): **Render-time "today", Europe/Berlin, `YYYY-MM-DD`.**
- Q7 (Settings UX): **Live-preview string field on `/settings`** + clickable `{token}` palette. Missing-rules use defaults (not user-editable in v1).
- Q8 (Artifact scope): **2 submission artifacts (`submission_draft_title`, `submission_docx_filename`) + extensible registry.** docforge-export, data-zip, projection-slug registered as known artifacts but unwired in v1.
These are necessary for a coder shift, **not** sufficient — the head still gates whether/who/when to implement (inventor→coder rule).
---
## §0 Premises (verified against the live system, 2026-06-01)
| # | Premise | How verified |
|---|---------|--------------|
| P1 | Draft title = `<date> <client> ./. <forum> ./. <opponent>`, project-bound only, missing segments dropped-with-separator. | Read `internal/services/submission_autoname.go` (`AutoSubmissionTitle`). |
| P2 | Non-project drafts fall back to `Entwurf N` / `Draft N` counter. | Read `submission_draft_service.go` `newDraftName`/`Create`. |
| P3 | Export filename = `<date> <keyword> (<case | "Az. folgt">).docx`; keyword overridable per-draft via `composer_meta.filename_keyword`. | Read `internal/handlers/submissions.go` `submissionFileName` + `submissionFilenameKeyword`. |
| P4 | Sanitiser `SanitiseSubmissionFileName` folds umlauts, maps `/\:*?<>|``_`, strips `"'`, **preserves spaces/parens**. Lives in `pkg/docforge/docx/dotm.go`, re-exported via `services.SanitiseSubmissionFileName` (`docforge_shims.go`). | Read `pkg/docforge/docx/dotm.go`. |
| P5 | `DashboardLayoutSpec` is a production precedent for a validated jsonb spec: code `FactoryDefaultLayout` → admin `firm_dashboard_default` (db row id=1) → per-user `user_dashboard_layouts`, with `Validate()` (write) + `SanitizeForRead()` (read). | Read `dashboard_layout_spec.go`, `firm_dashboard_default_service.go`. |
| P6 | `users.email_preferences jsonb` (per-user bag) and `projects.metadata jsonb` exist live. No dedicated `user_preferences` table — migration 017 only added the `email_preferences` column. | `information_schema.columns` query on live `paliad` schema. |
| P7 | Draft titles are de-duplicated at create time via `uniqueDraftName` (appends a counter on collision). | Read `newDraftName`. |
**Doc-is-the-bug flags raised:** none. The two shipped behaviours are exactly as the task described; `projects.metadata` exists so a project-level override needs no new column when v1.1 arrives (only a documented sub-key).
---
## §1 The problem
Two one-off naming functions shipped in successive tasks (#155, 354). Each hardcodes: a date format, an ordered set of segments, a separator, and a missing-value policy. m wants to stop re-deriving this per feature — "we will need a filename generator more often later on" — and to expose **defaults / compositions** as a **user (and maybe project) setting**. Plus one immediate gap: non-project drafts get no date-led name.
The design must:
1. Extract a **reusable composition engine** that renders a name from (template, variable-bag, render-target).
2. Reproduce **both shipped schemes byte-for-byte** as seed defaults (no behaviour regression).
3. Add **settings** with a clean precedence chain, built **on** the dashboard-spec pattern (P5), not beside it.
4. Fix the **non-project** gap inside the engine, not as another special case.
---
## §2 The engine
A new package **`pkg/nomen`** (Latin *nomen* = "name"; firm-agnostic, sits beside `pkg/docforge`). Pure, dependency-light, table-testable. No DB, no HTTP — consumers resolve variables and hand them in, exactly as `AutoSubmissionTitle` is pure today.
> **FLAG (coder + m):** package name `nomen` is the inventor pick. Alternatives: `pkg/naming`, `internal/services/namegen`. Pick at implementation; nothing downstream depends on the name.
### 2.1 Core types (interface sketch — not final Go)
```go
package nomen
// Segment is one piece of a composition: a variable reference, the
// separator that precedes it, and what to do when the variable resolves
// empty.
type Segment struct {
Var string // key into the variable catalog, e.g. "date", "keyword"
Sep string // TRAILING separator: emitted AFTER this segment iff a
// later segment also emits. The last emitted segment's
// Sep is never used. (See Slice-1 note below.)
Wrap [2]string // optional surrounding literals, e.g. {"(", ")"} for case-no.
Missing MissingRule // omit | placeholder | literal
}
type MissingRule struct {
Kind MissingKind // KindOmit | KindPlaceholder | KindLiteral
Value string // placeholder/literal text (e.g. "Az. folgt"); ignored for omit
}
// Composition is the canonical, validated model.
type Composition struct {
Version int // schema version (start at 1)
Segments []Segment
}
// VarResolver yields a variable's value for one render. Returns ("", false)
// when the variable is unavailable in this context (→ Missing rule applies).
type VarResolver func(key string) (string, bool)
// RenderTarget post-processes the assembled string (sanitisation, suffix).
type RenderTarget interface {
Name() string // "title" | "filename"
Transform(assembled string) string
}
func (c Composition) Render(resolve VarResolver, target RenderTarget) string
func (c Composition) Validate(catalog VarCatalog) error
```
> **Implementation note (Slice 1, 2026-06-01 — `Sep` is trailing, not leading).**
> This PRD originally sketched `Sep` as the separator emitted *before* a
> segment. During Slice 1 that model proved unable to reproduce #155
> byte-for-byte: the existing test `"no client — client segment omitted"`
> requires `2026-05-31 UPC ./. Novartis Pharma` — the date must join the
> *forum* with a single space when the client is absent, while the
> forum-to-opponent join stays ` ./. `. A separator owned by the right-hand
> segment would need two different values for the same segment depending on
> what was omitted before it. Making the separator **trailing** (owned by
> the left-hand segment) is the minimal faithful fix: the date's trailing
> ` ` is used whenever any identity segment follows, and each party's
> trailing ` ./. ` is used whenever another party follows. All shipped
> #155/354 tests pass unchanged. Implemented in `pkg/nomen/nomen.go`; the
> realised `RenderTarget` also splits `Transform` into `SanitiseValue`
> (per-variable) + `Finalise` (whole-string + suffix) per §2.3.
### 2.2 Render algorithm (reproduces both shipped schemes)
For each segment, in order:
1. `val, ok := resolve(seg.Var)`.
2. If `!ok || strings.TrimSpace(val) == ""`, apply `seg.Missing`:
- `KindOmit` → segment contributes nothing (and its `Sep` is suppressed).
- `KindPlaceholder``val = seg.Missing.Value` (treated as present).
- `KindLiteral``val = seg.Missing.Value` (same as placeholder; distinct *intent* in the model — "this is a fixed label", not "this is a stand-in for missing data" — so the settings UI can word them differently and future policy can diverge).
3. If the segment emits, prepend `seg.Sep` **iff at least one segment already emitted** (kills the leading-separator problem the #155 code solves by hand), then wrap with `seg.Wrap`.
4. Concatenate.
5. `target.Transform(assembled)` runs once on the whole string.
**Separator suppression** is the generalisation of #155's "drop segment + its leading separator". **Placeholder** is the generalisation of 354's `(Az. folgt)`.
### 2.3 Render targets
The **same** `Composition` renders to different targets:
| Target | `Transform` | Used by |
|--------|-------------|---------|
| `TitleTarget` | identity (spaces, umlauts, ` ./. ` all valid in a human title) | `submission_draft_title` |
| `FilenameTarget{ext: ".docx"}` | per-segment-aware: applies `services.SanitiseSubmissionFileName` to **variable values** (not the frame — preserve the spaces/parens/wrap), then appends `ext`. | `submission_docx_filename` |
> **Design note — where sanitisation runs.** 354 sanitises *each variable value* but keeps the assembled frame (`<date> <kw> (<case>)`) intact. To preserve that exactly, the `FilenameTarget` is **not** a dumb whole-string transform — the engine sanitises each resolved variable value *before* assembly when the target requests it, and the target only appends the extension at the end. So `RenderTarget` gains one more hook:
```go
type RenderTarget interface {
Name() string
SanitiseValue(v string) string // per-variable; identity for TitleTarget
Finalise(assembled string) string // whole-string; appends ".docx" for filename
}
```
This is the one subtlety that makes the engine faithful to 354. Both shipped schemes drop out of `(Composition, VarResolver, RenderTarget)` with no special-casing.
### 2.4 Variable catalog
A `VarCatalog` is an extensible registry: `key → VarDef{ Label, LabelEN, Description, Group }`. The catalog is **metadata only** (for validation + the settings palette); **values** come from the per-render `VarResolver` the consumer supplies. This keeps the engine pure — a consumer registers which keys it can resolve, the engine validates a composition only references known keys.
v1 catalog (the union of what the two schemes need + obvious near-neighbours):
| key | meaning | resolver source (submission consumer) |
|-----|---------|----------------------------------------|
| `date` | render-time today, Europe/Berlin, `YYYY-MM-DD` | engine-provided default resolver (see §2.5) |
| `keyword` | document/submission type; user-overridable | `composer_meta.filename_keyword` → rule name (lang-aware) → "submission" |
| `case_number` | project Aktenzeichen | `project.CaseNumber` |
| `client` | root-ancestor client name | project-tree walk (existing `autoNameForProject`) |
| `forum` | short forum label (UPC/EPA/LG/…) | `submissionForumShort(pt)` (existing) |
| `opponent` | primary opposing party name | `submissionOpponentName(parties, ourSide)` (existing) |
Registered-but-deferred keys (declared so compositions can reference them, resolvers added when a consumer needs them): `proceeding`, `lang`, `client_matter`, `project_name`, `draft_counter`.
**Extensibility contract:** a new consumer (e.g. docforge export) builds its own `VarCatalog` subset + `VarResolver` and registers an artifact (§4). It never edits the engine.
### 2.5 The `date` resolver
The engine ships a default `date` resolver: `time.Now()``Europe/Berlin``Format("2006-01-02")`. This is the **one** variable the engine resolves itself (both shipped schemes compute it identically), so a consumer that only wants the standard date doesn't re-implement it. A consumer may override `date` in its resolver (e.g. a created-at date) — but v1 does not.
---
## §3 Settings & precedence
### 3.1 Precedence chain (v1)
Resolution order for a given artifact, **first hit wins**:
```
per-document override → user override → firm default → system default
(highest priority) (always present)
```
- **System default** — code-resident, per artifact. The seed `Composition` literals (§5). Always exists; nothing can delete it.
- **Firm default** — optional admin-set row, mirrors `firm_dashboard_default` (P5). A firm can mandate a house naming convention. Cleared → reverts to system.
- **User override** — per-user, stored in a jsonb bag keyed by artifact id. Absent key → fall through.
- **Per-document override** — the **already-shipped** `composer_meta.filename_keyword`, generalised to a `composer_meta.name_overrides` map of `{var → value}` (back-compat: `filename_keyword` reads as `name_overrides.keyword` for the filename artifact). This is a *variable-value* override, not a *composition* override — the user is replacing one token's value for one document, not redefining the template.
> **Why per-document is a value override, not a template override:** the shipped "Stichwort" editor lets a lawyer change *what the keyword is* for one draft, not *the shape of the name*. Keeping per-document as value-only avoids giving every draft its own editable template (scope creep) while preserving the shipped UX exactly.
### 3.2 Storage
| Level | Where | Shape |
|-------|-------|-------|
| System | Go code (`nomen` consumer package) | `Composition` literals |
| Firm | **new** `paliad.firm_name_compositions` (id=1 singleton, mirrors `firm_dashboard_default`) | `jsonb`: `{ artifact_id: Composition }` map, validated |
| User | **new column** `paliad.users.name_compositions jsonb NOT NULL DEFAULT '{}'` (mirrors `email_preferences`) | `{ artifact_id: Composition }` map |
| Per-document | **existing** `submission_drafts.composer_meta` | `{ name_overrides: { var: value } }` (supersedes flat `filename_keyword`) |
A `NameCompositionSpec` type gets `Validate()` (write — references-known-vars, known-artifact, ≤ N segments) and `SanitizeForRead()` (read — drop segments referencing dropped vars, clamp version), exactly like `DashboardLayoutSpec`. This is the closest existing analog and the pattern is copy-shaped.
> **Project-level (v1.1, deferred):** when it lands, it slots between user and firm (`per-document → user → project → firm → system`) and stores under a documented `projects.metadata.name_compositions` sub-key — **no migration needed** (P6: column exists). The "project vs user, who wins?" call (Q2) is deferred with it; the v1.1 default is **user wins** (a lawyer's personal convention beats a matter's), but that's a v1.1 decision, flagged here so v1 storage doesn't preclude it.
---
## §4 Artifact registry
An **artifact** is a named thing that gets a name: it binds a default composition, an allowed-variable subset, and a render target.
```go
type Artifact struct {
ID string // "submission_draft_title", "submission_docx_filename"
Label string // for the settings UI
Catalog VarCatalog // which variables are available here
Target RenderTarget // title vs filename
SystemDefault Composition // the seed (§5)
}
```
v1 registry (`internal/services/namegen` — the paliad-side wiring; `pkg/nomen` stays pure):
| Artifact ID | Target | Wired in v1? |
|-------------|--------|--------------|
| `submission_draft_title` | title | **yes** |
| `submission_docx_filename` | filename `.docx` | **yes** |
| `docforge_export` | filename | registered, **unwired** (opts in when docforge ships) |
| `data_zip_export` | filename `.zip` | registered, **unwired** (keeps `ExportFilename` shape) |
| `projection_slug` | slug | registered, **unwired** |
Registering-but-not-wiring means: the artifact ID exists in the catalog so the settings UI *could* list it and a composition *could* be stored, but the consumer still calls its current code path until a follow-up task flips it. No dead behaviour, no speculative resolver code.
> **`data_zip_export` note:** `ExportFilename` (`paliad-export-project-<slug>-<short>-<ts>.zip`) is deliberately machine-shaped (UTC timestamp, uuid disambiguator) — it is **not** a legal title and should **not** inherit the legal-composition defaults. It is registered for *discoverability*, but its eventual opt-in would use a distinct catalog (slug/timestamp/uuid vars), confirming the engine generalises beyond the legal-title model without forcing that model on it.
---
## §5 Seed defaults (the two shipped schemes, as data)
### 5.1 `submission_draft_title` (reproduces `AutoSubmissionTitle`, #155)
```
Segments:
{ Var: "date", Sep: "", Missing: omit }
{ Var: "client", Sep: " ", Missing: omit }
{ Var: "forum", Sep: " ./. ", Missing: omit }
{ Var: "opponent", Sep: " ./. ", Missing: omit }
Target: TitleTarget
```
- All-omit + separator-suppression reproduces "drop empty segment with its leading separator".
- `date` with `Sep: ""` and the others' first-emitted-suppresses-Sep rule yields `2026-05-31 Bayer AG ./. UPC` when opponent is empty — identical to today.
- Non-project draft: `client`/`forum`/`opponent` resolve `("", false)` → all omitted → renders bare `<date>`. **This is the non-project fix** (§6).
### 5.2 `submission_docx_filename` (reproduces `submissionFileName`, 354)
```
Segments:
{ Var: "date", Sep: "", Missing: omit }
{ Var: "keyword", Sep: " ", Missing: literal("submission") }
{ Var: "case_number", Sep: " ", Wrap: {"(", ")"},
Missing: placeholder("Az. folgt") }
Target: FilenameTarget{ext: ".docx"}
```
- `keyword` missing → `literal("submission")` reproduces the `kw == "" → "submission"` fallback.
- `case_number` missing → `placeholder("Az. folgt")`, wrapped in parens → `(Az. folgt)`.
- `FilenameTarget` sanitises each value via `SanitiseSubmissionFileName`, preserves the frame, appends `.docx`. Output identical to 354.
**Faithfulness test (acceptance gate):** golden-file table tests assert the engine's output is byte-equal to the current `AutoSubmissionTitle` / `submissionFileName` across the existing test matrix (with/without opponent, with/without case-number, en/de, umlaut folding). The shipped funcs become thin wrappers over the engine, or are deleted once call-sites move.
---
## §6 The non-project fix
Currently `newDraftName` only calls `autoNameForProject` when `project != nil`; otherwise `nextDraftName``Entwurf N`. Under the engine:
- A non-project draft renders `submission_draft_title` with a resolver where `client/forum/opponent` are all `("", false)` → composition degrades to `<date>`.
- Per Q4, the default gains a `keyword` segment so non-project drafts read **`<date> <keyword>`** where `keyword` = submission/document type if the draft has a `submission_code` that maps to a rule, else falls back.
- **Fallback when no keyword context:** if `keyword` also resolves empty (project-less draft with no `submission_code`/rule), the title degrades to `<date> Entwurf N``Entwurf N` enters as the `keyword` segment's `literal` fallback **with** the existing counter, so uniqueness is preserved via `uniqueDraftName` (P7).
> **FLAG (coder):** confirm whether project-less drafts (t-paliad-243) carry a `submission_code`. If yes, `keyword` derives from the rule like the project path. If no, the `literal("Entwurf N")` fallback is the norm and non-project names read `<date> Entwurf N` — still satisfies "date first there" (m's ask). Resolve in implementation; both paths are handled by the same composition.
The non-project title is the **same** `submission_draft_title` artifact — not a separate composition. Degradation is data-driven, not a code branch. This is the payoff of the engine: the gap closes by *removing* the `project != nil` special-case, not adding another.
---
## §7 Settings UX (v1)
A section on the existing `/settings` page (017 surface):
- **Per artifact** (v1 lists the 2 wired ones): a single-line **token-template string** field, e.g. `{date} {keyword} ({case_number})`.
- A **token palette**: clickable chips (`{date}` `{client}` `{forum}` `{opponent}` `{keyword}` `{case_number}`) insert at cursor. Chips show the localised label (DE primary / EN secondary).
- A **live preview** rendered against a **sample project** (fixed fixture: client "Bayer AG", forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", today's date) so the user sees the result instantly — and a second preview line with empties so they see the missing-rule behaviour.
- **Reset to firm/system default** button (mirrors the dashboard "reset layout").
**String ⇄ segments:** the field is the *shorthand* (Q1). A small parser compiles `{var}` tokens + surrounding literals into `Segments` (separators = the literal runs between tokens; `(…)` around a token → `Wrap`). Missing-rules are **not** in the string (Q7) — they come from the system default for that var and are not user-editable in v1. So a user can reorder/drop/re-add tokens and change literals, but can't (yet) flip case-number from placeholder to omit. That's a deliberate v1 boundary; the structured model already supports it, the UI just doesn't expose it.
> Parser edge: a `{token}` the catalog doesn't know → inline validation error ("Unknown variable {foo}"), preview shows nothing, save disabled. Mirrors `DashboardLayoutSpec.Validate` rejecting unknown widget keys.
---
## §8 Slice train
Sliced so a **tracer bullet** ships value before the settings UI exists.
- **Slice 1 — Engine + faithful refactor (no behaviour change).**
`pkg/nomen` (types, render, targets, catalog) + `internal/services/namegen` (artifact registry + the 2 seed compositions + resolvers built from existing `submission_autoname.go` helpers). Re-point `AutoSubmissionTitle` and `submissionFileName` call-sites at the engine. **Acceptance:** §5 golden-file byte-equality; all existing #155/354 tests green unchanged. *No user-visible change — this is the safety net.*
- **Slice 2 — Non-project date-first (§6).**
Remove the `project != nil` special-case in `newDraftName`; non-project drafts render `submission_draft_title`. **Acceptance:** project-less draft gets `<date> <keyword>` (or `<date> Entwurf N` fallback); existing project drafts unchanged. *First user-visible win, m's immediate ask.*
- **Slice 3 — Precedence: system → user (per-document already shipped).**
`users.name_compositions jsonb` column + `NameCompositionSpec` (`Validate`/`SanitizeForRead`) + resolution that prefers a user override over the system default. Generalise `composer_meta.filename_keyword``name_overrides.keyword` (back-compat read). *No UI yet — overrides settable via API/test.*
- **Slice 4 — Settings UX (§7).**
`/settings` token-template field + palette + live preview for the 2 wired artifacts. *User can now customise.*
- **Slice 5 — Firm default.**
`firm_name_compositions` singleton + admin surface, mirroring `firm_dashboard_default`. Slots into precedence below user. *Firm-wide convention.*
Slices 12 are the tracer bullet (engine proven on shipped behaviour + the gap closed). 35 layer settings without re-touching the engine.
---
## §9 Out of scope (this PRD)
- Implementation, migration SQL drafting, Go code.
- Re-litigating #155 / 354 behaviour — they are the seed defaults, reproduced not redesigned.
- **Project-level** compositions (v1.1; storage path reserved in §3.2, precedence call deferred).
- Wiring `docforge_export`, `data_zip_export`, `projection_slug` — registered, not migrated (each is its own follow-up when the surface needs it).
- Naming for non-doc-generation strings across the app.
- User-editable **missing-rules** in the settings UI (model supports it; UI deferred past v1).
---
## §10 Open questions (historical record — resolved in § m's decisions)
1. Composition representation — token-string vs structured-segments vs both. → **Q1: structured + string shorthand.**
2. v1 precedence levels. → **Q2: system → firm → user → per-document.**
3. Generalisation depth (YAGNI vs engine-now). → **Q3: reusable engine, 3 consumers wired.**
4. Non-project default name. → **Q4: `<date> <keyword>`.**
5. Missing-rule policy set. → **Q5: omit + placeholder + literal.**
6. Date semantics. → **Q6: render-time today, Europe/Berlin, `YYYY-MM-DD`.**
7. Settings UX shape. → **Q7: live-preview string field + palette.**
8. Artifact registry scope. → **Q8: 2 submission artifacts + extensible registry.**
**Remaining FLAGs for the coder (not blocking design approval):**
- Package name `pkg/nomen` (vs `naming`/`namegen`) — implementation pick.
- Whether project-less drafts carry a `submission_code` (decides `keyword` source in §6).
- `name_overrides` back-compat read of the existing `filename_keyword` key — confirm the one shipped draft-keyword row migrates cleanly (live round-trip test, like t-paliad-354's).

View File

@@ -509,14 +509,14 @@ Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `pal
Transaction:
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
SET origin_scenario_id = <scenario.id>
2. INSERT into paliad.project_parties from step-2 payload
2. INSERT into paliad.parties from step-2 payload
3. For each scenario_proceeding (depth-first, parent before child):
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
children become sub-projects via parent_project_id)
b. For each filed scenario_event: INSERT paliad.deadlines row with
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
c. For each planned scenario_event: INSERT paliad.deadlines row with
status='open', due_date=computed (or actual_date override)
status='pending', due_date=computed (or actual_date override)
d. Skipped events: not inserted (no deadline row)
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
5. Navigate to /projects/<new>
@@ -636,7 +636,7 @@ Dead code to delete (verify with grep before deletion):
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
- `frontend/src/client/verfahrensablauf-detail-mode.ts` (replaced by per-triplet Detailgrad)
- ~~`frontend/src/client/verfahrensablauf-detail-mode.ts`~~ KEEP. Builder imports `filterByDetailMode` from it; per-triplet Detailgrad reuses this module.
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
**Kept**:

Binary file not shown.

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HL Patents Style</title>
<title>HLC Patents Style</title>
<style>
:root {
--bg: #002236;
@@ -81,31 +81,35 @@
<body>
<main>
<h1>HL <span class="accent">Patents Style</span></h1>
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
<h1>HLC <span class="accent">Patents Style</span></h1>
<!-- Lead line: provisional pending m's final de-brand wording (work/head delegation #2681).
"at HLC" matches the confirmed rebrand; swap when the final copy lands. -->
<p class="lead">The Word template for patent submissions at HLC.</p>
<h2>Was es kann</h2>
<h2>What it does</h2>
<ul>
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
<li>Document styles for every common submission building block (headings, margin numbers, motions, exhibits)</li>
<li>BuildingBlocks: insert ready-made sections straight from the ribbon</li>
<li>DE / EN language switch via a ribbon toggle</li>
<li>Scaffolding: build a complete submission with one click</li>
<li>Margin numbers, exhibit numbering, SEQ fields</li>
<li>Auto-update from the ribbon (see below)</li>
</ul>
<h2>Aktualisierungen</h2>
<p>Im Ribbon-Tab <em>HL Patent</em> &rarr; Gruppe <em>Manage</em> &rarr; <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
<h2>Updates</h2>
<p>In the ribbon tab <em>HLC Patent</em> &rarr; group <em>Manage</em> &rarr; <kbd>Check for Updates</kbd>. It fetches the current manifest from this server, checks the version, downloads the new <code>.dotm</code> only when needed, verifies it via SHA256, and installs it. Restart Word after updating.</p>
<h2>Frische Installation</h2>
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
<h2>Fresh install</h2>
<p>If you haven&rsquo;t installed the template yet, download the current version once manually and copy it into the Word startup folder. The <code>InstallTemplate</code> routine inside the template handles the rest.</p>
<!-- Download href stays on the current HL-Patents-Style.dotm until work/head confirms
HLC-Patents-Style.dotm is published (zero-downtime swap, delegation #2681). -->
<p><a class="download" href="HL-Patents-Style.dotm" download>Download HLC Patents Style</a></p>
<h2>Hilfe &amp; Feedback</h2>
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
<h2>Help &amp; feedback</h2>
<p>Bugs, requests, style questions, build problems: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HLC%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
<footer>
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> &middot; Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
<p>Update endpoint: <code>paliad.msbls.de/patentstyle/</code> &middot; Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
<p id="ver"></p>
</footer>
@@ -115,7 +119,7 @@
.then(r => r.ok ? r.json() : null)
.then(j => {
if (j && j.version) {
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
document.getElementById('ver').textContent = 'Currently served: ' + j.version;
}
})
.catch(() => {});

View File

@@ -1,5 +1,5 @@
{
"version": "v0.260518",
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
}
"version": "v0.260601",
"dotm_url": "https://paliad.msbls.de/patentstyle/HLC-Patents-Style.dotm",
"sha256": "DE6B6A17AC603FF4A9B3893CD2A7EF8263C9E2D4224A0A5E28E2FABF5E27A798"
}

View File

@@ -1550,7 +1550,25 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.tab.profil": "Profil",
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.tab.names": "Namensschemata",
"einstellungen.tab.export": "Datenexport",
"einstellungen.names.subtitle": "Legen Sie fest, wie Paliad Entwurfstitel und Dateinamen aus Projektdaten zusammensetzt. Klicken Sie auf einen Platzhalter, um ihn einzuf\u00fcgen; die Vorschau zeigt das Ergebnis sofort.",
"einstellungen.names.preview.sample": "Beispiel:",
"einstellungen.names.preview.empty": "Ohne Projektdaten:",
"einstellungen.names.reset": "Auf Standard zur\u00fccksetzen",
"einstellungen.names.saved": "Gespeichert.",
"einstellungen.names.reset_done": "Auf Standard zur\u00fcckgesetzt.",
"einstellungen.names.override_badge": "Angepasst",
"einstellungen.names.firm_badge": "Firmenstandard",
"einstellungen.names.firm.heading": "Firmenstandard (f\u00fcr alle)",
"einstellungen.names.firm.status_set": "Aktiver Firmenstandard:",
"einstellungen.names.firm.status_unset": "Kein Firmenstandard gesetzt \u2014 es gilt der Systemstandard.",
"einstellungen.names.firm.set": "Als Firmenstandard festlegen",
"einstellungen.names.firm.clear": "Firmenstandard l\u00f6schen",
"einstellungen.names.firm.saved": "Firmenstandard gespeichert.",
"einstellungen.names.firm.cleared": "Firmenstandard gel\u00f6scht \u2014 Systemstandard gilt wieder.",
"einstellungen.names.error.load": "Namensschemata konnten nicht geladen werden.",
"einstellungen.names.error.invalid": "Ung\u00fcltige Vorlage \u2014 bitte pr\u00fcfen Sie die Platzhalter.",
"einstellungen.export.subtitle": "Laden Sie Ihre pers\u00f6nlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter. Enthalten ist alles, was Sie aktuell sehen k\u00f6nnen \u2014 Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.",
"einstellungen.export.heading": "Pers\u00f6nlicher Datenexport",
"einstellungen.export.what": "Das Paket enth\u00e4lt Ihre sichtbaren Daten in drei Formaten in einem .zip:",
@@ -1745,6 +1763,10 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-354 — Dateiname-Stichwort (führt den Namen des exportierten Dokuments an).
"submissions.draft.keyword.label": "Stichwort (Dateiname)",
"submissions.draft.keyword.placeholder": "Automatisch aus dem Schriftsatztyp",
"submissions.draft.keyword.hint": "Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).",
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
"submissions.draft.base.label": "Vorlagenbasis",
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
@@ -4880,7 +4902,25 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.tab.profil": "Profile",
"einstellungen.tab.benachrichtigungen": "Notifications",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.tab.names": "Naming",
"einstellungen.tab.export": "Data export",
"einstellungen.names.subtitle": "Define how Paliad composes draft titles and file names from project data. Click a placeholder to insert it; the preview updates instantly.",
"einstellungen.names.preview.sample": "Sample:",
"einstellungen.names.preview.empty": "Without project data:",
"einstellungen.names.reset": "Reset to default",
"einstellungen.names.saved": "Saved.",
"einstellungen.names.reset_done": "Reset to default.",
"einstellungen.names.override_badge": "Customised",
"einstellungen.names.firm_badge": "Firm default",
"einstellungen.names.firm.heading": "Firm default (for everyone)",
"einstellungen.names.firm.status_set": "Active firm default:",
"einstellungen.names.firm.status_unset": "No firm default set \u2014 the system default applies.",
"einstellungen.names.firm.set": "Set as firm default",
"einstellungen.names.firm.clear": "Clear firm default",
"einstellungen.names.firm.saved": "Firm default saved.",
"einstellungen.names.firm.cleared": "Firm default cleared \u2014 system default applies again.",
"einstellungen.names.error.load": "Could not load naming schemes.",
"einstellungen.names.error.invalid": "Invalid template \u2014 please check the placeholders.",
"einstellungen.export.subtitle": "Download your personal Paliad data as an Excel + JSON + CSV bundle. The package contains everything you can currently see \u2014 your projects, deadlines, appointments, notes, approvals and settings.",
"einstellungen.export.heading": "Personal data export",
"einstellungen.export.what": "The package contains your visible data in three formats in one .zip:",
@@ -5070,6 +5110,10 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
// t-paliad-354 — filename keyword (leads the exported document name).
"submissions.draft.keyword.label": "Keyword (filename)",
"submissions.draft.keyword.placeholder": "Auto-derived from the submission type",
"submissions.draft.keyword.hint": "Leads the filename: <date> <keyword> (<case number>).",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",

View File

@@ -51,8 +51,8 @@ interface SyncLogEntry {
duration_ms?: number;
}
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
type TabName = "profil" | "benachrichtigungen" | "caldav" | "names" | "export";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "names", "export"];
const DEFAULT_TAB: TabName = "profil";
let me: Me | null = null;
@@ -115,6 +115,7 @@ function showTab(tab: TabName, pushHistory: boolean) {
if (tab === "profil") void loadProfilTab();
else if (tab === "benachrichtigungen") void loadPrefsTab();
else if (tab === "caldav") void loadCalDAVTab();
else if (tab === "names") void loadNamesTab();
else if (tab === "export") void loadExportTab();
}
}
@@ -1119,6 +1120,415 @@ function runExport(): void {
}
}
// --- Namensschemata tab (t-paliad-356 Slice 4) ------------------------------
//
// Per-artifact token-template editor. All parsing, validation and preview
// rendering happen server-side (the nomen engine is the single source of
// truth); this client only inserts {tokens} at the cursor, debounces a preview
// request, and persists via PUT/DELETE.
interface NameVar {
var: string;
label: string;
label_en: string;
}
interface NameArtifactCard {
artifact_id: string;
label: string;
label_en: string;
template: string;
system_template: string;
is_override: boolean;
firm_is_set: boolean;
firm_template: string;
palette: NameVar[];
preview_full: string;
preview_empty: string;
}
let nameCards: NameArtifactCard[] = [];
let nameIsAdmin = false;
const namePreviewTimers = new Map<string, number>();
function nameVarLabel(v: NameVar): string {
return getLang() === "en" ? v.label_en : v.label;
}
function artifactLabel(c: NameArtifactCard): string {
return getLang() === "en" ? c.label_en : c.label;
}
async function loadNamesTab(): Promise<void> {
const loading = document.getElementById("names-loading");
const list = document.getElementById("names-list");
if (!list) return;
try {
const resp = await fetch("/api/me/name-compositions");
if (!resp.ok) {
if (loading) loading.textContent = t("einstellungen.names.error.load");
return;
}
const data = await resp.json();
nameCards = (data.artifacts ?? []) as NameArtifactCard[];
nameIsAdmin = data.is_admin === true;
} catch {
if (loading) loading.textContent = t("einstellungen.names.error.load");
return;
}
if (loading) loading.style.display = "none";
list.style.display = "";
renderNameCards();
}
function renderNameCards(): void {
const list = document.getElementById("names-list");
if (!list) return;
list.innerHTML = nameCards.map(nameCardHTML).join("");
for (const card of nameCards) wireNameCard(card.artifact_id);
}
function nameCardHTML(c: NameArtifactCard): string {
const id = c.artifact_id;
const chips = c.palette
.map(
(v) =>
`<button type="button" class="names-chip" data-var="${esc(v.var)}" data-art="${esc(id)}">${esc(nameVarLabel(v))}</button>`,
)
.join("");
return `
<div class="names-artifact" data-art="${esc(id)}">
<div class="names-artifact-head">
<h2>${esc(artifactLabel(c))}</h2>
${nameBadgeHTML(c)}
</div>
<div class="names-palette" id="names-palette-${esc(id)}">${chips}</div>
<input type="text" class="names-template-input" id="names-input-${esc(id)}"
value="${esc(c.template)}" autocomplete="off" spellcheck="false" />
<p class="form-msg form-msg-error names-error" id="names-error-${esc(id)}" style="display:none"></p>
<div class="names-preview">
<div class="names-preview-row">
<span class="names-preview-label" data-i18n="einstellungen.names.preview.sample">${esc(t("einstellungen.names.preview.sample"))}</span>
<code class="names-preview-value" id="names-full-${esc(id)}">${esc(c.preview_full)}</code>
</div>
<div class="names-preview-row">
<span class="names-preview-label" data-i18n="einstellungen.names.preview.empty">${esc(t("einstellungen.names.preview.empty"))}</span>
<code class="names-preview-value" id="names-empty-${esc(id)}">${esc(c.preview_empty)}</code>
</div>
</div>
<p class="form-msg names-saved" id="names-saved-${esc(id)}"></p>
<div class="form-actions">
<button type="button" class="btn-secondary" id="names-reset-${esc(id)}" data-i18n="einstellungen.names.reset">${esc(t("einstellungen.names.reset"))}</button>
<button type="button" class="btn-primary btn-cta-lime" id="names-save-${esc(id)}" data-i18n="einstellungen.save">${esc(t("einstellungen.save"))}</button>
</div>
${nameIsAdmin ? nameFirmAdminHTML(c) : ""}
</div>`;
}
// Badge: "Angepasst" when the user has their own override, else "Firmenstandard"
// when the firm default is the source of the shown name. Hidden otherwise.
function nameBadgeHTML(c: NameArtifactCard): string {
const id = c.artifact_id;
if (c.is_override) {
return `<span class="names-badge" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.override_badge"))}</span>`;
}
if (c.firm_is_set) {
return `<span class="names-badge names-badge--firm" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.firm_badge"))}</span>`;
}
return `<span class="names-badge" id="names-badge-${esc(id)}" style="display:none"></span>`;
}
// Admin-only firm-default controls (mirrors the firm-dashboard-default promote
// pattern). "Set as firm default" takes whatever is in the template field;
// "Clear" reverts the firm tier to the system default for everyone.
function nameFirmAdminHTML(c: NameArtifactCard): string {
const id = c.artifact_id;
const status = c.firm_is_set
? `${esc(t("einstellungen.names.firm.status_set"))} <code>${esc(c.firm_template)}</code>`
: esc(t("einstellungen.names.firm.status_unset"));
return `
<div class="names-firm-admin" id="names-firm-${esc(id)}">
<h3 class="names-firm-heading" data-i18n="einstellungen.names.firm.heading">${esc(t("einstellungen.names.firm.heading"))}</h3>
<p class="form-hint names-firm-status" id="names-firm-status-${esc(id)}">${status}</p>
<p class="form-msg names-firm-msg" id="names-firm-msg-${esc(id)}"></p>
<div class="form-actions">
<button type="button" class="btn-danger" id="names-firm-clear-${esc(id)}" data-i18n="einstellungen.names.firm.clear"
style="${c.firm_is_set ? "" : "display:none"}">${esc(t("einstellungen.names.firm.clear"))}</button>
<button type="button" class="btn-secondary" id="names-firm-set-${esc(id)}" data-i18n="einstellungen.names.firm.set">${esc(t("einstellungen.names.firm.set"))}</button>
</div>
</div>`;
}
function wireNameCard(id: string): void {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (!input) return;
input.addEventListener("input", () => scheduleNamePreview(id));
document.querySelectorAll<HTMLButtonElement>(`.names-chip[data-art="${cssEscapeAttr(id)}"]`).forEach((chip) => {
chip.addEventListener("click", () => insertNameToken(id, chip.getAttribute("data-var") ?? ""));
});
document.getElementById(`names-reset-${id}`)?.addEventListener("click", () => resetNameComposition(id));
document.getElementById(`names-save-${id}`)?.addEventListener("click", () => saveNameComposition(id));
document.getElementById(`names-firm-set-${id}`)?.addEventListener("click", () => setFirmNameComposition(id));
document.getElementById(`names-firm-clear-${id}`)?.addEventListener("click", () => clearFirmNameComposition(id));
}
// Artifact ids are [a-z_] only, but keep the attribute-selector value safe.
function cssEscapeAttr(s: string): string {
return s.replace(/["\\]/g, "\\$&");
}
function insertNameToken(id: string, varName: string): void {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (!input || !varName) return;
const token = `{${varName}}`;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
input.value = input.value.slice(0, start) + token + input.value.slice(end);
const caret = start + token.length;
input.focus();
input.setSelectionRange(caret, caret);
scheduleNamePreview(id);
}
function scheduleNamePreview(id: string): void {
clearSavedMsg(id);
const existing = namePreviewTimers.get(id);
if (existing) window.clearTimeout(existing);
namePreviewTimers.set(id, window.setTimeout(() => void runNamePreview(id), 250));
}
async function runNamePreview(id: string): Promise<void> {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (!input) return;
const template = input.value;
try {
const resp = await fetch("/api/me/name-compositions/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ artifact_id: id, template }),
});
if (!resp.ok) {
setNameError(id, t("einstellungen.names.error.invalid"));
return;
}
const data = await resp.json();
if (data.ok) {
setNamePreview(id, data.preview_full, data.preview_empty);
clearNameError(id);
} else {
setNameError(id, t("einstellungen.names.error.invalid"));
}
} catch {
setNameError(id, t("einstellungen.names.error.invalid"));
}
}
function setNamePreview(id: string, full: string, empty: string): void {
const f = document.getElementById(`names-full-${id}`);
const e = document.getElementById(`names-empty-${id}`);
if (f) f.textContent = full;
if (e) e.textContent = empty;
}
function setNameError(id: string, msg: string): void {
const err = document.getElementById(`names-error-${id}`);
if (err) {
err.textContent = msg;
err.style.display = "";
}
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
if (save) save.disabled = true;
}
function clearNameError(id: string): void {
const err = document.getElementById(`names-error-${id}`);
if (err) {
err.textContent = "";
err.style.display = "none";
}
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
if (save) save.disabled = false;
}
function clearSavedMsg(id: string): void {
const saved = document.getElementById(`names-saved-${id}`);
if (saved) saved.textContent = "";
}
function applyNameCard(updated: NameArtifactCard): void {
const idx = nameCards.findIndex((c) => c.artifact_id === updated.artifact_id);
if (idx >= 0) nameCards[idx] = updated;
const id = updated.artifact_id;
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (input) input.value = updated.template;
setNamePreview(id, updated.preview_full, updated.preview_empty);
clearNameError(id);
updateNameBadge(updated);
updateFirmStatus(updated);
}
// updateNameBadge reflects the override → firm → none state on the chip.
function updateNameBadge(c: NameArtifactCard): void {
const badge = document.getElementById(`names-badge-${c.artifact_id}`);
if (!badge) return;
if (c.is_override) {
badge.textContent = t("einstellungen.names.override_badge");
badge.classList.remove("names-badge--firm");
badge.style.display = "";
} else if (c.firm_is_set) {
badge.textContent = t("einstellungen.names.firm_badge");
badge.classList.add("names-badge--firm");
badge.style.display = "";
} else {
badge.style.display = "none";
}
}
// updateFirmStatus refreshes the admin firm-default status line + clear button.
function updateFirmStatus(c: NameArtifactCard): void {
const status = document.getElementById(`names-firm-status-${c.artifact_id}`);
if (status) {
if (c.firm_is_set) {
status.innerHTML = `${esc(t("einstellungen.names.firm.status_set"))} <code>${esc(c.firm_template)}</code>`;
} else {
status.textContent = t("einstellungen.names.firm.status_unset");
}
}
const clearBtn = document.getElementById(`names-firm-clear-${c.artifact_id}`);
if (clearBtn) clearBtn.style.display = c.firm_is_set ? "" : "none";
}
async function setFirmNameComposition(id: string): Promise<void> {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
const msg = document.getElementById(`names-firm-msg-${id}`);
if (!input) return;
try {
const resp = await fetch(`/api/admin/name-compositions/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template: input.value }),
});
if (!resp.ok) {
if (msg) {
msg.textContent = t("einstellungen.names.error.invalid");
msg.className = "form-msg form-msg-error names-firm-msg";
}
return;
}
const updated = (await resp.json()) as NameArtifactCard;
// The admin PUT response carries no user override; preserve the caller's
// own is_override/template view by merging only the firm fields.
mergeFirmFields(id, updated);
if (msg) {
msg.textContent = t("einstellungen.names.firm.saved");
msg.className = "form-msg form-msg-success names-firm-msg";
}
} catch {
if (msg) {
msg.textContent = t("einstellungen.names.error.invalid");
msg.className = "form-msg form-msg-error names-firm-msg";
}
}
}
async function clearFirmNameComposition(id: string): Promise<void> {
const msg = document.getElementById(`names-firm-msg-${id}`);
try {
const resp = await fetch(`/api/admin/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!resp.ok) {
if (msg) {
msg.textContent = t("einstellungen.names.error.invalid");
msg.className = "form-msg form-msg-error names-firm-msg";
}
return;
}
const updated = (await resp.json()) as NameArtifactCard;
mergeFirmFields(id, updated);
if (msg) {
msg.textContent = t("einstellungen.names.firm.cleared");
msg.className = "form-msg form-msg-success names-firm-msg";
}
} catch {
if (msg) {
msg.textContent = t("einstellungen.names.error.invalid");
msg.className = "form-msg form-msg-error names-firm-msg";
}
}
}
// mergeFirmFields applies the firm-tier fields from an admin PUT/DELETE
// response onto the stored card without disturbing the caller's own
// user-override view, then refreshes the badge + firm status.
function mergeFirmFields(id: string, fromAdmin: NameArtifactCard): void {
const idx = nameCards.findIndex((c) => c.artifact_id === id);
if (idx < 0) return;
nameCards[idx].firm_is_set = fromAdmin.firm_is_set;
nameCards[idx].firm_template = fromAdmin.firm_template;
updateNameBadge(nameCards[idx]);
updateFirmStatus(nameCards[idx]);
}
async function saveNameComposition(id: string): Promise<void> {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (!input) return;
try {
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template: input.value }),
});
if (!resp.ok) {
setNameError(id, t("einstellungen.names.error.invalid"));
return;
}
const updated = (await resp.json()) as NameArtifactCard;
applyNameCard(updated);
const saved = document.getElementById(`names-saved-${id}`);
if (saved) {
saved.textContent = t("einstellungen.names.saved");
saved.className = "form-msg form-msg-success names-saved";
}
} catch {
setNameError(id, t("einstellungen.names.error.invalid"));
}
}
async function resetNameComposition(id: string): Promise<void> {
try {
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!resp.ok) {
setNameError(id, t("einstellungen.names.error.invalid"));
return;
}
const updated = (await resp.json()) as NameArtifactCard;
applyNameCard(updated);
const saved = document.getElementById(`names-saved-${id}`);
if (saved) {
saved.textContent = t("einstellungen.names.reset_done");
saved.className = "form-msg form-msg-success names-saved";
}
} catch {
setNameError(id, t("einstellungen.names.error.invalid"));
}
}
// Re-localise palette chips + artifact headings on language change without
// rebuilding the cards (which would discard in-progress edits).
function relocaliseNameCards(): void {
for (const card of nameCards) {
const head = document.querySelector(`.names-artifact[data-art="${cssEscapeAttr(card.artifact_id)}"] h2`);
if (head) head.textContent = artifactLabel(card);
const badge = document.getElementById(`names-badge-${card.artifact_id}`);
if (badge && badge.style.display !== "none") badge.textContent = t("einstellungen.names.override_badge");
for (const v of card.palette) {
const chip = document.querySelector(
`.names-chip[data-art="${cssEscapeAttr(card.artifact_id)}"][data-var="${cssEscapeAttr(v.var)}"]`,
);
if (chip) chip.textContent = nameVarLabel(v);
}
}
}
// --- Init -------------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
@@ -1152,6 +1562,7 @@ document.addEventListener("DOMContentLoaded", () => {
renderCalDAVStatus();
void loadCalDAVLog();
}
if (loadedTabs.has("names")) relocaliseNameCards();
});
showTab(parseTab(), false);

View File

@@ -503,7 +503,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string; filename_keyword?: string }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -558,6 +558,7 @@ function paint(): void {
paintPartyPicker();
paintLanguageRow();
paintLanguageFallback();
paintKeywordRow();
paintVariables();
paintSectionList();
paintPreview();
@@ -1034,6 +1035,53 @@ function paintLanguageFallback(): void {
el.style.display = fallback ? "" : "none";
}
// autoKeyword returns the lang-aware rule name that leads the exported
// filename when the user sets no override — shown as the keyword input's
// placeholder so the lawyer sees the default without it being forced.
// t-paliad-354.
function autoKeyword(): string {
const view = state.view;
if (!view?.rule) return "";
const en = (view.draft.language || view.lang || "de").toLowerCase() === "en";
const name = en && view.rule.name_en ? view.rule.name_en : view.rule.name;
return (name || "").trim();
}
// paintKeywordRow syncs the "Stichwort (Dateiname)" input with the
// draft's stored override (composer_meta.filename_keyword) and shows the
// auto-derived rule name as the placeholder. Editing PATCHes the draft on
// blur (change), persisting under composer_meta.filename_keyword.
// t-paliad-354.
function paintKeywordRow(): void {
const input = document.getElementById("submission-draft-keyword") as HTMLInputElement | null;
if (!input || !state.view) return;
const stored = state.view.draft.composer_meta?.["filename_keyword"];
input.value = typeof stored === "string" ? stored : "";
const auto = autoKeyword();
if (auto) input.placeholder = auto;
input.onchange = () => { void onKeywordChange(input.value.trim()); };
}
async function onKeywordChange(keyword: string): Promise<void> {
if (!state.view) return;
const stored = state.view.draft.composer_meta?.["filename_keyword"];
const current = typeof stored === "string" ? stored.trim() : "";
if (keyword === current) return;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ filename_keyword: keyword });
state.view = view;
paintKeywordRow();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft keyword save:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
// Revert to the persisted value so the field doesn't lie.
paintKeywordRow();
}
}
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
if (!state.view) return;
if ((state.view.draft.language || "de").toLowerCase() === lang) return;

View File

@@ -1762,6 +1762,23 @@ export type I18nKey =
| "einstellungen.export.what"
| "einstellungen.heading"
| "einstellungen.loading"
| "einstellungen.names.error.invalid"
| "einstellungen.names.error.load"
| "einstellungen.names.firm.clear"
| "einstellungen.names.firm.cleared"
| "einstellungen.names.firm.heading"
| "einstellungen.names.firm.saved"
| "einstellungen.names.firm.set"
| "einstellungen.names.firm.status_set"
| "einstellungen.names.firm.status_unset"
| "einstellungen.names.firm_badge"
| "einstellungen.names.override_badge"
| "einstellungen.names.preview.empty"
| "einstellungen.names.preview.sample"
| "einstellungen.names.reset"
| "einstellungen.names.reset_done"
| "einstellungen.names.saved"
| "einstellungen.names.subtitle"
| "einstellungen.optional"
| "einstellungen.prefs.escalation.default_option"
| "einstellungen.prefs.escalation.heading"
@@ -1804,6 +1821,7 @@ export type I18nKey =
| "einstellungen.tab.benachrichtigungen"
| "einstellungen.tab.caldav"
| "einstellungen.tab.export"
| "einstellungen.tab.names"
| "einstellungen.tab.profil"
| "einstellungen.title"
| "event.description.appointment_approval_approved"
@@ -2842,6 +2860,9 @@ export type I18nKey =
| "submissions.draft.base.hint"
| "submissions.draft.base.label"
| "submissions.draft.import.button"
| "submissions.draft.keyword.hint"
| "submissions.draft.keyword.label"
| "submissions.draft.keyword.placeholder"
| "submissions.draft.language"
| "submissions.draft.language.de"
| "submissions.draft.language.en"

View File

@@ -40,6 +40,7 @@ export function renderSettings(): string {
<a className="entity-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
<a className="entity-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
<a className="entity-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
<a className="entity-tab" data-tab="names" href="?tab=names" data-i18n="einstellungen.tab.names">Namensschemata</a>
<a className="entity-tab" data-tab="export" href="?tab=export" data-i18n="einstellungen.tab.export">Datenexport</a>
</nav>
@@ -362,6 +363,23 @@ export function renderSettings(): string {
</div>
</section>
{/* --- Namensschemata tab (t-paliad-356 Slice 4) -------- */}
<section className="entity-tab-panel" id="tab-names" style="display:none">
<p className="tool-subtitle" data-i18n="einstellungen.names.subtitle">
Legen Sie fest, wie Paliad Entwurfstitel und Dateinamen aus Projektdaten zusammensetzt.
Klicken Sie auf einen Platzhalter, um ihn einzuf&uuml;gen; die Vorschau zeigt das Ergebnis sofort.
</p>
<div id="names-loading" className="entity-loading">
<p data-i18n="einstellungen.loading">L&auml;dt&hellip;</p>
</div>
{/* Per-artifact cards are built client-side from
/api/me/name-compositions so the wired-artifact list stays
server-driven (no duplicated catalog in the frontend). */}
<div id="names-list" className="names-list" style="display:none" />
</section>
{/* --- Datenexport tab (t-paliad-214 Slice 1) ----------- */}
<section className="entity-tab-panel" id="tab-export" style="display:none">
<p className="tool-subtitle" data-i18n="einstellungen.export.subtitle">

View File

@@ -11203,6 +11203,129 @@ label.caldav-toggle-label {
margin-bottom: 0.3rem;
}
/* ===== Namensschemata (name-composition settings — t-paliad-356 S4) ===== */
.names-list {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.names-artifact {
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1rem 1.1rem;
background: var(--color-surface);
}
.names-artifact-head {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.6rem;
}
.names-artifact-head h2 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.names-badge {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-accent-soft-bg);
color: var(--color-accent-soft-fg);
border: 1px solid var(--color-accent-soft-border);
}
.names-palette {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.6rem;
}
.names-chip {
font-size: 0.82rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface-muted);
color: var(--color-text);
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.names-chip:hover {
background: var(--color-accent-light);
border-color: var(--color-accent);
}
.names-template-input {
width: 100%;
font-family: var(--font-mono);
font-size: 0.92rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface-2);
}
.names-error {
margin: 0.4rem 0 0;
}
.names-preview {
margin-top: 0.7rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.names-preview-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
}
.names-preview-label {
font-size: 0.82rem;
color: var(--color-text-muted);
min-width: 9rem;
}
.names-preview-value {
font-family: var(--font-mono);
font-size: 0.88rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: var(--color-surface-muted);
word-break: break-word;
}
.names-saved {
margin: 0.5rem 0 0;
}
.names-badge--firm {
background: var(--color-surface-muted);
color: var(--color-text-muted);
border-color: var(--color-border);
}
.names-firm-admin {
margin-top: 0.9rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--color-border);
}
.names-firm-heading {
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 0.3rem;
}
.names-firm-status {
margin: 0 0 0.3rem;
}
.names-firm-status code {
font-family: var(--font-mono);
font-size: 0.85rem;
padding: 0.05rem 0.35rem;
border-radius: 4px;
background: var(--color-surface-muted);
}
.names-firm-msg {
margin: 0 0 0.4rem;
}
/* ===== Notizen (polymorphic notes — Phase I) ===== */
.notiz-container {
display: flex;

View File

@@ -109,6 +109,35 @@ export function renderSubmissionDraft(): string {
</button>
</div>
{/* t-paliad-354 — keyword that leads the exported
document name "<date> <keyword> (<case>)". Empty
falls back to the auto-derived rule name; the
placeholder shows that default. Persisted to
composer_meta.filename_keyword via the draft-save
path on change. Grouped with the draft-name row
(naming controls) ahead of the template controls
(base + language) per t-paliad-359. */}
<div className="submission-draft-keyword-row">
<label
htmlFor="submission-draft-keyword"
data-i18n="submissions.draft.keyword.label">
Stichwort (Dateiname)
</label>
<input
type="text"
id="submission-draft-keyword"
className="entity-form-input"
data-i18n-placeholder="submissions.draft.keyword.placeholder"
placeholder="Automatisch aus dem Schriftsatztyp"
/>
<p
className="submission-draft-keyword-hint"
id="submission-draft-keyword-hint"
data-i18n="submissions.draft.keyword.hint">
Führt den Dateinamen an: &lt;Datum&gt; &lt;Stichwort&gt; (&lt;Aktenzeichen&gt;).
</p>
</div>
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
base picker. Hydrated by client/submission-draft.ts
once /api/submission-bases returns. Disabled

View File

@@ -0,0 +1,2 @@
ALTER TABLE paliad.users
DROP COLUMN IF EXISTS name_compositions;

View File

@@ -0,0 +1,12 @@
-- Per-user name-composition overrides (t-paliad-356 Slice 3, PRD §3.2).
--
-- A free-form JSONB map of { artifact_id: Composition } overriding the
-- code-resident system-default name composition for that artifact (the two
-- seed schemes: submission_draft_title, submission_docx_filename). An empty
-- object means "no overrides — use the system defaults"; unknown artifact
-- ids and segments referencing unknown variables are dropped on read
-- (NameCompositionSpec.SanitizeForRead) and rejected on write
-- (NameCompositionSpec.Validate), mirroring the user_dashboard_layouts
-- pattern.
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS name_compositions jsonb NOT NULL DEFAULT '{}'::jsonb;

View File

@@ -0,0 +1,55 @@
-- Revert t-paliad-358 A-S2: restore each base's original (pre-parametric)
-- caption seed_md from migrations 146 / 150, verbatim. One UPDATE per slug
-- because the originals differed per base.
-- hlc-letterhead (mig 146): heading + parties with "vertreten durch" + court.
UPDATE paliad.submission_bases AS b
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
SELECT jsonb_agg(
CASE WHEN elem->>'section_key' = 'caption'
THEN elem || jsonb_build_object(
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}')
ELSE elem END
ORDER BY ord)
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
WHERE b.slug = 'hlc-letterhead' AND b.section_spec ? 'defaults';
-- neutral (mig 146): heading + parties (no representative) + Aktenzeichen, no court.
UPDATE paliad.submission_bases AS b
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
SELECT jsonb_agg(
CASE WHEN elem->>'section_key' = 'caption'
THEN elem || jsonb_build_object(
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}',
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}')
ELSE elem END
ORDER BY ord)
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
WHERE b.slug = 'neutral' AND b.section_spec ? 'defaults';
-- lg-duesseldorf (mig 150): heading + parties (no representative) + court.
UPDATE paliad.submission_bases AS b
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
SELECT jsonb_agg(
CASE WHEN elem->>'section_key' = 'caption'
THEN elem || jsonb_build_object(
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}')
ELSE elem END
ORDER BY ord)
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
WHERE b.slug = 'lg-duesseldorf' AND b.section_spec ? 'defaults';
-- upc-formal (mig 150): UPC heading + parties with "represented by" + UPC case number + patent.
UPDATE paliad.submission_bases AS b
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
SELECT jsonb_agg(
CASE WHEN elem->>'section_key' = 'caption'
THEN elem || jsonb_build_object(
'seed_md_de', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}',
'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}')
ELSE elem END
ORDER BY ord)
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
WHERE b.slug = 'upc-formal' AND b.section_spec ? 'defaults';

View File

@@ -0,0 +1,43 @@
-- t-paliad-358 A-S2 — unify the Composer caption (Rubrum) seed across every
-- base onto the shared parametric caption.* resolver keys.
--
-- Before: each base seeded a hand-written caption with hard-coded designations
-- ("— Klägerin —" / "— Claimant —") and heading ("In der Sache" / "In the
-- matter"). That wording diverged from the per-code .docx templates and the
-- merge-fallback skeleton, and could not reflect the forum (UPC vs DE-LG vs
-- nullity vs appeal).
--
-- After: every base's caption section references the {{caption.*}} keys
-- (addCaptionVars, submission_vars.go), so the heading, party designations,
-- versus connector and "wegen" subject are resolved per forum from
-- project.proceeding (jurisdiction + code + role-label overrides) +
-- project.instance_level — the SAME wording the templates and the fallback
-- skeleton now use. One parametric caption, shared keys.
--
-- Forward-only effect: section seeds are applied when a NEW draft is created
-- from a base; existing drafts keep their already-seeded (possibly user-edited)
-- caption text untouched.
--
-- Position-independent: rewrites only the element whose section_key='caption'
-- inside section_spec->'defaults', preserving order (WITH ORDINALITY) and every
-- other field on the element (elem || patch).
UPDATE paliad.submission_bases AS b
SET section_spec = jsonb_set(
b.section_spec,
'{defaults}',
(
SELECT jsonb_agg(
CASE WHEN elem->>'section_key' = 'caption'
THEN elem || jsonb_build_object(
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\n{{project.court}}')
ELSE elem
END
ORDER BY ord
)
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)
)
)
WHERE b.slug IN ('hlc-letterhead', 'neutral', 'lg-duesseldorf', 'upc-formal')
AND b.section_spec ? 'defaults';

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS paliad.firm_name_compositions;

View File

@@ -0,0 +1,31 @@
-- Firm-wide default name compositions (t-paliad-356 Slice 5, PRD §3.2 / §8).
--
-- The firm tier of the name-composition precedence chain
-- (per-document → user → FIRM → system). A single optional row holds the
-- firm's house naming convention as a JSONB { artifact_id: Composition } map,
-- validated by NameCompositionSpec exactly like the per-user
-- users.name_compositions column (mig 160). Cleared → resolution falls through
-- to the always-present code-resident system default.
--
-- Mirrors paliad.firm_dashboard_default (mig 117) exactly: single-row design
-- via CHECK (id = 1), all authenticated users may SELECT (the render path
-- reads it for every draft-name / filename), writes happen only under the
-- service-role connection behind the admin HTTP gate.
CREATE TABLE paliad.firm_name_compositions (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
compositions_json jsonb NOT NULL,
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE paliad.firm_name_compositions ENABLE ROW LEVEL SECURITY;
-- All authenticated users can SELECT — the name-render path needs to read the
-- firm default when composing any draft title / export filename. The HTTP
-- handler enforces admin-only on the PUT/DELETE paths; the service runs under
-- service-role so writes bypass RLS anyway. No INSERT/UPDATE policy means no
-- Supabase-JWT-authenticated client can write, which is the desired posture.
CREATE POLICY firm_name_compositions_read
ON paliad.firm_name_compositions FOR SELECT
USING (true);

View File

@@ -0,0 +1,40 @@
-- Revert 163_caption_wording_followup (t-paliad-361). Restores the A-S2
-- (post-mig-161 / mig-137) state for all three changes.
-- ----------------------------------------------------------------
-- Change 1 down — UPC appeal EN responding party back to 'Appellee'.
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types
SET role_reactive_label_en = 'Appellee'
WHERE code = 'upc.apl.unified'
AND role_reactive_label_en = 'Respondent';
-- ----------------------------------------------------------------
-- Change 2 down — drop the Streitpatent line from the upc-formal caption seed,
-- restoring the verbatim post-mig-161 parametric seed.
-- ----------------------------------------------------------------
UPDATE paliad.submission_bases AS b
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
SELECT jsonb_agg(
CASE WHEN elem->>'section_key' = 'caption'
THEN elem || jsonb_build_object(
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\n{{project.court}}')
ELSE elem END
ORDER BY ord)
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
WHERE b.slug = 'upc-formal' AND b.section_spec ? 'defaults';
-- ----------------------------------------------------------------
-- Change 3 down — clear the backfilled role labels (back to NULL, the
-- pre-163 state for these four proceedings).
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types
SET role_proactive_label_de = NULL,
role_reactive_label_de = NULL,
role_proactive_label_en = NULL,
role_reactive_label_en = NULL
WHERE code IN ('de.inf.olg', 'de.inf.bgh', 'de.null.bpatg', 'de.null.bgh');

View File

@@ -0,0 +1,108 @@
-- 163_caption_wording_followup — t-paliad-361, follow-up to t-paliad-358 A-S2.
--
-- m ruled on the 7 lexy-wording flags from A-S2 via AskUserQuestion
-- (2026-06-01 14:30). Most flags CONFIRMED the live wording; three changes
-- land here. All three are caption (Rubrum) wording and share this one
-- reversible migration.
--
-- Change 1 — UPC appeal responding party (EN): 'Appellee' → 'Respondent'.
-- m chose Respondent over Appellee. The only place 'Appellee' is stored is
-- the mig-137 role-label override on upc.apl.unified (id=160, retired by
-- mig 155 but kept as the canonical UPC-appeal role-label row). The caption
-- resolver's instance-derived EN fallback already says 'Respondent'
-- (submission_vars.go), so this fixes the wording at the data source rather
-- than downstream. DE side (Berufungsbeklagter) is left untouched per m.
--
-- Change 2 — restore the standalone 'Streitpatent' / 'Patent in suit' line in
-- the upc-formal Composer caption seed. A-S2 (mig 161) dropped it when it
-- unified the caption onto the {{caption.*}} keys. m wants the patent-in-suit
-- line back, but KEEPS the parametric 'In der Sache' heading (he did not
-- revert that). Only the upc-formal base's caption seed is touched.
--
-- Change 3 — backfill role-label overrides for the four DE appeal/nullity
-- proceedings that carry none (de.inf.olg, de.inf.bgh, de.null.bpatg,
-- de.null.bgh). Without an override these fall to the instance-derived path,
-- which is only correct when project.instance_level is set. The backfill
-- makes the designations right regardless of instance_level. Wording is
-- lexy-confirmed (statute-grounded: §§ 511, 542, 544 ZPO; §§ 81, 110 PatG),
-- bracketed-inclusive gender style to match the A-S2-confirmed convention.
--
-- ADDITIVE / data-only. No schema changes. Reversible (see .down.sql).
-- ----------------------------------------------------------------
-- Change 1 — UPC appeal EN responding party: 'Appellee' → 'Respondent'.
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types
SET role_reactive_label_en = 'Respondent'
WHERE code = 'upc.apl.unified'
AND role_reactive_label_en = 'Appellee';
-- ----------------------------------------------------------------
-- Change 2 — restore the Streitpatent line in the upc-formal caption seed.
-- Position-independent: rewrites only the section_key='caption' element of
-- section_spec->'defaults', preserving order (WITH ORDINALITY) and every
-- other field on the element (elem || patch). Keeps the parametric heading;
-- re-adds 'Streitpatent: {{project.patent_number_upc}}' (DE) /
-- 'Patent in suit: {{...}}' (EN) grouped with the case number, ahead of the
-- {{project.court}} line.
-- ----------------------------------------------------------------
UPDATE paliad.submission_bases AS b
SET section_spec = jsonb_set(
b.section_spec,
'{defaults}',
(
SELECT jsonb_agg(
CASE WHEN elem->>'section_key' = 'caption'
THEN elem || jsonb_build_object(
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}\n{{project.court}}',
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}\n{{project.court}}')
ELSE elem
END
ORDER BY ord
)
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)
)
)
WHERE b.slug = 'upc-formal'
AND b.section_spec ? 'defaults';
-- ----------------------------------------------------------------
-- Change 3 — backfill lexy-confirmed role labels for the four DE
-- appeal/nullity proceedings (mig-137 mechanism). Bracketed-inclusive
-- gender style; EN equivalents.
--
-- de.inf.olg Berufungskläger(in) / Berufungsbeklagte(r) // Appellant / Respondent (§ 511 ZPO Berufung)
-- de.inf.bgh Revisionskläger(in) / Revisionsbeklagte(r) // Appellant / Respondent (§§ 542/544 ZPO; Revision as default over NZB)
-- de.null.bpatg Nichtigkeitskläger(in) / Beklagte(r) (Patentinhaber(in)) // Nullity claimant / Defendant (patent proprietor) (§ 81 PatG)
-- de.null.bgh Berufungskläger(in) / Berufungsbeklagte(r) // Appellant / Respondent (§ 110 PatG, post-2009 Berufung)
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Berufungskläger(in)',
role_reactive_label_de = 'Berufungsbeklagte(r)',
role_proactive_label_en = 'Appellant',
role_reactive_label_en = 'Respondent'
WHERE code = 'de.inf.olg';
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Revisionskläger(in)',
role_reactive_label_de = 'Revisionsbeklagte(r)',
role_proactive_label_en = 'Appellant',
role_reactive_label_en = 'Respondent'
WHERE code = 'de.inf.bgh';
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Nichtigkeitskläger(in)',
role_reactive_label_de = 'Beklagte(r) (Patentinhaber(in))',
role_proactive_label_en = 'Nullity claimant',
role_reactive_label_en = 'Defendant (patent proprietor)'
WHERE code = 'de.null.bpatg';
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Berufungskläger(in)',
role_reactive_label_de = 'Berufungsbeklagte(r)',
role_proactive_label_en = 'Appellant',
role_reactive_label_en = 'Respondent'
WHERE code = 'de.null.bgh';

View File

@@ -105,6 +105,11 @@ type Services struct {
// DashboardLayoutService.defaultLayout(). Nil-safe — falls back to
// the code-resident FactoryDefaultLayout.
FirmDashboardDefault *services.FirmDashboardDefaultService
// FirmNameComposition is the firm-wide default name-composition map
// (Slice 5). Admin-only writes; the render path reads it as the firm
// tier below a per-user override. Nil-safe — falls back to the
// code-resident system default.
FirmNameComposition *services.FirmNameCompositionService
Projection *services.ProjectionService
Export *services.ExportService
@@ -211,6 +216,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
cardLayout: svc.CardLayout,
dashboardLayout: svc.DashboardLayout,
firmDashboardDefault: svc.FirmDashboardDefault,
firmNameComposition: svc.FirmNameComposition,
projection: svc.Projection,
export: svc.Export,
backup: svc.Backup,
@@ -502,6 +508,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PUT /api/me/dashboard-layout", handlePutDashboardLayout)
protected.HandleFunc("POST /api/me/dashboard-layout/reset", handleResetDashboardLayout)
protected.HandleFunc("GET /api/dashboard-widget-catalog", handleGetWidgetCatalog)
// t-paliad-356 Slice 4 — per-user name-composition overrides (settings UX).
// Token-template shorthand per wired artifact; parse/validate/preview run
// server-side so the nomen engine stays the single source of truth.
protected.HandleFunc("GET /api/me/name-compositions", handleGetNameCompositions)
protected.HandleFunc("POST /api/me/name-compositions/preview", handlePreviewNameComposition)
protected.HandleFunc("PUT /api/me/name-compositions/{artifact_id}", handlePutNameComposition)
protected.HandleFunc("DELETE /api/me/name-compositions/{artifact_id}", handleDeleteNameComposition)
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
@@ -783,6 +798,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/admin/firm-dashboard-default", adminGate(users, handleGetFirmDashboardDefault))
protected.HandleFunc("PUT /api/admin/firm-dashboard-default", adminGate(users, handlePutFirmDashboardDefault))
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
// t-paliad-356 Slice 5 — firm-wide default name compositions. Admin
// sets the house naming convention (the firm tier below per-user
// overrides). Mirrors the firm-dashboard-default admin endpoints.
protected.HandleFunc("GET /api/admin/name-compositions", adminGate(users, handleGetFirmNameCompositions))
protected.HandleFunc("PUT /api/admin/name-compositions/{artifact_id}", adminGate(users, handlePutFirmNameComposition))
protected.HandleFunc("DELETE /api/admin/name-compositions/{artifact_id}", adminGate(users, handleDeleteFirmNameComposition))
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
// t-paliad-315 (m/paliad#141) Composer Slice C — admin building blocks editor.

View File

@@ -0,0 +1,325 @@
package handlers
// HTTP handlers for per-user name-composition overrides (t-paliad-356 Slice 4,
// PRD §7). The /settings "Namensschemata" tab reads and writes a token-template
// shorthand per wired artifact; these endpoints parse + validate + render
// through the nomen engine (services), so the frontend never parses templates
// itself.
//
// GET /api/me/name-compositions → all artifact cards
// POST /api/me/name-compositions/preview → live preview + validation
// PUT /api/me/name-compositions/{artifact_id} → store an override
// DELETE /api/me/name-compositions/{artifact_id} → reset to system default
//
// Storage reuses the Slice-3 service surface
// (SubmissionDraftService.UserNameCompositions / SetUserNameCompositions): the
// PUT/DELETE handlers read the full spec, mutate one artifact key, and write it
// back. No new column, no migration.
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
// nameCompositionsService returns the wired SubmissionDraftService (the owner
// of the name_compositions read/write path) or writes a 503 and returns nil.
func nameCompositionsService(w http.ResponseWriter) *services.SubmissionDraftService {
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "name-composition service not configured"})
return nil
}
return dbSvc.submissionDraft
}
// firmNameCompositions loads the firm-wide default spec (empty when unset or
// the firm service is unwired). Read on every card render so the effective
// template reflects the firm tier.
func firmNameCompositions(r *http.Request) services.NameCompositionSpec {
if dbSvc.firmNameComposition == nil {
return services.NameCompositionSpec{}
}
spec, _, err := dbSvc.firmNameComposition.Get(r.Context())
if err != nil {
return services.NameCompositionSpec{}
}
return spec
}
// GET /api/me/name-compositions — the caller's artifact cards with the
// effective template (user override → firm default → system) per artifact,
// palette, and live previews. is_admin tells the client whether to reveal the
// firm-default admin controls.
func handleGetNameCompositions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
svc := nameCompositionsService(w)
if svc == nil {
return
}
overrides, err := svc.UserNameCompositions(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
firm := firmNameCompositions(r)
isAdmin := false
if dbSvc.users != nil {
isAdmin, _ = dbSvc.users.IsAdmin(r.Context(), uid)
}
writeJSON(w, http.StatusOK, map[string]any{
"artifacts": services.SettingsNameArtifacts(overrides, firm),
"is_admin": isAdmin,
})
}
// POST /api/me/name-compositions/preview — render a candidate template against
// the fixed sample without persisting it. Returns {ok:false, error} on a parse
// or validation failure so the UI can show the error inline and disable Save.
func handlePreviewNameComposition(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
var in struct {
ArtifactID string `json:"artifact_id"`
Template string `json:"template"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
full, empty, err := services.PreviewNameComposition(in.ArtifactID, in.Template)
if err != nil {
// A bad template is expected user input, not a server error — return
// 200 with ok:false so the live-preview fetch path stays simple.
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"preview_full": full,
"preview_empty": empty,
})
}
// PUT /api/me/name-compositions/{artifact_id} — validate the body template and
// store it as the caller's override for that artifact. Returns the refreshed
// card.
func handlePutNameComposition(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
svc := nameCompositionsService(w)
if svc == nil {
return
}
artifactID := r.PathValue("artifact_id")
var in struct {
Template string `json:"template"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
comp, err := services.ParseNameTemplate(artifactID, in.Template)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
spec, err := svc.UserNameCompositions(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if spec == nil {
spec = services.NameCompositionSpec{}
}
spec[artifactID] = comp
if err := svc.SetUserNameCompositions(r.Context(), uid, spec); err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
view, _ := services.SettingsNameArtifact(artifactID, spec, firmNameCompositions(r))
writeJSON(w, http.StatusOK, view)
}
// DELETE /api/me/name-compositions/{artifact_id} — drop the caller's override
// for that artifact; the artifact reverts to the system default. Returns the
// refreshed card. Deleting an absent override is a no-op (still 200).
func handleDeleteNameComposition(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
svc := nameCompositionsService(w)
if svc == nil {
return
}
artifactID := r.PathValue("artifact_id")
if _, ok := services.NameArtifact(artifactID); !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown name artifact"})
return
}
spec, err := svc.UserNameCompositions(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if _, present := spec[artifactID]; present {
delete(spec, artifactID)
if err := svc.SetUserNameCompositions(r.Context(), uid, spec); err != nil {
writeServiceError(w, err)
return
}
}
view, _ := services.SettingsNameArtifact(artifactID, spec, firmNameCompositions(r))
writeJSON(w, http.StatusOK, view)
}
// ---------------------------------------------------------------------------
// Firm-wide default (admin) — t-paliad-356 Slice 5.
//
// Mirrors the firm_dashboard_default admin endpoints. All three sit behind the
// adminGate in handlers.go. The firm default is the tier below a per-user
// override and above the system default; setting/clearing it changes the
// effective name for every user who has no personal override.
//
// GET /api/admin/name-compositions → firm-tier cards
// PUT /api/admin/name-compositions/{artifact_id} → set firm default
// DELETE /api/admin/name-compositions/{artifact_id} → clear firm default
// ---------------------------------------------------------------------------
// firmAdminService returns the wired FirmNameCompositionService or writes 503.
func firmAdminService(w http.ResponseWriter) *services.FirmNameCompositionService {
if dbSvc.firmNameComposition == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-name-composition service not configured"})
return nil
}
return dbSvc.firmNameComposition
}
// GET /api/admin/name-compositions — the firm-tier cards. Each card's
// firm_is_set/firm_template reflects the firm default; the effective template
// is computed with no user override (the admin views the firm tier, not their
// personal one).
func handleGetFirmNameCompositions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if firmAdminService(w) == nil {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"artifacts": services.SettingsNameArtifacts(nil, firmNameCompositions(r)),
})
}
// PUT /api/admin/name-compositions/{artifact_id} — set the firm default for an
// artifact from the body template. Returns the refreshed firm-tier card.
func handlePutFirmNameComposition(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
svc := firmAdminService(w)
if svc == nil {
return
}
artifactID := r.PathValue("artifact_id")
var in struct {
Template string `json:"template"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
comp, err := services.ParseNameTemplate(artifactID, in.Template)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
spec, _, err := svc.Get(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
if spec == nil {
spec = services.NameCompositionSpec{}
}
spec[artifactID] = comp
if _, err := svc.Set(r.Context(), spec, uid); err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
view, _ := services.SettingsNameArtifact(artifactID, nil, spec)
writeJSON(w, http.StatusOK, view)
}
// DELETE /api/admin/name-compositions/{artifact_id} — drop the firm default
// for an artifact; it reverts to the system default for everyone without a
// personal override. Returns the refreshed firm-tier card. No-op when absent.
func handleDeleteFirmNameComposition(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
svc := firmAdminService(w)
if svc == nil {
return
}
artifactID := r.PathValue("artifact_id")
if _, ok := services.NameArtifact(artifactID); !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown name artifact"})
return
}
spec, _, err := svc.Get(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
if _, present := spec[artifactID]; present {
delete(spec, artifactID)
if _, err := svc.Set(r.Context(), spec, uid); err != nil {
writeServiceError(w, err)
return
}
}
view, _ := services.SettingsNameArtifact(artifactID, nil, spec)
writeJSON(w, http.StatusOK, view)
}

View File

@@ -18,49 +18,53 @@ import (
// dbServices bundles the Phase B services so handlers can stay thin.
// Nil if DATABASE_URL was unset at startup.
type dbServices struct {
projects *services.ProjectService
team *services.TeamService
partnerUnit *services.PartnerUnitService
parties *services.PartyService
deadline *services.DeadlineService
appointment *services.AppointmentService
caldav *services.CalDAVService
caldavBindings *services.CalendarBindingService
rules *services.DeadlineRuleService
calc *services.DeadlineCalculator
users *services.UserService
fristenrechner *services.FristenrechnerService
eventDeadline *services.EventDeadlineService
eventTrigger *services.EventTriggerService
ruleEditor *services.RuleEditorService
deadlineSearch *services.DeadlineSearchService
eventCategory *services.EventCategoryService
eventType *services.EventTypeService
dashboard *services.DashboardService
note *services.NoteService
checklistInst *services.ChecklistInstanceService
checklistCatalog *services.ChecklistCatalogService
checklistTemplate *services.ChecklistTemplateService
checklistShare *services.ChecklistShareService
checklistPromotion *services.ChecklistPromotionService
mail *services.MailService
invite *services.InviteService
agenda *services.AgendaService
audit *services.AuditService
emailTemplate *services.EmailTemplateService
link *services.LinkService
event *services.EventService
courts *services.CourtService
approval *services.ApprovalService
derivation *services.DerivationService
userView *services.UserViewService
broadcast *services.BroadcastService
pin *services.PinService
cardLayout *services.CardLayoutService
dashboardLayout *services.DashboardLayoutService
projects *services.ProjectService
team *services.TeamService
partnerUnit *services.PartnerUnitService
parties *services.PartyService
deadline *services.DeadlineService
appointment *services.AppointmentService
caldav *services.CalDAVService
caldavBindings *services.CalendarBindingService
rules *services.DeadlineRuleService
calc *services.DeadlineCalculator
users *services.UserService
fristenrechner *services.FristenrechnerService
eventDeadline *services.EventDeadlineService
eventTrigger *services.EventTriggerService
ruleEditor *services.RuleEditorService
deadlineSearch *services.DeadlineSearchService
eventCategory *services.EventCategoryService
eventType *services.EventTypeService
dashboard *services.DashboardService
note *services.NoteService
checklistInst *services.ChecklistInstanceService
checklistCatalog *services.ChecklistCatalogService
checklistTemplate *services.ChecklistTemplateService
checklistShare *services.ChecklistShareService
checklistPromotion *services.ChecklistPromotionService
mail *services.MailService
invite *services.InviteService
agenda *services.AgendaService
audit *services.AuditService
emailTemplate *services.EmailTemplateService
link *services.LinkService
event *services.EventService
courts *services.CourtService
approval *services.ApprovalService
derivation *services.DerivationService
userView *services.UserViewService
broadcast *services.BroadcastService
pin *services.PinService
cardLayout *services.CardLayoutService
dashboardLayout *services.DashboardLayoutService
firmDashboardDefault *services.FirmDashboardDefaultService
projection *services.ProjectionService
export *services.ExportService
// t-paliad-356 Slice 5 — firm-wide default name compositions (the firm
// tier of the name-composition precedence chain). Nil-safe: the render
// path falls through to user override / system default.
firmNameComposition *services.FirmNameCompositionService
projection *services.ProjectionService
export *services.ExportService
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
@@ -406,12 +410,13 @@ func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
// render the full hierarchy in one round-trip. Visibility-scoped.
//
// Query parameters (all optional, additive):
// ?scope=all|mine|pinned — chip-driven scope (default "all")
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
// ?type=client,litigation,patent,case,project,other — type whitelist
// ?has_open_deadlines=true|false — narrow by deadline activity
// ?q=<term> — search title / reference / clientmatter
// ?subtree_counts=true|false — populate *_subtree fields (default true)
//
// ?scope=all|mine|pinned — chip-driven scope (default "all")
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
// ?type=client,litigation,patent,case,project,other — type whitelist
// ?has_open_deadlines=true|false — narrow by deadline activity
// ?q=<term> — search title / reference / clientmatter
// ?subtree_counts=true|false — populate *_subtree fields (default true)
//
// Zero query string preserves the legacy behaviour for back-compat (existing
// callers that just want every visible project).

View File

@@ -30,6 +30,8 @@ package handlers
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -45,6 +47,7 @@ import (
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
)
// submissionDraftPreviewTimeout caps a single preview round-trip.
@@ -180,6 +183,11 @@ type submissionDraftPatchInput struct {
// base_id: absent = no change, uuid = pin, null = clear.
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
TemplateVersionIDSet bool `json:"-"`
// FilenameKeyword overrides the leading keyword of the exported
// document name (t-paliad-354). Absent = no change; "" = clear back
// to the auto-derived rule name; "x" = set. Persisted in
// composer_meta.filename_keyword.
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
@@ -446,6 +454,7 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
Variables: input.Variables,
SelectedParties: input.SelectedParties,
Language: input.Language,
FilenameKeyword: input.FilenameKeyword,
}
if input.BaseIDSet {
patch.BaseID = &input.BaseID
@@ -592,7 +601,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
filename := submissionDownloadFilename(ctx, uid, resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
// Audit + provenance updates are best-effort on a background
// context so the download still succeeds if the DB races.
@@ -939,6 +948,10 @@ type globalDraftPatchInput struct {
// (t-paliad-349 slice 7), same present/absent contract as base_id.
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
templateVersionIDProvided bool
// FilenameKeyword overrides the leading keyword of the exported
// document name (t-paliad-354). Absent = no change; "" = clear; "x" =
// set. Persisted in composer_meta.filename_keyword.
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
@@ -950,6 +963,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
BaseID *uuid.UUID `json:"base_id,omitempty"`
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
@@ -962,6 +976,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
g.SelectedParties = a.SelectedParties
g.BaseID = a.BaseID
g.TemplateVersionID = a.TemplateVersionID
g.FilenameKeyword = a.FilenameKeyword
// Detect whether "project_id" / "base_id" / "template_version_id" were
// present in the JSON object.
var raw map[string]json.RawMessage
@@ -1006,6 +1021,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
Variables: in.Variables,
SelectedParties: in.SelectedParties,
Language: in.Language,
FilenameKeyword: in.FilenameKeyword,
}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
@@ -1141,7 +1157,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
filename := submissionDownloadFilename(ctx, uid, resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()
@@ -1301,12 +1317,17 @@ const (
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierFallback submissionTemplateTier = "fallback" // embedded merge-safe basic-Rubrum skeleton
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
)
// resolveSubmissionTemplate returns the .docx bytes for the given
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
// (submission_code, language). This is the *merge-path* resolver: every
// caller feeds the result into SubmissionRenderer (merge.go), which fills
// {{key}} tokens. The result must therefore be merge-safe — it must carry
// real {{key}} placeholders. Merges t-paliad-275 (firm-skeleton tier),
// t-paliad-276 (language-selector + EN skeleton tier), t-paliad-358 A-S1
// (merge-safe guard + embedded fallback). Lookup order:
//
// 1. per-firm per-(code, lang) template — most specific. e.g.
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
@@ -1315,12 +1336,22 @@ const (
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
// HL paragraph + character styles + letterhead, full placeholder
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
// Backstop when the firm skeleton is unreachable.
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Last-ditch when every skeleton tier is unreachable.
// 5. universal _skeleton.docx.
// 6. embedded merge-safe fallback — a lang-aware basic-Rubrum skeleton
// built in-process (docx.BuildFallbackSkeleton). Always available, no
// Gitea round-trip. This is what makes one-click /generate produce a
// real merged document for ANY submission_code.
// 7. HL Patents Style .dotm — placeholder-free letterhead, the pre-358
// last-ditch. Reached only if the in-process build (6) fails.
//
// Tiers 3/4/5 are GUARDED by docx.HasMergePlaceholders: the firm and
// universal skeletons were repurposed into anchors-only Composer bases
// (t-paliad-313 Slice B) — their bodies hold only {{#section:KEY}} markers
// the merge engine can't fill, so feeding them to merge.go produced literal
// "{{#section:…}}" junk (kepler audit §1 Path 3 / §2). The guard skips any
// fetched skeleton that lacks real placeholders, so today they fall through
// to the embedded fallback (6); should a merge-safe firm-skeleton (with
// letterhead) be restored later it is preferred again automatically.
//
// The returned SHA pins the audit row's template provenance. The tier
// tells the editor whether the result language-matches the request so
@@ -1344,25 +1375,30 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string)
// 3. language-matched skeleton — only meaningful for EN drafts; DE
// drafts fall through to the firm/universal DE skeletons below.
if lang == "en" {
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched && docx.HasMergePlaceholders(data) {
return data, sha, tplTierSkeletonLang, nil
}
}
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
// this is a first-class match; for EN drafts it counts as a
// language fallback (handled by languageFallback()).
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
// 4. firm-formatted skeleton — used only if it is merge-safe (carries
// real {{key}} placeholders, not anchors-only Composer markers).
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil && docx.HasMergePlaceholders(data) {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
}
// 5. universal plain DE skeleton.
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
// 5. universal plain DE skeleton — same merge-safe guard.
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil && docx.HasMergePlaceholders(data) {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
}
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
// 6. embedded merge-safe fallback — lang-aware basic Rubrum, always
// available. Supersedes the placeholder-free .dotm so /generate on
// any code yields a real merged document (basic Rubrum), never the
// {{#section:…}} junk an anchors-only base produced (t-paliad-358 A-S1).
if data, err := docx.BuildFallbackSkeleton(lang); err == nil {
sum := sha256.Sum256(data)
return data, hex.EncodeToString(sum[:]), tplTierFallback, nil
} else {
log.Printf("submission_drafts: embedded fallback skeleton build failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
}
// 7. HL Patents Style letterhead (no placeholders, last-ditch).
bytes, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
return nil, "", "", err
@@ -1373,16 +1409,19 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string)
// languageFallback reports whether the resolved template tier failed
// to match the requested draft language. For an EN draft, anything
// other than per_code_lang or skeleton_lang is a fallback (per_code is
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
// draft, only `letterhead` counts as a fallback — the DE skeleton and
// per-code template are both first-class DE outputs. t-paliad-276.
// other than per_code_lang, skeleton_lang or the lang-aware embedded
// fallback is a fallback (per_code is the legacy DE-baked template,
// skeleton is the DE skeleton). For a DE draft, only `letterhead` counts
// as a fallback — the DE skeleton, per-code template, and the embedded
// fallback are all first-class DE outputs. t-paliad-276 / t-paliad-358 A-S1.
func languageFallback(lang string, tier submissionTemplateTier) bool {
if tier == tplTierLetterhead {
return true
}
if strings.EqualFold(lang, "en") {
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
// tplTierFallback is built per-language (English labels for EN), so
// it is NOT a language fallback.
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang && tier != tplTierFallback
}
return false
}

View File

@@ -0,0 +1,157 @@
package handlers
// Regression tests for the generated-document download name
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
// The date segment is environment-dependent (Europe/Berlin "today"),
// so the assertions pin the keyword + bracketed case-number frame and
// the .docx suffix rather than the literal date.
import (
"strings"
"testing"
"time"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
func strptr(s string) *string { return &s }
func todayBerlin() string {
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
}
return day.Format("2006-01-02")
}
func TestSubmissionFileName(t *testing.T) {
t.Parallel()
rule := &models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of defence"}
date := todayBerlin()
cases := []struct {
name string
rule *models.DeadlineRule
project *models.Project
lang string
keyword string
want string
}{
{
name: "full data — rule name + case number",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "missing case number falls back to placeholder",
rule: rule,
project: &models.Project{CaseNumber: nil},
lang: "de",
want: date + " Klageerwiderung (Az. folgt).docx",
},
{
name: "user override keyword wins over rule name",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
keyword: "Replik Hauptantrag",
want: date + " Replik Hauptantrag (UPC_CFI_123_2026).docx",
},
{
name: "EN lang uses NameEN when no override",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "en",
want: date + " Statement of defence (UPC_CFI_123_2026).docx",
},
{
name: "case number containing slash is sanitised inside brackets",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123/2026")},
lang: "de",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "blank override falls back to rule name",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
keyword: " ",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "empty rule name + no override falls back to submission",
rule: &models.DeadlineRule{Name: "", NameEN: ""},
project: &models.Project{CaseNumber: nil},
lang: "de",
want: date + " submission (Az. folgt).docx",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := services.RenderSubmissionFilename(tc.rule, tc.project, tc.lang, tc.keyword)
if got != tc.want {
t.Errorf("RenderSubmissionFilename() = %q, want %q", got, tc.want)
}
if !strings.HasSuffix(got, ".docx") {
t.Errorf("filename %q missing .docx suffix", got)
}
})
}
}
func TestSubmissionFilenameKeyword(t *testing.T) {
t.Parallel()
cases := []struct {
name string
draft *services.SubmissionDraft
want string
}{
{"nil draft", nil, ""},
{"nil meta", &services.SubmissionDraft{}, ""},
{
"key absent",
&services.SubmissionDraft{ComposerMeta: map[string]any{"other": "x"}},
"",
},
{
"legacy filename_keyword reads back-compat",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": "Replik"}},
"Replik",
},
{
"new name_overrides.keyword shape",
&services.SubmissionDraft{ComposerMeta: map[string]any{"name_overrides": map[string]any{"keyword": "Duplik"}}},
"Duplik",
},
{
"name_overrides.keyword wins over legacy filename_keyword",
&services.SubmissionDraft{ComposerMeta: map[string]any{
"name_overrides": map[string]any{"keyword": "Duplik"},
"filename_keyword": "Replik",
}},
"Duplik",
},
{
"key set with surrounding whitespace is trimmed",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": " Replik "}},
"Replik",
},
{
"non-string value ignored",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": 42}},
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := submissionFilenameKeyword(tc.draft); got != tc.want {
t.Errorf("submissionFilenameKeyword() = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -21,6 +21,7 @@ func TestLanguageFallback(t *testing.T) {
{"de_per_code", "de", tplTierPerCode, false},
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
{"de_skeleton", "de", tplTierSkeleton, false},
{"de_fallback", "de", tplTierFallback, false},
{"de_letterhead", "de", tplTierLetterhead, true},
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
@@ -30,6 +31,9 @@ func TestLanguageFallback(t *testing.T) {
{"en_per_code", "en", tplTierPerCode, true},
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
{"en_skeleton", "en", tplTierSkeleton, true},
// The embedded fallback is built per-language (EN labels for EN),
// so it is NOT a language fallback (t-paliad-358 A-S1).
{"en_fallback", "en", tplTierFallback, false},
{"en_letterhead", "en", tplTierLetterhead, true},
}
for _, c := range cases {

View File

@@ -336,7 +336,9 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
// One-click /generate has no saved draft row → no per-document keyword
// override, but the user's composition override still applies.
filename := submissionDownloadFilename(ctx, uid, resolved.Rule, resolved.Project, resolved.Lang, "")
// Audit write is best-effort with a background context so the
// download still succeeds if the DB races. Audit failure here only
@@ -355,34 +357,29 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
}
}
// submissionFileName produces the user-facing download name per
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
// Empty case_number drops the segment entirely (no fallback hash —
// the lawyer can rename if the project lacks an Aktenzeichen).
// Umlauts in the rule name are ASCII-folded by SanitiseSubmissionFileName
// so the file lands cleanly on legacy SMB shares.
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang string) string {
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
// submissionDownloadFilename produces the user-facing download name
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx", rendered
// through the submission_docx_filename artifact and honouring the user's
// per-user composition override (Slice 3). A failed override load is
// non-fatal — it falls back to the system default. keyword is the
// per-document value override (name_overrides.keyword).
func submissionDownloadFilename(ctx context.Context, uid uuid.UUID, rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
var overrides, firm services.NameCompositionSpec
if dbSvc.submissionDraft != nil {
overrides, _ = dbSvc.submissionDraft.UserNameCompositions(ctx, uid)
}
ruleName := strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
ruleName = strings.TrimSpace(rule.NameEN)
if dbSvc.firmNameComposition != nil {
firm, _, _ = dbSvc.firmNameComposition.Get(ctx)
}
if ruleName == "" {
ruleName = "submission"
}
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
caseNo := ""
if project != nil && project.CaseNumber != nil {
caseNo = strings.TrimSpace(*project.CaseNumber)
}
if caseNo != "" {
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
}
parts = append(parts, day.Format("2006-01-02"))
return strings.Join(parts, "-") + ".docx"
return services.RenderSubmissionFilenameFor(overrides, firm, rule, project, lang, keyword)
}
// submissionFilenameKeyword delegates to services.SubmissionFilenameKeyword
// (the back-compat read of the per-document keyword override). Kept as a
// package-local alias so the existing call-sites and unit test read
// unchanged.
func submissionFilenameKeyword(d *services.SubmissionDraft) string {
return services.SubmissionFilenameKeyword(d)
}
// writeSubmissionAuditRow files one row in paliad.system_audit_log per

View File

@@ -0,0 +1,122 @@
package services
// Live-DB tests for FirmNameCompositionService (t-paliad-356 Slice 5) — gated
// on TEST_DATABASE_URL like the rest of the integration suite. Covers the
// round-trip (Set → Get → Clear → Get), the Validate rejection on write, and
// that a stored firm default flows through the render path below a per-user
// override and above the system default. Pure-function precedence is pinned in
// name_template_test.go (TestResolveComposition_Precedence).
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
func openTestDBForFirmNameComp(t *testing.T) *sqlx.DB {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping firm-name-composition live test")
}
// Apply embedded migrations (incl. 162 which creates the table) so the
// test is self-sufficient regardless of run order — mirrors the Slice-3
// live test (TestNameCompositions_Precedence_Live).
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
conn, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
return conn
}
func TestFirmNameComposition_RoundTripAndRender(t *testing.T) {
db := openTestDBForFirmNameComp(t)
defer db.Close()
svc := NewFirmNameCompositionService(db)
ctx := context.Background()
// Start clean — a prior test may have left a row.
if err := svc.Clear(ctx); err != nil {
t.Fatalf("pre-clear: %v", err)
}
if _, ok, err := svc.Get(ctx); err != nil || ok {
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
}
// A firm default that drops the case-number segment from the filename:
// "<date> <keyword>".
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
}}
spec := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
if _, err := svc.Set(ctx, spec, uuid.Nil); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok, err := svc.Get(ctx)
if err != nil || !ok {
t.Fatalf("Get after Set: ok=%v err=%v; want true/nil", ok, err)
}
if c := got[ArtifactSubmissionDocxFilename]; c.Template() != "{date} {keyword}" {
t.Errorf("stored firm composition = %q, want '{date} {keyword}'", c.Template())
}
// Render path: with the firm default and no user override, the filename
// loses the "(Az. folgt)" case segment.
rule := &models.DeadlineRule{Name: "Klageerwiderung"}
proj := &models.Project{}
firm, _, _ := svc.Get(ctx)
if name := RenderSubmissionFilenameFor(nil, firm, rule, proj, "de", ""); name != nomenDateBerlin(time.Now())+" Klageerwiderung.docx" {
t.Errorf("firm-tier filename = %q, want '<date> Klageerwiderung.docx'", name)
}
// A per-user override still wins over the firm default.
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
}}
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
if name := RenderSubmissionFilenameFor(user, firm, rule, proj, "de", ""); name != "Klageerwiderung.docx" {
t.Errorf("user override should beat firm: got %q, want 'Klageerwiderung.docx'", name)
}
// Clear is idempotent and reverts the render to the system default.
if err := svc.Clear(ctx); err != nil {
t.Fatalf("clear: %v", err)
}
if err := svc.Clear(ctx); err != nil {
t.Fatalf("second clear: %v", err)
}
if _, ok, err := svc.Get(ctx); err != nil || ok {
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
}
}
func TestFirmNameComposition_RejectsInvalid(t *testing.T) {
db := openTestDBForFirmNameComp(t)
defer db.Close()
svc := NewFirmNameCompositionService(db)
ctx := context.Background()
// A composition referencing a variable the artifact catalog does not know
// must be rejected on write (Validate), never persisted.
bad := NameCompositionSpec{ArtifactSubmissionDocxFilename: nomen.Composition{
Version: nomen.Version,
Segments: []nomen.Segment{{Var: "client", Missing: nomen.Omit()}}, // client not in filename catalog
}}
if _, err := svc.Set(ctx, bad, uuid.Nil); err == nil {
t.Fatal("Set with unknown variable: err=nil; want ErrInvalidInput")
}
}

View File

@@ -0,0 +1,61 @@
package services
// FirmNameCompositionService manages paliad.firm_name_compositions — the
// optional firm-wide default name-composition map that the render path prefers
// over the code-resident system default (but below a per-user override) when
// composing draft titles and export filenames.
//
// PRD §3.1/§3.2 of docs/plans/prd-filename-generator-2026-06-01.md (Slice 5).
// Mirrors FirmDashboardDefaultService exactly: a single optional row (id=1).
// Get returns (spec, true, nil) when set, (empty, false, nil) when never set.
// Set validates + upserts; Clear deletes (so resolution reverts to system).
//
// The HTTP layer (handlers/name_compositions.go admin endpoints) enforces
// admin-only via auth.RequireAdmin. The service takes no admin parameter — the
// only writer is the admin handler; the read path is used by the render path
// on every name composition.
import (
"context"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// FirmNameCompositionService manages paliad.firm_name_compositions.
type FirmNameCompositionService struct {
db *sqlx.DB
}
// NewFirmNameCompositionService wires the service.
func NewFirmNameCompositionService(db *sqlx.DB) *FirmNameCompositionService {
return &FirmNameCompositionService{db: db}
}
// Get returns (spec, true, nil) when a firm default is set, (empty, false,
// nil) otherwise. The spec is SanitizeForRead'd so callers always get a
// version-coherent map. "Set" means the singleton row exists AND carries at
// least one artifact override — an empty stored map reads as "not set" so the
// admin UI and the render fall-through treat it the same as absent.
func (s *FirmNameCompositionService) Get(ctx context.Context) (NameCompositionSpec, bool, error) {
spec, err := getFirmNameCompositions(ctx, s.db)
if err != nil {
return nil, false, err
}
return spec, len(spec) > 0, nil
}
// Set validates and persists the firm-wide default. updatedBy is recorded for
// audit; uuid.Nil clears the column.
func (s *FirmNameCompositionService) Set(ctx context.Context, spec NameCompositionSpec, updatedBy uuid.UUID) (NameCompositionSpec, error) {
if err := setFirmNameCompositions(ctx, s.db, spec, updatedBy); err != nil {
return nil, err
}
return spec, nil
}
// Clear deletes the firm default so resolution reverts to the system default.
// Idempotent.
func (s *FirmNameCompositionService) Clear(ctx context.Context) error {
return clearFirmNameCompositions(ctx, s.db)
}

View File

@@ -0,0 +1,167 @@
package services
// Live-DB gate for the system→user name-composition precedence
// (t-paliad-356 Slice 3, PRD §3). Skipped without TEST_DATABASE_URL.
//
// Covers: (a) users.name_compositions round-trip via Set/Get + write-time
// Validate rejection; (b) a user override beating the system default for both
// the draft-title artifact (through Create) and the .docx-filename artifact
// (through RenderSubmissionFilenameFor); (c) the legacy
// composer_meta.filename_keyword reading cleanly as name_overrides.keyword.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
func TestNameCompositions_Precedence_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 := "nc-" + 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, 'NameComp 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()
// (a) Round-trip + Validate ------------------------------------------
validSpec := NameCompositionSpec{
ArtifactSubmissionDocxFilename: {
Version: nomen.Version,
Segments: []nomen.Segment{
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
},
},
}
if err := drafts.SetUserNameCompositions(ctx, userID, validSpec); err != nil {
t.Fatalf("set valid spec: %v", err)
}
got, err := drafts.UserNameCompositions(ctx, userID)
if err != nil {
t.Fatalf("get spec: %v", err)
}
if comp, ok := got[ArtifactSubmissionDocxFilename]; !ok || len(comp.Segments) != 1 || comp.Segments[0].Var != "keyword" {
t.Fatalf("round-trip mismatch: %+v", got)
}
// An override referencing a variable outside the artifact catalog is
// rejected on write.
badSpec := NameCompositionSpec{
ArtifactSubmissionDocxFilename: {
Version: nomen.Version,
Segments: []nomen.Segment{{Var: "opponent"}}, // not a filename variable
},
}
if err := drafts.SetUserNameCompositions(ctx, userID, badSpec); err == nil {
t.Fatalf("invalid spec was accepted on write")
}
// (b1) Title override beats system default (through Create) ----------
titleOverride := NameCompositionSpec{
ArtifactSubmissionDraftTitle: {
Version: nomen.Version,
Segments: []nomen.Segment{
{Var: "keyword", Sep: " ", Missing: nomen.Omit()},
{Var: "date", Sep: "", Missing: nomen.Omit()},
},
},
}
if err := drafts.SetUserNameCompositions(ctx, userID, titleOverride); err != nil {
t.Fatalf("set title override: %v", err)
}
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("create with title override: %v", err)
}
// System default would be "<date> Klageerwiderung"; the override flips
// the order to "<keyword> <date>".
if want := "Klageerwiderung " + date; d.Name != want {
t.Errorf("title override not applied: name = %q, want %q", d.Name, want)
}
// (b2) Filename override beats system default ------------------------
fnOverride := NameCompositionSpec{
ArtifactSubmissionDocxFilename: {
Version: nomen.Version,
Segments: []nomen.Segment{
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
},
},
}
if err := drafts.SetUserNameCompositions(ctx, userID, fnOverride); err != nil {
t.Fatalf("set filename override: %v", err)
}
overrides, err := drafts.UserNameCompositions(ctx, userID)
if err != nil {
t.Fatalf("load overrides: %v", err)
}
rule := &models.DeadlineRule{Name: "Klageerwiderung"}
proj := &models.Project{CaseNumber: strPtr("UPC_CFI_1_2026")}
// System default would be "<date> Klageerwiderung (UPC_CFI_1_2026).docx";
// the override reduces it to just the keyword.
if got := RenderSubmissionFilenameFor(overrides, nil, rule, proj, "de", ""); got != "Klageerwiderung.docx" {
t.Errorf("filename override not applied: %q, want %q", got, "Klageerwiderung.docx")
}
// And the system default (nil overrides) is unchanged.
if got := RenderSubmissionFilename(rule, proj, "de", ""); got != date+" Klageerwiderung (UPC_CFI_1_2026).docx" {
t.Errorf("system default filename drifted: %q", got)
}
// (c) Legacy filename_keyword reads back-compat ----------------------
dLegacy, err := drafts.Create(ctx, userID, nil, "de.inf.lg.duplik", "de")
if err != nil {
t.Fatalf("create legacy draft: %v", err)
}
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.submission_drafts SET composer_meta = '{"filename_keyword":"LegacyKW"}'::jsonb WHERE id = $1`,
dLegacy.ID); err != nil {
t.Fatalf("seed legacy composer_meta: %v", err)
}
reloaded, err := drafts.Get(ctx, userID, dLegacy.ID)
if err != nil {
t.Fatalf("get legacy draft: %v", err)
}
if kw := SubmissionFilenameKeyword(reloaded); kw != "LegacyKW" {
t.Errorf("legacy filename_keyword back-compat read = %q, want %q", kw, "LegacyKW")
}
}

View File

@@ -0,0 +1,225 @@
package services
// Per-user name-composition overrides (t-paliad-356 Slice 3, PRD §3).
//
// users.name_compositions is a JSONB map { artifact_id: Composition } that
// overrides the code-resident system default for an artifact. The validation
// surface mirrors DashboardLayoutSpec exactly: Validate on write (known
// artifact, segments reference known variables, version + segment cap),
// SanitizeForRead on read (drop unknown artifacts and segments referencing
// variables the catalog no longer has, clamp version). Resolution prefers a
// valid user override over the system default; the firm slot (PRD §3.1) is
// reserved for Slice 5 and not wired yet, so the system default is the
// fallback directly below the user level in Slice 3.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
// NameCompositionSpec is the parsed users.name_compositions jsonb: a map of
// artifact_id -> overriding Composition. It marshals as the bare map.
type NameCompositionSpec map[string]nomen.Composition
// Validate enforces the write-time invariants: every key is a known artifact
// and every composition is valid against that artifact's variable catalog.
func (s NameCompositionSpec) Validate() error {
for id, comp := range s {
art, ok := NameArtifact(id)
if !ok {
return fmt.Errorf("%w: unknown name artifact %q", ErrInvalidInput, id)
}
if err := comp.Validate(art.Catalog); err != nil {
return fmt.Errorf("%w: artifact %q: %v", ErrInvalidInput, id, err)
}
}
return nil
}
// SanitizeForRead applies the forgiving read-path rules: drop overrides for
// artifacts that no longer exist, and within each surviving override drop
// segments referencing unknown variables and clamp the version. Mutates the
// receiver; returns true if anything changed so the caller can persist the
// cleaned value.
func (s NameCompositionSpec) SanitizeForRead() bool {
changed := false
for id, comp := range s {
art, ok := NameArtifact(id)
if !ok {
delete(s, id)
changed = true
continue
}
if comp.SanitizeForRead(art.Catalog) {
changed = true
}
s[id] = comp
}
return changed
}
// ParseNameCompositionSpec decodes and validates a name_compositions payload.
// Used on writes (API/test). An empty/NULL payload yields an empty spec.
func ParseNameCompositionSpec(b []byte) (NameCompositionSpec, error) {
spec := NameCompositionSpec{}
if len(b) > 0 {
if err := json.Unmarshal(b, &spec); err != nil {
return nil, fmt.Errorf("%w: name_compositions JSON decode: %v", ErrInvalidInput, err)
}
}
if err := spec.Validate(); err != nil {
return nil, err
}
return spec, nil
}
// resolveComposition returns the first valid override for an artifact from the
// supplied specs (highest precedence first), else the artifact's system
// default. The precedence chain is per-document → user → firm → system (PRD
// §3.1); the per-document layer is a variable-value override resolved in the
// VarResolver, not here, so the specs passed are [user, firm] in that order
// (Slice 5). A stored override is sanitised then validated; anything that
// fails validation is skipped so a broken stored value can never render — the
// next valid tier (or the system default) wins.
func resolveComposition(artifactID string, specs ...NameCompositionSpec) nomen.Composition {
art := nameArtifacts[artifactID]
for _, spec := range specs {
if spec == nil {
continue
}
comp, ok := spec[artifactID]
if !ok {
continue
}
comp.SanitizeForRead(art.Catalog)
if len(comp.Segments) > 0 && comp.Validate(art.Catalog) == nil {
return comp
}
}
return art.SystemDefault
}
// getUserNameCompositions loads a user's name_compositions, sanitised for
// read. A missing user or NULL column yields an empty (nil-safe) spec — the
// caller then renders with system defaults. Shared by the title create path
// and the filename download path so the SELECT lives in one place.
func getUserNameCompositions(ctx context.Context, db *sqlx.DB, userID uuid.UUID) (NameCompositionSpec, error) {
var raw []byte
err := db.GetContext(ctx, &raw,
`SELECT name_compositions FROM paliad.users WHERE id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return NameCompositionSpec{}, nil
}
if err != nil {
return nil, fmt.Errorf("load name_compositions: %w", err)
}
spec := NameCompositionSpec{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &spec); err != nil {
// A corrupt stored value must not break draft creation — treat
// it as "no overrides" and let the next write replace it.
return NameCompositionSpec{}, nil
}
}
spec.SanitizeForRead()
return spec, nil
}
// getFirmNameCompositions loads the firm-wide default name_compositions
// (Slice 5), sanitised for read. A missing singleton row yields an empty
// (nil-safe) spec — the caller then renders with the user override or the
// system default. Shared by the render path and the admin service so the
// SELECT lives in one place; mirrors getUserNameCompositions.
func getFirmNameCompositions(ctx context.Context, db *sqlx.DB) (NameCompositionSpec, error) {
var raw []byte
err := db.GetContext(ctx, &raw,
`SELECT compositions_json FROM paliad.firm_name_compositions WHERE id = 1`)
if errors.Is(err, sql.ErrNoRows) {
return NameCompositionSpec{}, nil
}
if err != nil {
return nil, fmt.Errorf("load firm_name_compositions: %w", err)
}
spec := NameCompositionSpec{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &spec); err != nil {
// A corrupt stored value must not break name rendering — treat it
// as "no firm default" and let the next admin write replace it.
return NameCompositionSpec{}, nil
}
}
spec.SanitizeForRead()
return spec, nil
}
// setFirmNameCompositions validates and upserts the firm-wide default map into
// the id=1 singleton, recording updatedBy (uuid.Nil clears the column). The
// admin API is the only writer.
func setFirmNameCompositions(ctx context.Context, db *sqlx.DB, spec NameCompositionSpec, updatedBy uuid.UUID) error {
if err := spec.Validate(); err != nil {
return err
}
if spec == nil {
spec = NameCompositionSpec{}
}
b, err := json.Marshal(spec)
if err != nil {
return fmt.Errorf("marshal firm_name_compositions: %w", err)
}
var updaterArg any
if updatedBy != uuid.Nil {
updaterArg = updatedBy
}
_, err = db.ExecContext(ctx, `
INSERT INTO paliad.firm_name_compositions (id, compositions_json, updated_by, updated_at)
VALUES (1, $1::jsonb, $2, now())
ON CONFLICT (id) DO UPDATE
SET compositions_json = EXCLUDED.compositions_json,
updated_by = EXCLUDED.updated_by,
updated_at = now()
`, json.RawMessage(b), updaterArg)
if err != nil {
return fmt.Errorf("persist firm_name_compositions: %w", err)
}
return nil
}
// clearFirmNameCompositions deletes the firm default so resolution falls
// through to the system default. Idempotent.
func clearFirmNameCompositions(ctx context.Context, db *sqlx.DB) error {
if _, err := db.ExecContext(ctx, `DELETE FROM paliad.firm_name_compositions WHERE id = 1`); err != nil {
return fmt.Errorf("clear firm_name_compositions: %w", err)
}
return nil
}
// setUserNameCompositions validates and persists a user's full
// name_compositions map. The S4 settings API and the Slice-3 live tests call
// this; it is the single write path.
func setUserNameCompositions(ctx context.Context, db *sqlx.DB, userID uuid.UUID, spec NameCompositionSpec) error {
if err := spec.Validate(); err != nil {
return err
}
if spec == nil {
spec = NameCompositionSpec{}
}
b, err := json.Marshal(spec)
if err != nil {
return fmt.Errorf("marshal name_compositions: %w", err)
}
_, err = db.ExecContext(ctx,
`UPDATE paliad.users SET name_compositions = $1::jsonb WHERE id = $2`,
json.RawMessage(b), userID)
if err != nil {
return fmt.Errorf("persist name_compositions: %w", err)
}
return nil
}

View File

@@ -0,0 +1,81 @@
package services
import (
"testing"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
// A minimal valid override for the filename artifact: date + keyword only.
func sampleFilenameOverride() nomen.Composition {
return nomen.Composition{
Version: nomen.Version,
Segments: []nomen.Segment{
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
},
}
}
func TestNameCompositionSpec_Validate(t *testing.T) {
ok := NameCompositionSpec{ArtifactSubmissionDocxFilename: sampleFilenameOverride()}
if err := ok.Validate(); err != nil {
t.Fatalf("valid spec rejected: %v", err)
}
unknownArtifact := NameCompositionSpec{"no_such_artifact": sampleFilenameOverride()}
if err := unknownArtifact.Validate(); err == nil {
t.Errorf("unknown artifact accepted")
}
unknownVar := NameCompositionSpec{ArtifactSubmissionDocxFilename: {
Version: nomen.Version,
Segments: []nomen.Segment{{Var: "opponent"}}, // not in the filename catalog
}}
if err := unknownVar.Validate(); err == nil {
t.Errorf("override referencing a variable outside the artifact catalog accepted")
}
}
func TestNameCompositionSpec_SanitizeForRead(t *testing.T) {
spec := NameCompositionSpec{
"no_such_artifact": sampleFilenameOverride(),
ArtifactSubmissionDocxFilename: {Version: 0, Segments: []nomen.Segment{{Var: "date"}, {Var: "ghost"}}},
}
changed := spec.SanitizeForRead()
if !changed {
t.Fatalf("SanitizeForRead reported no change")
}
if _, ok := spec["no_such_artifact"]; ok {
t.Errorf("unknown artifact survived sanitisation")
}
got := spec[ArtifactSubmissionDocxFilename]
if got.Version != nomen.Version {
t.Errorf("version not clamped: %d", got.Version)
}
if len(got.Segments) != 1 || got.Segments[0].Var != "date" {
t.Errorf("ghost segment survived: %+v", got.Segments)
}
}
func TestResolveComposition(t *testing.T) {
// nil overrides → system default.
sys := resolveComposition(ArtifactSubmissionDocxFilename, nil)
if len(sys.Segments) != 3 {
t.Errorf("system default filename composition = %d segments, want 3", len(sys.Segments))
}
// A valid user override wins.
override := sampleFilenameOverride()
got := resolveComposition(ArtifactSubmissionDocxFilename, NameCompositionSpec{ArtifactSubmissionDocxFilename: override})
if len(got.Segments) != 2 {
t.Errorf("override not applied: got %d segments, want 2", len(got.Segments))
}
// An override that sanitises down to zero segments falls back to system.
empty := NameCompositionSpec{ArtifactSubmissionDocxFilename: {Version: nomen.Version, Segments: []nomen.Segment{{Var: "ghost"}}}}
fb := resolveComposition(ArtifactSubmissionDocxFilename, empty)
if len(fb.Segments) != 3 {
t.Errorf("invalid override should fall back to system default; got %d segments", len(fb.Segments))
}
}

View File

@@ -0,0 +1,241 @@
package services
// Paliad-side glue for the nomen token-template shorthand (t-paliad-356 Slice 4,
// PRD §7). The settings UI edits a single-line "{var}" template per artifact;
// this file is the single authority that turns that string into a validated
// nomen.Composition and renders the live previews. The frontend never parses
// templates itself — it round-trips through these functions so the engine stays
// the one source of truth (no duplicated parser to drift out of sync).
//
// - ParseNameTemplate: shorthand -> Composition. The shorthand carries Var,
// separators and paren Wraps (nomen.ParseTemplate); MissingRules are NOT in
// the shorthand (PRD §7), so they are overlaid here from the artifact's
// system default. The result is validated against the artifact catalog.
// - PreviewNameComposition: renders a parsed template against a fixed sample
// (all project vars present) and an empties resolver (only the always-on
// date), so the user sees both the normal result and the missing-rule
// behaviour.
// - SettingsNameArtifacts: the ordered, localised view the settings page
// reads to build its per-artifact cards.
import (
"fmt"
"sort"
"time"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
// settingsNameArtifactOrder fixes the order the two wired artifacts appear in
// the settings UI (title before filename). New wired artifacts append here.
var settingsNameArtifactOrder = []string{
ArtifactSubmissionDraftTitle,
ArtifactSubmissionDocxFilename,
}
// canonicalVarOrder fixes the palette chip order so it is deterministic across
// requests (catalogs are maps). Vars absent from this list sort after the known
// ones, alphabetically — a safety net for future catalog additions.
var canonicalVarOrder = []string{"date", "client", "forum", "opponent", "keyword", "case_number"}
// ParseNameTemplate compiles a token-template shorthand into a validated
// Composition for an artifact. MissingRules come from the artifact's system
// default (a var the default does not carry keeps the parser's KindOmit); the
// shorthand never sets them (PRD §7). Returns an ErrInvalidInput-wrapped error
// for an unknown artifact, a malformed template, or an unknown variable.
func ParseNameTemplate(artifactID, template string) (nomen.Composition, error) {
art, ok := NameArtifact(artifactID)
if !ok {
return nomen.Composition{}, fmt.Errorf("%w: unknown name artifact %q", ErrInvalidInput, artifactID)
}
comp, err := nomen.ParseTemplate(template)
if err != nil {
return nomen.Composition{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
missing := make(map[string]nomen.MissingRule, len(art.SystemDefault.Segments))
for _, seg := range art.SystemDefault.Segments {
missing[seg.Var] = seg.Missing
}
for i := range comp.Segments {
if m, ok := missing[comp.Segments[i].Var]; ok {
comp.Segments[i].Missing = m
}
}
if err := comp.Validate(art.Catalog); err != nil {
return nomen.Composition{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
return comp, nil
}
// nameSampleResolver is the fixed preview fixture (PRD §7): client "Bayer AG",
// forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", render-time today.
// keyword is intentionally absent so it exercises its missing rule (the title
// omits it — matching a real project draft; the filename falls back to the
// "submission" literal). When full is false only the always-on date resolves,
// so the preview shows the missing-rule behaviour for every project-derived
// variable.
func nameSampleResolver(full bool) nomen.VarResolver {
return func(key string) (string, bool) {
if key == "date" {
return nomenDateBerlin(time.Now()), true
}
if !full {
return "", false
}
switch key {
case "client":
return "Bayer AG", true
case "forum":
return "UPC", true
case "opponent":
return "Sandoz", true
case "case_number":
return "UPC_CFI_123/2026", true
}
return "", false
}
}
// PreviewNameComposition parses a template for an artifact and renders it twice:
// full (the fixed sample with all project vars present) and empty (only the
// always-on date, so missing rules show). A parse/validation error is returned
// instead — the caller surfaces it inline and disables Save.
func PreviewNameComposition(artifactID, template string) (full, empty string, err error) {
comp, err := ParseNameTemplate(artifactID, template)
if err != nil {
return "", "", err
}
art, _ := NameArtifact(artifactID)
full = comp.Render(nameSampleResolver(true), art.Target)
empty = comp.Render(nameSampleResolver(false), art.Target)
return full, empty, nil
}
// NameVarView is one palette chip: a variable's key plus its localised labels.
type NameVarView struct {
Var string `json:"var"`
Label string `json:"label"`
LabelEN string `json:"label_en"`
}
// NameCompositionView is one artifact's settings card. Template is the
// effective composition shown to the user (user override → firm default →
// system, first present wins); previews render that effective template.
// IsOverride flags a per-user override; FirmIsSet/FirmTemplate expose the firm
// tier (for the admin firm controls and the "firm default" badge);
// SystemTemplate is the code-resident default (the ultimate fallback and the
// admin "reset firm to system" reference).
type NameCompositionView struct {
ArtifactID string `json:"artifact_id"`
Label string `json:"label"`
LabelEN string `json:"label_en"`
Template string `json:"template"`
SystemTemplate string `json:"system_template"`
IsOverride bool `json:"is_override"`
FirmIsSet bool `json:"firm_is_set"`
FirmTemplate string `json:"firm_template"`
Palette []NameVarView `json:"palette"`
PreviewFull string `json:"preview_full"`
PreviewEmpty string `json:"preview_empty"`
}
// orderedPalette returns an artifact catalog's variables as palette chips in
// canonicalVarOrder (unknown vars alphabetical, last).
func orderedPalette(catalog nomen.VarCatalog) []NameVarView {
rank := make(map[string]int, len(canonicalVarOrder))
for i, v := range canonicalVarOrder {
rank[v] = i
}
out := make([]NameVarView, 0, len(catalog))
for key, def := range catalog {
out = append(out, NameVarView{Var: key, Label: def.Label, LabelEN: def.LabelEN})
}
sort.Slice(out, func(i, j int) bool {
ri, oki := rank[out[i].Var]
rj, okj := rank[out[j].Var]
switch {
case oki && okj:
return ri < rj
case oki != okj:
return oki // known vars before unknown
default:
return out[i].Var < out[j].Var
}
})
return out
}
// SettingsNameArtifacts builds the per-artifact views for the settings page,
// applying the precedence chain user → firm → system per artifact. Both spec
// maps are already SanitizeForRead'd by their loaders; either may be nil.
// Order is fixed by settingsNameArtifactOrder.
func SettingsNameArtifacts(user, firm NameCompositionSpec) []NameCompositionView {
views := make([]NameCompositionView, 0, len(settingsNameArtifactOrder))
for _, id := range settingsNameArtifactOrder {
if v, ok := SettingsNameArtifact(id, user, firm); ok {
views = append(views, v)
}
}
return views
}
// SettingsNameArtifact builds one artifact's settings view, resolving the
// effective template via user → firm → system. Returns (zero, false) for an
// unknown artifact id. Used by the per-artifact PUT/DELETE responses so the
// client refreshes only the touched card.
func SettingsNameArtifact(id string, user, firm NameCompositionSpec) (NameCompositionView, bool) {
art, ok := NameArtifact(id)
if !ok {
return NameCompositionView{}, false
}
systemTemplate := art.SystemDefault.Template()
firmComp, firmIsSet := storedComposition(firm, id)
firmTemplate := ""
if firmIsSet {
firmTemplate = firmComp.Template()
}
// Effective template: user override wins, else the firm default, else
// system. IsOverride flags only the per-user tier (the "you customised
// this" badge); the firm tier surfaces via FirmIsSet/FirmTemplate.
template := systemTemplate
isOverride := false
if userComp, ok := storedComposition(user, id); ok {
template = userComp.Template()
isOverride = true
} else if firmIsSet {
template = firmTemplate
}
// Previews reflect the effective template; a parse error here would mean a
// stored composition we already validated is somehow unparseable — fall
// back to empty previews rather than failing the page.
full, empty, _ := PreviewNameComposition(id, template)
return NameCompositionView{
ArtifactID: id,
Label: art.Label,
LabelEN: art.LabelEN,
Template: template,
SystemTemplate: systemTemplate,
IsOverride: isOverride,
FirmIsSet: firmIsSet,
FirmTemplate: firmTemplate,
Palette: orderedPalette(art.Catalog),
PreviewFull: full,
PreviewEmpty: empty,
}, true
}
// storedComposition returns (comp, true) when spec carries a non-empty
// composition for the artifact, else (zero, false).
func storedComposition(spec NameCompositionSpec, id string) (nomen.Composition, bool) {
if spec == nil {
return nomen.Composition{}, false
}
comp, ok := spec[id]
if !ok || len(comp.Segments) == 0 {
return nomen.Composition{}, false
}
return comp, true
}

View File

@@ -0,0 +1,174 @@
package services
import (
"regexp"
"strings"
"testing"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`)
// TestParseNameTemplate_RoundTripsSystemDefaults asserts the system-default
// compositions survive Template() -> ParseNameTemplate unchanged in
// Var/Sep/Wrap, with MissingRules re-overlaid from the default. This is the
// guard that the settings shorthand is a faithful authoring view of the seed.
func TestParseNameTemplate_RoundTripsSystemDefaults(t *testing.T) {
for _, id := range []string{ArtifactSubmissionDraftTitle, ArtifactSubmissionDocxFilename} {
art, _ := NameArtifact(id)
tmpl := art.SystemDefault.Template()
got, err := ParseNameTemplate(id, tmpl)
if err != nil {
t.Fatalf("%s: ParseNameTemplate(%q): %v", id, tmpl, err)
}
want := art.SystemDefault
if len(got.Segments) != len(want.Segments) {
t.Fatalf("%s: %d segments, want %d", id, len(got.Segments), len(want.Segments))
}
for i, seg := range got.Segments {
w := want.Segments[i]
if seg.Var != w.Var || seg.Sep != w.Sep || seg.Wrap != w.Wrap || seg.Missing != w.Missing {
t.Errorf("%s seg %d = %+v, want %+v", id, i, seg, w)
}
}
}
}
func TestParseNameTemplate_Errors(t *testing.T) {
cases := []struct {
name, artifact, template string
}{
{"unknown artifact", "nope", "{date}"},
{"unknown variable", ArtifactSubmissionDocxFilename, "{date} {client}"}, // client not in filename catalog
{"malformed", ArtifactSubmissionDraftTitle, "{date"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := ParseNameTemplate(c.artifact, c.template); err == nil {
t.Errorf("expected error, got nil")
}
})
}
}
// TestPreviewNameComposition_SystemDefaults asserts the fixed-sample previews
// match the two shipped schemes. The date is render-time today, so only its
// shape is checked; the rest is byte-exact.
func TestPreviewNameComposition_SystemDefaults(t *testing.T) {
titleTmpl, _ := NameArtifact(ArtifactSubmissionDraftTitle)
full, empty, err := PreviewNameComposition(ArtifactSubmissionDraftTitle, titleTmpl.SystemDefault.Template())
if err != nil {
t.Fatalf("title preview: %v", err)
}
if !datePrefix.MatchString(full) {
t.Errorf("title full preview %q has no leading date", full)
}
if !strings.HasSuffix(full, " Bayer AG ./. UPC ./. Sandoz") {
t.Errorf("title full preview = %q, want date + ' Bayer AG ./. UPC ./. Sandoz'", full)
}
if !datePrefix.MatchString(empty) || strings.ContainsAny(empty, " ") {
t.Errorf("title empty preview = %q, want bare date (all party segments omitted)", empty)
}
fnTmpl, _ := NameArtifact(ArtifactSubmissionDocxFilename)
full, empty, err = PreviewNameComposition(ArtifactSubmissionDocxFilename, fnTmpl.SystemDefault.Template())
if err != nil {
t.Fatalf("filename preview: %v", err)
}
if !strings.HasSuffix(full, " submission (UPC_CFI_123_2026).docx") {
// '/' in the sample case number is sanitised to '_' by the filename target.
t.Errorf("filename full preview = %q, want date + ' submission (UPC_CFI_123_2026).docx'", full)
}
if !strings.HasSuffix(empty, " submission (Az. folgt).docx") {
t.Errorf("filename empty preview = %q, want date + ' submission (Az. folgt).docx'", empty)
}
}
// TestSettingsNameArtifacts_OverrideShown asserts a stored override surfaces as
// IsOverride with its own template, while the untouched artifact stays system.
func TestSettingsNameArtifacts_OverrideShown(t *testing.T) {
override := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
}}
spec := NameCompositionSpec{ArtifactSubmissionDocxFilename: override}
views := SettingsNameArtifacts(spec, nil)
if len(views) != 2 {
t.Fatalf("got %d views, want 2", len(views))
}
byID := map[string]NameCompositionView{}
for _, v := range views {
byID[v.ArtifactID] = v
}
if v := byID[ArtifactSubmissionDocxFilename]; !v.IsOverride || v.Template != "{date} {keyword}" {
t.Errorf("filename view = %+v, want IsOverride + template '{date} {keyword}'", v)
}
if v := byID[ArtifactSubmissionDraftTitle]; v.IsOverride {
t.Errorf("title view should be system default (no override), got IsOverride")
}
// Order is fixed: title first, filename second.
if views[0].ArtifactID != ArtifactSubmissionDraftTitle || views[1].ArtifactID != ArtifactSubmissionDocxFilename {
t.Errorf("artifact order = [%s %s], want [title filename]", views[0].ArtifactID, views[1].ArtifactID)
}
}
// TestSettingsNameArtifact_FirmTier asserts the firm tier shows through when
// the user has no override, and that a user override still wins over the firm
// default. Mirrors the precedence user → firm → system.
func TestSettingsNameArtifact_FirmTier(t *testing.T) {
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
}}
firm := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
// No user override → effective template is the firm default; FirmIsSet set.
v, ok := SettingsNameArtifact(ArtifactSubmissionDocxFilename, nil, firm)
if !ok {
t.Fatal("artifact not found")
}
if v.IsOverride {
t.Errorf("IsOverride should be false (no user override), got true")
}
if !v.FirmIsSet || v.FirmTemplate != "{date} {keyword}" {
t.Errorf("firm tier = (set=%v, tmpl=%q), want (true, '{date} {keyword}')", v.FirmIsSet, v.FirmTemplate)
}
if v.Template != "{date} {keyword}" {
t.Errorf("effective template = %q, want firm default '{date} {keyword}'", v.Template)
}
// A user override beats the firm default in the effective template.
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "date", Missing: nomen.Omit()},
}}
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
v, _ = SettingsNameArtifact(ArtifactSubmissionDocxFilename, user, firm)
if !v.IsOverride || v.Template != "{date}" {
t.Errorf("user override should win: IsOverride=%v template=%q, want true '{date}'", v.IsOverride, v.Template)
}
if !v.FirmIsSet {
t.Errorf("FirmIsSet should remain true even when user override wins")
}
}
// TestResolveComposition_Precedence pins the render-path precedence: user beats
// firm beats system; nil/empty tiers are skipped.
func TestResolveComposition_Precedence(t *testing.T) {
sys := nameArtifacts[ArtifactSubmissionDocxFilename].SystemDefault
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{{Var: "date", Missing: nomen.Omit()}}}
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{{Var: "keyword", Missing: nomen.Literal("x")}}}
firm := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
if got := resolveComposition(ArtifactSubmissionDocxFilename, nil, nil); len(got.Segments) != len(sys.Segments) {
t.Errorf("no overrides → system default, got %d segments", len(got.Segments))
}
if got := resolveComposition(ArtifactSubmissionDocxFilename, nil, firm); got.Template() != firmComp.Template() {
t.Errorf("firm beats system: got %q", got.Template())
}
if got := resolveComposition(ArtifactSubmissionDocxFilename, user, firm); got.Template() != userComp.Template() {
t.Errorf("user beats firm: got %q", got.Template())
}
}

View File

@@ -0,0 +1,302 @@
package services
// Paliad-side wiring for the pkg/nomen composition engine
// (docs/plans/prd-filename-generator-2026-06-01.md, Slice 1).
//
// pkg/nomen stays pure; this file holds the paliad-specific pieces:
// - the variable catalogs (which variables each artifact exposes),
// - the seed system-default Compositions that reproduce the two shipped
// naming schemes byte-for-byte (#155 draft title, t-paliad-354 .docx
// filename),
// - the per-render VarResolvers built from the existing submission_autoname
// helpers (submissionForumShort / submissionOpponentName / derefString),
// - and the artifact registry binding artifact -> catalog -> target ->
// default.
//
// The two public entry points (AutoSubmissionTitle here-adjacent, and
// RenderSubmissionFilename) render through the registry so the engine is the
// single source of truth. Folding the two schemes in as DATA (compositions)
// rather than code is the whole point: future levels (user/firm overrides,
// non-project degradation) layer on without re-deriving the assembly logic.
import (
"strings"
"time"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
// Artifact identifiers. v1 wires the two submission artifacts; further
// artifacts (docforge export, data-zip, projection slug — PRD §4) register
// alongside their own slice, with their own catalog/resolver, when they opt
// in. They are intentionally NOT registered here as placeholders: an
// artifact with no resolver and no consumer would be dead code.
const (
ArtifactSubmissionDraftTitle = "submission_draft_title"
ArtifactSubmissionDocxFilename = "submission_docx_filename"
)
// submissionFilenamePlaceholder fills the bracketed case-number slot when the
// project has no Aktenzeichen yet (t-paliad-354). Kept as a named const so
// the wording stays one-line changeable (m left the exact text open).
const submissionFilenamePlaceholder = "Az. folgt"
// submissionKeywordFallback is the keyword used when neither a user override
// nor a rule name resolves (t-paliad-354).
const submissionKeywordFallback = "submission"
// Artifact binds a named output to its variable catalog, render target, and
// system-default composition. The catalog drives validation + the settings
// palette; the default is the seed used when no override exists.
type Artifact struct {
ID string
Label string
LabelEN string
Catalog nomen.VarCatalog
Target nomen.RenderTarget
SystemDefault nomen.Composition
}
// nameArtifacts is the v1 registry. Lookup via NameArtifact.
var nameArtifacts = map[string]Artifact{
ArtifactSubmissionDraftTitle: {
ID: ArtifactSubmissionDraftTitle,
Label: "Entwurfstitel",
LabelEN: "Draft title",
Catalog: submissionTitleCatalog(),
Target: nomen.PlainTarget("title"),
SystemDefault: submissionDraftTitleComposition(),
},
ArtifactSubmissionDocxFilename: {
ID: ArtifactSubmissionDocxFilename,
Label: "Dateiname (.docx)",
LabelEN: "File name (.docx)",
Catalog: submissionFilenameCatalog(),
Target: nomen.FuncTarget{
NameVal: "filename",
Sanitiser: SanitiseSubmissionFileName,
Suffix: ".docx",
},
SystemDefault: submissionDocxFilenameComposition(),
},
}
// NameArtifact returns the registered artifact for id, or (zero, false).
func NameArtifact(id string) (Artifact, bool) {
a, ok := nameArtifacts[id]
return a, ok
}
// SubmissionFilenameKeyword reads the per-document keyword override from a
// draft's decoded composer_meta. The canonical shape is
// composer_meta.name_overrides.keyword (Slice 3); the legacy
// composer_meta.filename_keyword (t-paliad-354) is still honoured as
// name_overrides.keyword (back-compat read). Returns "" when absent/blank —
// the caller then falls back to the auto-derived rule name.
func SubmissionFilenameKeyword(d *SubmissionDraft) string {
if d == nil || d.ComposerMeta == nil {
return ""
}
if no, ok := d.ComposerMeta["name_overrides"].(map[string]any); ok {
if v, ok := no["keyword"].(string); ok {
if t := strings.TrimSpace(v); t != "" {
return t
}
}
}
if v, ok := d.ComposerMeta["filename_keyword"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
// ---------------------------------------------------------------------------
// Seed compositions (the two shipped schemes, as data — PRD §5).
// ---------------------------------------------------------------------------
// submissionDraftTitleComposition reproduces AutoSubmissionTitle (#155) and
// carries the non-project degradation (Slice 2, PRD §6):
//
// project draft: <date> <client> ./. <forum> ./. <opponent>
// non-project draft: <date> <keyword>
//
// 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,
Segments: []nomen.Segment{
{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: "keyword", Sep: "", Missing: nomen.Omit()},
},
}
}
// submissionDocxFilenameComposition reproduces submissionFileName (354):
//
// <date> <keyword> (<case number>).docx
//
// keyword falls back to a fixed "submission" literal; the case number is
// always rendered in parentheses, falling back to a placeholder when the
// project has no Aktenzeichen. The .docx suffix and per-value sanitisation
// come from the artifact's FuncTarget, not the composition.
func submissionDocxFilenameComposition() nomen.Composition {
return nomen.Composition{
Version: nomen.Version,
Segments: []nomen.Segment{
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "keyword", Sep: " ", Missing: nomen.Literal(submissionKeywordFallback)},
{Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: nomen.Placeholder(submissionFilenamePlaceholder)},
},
}
}
// ---------------------------------------------------------------------------
// Variable catalogs.
// ---------------------------------------------------------------------------
func submissionTitleCatalog() nomen.VarCatalog {
return nomen.VarCatalog{
"date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"},
"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"},
}
}
func submissionFilenameCatalog() nomen.VarCatalog {
return nomen.VarCatalog{
"date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"},
"keyword": {Key: "keyword", Label: "Stichwort", LabelEN: "Keyword", Group: "document", Description: "Dokument-/Schriftsatztyp; überschreibbar"},
"case_number": {Key: "case_number", Label: "Aktenzeichen", LabelEN: "Case number", Group: "proceeding", Description: "Aktenzeichen des Verfahrens"},
}
}
// ---------------------------------------------------------------------------
// Resolvers.
// ---------------------------------------------------------------------------
// nomenDateBerlin formats t as the JJJJ-MM-TT date in Europe/Berlin,
// matching both shipped schemes. A failed zone load leaves t untouched
// (same fallback the original code used).
func nomenDateBerlin(t time.Time) string {
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
t = t.In(loc)
}
return t.Format("2006-01-02")
}
// 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. 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":
return nomenDateBerlin(now), true
case "client":
c := strings.TrimSpace(clientName)
return c, c != ""
case "forum":
f := submissionForumShort(pt)
return f, f != ""
case "opponent":
ourSide := ""
if project != nil {
ourSide = derefString(project.OurSide)
}
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). overrides may carry a
// per-user composition override (Slice 3); nil renders the system default.
func renderSubmissionDraftTitle(user, firm NameCompositionSpec, now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType, keyword string) string {
comp := resolveComposition(ArtifactSubmissionDraftTitle, user, firm)
resolve := submissionTitleResolver(now, clientName, project, parties, pt, keyword)
return comp.Render(resolve, nameArtifacts[ArtifactSubmissionDraftTitle].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
// composition's "submission" literal kicks in; case_number reports absence so
// the "(Az. folgt)" placeholder kicks in.
func submissionFilenameResolver(rule *models.DeadlineRule, project *models.Project, lang, keyword string) nomen.VarResolver {
return func(key string) (string, bool) {
switch key {
case "date":
return nomenDateBerlin(time.Now()), true
case "keyword":
kw := strings.TrimSpace(keyword)
if kw == "" && rule != nil {
kw = strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
kw = strings.TrimSpace(rule.NameEN)
}
}
return kw, kw != ""
case "case_number":
if project != nil && project.CaseNumber != nil {
c := strings.TrimSpace(*project.CaseNumber)
if c != "" {
return c, true
}
}
return "", false
}
return "", false
}
}
// RenderSubmissionFilename produces the user-facing download name for a
// generated submission (t-paliad-354), rendered through the nomen engine:
// "<JJJJ-MM-TT> <keyword> (<case number>).docx". keyword is the user override
// when set, else the lang-aware rule name, else "submission"; the case number
// falls back to "(Az. folgt)" when the project has no Aktenzeichen. Each
// variable value is sanitised for SMB-safe filenames while the frame (spaces,
// parentheses, .docx) is preserved.
func RenderSubmissionFilename(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
return RenderSubmissionFilenameFor(nil, nil, rule, project, lang, keyword)
}
// RenderSubmissionFilenameFor renders the .docx filename honouring the
// composition precedence chain user → firm → system (Slice 3 + Slice 5); pass
// nil for a tier the caller hasn't loaded. keyword is still the per-document
// value override (name_overrides.keyword); the value override and the
// composition overrides are independent — one swaps a variable's value, the
// other swaps the template.
func RenderSubmissionFilenameFor(user, firm NameCompositionSpec, rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
comp := resolveComposition(ArtifactSubmissionDocxFilename, user, firm)
resolve := submissionFilenameResolver(rule, project, lang, keyword)
return comp.Render(resolve, nameArtifacts[ArtifactSubmissionDocxFilename].Target)
}

View File

@@ -0,0 +1,34 @@
package services
import "testing"
// TestNameArtifactsValidate guards the seed system-default compositions
// against their own catalogs — a typo'd variable in a seed composition (a key
// the catalog doesn't declare) fails here rather than silently rendering
// nothing in production.
func TestNameArtifactsValidate(t *testing.T) {
for id, art := range nameArtifacts {
if art.ID != id {
t.Errorf("artifact %q has mismatched ID %q", id, art.ID)
}
if art.Target == nil {
t.Errorf("artifact %q has nil target", id)
}
if err := art.SystemDefault.Validate(art.Catalog); err != nil {
t.Errorf("artifact %q system default invalid: %v", id, err)
}
}
}
// TestNameArtifactLookup covers the registry accessor.
func TestNameArtifactLookup(t *testing.T) {
if _, ok := NameArtifact(ArtifactSubmissionDraftTitle); !ok {
t.Errorf("draft-title artifact not registered")
}
if _, ok := NameArtifact(ArtifactSubmissionDocxFilename); !ok {
t.Errorf("docx-filename artifact not registered")
}
if _, ok := NameArtifact("nonexistent"); ok {
t.Errorf("lookup of unknown artifact returned ok")
}
}

View File

@@ -0,0 +1,157 @@
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 promotes this scheme into the pkg/nomen composition engine: the
// template lives as the submission_draft_title artifact's system-default
// Composition (see namegen.go, PRD §5.1) and the identity resolvers below
// stay as the value source. AutoSubmissionTitle is now a thin wrapper that
// renders that composition; the assembly logic (separators, missing-segment
// rules) is the engine's. Per-user / per-firm overrides (Slices 35) layer
// onto the artifact without touching this file.
import (
"strings"
"time"
"mgit.msbls.de/m/paliad/internal/models"
)
// 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); the three
// 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 {
// Pure system-default render (nil overrides). The identity trio carries
// the name, keyword stays empty (and its segment omits) — so a project
// draft renders identically to #155. The create path uses the
// overrides-aware autoNameForProject; this stays the system-default
// reference that the #155 test matrix pins.
return renderSubmissionDraftTitle(nil, nil, now, clientName, project, parties, pt, "")
}
// 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"
}
}

View 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)
}
})
}
}

View 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")
}
}

View File

@@ -0,0 +1,123 @@
package services
// Live-DB test for the user-replaceable filename keyword
// (t-paliad-354). Skipped without TEST_DATABASE_URL.
//
// Exercises the real Update → Get code path against Postgres: setting the
// override merges into composer_meta.filename_keyword without clobbering
// other composer keys, clearing it removes only that key, and the value
// reads back through the same jsonb decode the export handler relies on.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestSubmissionDraft_FilenameKeyword_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
email := "kw-" + userID.String()[:8] + "@hlc.com"
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
defer cleanup()
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Keyword Tester', 'munich', 'standard', 'de')`, userID, email); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
parties := NewPartyService(pool, projects)
vars := NewSubmissionVarsService(pool, projects, parties, users)
renderer := NewSubmissionRenderer()
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
// A project-less draft is the simplest fixture — no project tree
// needed to exercise composer_meta persistence.
d, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create draft: %v", err)
}
// Pre-seed an unrelated composer_meta key to prove the merge/delete
// only touches filename_keyword.
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.submission_drafts SET composer_meta = '{"other":"keep-me"}'::jsonb WHERE id = $1`,
d.ID); err != nil {
t.Fatalf("seed composer_meta: %v", err)
}
// Set the override. The canonical shape is now
// composer_meta.name_overrides.keyword (Slice 3).
kw := "Replik Hauptantrag"
got, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &kw})
if err != nil {
t.Fatalf("update set keyword: %v", err)
}
if v := nameOverrideKeyword(got.ComposerMeta); v != kw {
t.Fatalf("after set: name_overrides.keyword = %q, want %q", v, kw)
}
if v, _ := got.ComposerMeta["other"].(string); v != "keep-me" {
t.Fatalf("after set: unrelated key 'other' = %q, want %q (merge clobbered it)", v, "keep-me")
}
// Read back through Get (the path the export handler uses).
reload, err := drafts.Get(ctx, userID, d.ID)
if err != nil {
t.Fatalf("get after set: %v", err)
}
if v := nameOverrideKeyword(reload.ComposerMeta); v != kw {
t.Fatalf("reload: name_overrides.keyword = %q, want %q", v, kw)
}
// Clear the override (empty string) — only the keyword should go.
empty := ""
cleared, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &empty})
if err != nil {
t.Fatalf("update clear keyword: %v", err)
}
if v := nameOverrideKeyword(cleared.ComposerMeta); v != "" {
t.Fatalf("after clear: name_overrides.keyword still present: %v", cleared.ComposerMeta)
}
if v, _ := cleared.ComposerMeta["other"].(string); v != "keep-me" {
t.Fatalf("after clear: unrelated key 'other' = %q, want %q (delete removed too much)", v, "keep-me")
}
}
// nameOverrideKeyword reads composer_meta.name_overrides.keyword from a
// decoded composer_meta map (the new Slice-3 shape).
func nameOverrideKeyword(meta map[string]any) string {
no, ok := meta["name_overrides"].(map[string]any)
if !ok {
return ""
}
v, _ := no["keyword"].(string)
return v
}

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

@@ -183,6 +183,14 @@ type DraftPatch struct {
// **p → pin the version (validated via TemplateStore.GetVersion)
// t-paliad-349 slice 7.
TemplateVersionID **uuid.UUID
// FilenameKeyword sets (or clears) the user override that leads the
// exported document name "<date> <keyword> (<case>)" (t-paliad-354).
// Stored under composer_meta.filename_keyword — no dedicated column:
// nil → no change
// *p == "" → clear the key (back to the auto-derived rule name)
// *p == "x" → set the override
FilenameKeyword *string
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -356,12 +364,15 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
// creates with base_id=NULL — Composer is additive, the v1 fallback
// path remains valid.
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
var project *models.Project
if projectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
p, err := s.projects.GetByID(ctx, userID, *projectID)
if err != nil {
return nil, err
}
project = p
}
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
name, err := s.newDraftName(ctx, userID, project, projectID, submissionCode, lang)
if err != nil {
return nil, err
}
@@ -431,20 +442,186 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
return &d, nil
}
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
// suffix if two callers race; the unique constraint on the table is
// the final guard.
// newDraftName picks the title for a freshly-created draft. Project-
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
// "<date> <client> ./. <forum> ./. <opponent>", de-duplicated against
// the user's existing drafts for the same (project, submission_code).
// Project-less drafts (and any project-bound draft whose auto-name
// resolves to nothing) fall back to the "Entwurf N" / "Draft N"
// counter.
//
// A nil projectID scopes the search to the user's project-less drafts
// for this submission_code — matches the row-uniqueness contract on
// the DB side (project_id, submission_code, user_id, name) where
// project_id IS NULL is its own equivalence class.
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
prefix := "Entwurf"
if strings.EqualFold(lang, "en") {
prefix = "Draft"
// Only Create calls this — existing drafts are never renamed (the
// scheme is create-time only, per #155). A lawyer's later manual rename
// flows through Update and is left untouched.
func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.UUID, project *models.Project, projectID *uuid.UUID, submissionCode, lang string) (string, error) {
existing, err := s.existingDraftNames(ctx, projectID, submissionCode, userID)
if err != nil {
return "", err
}
// Composition precedence (Slice 3 + Slice 5): per-user override, then the
// firm-wide default, then the system default. Both loads are empty-safe.
overrides, err := getUserNameCompositions(ctx, s.db, userID)
if err != nil {
return "", err
}
firm, err := getFirmNameCompositions(ctx, s.db)
if err != nil {
return "", err
}
if project != nil {
auto, err := s.autoNameForProject(ctx, time.Now(), project, overrides, firm)
if err != nil {
return "", err
}
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
}
// 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, overrides, firm)
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, overrides, firm NameCompositionSpec) (string, error) {
keyword, err := s.keywordForSubmissionCode(ctx, submissionCode, lang)
if err != nil {
return "", err
}
if strings.TrimSpace(keyword) == "" {
keyword = draftWord(lang)
}
return renderSubmissionDraftTitle(overrides, firm, 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
}
// UserNameCompositions loads a user's per-user name-composition overrides
// (Slice 3), sanitised for read. Empty when the user has none. Exposed so the
// download handlers can apply the filename override and the settings API
// (Slice 4) can read the current value.
func (s *SubmissionDraftService) UserNameCompositions(ctx context.Context, userID uuid.UUID) (NameCompositionSpec, error) {
return getUserNameCompositions(ctx, s.db, userID)
}
// SetUserNameCompositions validates and persists a user's full
// name-composition override map. The single write path — used by the
// settings API (Slice 4) and the Slice-3 live tests.
func (s *SubmissionDraftService) SetUserNameCompositions(ctx context.Context, userID uuid.UUID, spec NameCompositionSpec) error {
return setUserNameCompositions(ctx, s.db, userID, spec)
}
// 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, overrides, firm NameCompositionSpec) (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 renderSubmissionDraftTitle(overrides, firm, 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 +636,55 @@ 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 := draftWord(lang)
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)
}
// 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
// nextDraftName; pure over the supplied name list.
func uniqueDraftName(base string, existing []string) string {
taken := make(map[string]struct{}, len(existing))
for _, n := range existing {
taken[n] = struct{}{}
}
if _, clash := taken[base]; !clash {
return base
}
for i := 2; ; i++ {
cand := fmt.Sprintf("%s (%d)", base, i)
if _, clash := taken[cand]; !clash {
return cand
}
}
}
// Update patches the draft. Variables is replace-semantics — pass the
@@ -589,6 +805,31 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.FilenameKeyword != nil {
// Per-document value override, now under the general
// composer_meta.name_overrides.{var:value} shape (Slice 3, PRD §3.1);
// the keyword lives at name_overrides.keyword. Targeted jsonb edits so
// other composer_meta keys survive. An empty override removes the key,
// restoring the auto-derived rule name. Legacy composer_meta.
// filename_keyword rows are still honoured on read (back-compat).
kw := strings.TrimSpace(*patch.FilenameKeyword)
if kw == "" {
// Drop both the new and the legacy key so a clear always clears.
setParts = append(setParts,
"composer_meta = (coalesce(composer_meta, '{}'::jsonb) #- '{name_overrides,keyword}') - 'filename_keyword'")
} else {
// jsonb_set won't create a missing parent, so ensure
// name_overrides exists (preserving any sibling overrides) before
// setting the keyword leaf.
setParts = append(setParts,
fmt.Sprintf("composer_meta = jsonb_set("+
"jsonb_set(coalesce(composer_meta, '{}'::jsonb), '{name_overrides}', coalesce(composer_meta->'name_overrides', '{}'::jsonb), true), "+
"'{name_overrides,keyword}', to_jsonb($%d::text), true)", idx))
args = append(args, kw)
idx++
}
}
if len(setParts) == 0 {
return existing, nil
}

View File

@@ -202,6 +202,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
resolvers = append(resolvers,
projectResolver{project: project, pt: pt, lang: lang},
captionResolver{project: project, pt: pt, lang: lang},
partiesResolver{parties: filterPartiesBySelection(parties, in.SelectedParties)},
deadlineResolver{deadline: next, project: project, lang: lang},
)
@@ -318,10 +319,19 @@ func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID,
// addFirmVars populates the firm.* namespace.
func addFirmVars(bag PlaceholderMap) {
bag["firm.name"] = branding.Name
// firm.signature_block is reserved for Phase 2; emit empty so
// templates that already reference it don't render the missing
// marker (less noisy for the lawyer).
bag["firm.signature_block"] = ""
// firm.signature_block is the firm identity line of a submission's
// signature block — the signature section seeds with
// {{firm.signature_block}} + {{user.display_name}} (the lawyer's name),
// so this carries the firm, not the person. It is firm-agnostic:
// derived from branding.Name so a FIRM_NAME redeploy or non-HLC
// deployment signs with the right firm (t-paliad-358 A-S1). It used to
// emit "" ("reserved for Phase 2"), which left every template that
// referenced it blank. A richer block (postal/contact address,
// professional designation such as "Rechtsanwälte/Patentanwälte") needs
// per-firm config paliad does not capture yet — deferred to the
// structured-data work (Option B); we do not guess legally-flavoured
// designations here.
bag["firm.signature_block"] = branding.Name
}
// addTodayVars populates today.* in both DE and EN long forms. ISO
@@ -368,6 +378,7 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
bag["project.matter_number"] = derefString(p.MatterNumber)
if pt != nil {
bag["project.proceeding.code"] = pt.Code
bag["project.proceeding.jurisdiction"] = derefString(pt.Jurisdiction)
if strings.EqualFold(lang, "en") {
bag["project.proceeding.name"] = pt.NameEN
} else {
@@ -378,6 +389,160 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
}
}
// addCaptionVars populates the caption.* namespace — the parametric pieces of
// the case caption (Rubrum) shared by every render path (the merge fallback
// skeleton, the per-code .docx templates, and the Composer caption seeds) so
// the wording stays unified rather than diverging per path (t-paliad-358 A-S2).
//
// Each piece is offered in three forms, mirroring the project.proceeding.name
// convention: a bare key resolved to the draft language, plus explicit _de /
// _en variants (the bilingual .docx/seed surfaces reference the explicit
// variant for the language they are written in).
//
// Parametrisation is driven by data the bag already has — no new schema:
// - designations (claimant/defendant) reuse the proceeding-type role-label
// overrides (Berufungskläger, Antragsteller (Nichtigkeit), Einsprechende(r),
// …; mig 137). Where a proceeding carries no override the caption falls back
// to the civil default Klägerin/Beklagte // Claimant/Defendant. This means
// DE appeal/nullity/cassation forums that lack role-label data today
// (de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh) render the generic
// designation — flagged for a lexy review + role-label backfill, NOT
// guessed here.
// - heading / subject are computed from the proceeding jurisdiction + the
// "nature" segment of the dotted code (inf / null / rev / opp / …). These
// are practitioner-convention wordings (German caption conventions are not
// in the youpc corpus) — flagged for lexy.
// - the court line is left as {{project.court}} (free text); forum-specific
// framing ("an das Landgericht …, … Kammer/Senat") needs chamber data we
// do not capture (Option B).
//
// our_side is intentionally NOT a driver: the caption designates BOTH parties
// by their procedural role regardless of which side we act for; our_side has
// its own prose keys (project.our_side_*).
func addCaptionVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
c := resolveCaption(p, pt)
set := func(base, de, en string) {
bag["caption."+base+"_de"] = de
bag["caption."+base+"_en"] = en
if strings.EqualFold(lang, "en") {
bag["caption."+base] = en
} else {
bag["caption."+base] = de
}
}
set("heading", c.headingDE, c.headingEN)
set("claimant_designation", c.claimantDE, c.claimantEN)
set("defendant_designation", c.defendantDE, c.defendantEN)
set("versus", c.versusDE, c.versusEN)
set("subject", c.subjectDE, c.subjectEN)
}
// captionParts holds the resolved bilingual caption pieces.
type captionParts struct {
headingDE, headingEN string
claimantDE, claimantEN string
defendantDE, defendantEN string
versusDE, versusEN string
subjectDE, subjectEN string
}
// resolveCaption computes the parametric caption pieces from the proceeding
// type (jurisdiction + dotted code + role-label overrides). Pure function for
// unit testing — no DB, no bag.
func resolveCaption(p *models.Project, pt *models.ProceedingType) captionParts {
c := captionParts{
// Civil defaults — overridden below per forum / role-label data.
headingDE: "In der Sache", headingEN: "In the matter",
claimantDE: "Klägerin", claimantEN: "Claimant",
defendantDE: "Beklagte", defendantEN: "Defendant",
versusDE: "gegen", versusEN: "v.",
subjectDE: "Patentstreitsache", subjectEN: "patent matter",
}
var jurisdiction, nature string
if pt != nil {
jurisdiction = strings.ToUpper(derefString(pt.Jurisdiction))
nature = captionNature(pt.Code)
}
// Heading + subject by jurisdiction and proceeding nature.
switch {
case jurisdiction == "UPC":
c.headingDE, c.headingEN = "In der Sache", "In the matter"
case jurisdiction == "DE" && nature == "null":
c.headingDE, c.headingEN = "In der Patentnichtigkeitssache", "In the nullity matter"
case jurisdiction == "DE" && nature == "inf":
c.headingDE, c.headingEN = "In dem Rechtsstreit", "In the matter"
case nature == "opp": // EPA / DPMA opposition
c.headingDE, c.headingEN = "Im Einspruchsverfahren", "In the opposition proceedings"
}
switch nature {
case "inf":
c.subjectDE, c.subjectEN = "Patentverletzung", "patent infringement"
case "null", "rev":
c.subjectDE, c.subjectEN = "Nichtigkeit des Streitpatents", "revocation of the patent in suit"
case "opp":
c.subjectDE, c.subjectEN = "Einspruch gegen das Streitpatent", "opposition to the patent in suit"
}
// Designations — precedence: explicit proceeding role-label override >
// instance-derived (appeal/cassation) > civil default.
//
// 1. Role-label overrides (mig 137) capture the proceedings whose naming
// diverges in a forum-specific way: upc.apl.unified (Berufungskläger),
// upc.rev.cfi (Antragsteller (Nichtigkeit)), epa.opp.* (Einsprechende(r)
// / Patentinhaber(in)). These are authoritative — use them verbatim.
// 2. Otherwise the procedural instance shifts the civil designation: an
// appeal makes the parties Berufungskläger(in)/Berufungsbeklagte(r)
// (Appellant/Respondent), a cassation Revisionskläger(in)/
// Revisionsbeklagte(r). DE appeal/nullity forums (de.inf.olg,
// de.null.bgh, …) carry no role-label override today, so this fills the
// gap when project.instance_level is set.
// 3. Else the first-instance civil default (Klägerin/Beklagte // Claimant/
// Defendant) already in c.
instance := ""
if p != nil {
instance = strings.ToLower(derefString(p.InstanceLevel))
}
switch instance {
case "appeal":
c.claimantDE, c.defendantDE = "Berufungskläger(in)", "Berufungsbeklagte(r)"
c.claimantEN, c.defendantEN = "Appellant", "Respondent"
case "cassation":
c.claimantDE, c.defendantDE = "Revisionskläger(in)", "Revisionsbeklagte(r)"
c.claimantEN, c.defendantEN = "Appellant", "Respondent"
}
if pt != nil {
if v := derefString(pt.RoleProactiveLabelDE); v != "" {
c.claimantDE = v
}
if v := derefString(pt.RoleProactiveLabelEN); v != "" {
c.claimantEN = v
}
if v := derefString(pt.RoleReactiveLabelDE); v != "" {
c.defendantDE = v
}
if v := derefString(pt.RoleReactiveLabelEN); v != "" {
c.defendantEN = v
}
}
return c
}
// captionNature returns the proceeding "nature" segment of a dotted proceeding
// code (e.g. "de.inf.lg" → "inf", "upc.rev.cfi" → "rev", "epa.opp.opd" →
// "opp", "de.null.bpatg" → "null"). Empty when the code has no second segment.
func captionNature(code string) string {
parts := strings.Split(code, ".")
if len(parts) >= 2 {
return parts[1]
}
return ""
}
// addPartyVars populates the parties.* namespace from the (already
// filtered) list of parties.
//
@@ -412,11 +577,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])

View File

@@ -0,0 +1,245 @@
package services
// Pins the parametric caption resolver (t-paliad-358 A-S2 + t-paliad-361
// wording follow-up): heading / subject derive from jurisdiction + the
// proceeding code's nature segment; designations reuse the proceeding
// role-label overrides, fall back to instance-derived appeal/cassation
// wording, then to the civil default.
//
// t-paliad-361 additions:
// - UPC appeal EN responding party is now "Respondent" (not "Appellee").
// - The four DE appeal/nullity proceedings (de.inf.olg, de.inf.bgh,
// de.null.bpatg, de.null.bgh) carry lexy-confirmed role-label overrides
// (mig 163), so their designations are correct even when
// project.instance_level is unset — pinned by the cases below that pass an
// empty Project but still expect the appeal/nullity wording.
import (
"testing"
"mgit.msbls.de/m/paliad/internal/models"
)
func sp(s string) *string { return &s }
func ptType(code, jurisdiction string) *models.ProceedingType {
return &models.ProceedingType{Code: code, Jurisdiction: sp(jurisdiction)}
}
// ptRoles builds a proceeding type carrying the mig-137/mig-163 role-label
// overrides (the four-column bracketed-inclusive designations).
func ptRoles(code, jurisdiction, proDE, reDE, proEN, reEN string) *models.ProceedingType {
return &models.ProceedingType{
Code: code, Jurisdiction: sp(jurisdiction),
RoleProactiveLabelDE: sp(proDE),
RoleReactiveLabelDE: sp(reDE),
RoleProactiveLabelEN: sp(proEN),
RoleReactiveLabelEN: sp(reEN),
}
}
func TestResolveCaption(t *testing.T) {
cases := []struct {
name string
project *models.Project
pt *models.ProceedingType
wantHeadDE string
wantClaimDE string
wantDefDE string
wantSubjDE string
wantClaimEN string
wantDefEN string
}{
{
name: "DE LG infringement → Rechtsstreit / Kläger-Beklagte / Patentverletzung",
project: &models.Project{},
pt: ptType("de.inf.lg", "DE"),
wantHeadDE: "In dem Rechtsstreit",
wantClaimDE: "Klägerin",
wantDefDE: "Beklagte",
wantSubjDE: "Patentverletzung",
wantClaimEN: "Claimant",
wantDefEN: "Defendant",
},
{
name: "DE BPatG nullity (no role-label data) → civil default fallback",
project: &models.Project{},
pt: ptType("de.null.bpatg", "DE"),
wantHeadDE: "In der Patentnichtigkeitssache",
wantClaimDE: "Klägerin",
wantDefDE: "Beklagte",
wantSubjDE: "Nichtigkeit des Streitpatents",
wantClaimEN: "Claimant",
wantDefEN: "Defendant",
},
{
name: "UPC infringement → In der Sache / civil default",
project: &models.Project{},
pt: ptType("upc.inf.cfi", "UPC"),
wantHeadDE: "In der Sache",
wantClaimDE: "Klägerin",
wantDefDE: "Beklagte",
wantSubjDE: "Patentverletzung",
wantClaimEN: "Claimant",
wantDefEN: "Defendant",
},
{
name: "UPC revocation → role-label override (Antragsteller Nichtigkeit)",
project: &models.Project{},
pt: ptRoles("upc.rev.cfi", "UPC",
"Antragsteller (Nichtigkeit)", "Antragsgegner (Nichtigkeit)",
"Revocation claimant", "Revocation defendant"),
wantHeadDE: "In der Sache",
wantClaimDE: "Antragsteller (Nichtigkeit)",
wantDefDE: "Antragsgegner (Nichtigkeit)",
wantSubjDE: "Nichtigkeit des Streitpatents",
wantClaimEN: "Revocation claimant",
wantDefEN: "Revocation defendant",
},
{
// t-paliad-361 Change 1: the role-label override wins over the
// instance-derived path, and its EN reactive label is now
// "Respondent" (was "Appellee", mig 163).
name: "UPC appeal → role-label override wins, EN reactive is Respondent",
project: &models.Project{InstanceLevel: sp("appeal")},
pt: ptRoles("upc.apl.unified", "UPC",
"Berufungskläger", "Berufungsbeklagter",
"Appellant", "Respondent"),
wantHeadDE: "In der Sache",
wantClaimDE: "Berufungskläger",
wantDefDE: "Berufungsbeklagter",
wantSubjDE: "Patentstreitsache",
wantClaimEN: "Appellant",
wantDefEN: "Respondent",
},
{
name: "DE OLG appeal via instance_level (no role-label data) → instance-derived",
project: &models.Project{InstanceLevel: sp("appeal")},
pt: ptType("de.inf.olg", "DE"),
wantHeadDE: "In dem Rechtsstreit",
wantClaimDE: "Berufungskläger(in)",
wantDefDE: "Berufungsbeklagte(r)",
wantSubjDE: "Patentverletzung",
wantClaimEN: "Appellant",
wantDefEN: "Respondent",
},
{
name: "EPA opposition → Einsprechende(r) / Patentinhaber(in)",
project: &models.Project{},
pt: ptRoles("epa.opp.opd", "EPA",
"Einsprechende(r)", "Patentinhaber(in)",
"Opponent", "Patentee"),
wantHeadDE: "Im Einspruchsverfahren",
wantClaimDE: "Einsprechende(r)",
wantDefDE: "Patentinhaber(in)",
wantSubjDE: "Einspruch gegen das Streitpatent",
wantClaimEN: "Opponent",
wantDefEN: "Patentee",
},
// ---------------------------------------------------------------
// t-paliad-361 Change 3: the four DE appeal/nullity proceedings now
// carry lexy-confirmed role-label overrides (mig 163). Each case
// passes an EMPTY Project (instance_level unset) to prove the override
// yields the correct designation without relying on the instance path.
// ---------------------------------------------------------------
{
name: "de.inf.olg backfill → Berufungskläger(in)/Berufungsbeklagte(r), instance unset",
project: &models.Project{},
pt: ptRoles("de.inf.olg", "DE",
"Berufungskläger(in)", "Berufungsbeklagte(r)",
"Appellant", "Respondent"),
wantHeadDE: "In dem Rechtsstreit",
wantClaimDE: "Berufungskläger(in)",
wantDefDE: "Berufungsbeklagte(r)",
wantSubjDE: "Patentverletzung",
wantClaimEN: "Appellant",
wantDefEN: "Respondent",
},
{
name: "de.inf.bgh backfill → Revisionskläger(in)/Revisionsbeklagte(r), instance unset",
project: &models.Project{},
pt: ptRoles("de.inf.bgh", "DE",
"Revisionskläger(in)", "Revisionsbeklagte(r)",
"Appellant", "Respondent"),
wantHeadDE: "In dem Rechtsstreit",
wantClaimDE: "Revisionskläger(in)",
wantDefDE: "Revisionsbeklagte(r)",
wantSubjDE: "Patentverletzung",
wantClaimEN: "Appellant",
wantDefEN: "Respondent",
},
{
name: "de.null.bpatg backfill → Nichtigkeitskläger(in)/Beklagte(r) (Patentinhaber(in))",
project: &models.Project{},
pt: ptRoles("de.null.bpatg", "DE",
"Nichtigkeitskläger(in)", "Beklagte(r) (Patentinhaber(in))",
"Nullity claimant", "Defendant (patent proprietor)"),
wantHeadDE: "In der Patentnichtigkeitssache",
wantClaimDE: "Nichtigkeitskläger(in)",
wantDefDE: "Beklagte(r) (Patentinhaber(in))",
wantSubjDE: "Nichtigkeit des Streitpatents",
wantClaimEN: "Nullity claimant",
wantDefEN: "Defendant (patent proprietor)",
},
{
name: "de.null.bgh backfill → Berufungskläger(in)/Berufungsbeklagte(r) (§110 PatG Berufung)",
project: &models.Project{},
pt: ptRoles("de.null.bgh", "DE",
"Berufungskläger(in)", "Berufungsbeklagte(r)",
"Appellant", "Respondent"),
wantHeadDE: "In der Patentnichtigkeitssache",
wantClaimDE: "Berufungskläger(in)",
wantDefDE: "Berufungsbeklagte(r)",
wantSubjDE: "Nichtigkeit des Streitpatents",
wantClaimEN: "Appellant",
wantDefEN: "Respondent",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := resolveCaption(c.project, c.pt)
if got.headingDE != c.wantHeadDE {
t.Errorf("headingDE = %q, want %q", got.headingDE, c.wantHeadDE)
}
if got.claimantDE != c.wantClaimDE {
t.Errorf("claimantDE = %q, want %q", got.claimantDE, c.wantClaimDE)
}
if got.defendantDE != c.wantDefDE {
t.Errorf("defendantDE = %q, want %q", got.defendantDE, c.wantDefDE)
}
if got.subjectDE != c.wantSubjDE {
t.Errorf("subjectDE = %q, want %q", got.subjectDE, c.wantSubjDE)
}
if got.claimantEN != c.wantClaimEN {
t.Errorf("claimantEN = %q, want %q", got.claimantEN, c.wantClaimEN)
}
if got.defendantEN != c.wantDefEN {
t.Errorf("defendantEN = %q, want %q", got.defendantEN, c.wantDefEN)
}
})
}
}
// addCaptionVars must emit bare + _de + _en forms, with the bare form resolved
// to the draft language.
func TestAddCaptionVars_BareResolvesToLang(t *testing.T) {
pt := ptType("de.inf.lg", "DE")
proj := &models.Project{}
bagDE := PlaceholderMap{}
addCaptionVars(bagDE, proj, pt, "de")
if bagDE["caption.heading"] != "In dem Rechtsstreit" {
t.Errorf("DE bare heading = %q", bagDE["caption.heading"])
}
if bagDE["caption.heading_en"] != "In the matter" {
t.Errorf("heading_en = %q", bagDE["caption.heading_en"])
}
bagEN := PlaceholderMap{}
addCaptionVars(bagEN, proj, pt, "en")
if bagEN["caption.heading"] != "In the matter" {
t.Errorf("EN bare heading = %q", bagEN["caption.heading"])
}
}

View File

@@ -0,0 +1,27 @@
package services
// Pins the firm.* namespace (t-paliad-358 A-S1): firm.signature_block must
// be filled from branding.Name, not left empty. Before A-S1 it emitted ""
// ("reserved for Phase 2"), which made every template that referenced
// {{firm.signature_block}} render blank.
import (
"testing"
"mgit.msbls.de/m/paliad/internal/branding"
)
func TestAddFirmVars_SignatureBlockFilledFromBranding(t *testing.T) {
bag := PlaceholderMap{}
addFirmVars(bag)
if got := bag["firm.name"]; got != branding.Name {
t.Errorf("firm.name = %q, want %q", got, branding.Name)
}
if got := bag["firm.signature_block"]; got == "" {
t.Fatal("firm.signature_block is empty — the A-S1 fix should fill it from branding")
}
if got := bag["firm.signature_block"]; got != branding.Name {
t.Errorf("firm.signature_block = %q, want %q (firm identity line, firm-agnostic)", got, branding.Name)
}
}

View File

@@ -26,6 +26,7 @@ var (
_ docforge.VariableResolver = userResolver{}
_ docforge.VariableResolver = proceduralEventResolver{}
_ docforge.VariableResolver = projectResolver{}
_ docforge.VariableResolver = captionResolver{}
_ docforge.VariableResolver = partiesResolver{}
_ docforge.VariableResolver = deadlineResolver{}
)
@@ -48,6 +49,7 @@ func SubmissionVariableCatalogue() []docforge.VariableKey {
userResolver{},
proceduralEventResolver{},
projectResolver{},
captionResolver{},
partiesResolver{},
deadlineResolver{},
).Catalogue()
@@ -149,12 +151,38 @@ func (projectResolver) Keys() []docforge.VariableKey {
vk("project", "project.client_number", "Mandantennummer", "Client number"),
vk("project", "project.matter_number", "Matter-Nummer", "Matter number"),
vk("project", "project.proceeding.code", "Verfahrenstyp (Code)", "Proceeding type (code)"),
vk("project", "project.proceeding.jurisdiction", "Gerichtsbarkeit", "Jurisdiction"),
vk("project", "project.proceeding.name", "Verfahrenstyp", "Proceeding type"),
vk("project", "project.proceeding.name_de", "Verfahrenstyp (DE)", "Proceeding type (DE)"),
vk("project", "project.proceeding.name_en", "Verfahrenstyp (EN)", "Proceeding type (EN)"),
}
}
// captionResolver populates caption.* — the parametric case-caption (Rubrum)
// pieces shared across every render path (merge fallback skeleton, per-code
// .docx templates, Composer caption seeds) so the wording stays unified
// (t-paliad-358 A-S2). Needs the project (instance level) + proceeding type
// (jurisdiction, code, role-label overrides); see addCaptionVars.
type captionResolver struct {
project *models.Project
pt *models.ProceedingType
lang string
}
func (captionResolver) Namespace() string { return "caption" }
func (r captionResolver) Populate(bag PlaceholderMap) {
addCaptionVars(bag, r.project, r.pt, r.lang)
}
func (captionResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("caption", "caption.heading", "Rubrum-Überschrift", "Caption heading"),
vk("caption", "caption.claimant_designation", "Bezeichnung Klägerseite", "Claimant designation"),
vk("caption", "caption.defendant_designation", "Bezeichnung Beklagtenseite", "Defendant designation"),
vk("caption", "caption.versus", "Gegen-Konnektor", "Versus connector"),
vk("caption", "caption.subject", "Streitgegenstand (wegen)", "Subject matter (re)"),
}
}
// partiesResolver populates parties.* from the (already filtered) party list.
type partiesResolver struct{ parties []models.Party }

View File

@@ -185,7 +185,12 @@ func SanitiseSubmissionFileName(s string) string {
s = umlautFolder.Replace(s)
s = strings.Map(func(r rune) rune {
switch r {
case '/', '\\':
// Path separators and the rest of the Windows-reserved set —
// fold to underscore so a case number like "UPC_CFI_123/2026"
// stays one filesystem-safe segment. Spaces and parentheses are
// intentionally preserved: the human-facing download name
// "<date> <keyword> (<case>)" relies on them (t-paliad-354).
case '/', '\\', ':', '*', '?', '<', '>', '|':
return '_'
case '"', '\'':
return -1

View File

@@ -241,9 +241,12 @@ func TestSanitiseSubmissionFileName(t *testing.T) {
"Klageerwiderung": "Klageerwiderung",
"Berufungsbegründung": "Berufungsbegruendung",
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
`Statement of "Defence"`: "Statement of Defence",
` Klage `: "Klage",
"Größe": "Groesse",
`Statement of "Defence"`: "Statement of Defence",
` Klage `: "Klage",
"Größe": "Groesse",
"UPC_CFI_123/2026": "UPC_CFI_123_2026",
"a:b*c?d<e>f|g": "a_b_c_d_e_f_g",
"Klageerwiderung (Frist)": "Klageerwiderung (Frist)",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {

View File

@@ -0,0 +1,334 @@
package docx
// Merge-safe fallback skeleton (t-paliad-358 A-S1).
//
// Why this exists: resolveSubmissionTemplate is the *merge-path* template
// resolver — every caller feeds its result into SubmissionRenderer (merge.go),
// which substitutes {{key}} tokens. Its lower fallback tiers used to fetch the
// universal / firm skeletons from mWorkRepo, but those .docx files were
// repurposed into Composer *bases* (t-paliad-313 Slice B): their bodies now
// carry only {{#section:KEY}} anchor markers, which the Composer (compose.go)
// splices section content into. placeholderRegex deliberately ignores markers
// that start with '#' or '/', so when an anchors-only base reaches merge.go the
// markers pass through verbatim and the lawyer sees literal
// "{{#section:letterhead}}…" junk in Word (kepler audit §1 Path 3 / §2).
//
// Only de.inf.lg.erwidg ships a real per-code merge template today, so every
// other submission_code's one-click /generate (and the v1 draft-export
// fallback) was exposed to that junk. This builder gives the merge path a
// self-contained, merge-safe fallback: a clean basic Schriftsatz with a
// data-driven basic Rubrum built from real {{key}} placeholders the variable
// bag fills. No Gitea round-trip, no Composer anchors, always available.
//
// Scope: a *basic* caption with parametric, forum-resolved wording (the
// caption.* keys; A-S2) and a minimal firm-agnostic page-header letterhead
// (word/header1.xml → {{firm.name}}; A-S3). Everything firm-facing flows from
// branding via the {{firm.*}} / {{caption.*}} bag keys — no hard-coded firm
// name anywhere — so a FIRM_NAME redeploy / non-HLC deployment never ships the
// wrong firm. This is the fallback starter, deliberately minimal; the full
// firm chrome lives in the firm-skeleton Composer base.
import (
"archive/zip"
"bytes"
"fmt"
"strings"
"time"
)
// fallbackSkeletonTime pins every zip entry's mtime so the generated bytes are
// byte-stable across calls (cheap to cache / diff, no spurious churn).
var fallbackSkeletonTime = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
// BuildFallbackSkeleton returns a minimal, Word-compatible .docx whose body is
// a basic Schriftsatz with a data-driven Rubrum. Every dynamic value is a real
// {{key}} placeholder resolved by SubmissionVarsService, so rendering it
// through SubmissionRenderer.Render produces a merged document — never the
// {{#section:…}} junk an anchors-only Composer base would.
//
// lang selects the static label language ("en" → English labels + EN date /
// our-side aliases; anything else → German). The returned bytes are
// self-contained: a firm-agnostic {{firm.name}} page-header letterhead, no
// external media, no macros.
func BuildFallbackSkeleton(lang string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) error {
w, err := zw.CreateHeader(&zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fallbackSkeletonTime,
})
if err != nil {
return fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
return nil
}
for _, part := range []struct{ name, body string }{
{"[Content_Types].xml", fallbackContentTypesXML},
{"_rels/.rels", fallbackRootRelsXML},
{"word/_rels/document.xml.rels", fallbackDocumentRelsXML},
{"word/styles.xml", fallbackStylesXML},
{"word/header1.xml", fallbackHeaderXML},
{"word/document.xml", buildFallbackDocumentXML(lang)},
} {
if err := add(part.name, part.body); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
const fallbackContentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
</Types>`
// fallbackHeaderXML is the firm-agnostic page-header letterhead (t-paliad-358
// A-S3). It carries only the {{firm.name}} placeholder — filled from branding
// by the variable bag — so a generated fallback document repeats a correct
// firm identity on every page without ever hard-coding a firm name. Minimal by
// design: the merge fallback is a starter, not the full firm chrome.
const fallbackHeaderXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>{{firm.name}}</w:t></w:r></w:p>
</w:hdr>`
const fallbackRootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`
const fallbackDocumentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>
</Relationships>`
const fallbackStylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
</w:style>
</w:styles>`
// fallbackLabels holds the language-dependent static text for the skeleton.
// Dynamic values stay as {{key}} placeholders regardless of language. The
// caption pieces (heading / designations / versus / subject) are themselves
// {{caption.*}} placeholders so the Rubrum wording is the SAME parametric
// caption the per-code templates and Composer seeds use (t-paliad-358 A-S2) —
// resolved per forum by addCaptionVars from the variable bag.
type fallbackLabels struct {
editor string // "Bearbeiter:" / "Attorney:"
dateKey string // {{today.long_de}} / {{today.long_en}}
caseNo string // "Aktenzeichen:" / "Case no.:"
heading string // {{caption.heading_de}} / {{caption.heading_en}}
representedBy string // "vertreten durch" / "represented by"
claimantDesig string // — {{caption.claimant_designation_*}} —
versus string // {{caption.versus_de}} / {{caption.versus_en}}
defendantDesig string
others string // "Weitere Beteiligte:" / "Further parties:"
wegen string // "wegen" / "re"
subjectKey string // {{caption.subject_de}} / {{caption.subject_en}}
subjectLabel string // "Betreff" / "Subject"
patent string // "Streitpatent:" / "Patent in suit:"
proceeding string // "Verfahrensart:" / "Proceeding:"
ourSideKey string // {{project.our_side_de}} / {{project.our_side_en}}
bodyHint string // editorial placeholder for the actual submission text
closing string // "Schlussformel" / "Closing"
}
func fallbackLabelsFor(lang string) fallbackLabels {
if strings.EqualFold(lang, "en") {
return fallbackLabels{
editor: "Attorney:",
dateKey: "{{today.long_en}}",
caseNo: "Case no.:",
heading: "{{caption.heading_en}}",
representedBy: "represented by",
claimantDesig: "— {{caption.claimant_designation_en}} —",
versus: "{{caption.versus_en}}",
defendantDesig: "— {{caption.defendant_designation_en}} —",
others: "Further parties:",
wegen: "re",
subjectKey: "{{caption.subject_en}}",
subjectLabel: "Subject",
patent: "Patent in suit:",
proceeding: "Proceeding:",
ourSideKey: "{{project.our_side_en}}",
bodyHint: "[Body of the submission goes here. This is a basic skeleton — fill in according to the submission type.]",
closing: "Closing",
}
}
return fallbackLabels{
editor: "Bearbeiter:",
dateKey: "{{today.long_de}}",
caseNo: "Aktenzeichen:",
heading: "{{caption.heading_de}}",
representedBy: "vertreten durch",
claimantDesig: "— {{caption.claimant_designation_de}} —",
versus: "{{caption.versus_de}}",
defendantDesig: "— {{caption.defendant_designation_de}} —",
others: "Weitere Beteiligte:",
wegen: "wegen",
subjectKey: "{{caption.subject_de}}",
subjectLabel: "Betreff",
patent: "Streitpatent:",
proceeding: "Verfahrensart:",
ourSideKey: "{{project.our_side_de}}",
bodyHint: "[Hier folgt der Schriftsatztext. Diese Skelett-Vorlage trägt keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ergänzen.]",
closing: "Schlussformel",
}
}
// buildFallbackDocumentXML emits the document body. Layout: firm header line →
// court + case number → basic Rubrum (heading / claimant / vs / defendant /
// others / wegen-subject) → patent details → submission body placeholder →
// closing (date / author / firm signature block). Caption wording comes from
// the shared {{caption.*}} keys. Every placeholder occupies its own run so the
// renderer's pass-1 single-run substitution catches it.
func buildFallbackDocumentXML(lang string) string {
l := fallbackLabelsFor(lang)
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
b.WriteString(`<w:body>`)
// Author / date block. The firm identity itself lives in the page-header
// letterhead (word/header1.xml → {{firm.name}}), so it is not repeated
// here (t-paliad-358 A-S3).
fbPlain(&b, l.editor+" {{user.display_name}}")
fbPlain(&b, "{{user.email}} · {{user.office}}")
fbPlain(&b, l.dateKey)
// Court + case number.
fbHeading2(&b, "{{project.court}}")
fbPlain(&b, l.caseNo+" {{project.case_number}}")
fbPlain(&b, l.proceeding+" {{project.proceeding.name}}")
// Basic Rubrum — parametric caption.* wording.
fbHeading2(&b, l.heading)
fbPlain(&b, "{{parties.claimant.name}}")
fbPlain(&b, l.representedBy+" {{parties.claimant.representative}}")
fbBold(&b, l.claimantDesig)
fbPlain(&b, "")
fbPlain(&b, l.versus)
fbPlain(&b, "")
fbPlain(&b, "{{parties.defendant.name}}")
fbPlain(&b, l.representedBy+" {{parties.defendant.representative}}")
fbBold(&b, l.defendantDesig)
fbPlain(&b, l.others+" {{parties.other.name}}")
fbPlain(&b, l.wegen+" "+l.subjectKey)
// Patent in suit.
fbHeading2(&b, l.subjectLabel)
fbPlain(&b, l.patent+" {{project.patent_number}}")
fbPlain(&b, "{{project.title}} ("+l.ourSideKey+")")
// Body placeholder for the actual submission text.
fbPlain(&b, "")
fbPlain(&b, l.bodyHint)
fbPlain(&b, "")
// Closing / signature.
fbHeading2(&b, l.closing)
fbPlain(&b, l.dateKey)
fbPlain(&b, "{{user.display_name}}")
fbPlain(&b, "{{firm.signature_block}}")
// Section properties: reference the firm-agnostic page-header letterhead.
b.WriteString(`<w:sectPr><w:headerReference w:type="default" r:id="rId2"/></w:sectPr>`)
b.WriteString(`</w:body></w:document>`)
return b.String()
}
func fbHeading2(b *strings.Builder, text string) { fbParagraph(b, "Heading2", text, false) }
func fbPlain(b *strings.Builder, text string) { fbParagraph(b, "", text, false) }
func fbBold(b *strings.Builder, text string) { fbParagraph(b, "", text, true) }
// fbParagraph writes one paragraph with the given pStyle and optional bold runs.
// Placeholders are split into their own runs so the renderer's format-preserving
// pass-1 substitution catches each one independently.
func fbParagraph(b *strings.Builder, style, text string, bold bool) {
b.WriteString(`<w:p>`)
if style != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(style)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range fbSplitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if bold {
b.WriteString(`<w:rPr><w:b/></w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(fbXMLEscape(seg))
b.WriteString(`</w:t></w:r>`)
}
b.WriteString(`</w:p>`)
}
// fbSplitOnPlaceholders splits text so each {{placeholder}} sits in its own
// segment (and therefore its own run), keeping every key inside a single run.
func fbSplitOnPlaceholders(s string) []string {
if s == "" {
return []string{""}
}
var out []string
for {
open := strings.Index(s, "{{")
if open < 0 {
out = append(out, s)
return out
}
closeIdx := strings.Index(s[open:], "}}")
if closeIdx < 0 {
out = append(out, s)
return out
}
end := open + closeIdx + 2
if open > 0 {
out = append(out, s[:open])
}
out = append(out, s[open:end])
s = s[end:]
if s == "" {
return out
}
}
}
func fbXMLEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

View File

@@ -0,0 +1,151 @@
package docx
// Tests for the merge-safe fallback skeleton + the merge-path guards that
// keep anchors-only Composer bases from leaking {{#section:…}} junk into a
// merged document (t-paliad-358 A-S1).
import (
"archive/zip"
"bytes"
"io"
"strings"
"testing"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// readFallbackZipPart pulls a named part out of a .docx zip.
func readFallbackZipPart(t *testing.T, b []byte, name string) string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open %s: %v", name, err)
}
defer rc.Close()
body, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return string(body)
}
t.Fatalf("zip part %s not found", name)
return ""
}
func TestBuildFallbackSkeleton_IsMergeSafeAndRendersRubrum(t *testing.T) {
for _, lang := range []string{"de", "en"} {
t.Run(lang, func(t *testing.T) {
tpl, err := BuildFallbackSkeleton(lang)
if err != nil {
t.Fatalf("BuildFallbackSkeleton(%q): %v", lang, err)
}
if !HasMergePlaceholders(tpl) {
t.Fatalf("fallback skeleton (%s) reported no merge placeholders", lang)
}
// The fallback must never carry Composer section anchors — it is a
// merge template, not a Composer base.
body := readMergeDocumentXML(t, tpl)
if strings.Contains(body, "{{#section:") || strings.Contains(body, "{{/section:") {
t.Fatalf("fallback skeleton (%s) leaked a section anchor: %s", lang, body)
}
// A-S3: firm-agnostic page-header letterhead carrying {{firm.name}}
// (no hard-coded firm name).
hdr := readFallbackZipPart(t, tpl, "word/header1.xml")
if !strings.Contains(hdr, "{{firm.name}}") {
t.Errorf("fallback skeleton (%s) header lacks {{firm.name}}: %s", lang, hdr)
}
// Render it the way the merge path does and confirm the basic Rubrum
// fills from the bag (claimant + defendant + court + case number).
suffix := "_" + lang
r := NewSubmissionRenderer()
out, err := r.Render(tpl, docforge.PlaceholderMap{
"firm.name": "HLC",
"firm.signature_block": "HLC",
"user.display_name": "Dr. Max Mustermann",
"parties.claimant.name": "Acme Corp.",
"parties.defendant.name": "Globex GmbH",
"project.court": "Landgericht München I",
"project.case_number": "7 O 1234/26",
"project.patent_number": "EP 1 234 567 B1",
"caption.heading" + suffix: "FORUM-HEADING",
"caption.claimant_designation" + suffix: "FORUM-CLAIMANT",
"caption.defendant_designation" + suffix: "FORUM-DEFENDANT",
"caption.versus" + suffix: "FORUM-VS",
"caption.subject" + suffix: "FORUM-SUBJECT",
}, docforge.DefaultMissingMarker(lang))
if err != nil {
t.Fatalf("render fallback (%s): %v", lang, err)
}
rendered := readMergeDocumentXML(t, out)
for _, want := range []string{
"Acme Corp.", "Globex GmbH", "Landgericht München I",
"7 O 1234/26", "EP 1 234 567 B1", "HLC",
// Parametric caption wording fills from the shared caption.* keys.
"FORUM-HEADING", "FORUM-CLAIMANT", "FORUM-DEFENDANT", "FORUM-VS", "FORUM-SUBJECT",
} {
if !strings.Contains(rendered, want) {
t.Errorf("rendered fallback (%s) missing %q\n%s", lang, want, rendered)
}
}
// No unresolved placeholder braces for the keys we bound.
if strings.Contains(rendered, "{{parties.claimant.name}}") {
t.Errorf("rendered fallback (%s) left an unresolved bound placeholder", lang)
}
})
}
}
func TestHasMergePlaceholders(t *testing.T) {
mergeSafe := minimalMergeDOCX(t, `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`+
`<w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`)
if !HasMergePlaceholders(mergeSafe) {
t.Error("expected merge-safe body to report placeholders")
}
anchorsOnly := minimalMergeDOCX(t, `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`+
`<w:p><w:r><w:t>{{#section:letterhead}}</w:t></w:r></w:p>`+
`<w:p><w:r><w:t>{{/section:letterhead}}</w:t></w:r></w:p></w:body></w:document>`)
if HasMergePlaceholders(anchorsOnly) {
t.Error("anchors-only Composer base must NOT report merge placeholders")
}
noPlaceholders := minimalMergeDOCX(t, `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`+
`<w:p><w:r><w:t>Letterhead only, no merge fields.</w:t></w:r></w:p></w:body></w:document>`)
if HasMergePlaceholders(noPlaceholders) {
t.Error("placeholder-free body must NOT report merge placeholders")
}
}
// TestRender_StripsStraySectionMarkers is the depth-in-defense check: if an
// anchors-only Composer base ever reaches the merge path, the output must be
// clean (markers stripped), never literal "{{#section:…}}" junk.
func TestRender_StripsStraySectionMarkers(t *testing.T) {
tmpl := minimalMergeDOCX(t, `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`+
`<w:p><w:r><w:t>{{#section:letterhead}}</w:t></w:r></w:p>`+
`<w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p>`+
`<w:p><w:r><w:t>{{/section:letterhead}}</w:t></w:r></w:p></w:body></w:document>`)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
body := readMergeDocumentXML(t, out)
if strings.Contains(body, "{{#section:") || strings.Contains(body, "{{/section:") {
t.Errorf("section markers survived the merge: %s", body)
}
if !strings.Contains(body, "HLC") {
t.Errorf("real placeholder around the markers was not substituted: %s", body)
}
}

View File

@@ -79,6 +79,19 @@ func htmlPreviewWrapper(key, value string) string {
// always starts with an ASCII letter.
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
// sectionMarkerRegex matches a Composer section anchor —
// {{#section:KEY}} (open) or {{/section:KEY}} (close). These markers are
// the Composer's (compose.go) splice points; they are NOT merge
// placeholders (placeholderRegex ignores them because they start with
// '#' / '/'). When an anchors-only Composer base is mistakenly fed to
// the merge path, the markers would otherwise survive verbatim into the
// output and show up as literal "{{#section:letterhead}}…" junk in Word
// (kepler audit §1 Path 3). substituteInDocumentXML strips them
// defensively so no merged document ever leaks a Composer anchor — the
// normal merge path uses a merge-safe template (BuildFallbackSkeleton),
// this is depth-in-defense for any stray anchors-only carrier.
var sectionMarkerRegex = regexp.MustCompile(`\{\{\s*[#/]\s*section\s*:\s*[A-Za-z0-9_.\-]+\s*\}\}`)
// SubmissionRenderer renders a .docx template into a .docx output by
// substituting {{placeholder}} tokens with values from a docforge.PlaceholderMap.
// Stateless; safe for concurrent use.
@@ -181,6 +194,37 @@ func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars docforge.Plac
return docXMLToHTML(merged), nil
}
// HasMergePlaceholders reports whether the .docx at templateBytes carries
// at least one real {{key}} merge placeholder in word/document.xml. The
// merge path (resolveSubmissionTemplate → Render) needs this to tell a
// merge-usable template apart from an anchors-only Composer base (whose
// body holds only {{#section:KEY}} markers, which placeholderRegex
// ignores) or a placeholder-free letterhead (.dotm) — both of which would
// render an empty Rubrum. Returns false on any read/zip error so the
// caller safely falls back to a known merge-safe skeleton
// (t-paliad-358 A-S1).
func HasMergePlaceholders(templateBytes []byte) bool {
clean, err := ConvertDotmToDocx(templateBytes)
if err != nil {
return false
}
zr, err := zip.NewReader(bytes.NewReader(clean), int64(len(clean)))
if err != nil {
return false
}
for _, entry := range zr.File {
if entry.Name != "word/document.xml" {
continue
}
body, err := readMergeZipEntry(entry)
if err != nil {
return false
}
return placeholderRegex.Match(body)
}
return false
}
// isWordXMLEntry returns true for the .docx parts that contain
// substitutable text. We touch document.xml plus header*.xml and
// footer*.xml (templates may put firm letterhead in a header) but
@@ -227,6 +271,7 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
// the formatting properties of the first run.
func substituteInDocumentXML(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
body = stripSectionMarkers(body)
replaced := substituteInTextNodes(body, vars, missing, wrap)
if !needsCrossRunMerge(replaced) {
return replaced
@@ -234,6 +279,24 @@ func substituteInDocumentXML(body []byte, vars docforge.PlaceholderMap, missing
return substituteAcrossRuns(replaced, vars, missing, wrap)
}
// stripSectionMarkers removes any Composer section anchor ({{#section:KEY}}
// / {{/section:KEY}}) from the <w:t> text nodes so a stray anchors-only
// carrier rendered through the merge path produces a clean document
// instead of literal "{{#section:…}}" junk. Markers are removed token-only
// (the enclosing run/paragraph survives, just emptied of the marker), which
// is safe because the generator emits each marker in its own paragraph.
func stripSectionMarkers(body []byte) []byte {
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
sub := wTextNodeRegex.FindSubmatch(match)
contents := xmlDecode(string(sub[2]))
if !sectionMarkerRegex.MatchString(contents) {
return match
}
stripped := sectionMarkerRegex.ReplaceAllString(contents, "")
return []byte(`<w:t` + string(sub[1]) + `>` + xmlEncode(stripped) + `</w:t>`)
})
}
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
// the contents.
var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)

401
pkg/nomen/nomen.go Normal file
View File

@@ -0,0 +1,401 @@
// Package nomen renders human- and machine-facing names from a reusable
// composition model (Latin nomen, "name"). It is the engine extracted from
// the one-off naming functions that shipped for submission draft titles
// (m/paliad#155) and exported .docx filenames (t-paliad-354); see
// docs/plans/prd-filename-generator-2026-06-01.md.
//
// The package is pure: no DB, no HTTP, no filesystem, and no dependency on
// the rest of paliad. A consumer supplies a Composition (the template), a
// VarResolver (the values for this render), and a RenderTarget (the output
// policy — a human title vs a sanitised filename). The same Composition
// renders to different targets.
//
// # Separator semantics (trailing, not leading)
//
// Each Segment carries a Sep that is the separator emitted AFTER it, and
// only when a later segment also emits. So the separator between two
// consecutive emitted segments is owned by the LEFT segment. This is what
// lets a composition stay byte-faithful when a middle segment drops out:
// the draft-title scheme joins the date to the party trio with a space and
// the parties to each other with " ./. ", and when the client is absent the
// date must still join the forum with a space — which only works if the
// space is the date's trailing separator, independent of which identity
// segment happens to come next. A leading-separator model can't express
// that (the same segment would need two different leading separators
// depending on what was omitted before it).
package nomen
import "strings"
// Version is the current Composition schema version. Stored compositions
// (firm/user overrides, once those land) carry it so a future change can be
// detected and migrated; the seed system defaults always use this value.
const Version = 1
// MaxSegments is a sanity cap on how many segments a single composition may
// have. The wired artifacts use 34; the cap exists so a stored override
// can't smuggle an unbounded blob through Validate.
const MaxSegments = 16
// MissingKind selects what a segment contributes when its variable resolves
// empty or unavailable.
type MissingKind int
const (
// KindOmit drops the segment entirely (and suppresses its trailing
// separator). Generalises the #155 "drop empty segment with its
// separator" rule.
KindOmit MissingKind = iota
// KindPlaceholder substitutes a stand-in value for missing data, e.g.
// "(Az. folgt)" for an as-yet-unknown case number (t-paliad-354).
KindPlaceholder
// KindLiteral substitutes a fixed label. Functionally identical to a
// placeholder today, but kept distinct so the settings UI can word them
// differently ("fixed label" vs "stand-in for missing data") and so
// future policy can diverge.
KindLiteral
)
// MissingRule is a segment's missing-value policy. Value is ignored for
// KindOmit.
type MissingRule struct {
Kind MissingKind `json:"kind"`
Value string `json:"value,omitempty"`
}
// Omit returns a drop-when-empty rule.
func Omit() MissingRule { return MissingRule{Kind: KindOmit} }
// Placeholder returns a substitute-when-empty rule for missing data.
func Placeholder(v string) MissingRule { return MissingRule{Kind: KindPlaceholder, Value: v} }
// Literal returns a substitute-when-empty rule for a fixed label.
func Literal(v string) MissingRule { return MissingRule{Kind: KindLiteral, Value: v} }
// Segment is one piece of a composition.
type Segment struct {
// Var is the variable key resolved against the catalog/resolver.
Var string `json:"var"`
// Sep is the trailing separator: emitted AFTER this segment iff a later
// segment also emits. The last emitted segment's Sep is never used.
Sep string `json:"sep,omitempty"`
// Wrap surrounds the resolved value with fixed literals, e.g.
// {"(", ")"} for a bracketed case number. The wrap is part of the frame:
// it is NOT passed through the target's value sanitiser.
Wrap [2]string `json:"wrap,omitempty"`
// Missing is the policy applied when Var resolves empty/unavailable.
Missing MissingRule `json:"missing"`
}
// Composition is the canonical, validated name template: an ordered list of
// segments plus a schema version.
type Composition struct {
Version int `json:"v"`
Segments []Segment `json:"segments"`
}
// VarResolver yields a variable's value for one render. It returns
// (value, true) when the variable is available (even if the consumer wants
// to force it empty by returning ("", true) — though the engine treats a
// blank value as absent regardless), and ("", false) when the variable is
// unavailable in this context, in which case the segment's MissingRule
// applies.
type VarResolver func(key string) (value string, ok bool)
// RenderTarget post-processes a render. SanitiseValue runs per resolved
// variable value (before wrapping/assembly); Finalise runs once on the
// fully-assembled string (e.g. to append an extension).
type RenderTarget interface {
Name() string
SanitiseValue(v string) string
Finalise(assembled string) string
}
// Render assembles the name. For each segment in order it resolves the
// value (applying the MissingRule when empty), sanitises the value via the
// target, wraps it, and joins it to the previous emitted segment using that
// previous segment's trailing Sep. The assembled string is passed once
// through Finalise.
func (c Composition) Render(resolve VarResolver, target RenderTarget) string {
var b strings.Builder
var pendingSep string
emitted := false
for _, seg := range c.Segments {
val, ok := effectiveValue(seg, resolve)
if !ok {
continue
}
val = target.SanitiseValue(val)
piece := seg.Wrap[0] + val + seg.Wrap[1]
if emitted {
b.WriteString(pendingSep)
}
b.WriteString(piece)
pendingSep = seg.Sep
emitted = true
}
return target.Finalise(b.String())
}
// effectiveValue resolves a segment to its emitted value, applying the
// MissingRule. The second return is false when the segment contributes
// nothing (omit, or a placeholder/literal whose value is itself blank).
// A resolved value is trimmed; a blank resolved value is treated as absent.
func effectiveValue(seg Segment, resolve VarResolver) (string, bool) {
val, ok := resolve(seg.Var)
val = strings.TrimSpace(val)
if ok && val != "" {
return val, true
}
switch seg.Missing.Kind {
case KindPlaceholder, KindLiteral:
v := strings.TrimSpace(seg.Missing.Value)
if v == "" {
return "", false
}
return v, true
default: // KindOmit
return "", false
}
}
// VarDef is a variable's catalog metadata: it drives write-time validation
// and the settings palette. Values come from the per-render VarResolver, not
// from here — the catalog is metadata only.
type VarDef struct {
Key string
Label string // DE primary
LabelEN string
Description string
Group string
}
// VarCatalog is the set of variables available to an artifact, keyed by Var.
type VarCatalog map[string]VarDef
// Validate enforces the structural invariants on a composition against the
// catalog of an artifact. Used on writes (stored firm/user overrides). The
// seed system defaults are validated by a unit test so a typo can't ship.
func (c Composition) Validate(catalog VarCatalog) error {
if c.Version != Version {
return &ValidationError{Msg: "unsupported composition version"}
}
if len(c.Segments) > MaxSegments {
return &ValidationError{Msg: "too many segments"}
}
for _, seg := range c.Segments {
if strings.TrimSpace(seg.Var) == "" {
return &ValidationError{Msg: "segment has empty variable"}
}
if _, ok := catalog[seg.Var]; !ok {
return &ValidationError{Msg: "unknown variable: " + seg.Var}
}
}
return nil
}
// SanitizeForRead applies the forgiving read-path rules to a stored
// composition: bump an unknown version to the current one, and drop any
// segment whose variable is not in the catalog (the catalog shrank since the
// override was written). Returns true if anything changed, so callers can
// decide whether to persist the cleaned value. Mirrors
// DashboardLayoutSpec.SanitizeForRead.
func (c *Composition) SanitizeForRead(catalog VarCatalog) bool {
changed := false
if c.Version != Version {
c.Version = Version
changed = true
}
if len(c.Segments) == 0 {
return changed
}
out := make([]Segment, 0, len(c.Segments))
for _, seg := range c.Segments {
if _, ok := catalog[seg.Var]; !ok {
changed = true
continue
}
out = append(out, seg)
}
c.Segments = out
return changed
}
// ValidationError is returned by Composition.Validate. It is a distinct type
// so consumers can map it to a 400 without string-matching.
type ValidationError struct{ Msg string }
func (e *ValidationError) Error() string { return "nomen: " + e.Msg }
// FuncTarget is the general RenderTarget: an optional per-value sanitiser
// and a fixed suffix appended on finalise. A zero FuncTarget (nil sanitiser,
// empty suffix) is an identity target suitable for human titles.
type FuncTarget struct {
NameVal string
Sanitiser func(string) string
Suffix string
}
// Name reports the target name (e.g. "title", "filename").
func (t FuncTarget) Name() string { return t.NameVal }
// SanitiseValue applies the per-value sanitiser, or is identity when none.
func (t FuncTarget) SanitiseValue(v string) string {
if t.Sanitiser == nil {
return v
}
return t.Sanitiser(v)
}
// Finalise appends the target's suffix to the assembled string.
func (t FuncTarget) Finalise(assembled string) string { return assembled + t.Suffix }
// PlainTarget returns an identity target (no sanitisation, no suffix) for
// human-facing names such as draft titles.
func PlainTarget(name string) RenderTarget { return FuncTarget{NameVal: name} }
// ---------------------------------------------------------------------------
// Token-template shorthand (PRD §7).
//
// A composition has an authoring shorthand: a single line of "{var}" tokens
// and literal text, e.g. "{date} {keyword} ({case_number})". This is the
// settings field the user edits. The shorthand expresses Var, the trailing
// separators (the literal runs between tokens), and a paren Wrap; it does NOT
// express MissingRules (PRD §7 keeps those at the system default, not
// user-editable in v1). ParseTemplate therefore returns every segment with
// KindOmit; the paliad consumer overlays per-var rules from the artifact's
// system default after parsing.
// ---------------------------------------------------------------------------
// Template renders a composition back to its token-template shorthand — the
// exact inverse of ParseTemplate for any composition ParseTemplate can
// produce. Each segment becomes "<wrap0>{var}<wrap1>" and the trailing
// separator is emitted between consecutive segments. The last segment's Sep is
// omitted (it never renders, and emitting it would add unrepresentable
// trailing text), matching ParseTemplate's "no trailing literal" rule.
func (c Composition) Template() string {
var b strings.Builder
for i, seg := range c.Segments {
b.WriteString(seg.Wrap[0])
b.WriteString("{")
b.WriteString(seg.Var)
b.WriteString("}")
b.WriteString(seg.Wrap[1])
if i < len(c.Segments)-1 {
b.WriteString(seg.Sep)
}
}
return b.String()
}
// ParseTemplate compiles the token-template shorthand into a Composition.
//
// - Each "{var}" becomes a segment (var is trimmed; empty or brace-nested
// names are rejected).
// - A "(" immediately before a token and a ")" immediately after it become
// that segment's Wrap; any other parentheses are literal text.
// - The literal run between two tokens (after stripping adjacent wrap parens)
// becomes the LEFT token's trailing separator.
// - Literal text before the first token or after the last token cannot be
// represented in the trailing-separator model and is rejected, so a save
// never silently drops characters. The token palette and both seed
// defaults never produce such text.
//
// Every returned segment has Missing == Omit; callers overlay per-var rules.
// A blank or token-less string yields a zero-segment composition (renders "").
func ParseTemplate(s string) (Composition, error) {
type token struct {
varName string
start, end int // start = index of '{'; end = index just past '}'
}
var toks []token
for i := 0; i < len(s); {
switch s[i] {
case '{':
rel := strings.IndexByte(s[i:], '}')
if rel < 0 {
return Composition{}, &ValidationError{Msg: "unterminated '{' in template"}
}
end := i + rel + 1
name := strings.TrimSpace(s[i+1 : i+rel])
if name == "" {
return Composition{}, &ValidationError{Msg: "empty {} token in template"}
}
if strings.ContainsRune(name, '{') {
return Composition{}, &ValidationError{Msg: "nested '{' in template"}
}
toks = append(toks, token{varName: name, start: i, end: end})
i = end
case '}':
return Composition{}, &ValidationError{Msg: "unexpected '}' in template"}
default:
i++
}
}
if len(toks) == 0 {
if strings.TrimSpace(s) == "" {
return Composition{Version: Version}, nil
}
return Composition{}, &ValidationError{Msg: "template has no {variable} tokens"}
}
// lits[k] is the literal run before token k; lits[len] is the run after the
// last token. lits[k] and the run after token k-1 are the same span.
lits := make([]string, len(toks)+1)
for k := range toks {
prevEnd := 0
if k > 0 {
prevEnd = toks[k-1].end
}
lits[k] = s[prevEnd:toks[k].start]
}
lits[len(toks)] = s[toks[len(toks)-1].end:]
// A token is wrapped iff the literal directly before it ends with '(' and
// the literal directly after it starts with ')'.
wrapped := make([]bool, len(toks))
for k := range toks {
if strings.HasSuffix(lits[k], "(") && strings.HasPrefix(lits[k+1], ")") {
wrapped[k] = true
}
}
segs := make([]Segment, len(toks))
for k := range toks {
segs[k].Var = toks[k].varName
segs[k].Missing = Omit()
if wrapped[k] {
segs[k].Wrap = [2]string{"(", ")"}
}
}
// Derive each segment's trailing separator from the literal run that
// follows it, stripping the wrap parens owned by the adjacent tokens.
for m := 1; m < len(lits); m++ {
run := lits[m]
if wrapped[m-1] { // ')' closing the previous token's wrap
run = strings.TrimPrefix(run, ")")
}
if m < len(toks) && wrapped[m] { // '(' opening the next token's wrap
run = strings.TrimSuffix(run, "(")
}
if m == len(toks) {
if run != "" {
return Composition{}, &ValidationError{Msg: "literal text after the last {variable} is not supported"}
}
continue
}
segs[m-1].Sep = run
}
// Leading literal (before the first token) has no left segment to own it.
lead := lits[0]
if wrapped[0] {
lead = strings.TrimSuffix(lead, "(")
}
if lead != "" {
return Composition{}, &ValidationError{Msg: "literal text before the first {variable} is not supported"}
}
return Composition{Version: Version, Segments: segs}, nil
}

266
pkg/nomen/nomen_test.go Normal file
View File

@@ -0,0 +1,266 @@
package nomen
import (
"strings"
"testing"
)
// mapResolver builds a VarResolver from a map: a present key (even empty) is
// reported present only when its value is non-blank, matching the engine's
// blank-is-absent contract.
func mapResolver(m map[string]string) VarResolver {
return func(key string) (string, bool) {
v, ok := m[key]
return v, ok
}
}
// upperSanitiser is a stand-in per-value transform used to prove SanitiseValue
// runs on values but not on separators or wraps.
func upperSanitiser(s string) string { return strings.ToUpper(s) }
func TestRender_TrailingSeparators(t *testing.T) {
// date joins with " ", parties join with " ./. " — the draft-title shape.
comp := Composition{Version: Version, Segments: []Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "client", Sep: " ./. ", Missing: Omit()},
{Var: "forum", Sep: " ./. ", Missing: Omit()},
{Var: "opponent", Sep: "", Missing: Omit()},
}}
cases := []struct {
name string
vars map[string]string
want string
}{
{"all present", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "forum": "UPC", "opponent": "Novartis"}, "2026-05-31 Bayer AG ./. UPC ./. Novartis"},
{"client absent — date joins forum with a space", map[string]string{"date": "2026-05-31", "forum": "UPC", "opponent": "Novartis"}, "2026-05-31 UPC ./. Novartis"},
{"only opponent absent", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "forum": "UPC"}, "2026-05-31 Bayer AG ./. UPC"},
{"forum absent", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "opponent": "Acme"}, "2026-05-31 Bayer AG ./. Acme"},
{"date only", map[string]string{"date": "2026-05-31"}, "2026-05-31"},
{"blank value treated as absent", map[string]string{"date": "2026-05-31", "client": " ", "forum": "UPC"}, "2026-05-31 UPC"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := comp.Render(mapResolver(c.vars), PlainTarget("title"))
if got != c.want {
t.Errorf("Render = %q, want %q", got, c.want)
}
})
}
}
func TestRender_MissingRulesAndTargets(t *testing.T) {
// The filename shape: keyword literal fallback, case placeholder + wrap,
// a sanitiser + suffix target.
comp := Composition{Version: Version, Segments: []Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "keyword", Sep: " ", Missing: Literal("submission")},
{Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: Placeholder("Az. folgt")},
}}
target := FuncTarget{NameVal: "filename", Sanitiser: upperSanitiser, Suffix: ".docx"}
cases := []struct {
name string
vars map[string]string
want string
}{
{"all present — value sanitised, frame preserved", map[string]string{"date": "2026-05-31", "keyword": "Replik", "case_number": "x/y"}, "2026-05-31 REPLIK (X/Y).docx"},
{"keyword empty → literal fallback (also sanitised)", map[string]string{"date": "2026-05-31", "case_number": "abc"}, "2026-05-31 SUBMISSION (ABC).docx"},
{"case empty → placeholder, wrapped", map[string]string{"date": "2026-05-31", "keyword": "Replik"}, "2026-05-31 REPLIK (AZ. FOLGT).docx"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := comp.Render(mapResolver(c.vars), target)
if got != c.want {
t.Errorf("Render = %q, want %q", got, c.want)
}
})
}
}
func TestRender_EmptyPlaceholderOmits(t *testing.T) {
// A placeholder/literal whose value is itself blank contributes nothing
// (and suppresses its trailing separator).
comp := Composition{Version: Version, Segments: []Segment{
{Var: "a", Sep: "-", Missing: Placeholder(" ")},
{Var: "b", Sep: "", Missing: Omit()},
}}
got := comp.Render(mapResolver(map[string]string{"b": "tail"}), PlainTarget("x"))
if got != "tail" {
t.Errorf("Render = %q, want %q", got, "tail")
}
}
func TestSanitizeForRead(t *testing.T) {
cat := VarCatalog{"date": {Key: "date"}, "client": {Key: "client"}}
// Unknown version bumps; unknown-var segment drops.
c := Composition{Version: 0, Segments: []Segment{
{Var: "date"}, {Var: "gone"}, {Var: "client"},
}}
changed := c.SanitizeForRead(cat)
if !changed {
t.Errorf("SanitizeForRead reported no change, want changed")
}
if c.Version != Version {
t.Errorf("version = %d, want %d", c.Version, Version)
}
if len(c.Segments) != 2 || c.Segments[0].Var != "date" || c.Segments[1].Var != "client" {
t.Errorf("segments = %+v, want [date client]", c.Segments)
}
// A clean composition reports no change.
clean := Composition{Version: Version, Segments: []Segment{{Var: "date"}}}
if clean.SanitizeForRead(cat) {
t.Errorf("clean composition reported change")
}
}
func TestParseTemplate(t *testing.T) {
cases := []struct {
name string
in string
want []Segment // Missing is always Omit from the parser; not asserted here
}{
{
"filename shorthand with wrap",
"{date} {keyword} ({case_number})",
[]Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "keyword", Sep: " ", Missing: Omit()},
{Var: "case_number", Wrap: [2]string{"(", ")"}, Missing: Omit()},
},
},
{
"title shorthand with ./. separators",
"{date} {client} ./. {forum} ./. {opponent}",
[]Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "client", Sep: " ./. ", Missing: Omit()},
{Var: "forum", Sep: " ./. ", Missing: Omit()},
{Var: "opponent", Missing: Omit()},
},
},
{
"adjacent wrap, no surrounding space",
"{date}({case_number})",
[]Segment{
{Var: "date", Missing: Omit()},
{Var: "case_number", Wrap: [2]string{"(", ")"}, Missing: Omit()},
},
},
{
"non-wrap parens stay literal in the separator",
"{date} ) {keyword}",
[]Segment{
{Var: "date", Sep: " ) ", Missing: Omit()},
{Var: "keyword", Missing: Omit()},
},
},
{
"whitespace var names trimmed",
"{ date } {keyword}",
[]Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "keyword", Missing: Omit()},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
comp, err := ParseTemplate(c.in)
if err != nil {
t.Fatalf("ParseTemplate(%q): %v", c.in, err)
}
if len(comp.Segments) != len(c.want) {
t.Fatalf("segments = %+v, want %+v", comp.Segments, c.want)
}
for i, seg := range comp.Segments {
w := c.want[i]
if seg.Var != w.Var || seg.Sep != w.Sep || seg.Wrap != w.Wrap {
t.Errorf("segment %d = %+v, want %+v", i, seg, w)
}
}
})
}
}
func TestParseTemplate_Errors(t *testing.T) {
bad := []string{
"{date", // unterminated
"date}", // stray close
"{} {keyword}", // empty token
"Entwurf {date}", // leading literal
"{date} (final)", // trailing literal
"plain text no tokens", // no tokens
}
for _, in := range bad {
t.Run(in, func(t *testing.T) {
if _, err := ParseTemplate(in); err == nil {
t.Errorf("ParseTemplate(%q) = nil error, want error", in)
}
})
}
}
func TestTemplateRoundTrip(t *testing.T) {
// Format → Parse → Format is stable, and the parsed segment shape matches
// the original (separators + wraps; Missing is overlaid by the consumer,
// not the parser, so it is normalised to Omit on the way back).
comps := map[string]Composition{
"filename": {Version: Version, Segments: []Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "keyword", Sep: " ", Missing: Literal("submission")},
{Var: "case_number", Wrap: [2]string{"(", ")"}, Missing: Placeholder("Az. folgt")},
}},
"title": {Version: Version, Segments: []Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "client", Sep: " ./. ", Missing: Omit()},
{Var: "forum", Sep: " ./. ", Missing: Omit()},
{Var: "opponent", Missing: Omit()},
}},
}
for name, comp := range comps {
t.Run(name, func(t *testing.T) {
tmpl := comp.Template()
parsed, err := ParseTemplate(tmpl)
if err != nil {
t.Fatalf("ParseTemplate(%q): %v", tmpl, err)
}
if got := parsed.Template(); got != tmpl {
t.Errorf("round-trip template = %q, want %q", got, tmpl)
}
if len(parsed.Segments) != len(comp.Segments) {
t.Fatalf("segment count = %d, want %d", len(parsed.Segments), len(comp.Segments))
}
for i, seg := range parsed.Segments {
if seg.Var != comp.Segments[i].Var || seg.Sep != comp.Segments[i].Sep || seg.Wrap != comp.Segments[i].Wrap {
t.Errorf("segment %d = %+v, want var/sep/wrap of %+v", i, seg, comp.Segments[i])
}
}
})
}
}
func TestValidate(t *testing.T) {
cat := VarCatalog{"date": {Key: "date"}, "client": {Key: "client"}}
ok := Composition{Version: Version, Segments: []Segment{{Var: "date"}, {Var: "client"}}}
if err := ok.Validate(cat); err != nil {
t.Fatalf("valid composition rejected: %v", err)
}
bad := []struct {
name string
comp Composition
}{
{"wrong version", Composition{Version: 0, Segments: []Segment{{Var: "date"}}}},
{"unknown var", Composition{Version: Version, Segments: []Segment{{Var: "nope"}}}},
{"empty var", Composition{Version: Version, Segments: []Segment{{Var: " "}}}},
}
for _, c := range bad {
t.Run(c.name, func(t *testing.T) {
if err := c.comp.Validate(cat); err == nil {
t.Errorf("expected validation error, got nil")
}
})
}
}

View File

@@ -152,12 +152,12 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
//
// Structure mirrors a real submission:
//
// 1. Firm letterhead + author block (firm.*, user.*, today.*)
// 2. Court caption (project.*, project.proceeding.*)
// 3. Parties block (parties.*)
// 4. Submission title + legal source (rule.*)
// 5. Deadline (deadline.*)
// 6. Boilerplate body + signature
// 1. Firm letterhead + author block (firm.*, user.*, today.*)
// 2. Court caption (project.*, project.proceeding.*)
// 3. Parties block (parties.*)
// 4. Submission title + legal source (rule.*)
// 5. Deadline (deadline.*)
// 6. Boilerplate body + signature
//
// Order matches what a lawyer drafting a real Klageerwiderung would put
// at the top of the document, so when the lawyer customises this
@@ -180,17 +180,23 @@ func buildDocumentXML() string {
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
plain(&b, "Instanz: {{project.instance_level}}")
heading2(&b, "In der Patentstreitsache")
// Rubrum — parametric caption.* wording (t-paliad-358 A-S2). Heading,
// designations, versus connector and "wegen" subject resolve per forum
// from the variable bag (addCaptionVars), so this caption renders the same
// wording as the Composer seeds and the merge-fallback skeleton.
heading2(&b, "{{caption.heading_de}}")
plain(&b, "{{parties.claimant.name}}")
plain(&b, "vertreten durch {{parties.claimant.representative}}")
bold(&b, "— Klägerin —")
bold(&b, "— {{caption.claimant_designation_de}} —")
plain(&b, "")
plain(&b, "gegen")
plain(&b, "{{caption.versus_de}}")
plain(&b, "")
plain(&b, "{{parties.defendant.name}}")
plain(&b, "vertreten durch {{parties.defendant.representative}}")
bold(&b, "— Beklagte —")
bold(&b, "— {{caption.defendant_designation_de}} —")
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
plain(&b, "")
plain(&b, "wegen {{caption.subject_de}}")
heading2(&b, "Betreff")
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
@@ -227,8 +233,11 @@ func buildDocumentXML() string {
plain(&b, "{{today.long_de}}")
plain(&b, "")
plain(&b, "{{user.display_name}}")
plain(&b, "{{firm.name}}")
plainOptional(&b, "{{firm.signature_block}}")
// firm.signature_block now carries the firm identity (branding.Name) since
// A-S1, so the standalone {{firm.name}} line that used to sit here was a
// duplicate — dropped (t-paliad-358 A-S2). The firm appears once, via the
// signature block.
plain(&b, "{{firm.signature_block}}")
// English-locale exercise — lets the lawyer verify the EN long-form
// date and EN proceeding name resolve correctly when the user's