Compare commits

...

67 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
264cc39a6b chore(builder): B6 — mobile basic-read + dead U0-U4 cleanup + i18n finalise (t-paliad-350)
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
Litigation Builder slice B6 (m/paliad#153 PRD §7.1 + §7.4 + §10) — last
slice of the train; the Builder is now complete.

Mobile basic-read (<640px, PRD §10):
- builder.ts wireMobileGuard — a capture-phase document click listener that,
  only when matchMedia("(max-width:640px)") matches, intercepts taps on
  mutating affordances (rename/share/promote/new-scenario/add-proceeding +
  every in-triplet button/input/select), preventDefault+stopPropagation,
  and surfaces a "Auf größerem Bildschirm öffnen" toast. Desktop code paths
  are untouched (guard early-returns off-mobile). Column-triplets already
  collapse to a single column via the reused .fr-columns-view @640px rule;
  reading (open/switch scenarios, search, mode tabs) stays fully functional.
- global.css — .builder-mobile-toast + full-bleed modal on phones.

Dead U0-U4 catalog cleanup (PRD §7.4) — deleted, no remaining references
(grep "from.*fristenrechner-|from.*verfahrensablauf" shows only the kept
verfahrensablauf-core + verfahrensablauf-detail-mode the builder reuses):
- client/fristenrechner-mode-a.ts, fristenrechner-result.ts(+test),
  fristenrechner-wizard.ts(+test)
- client/verfahrensablauf.ts, client/views/event-card-choices.ts,
  client/views/verfahrensablauf-state.ts(+test)
- components/VerfahrensablaufBody.tsx
(/tools/fristenrechner + /tools/verfahrensablauf stay as 301 redirects to
/tools/procedures — handlers already redirectToProcedures.)

i18n finalised (DE+EN): removed 4 duplicate deadlines.party.* keys per
block (behaviour-preserving — the later, winning copy is kept) and added
the missing DE "cal.today". Codegen clean, no dupes, full DE/EN parity.

go build/vet clean; bun build + 227 frontend tests pass. Playwright-
verified: at 375px the triplet collapses to one column + the scenario
list reads, while "+ Verfahren hinzufügen" and "Teilen" are blocked
(toast shown, no action); at 1280px the same actions work normally.
2026-05-29 20:44:40 +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
mAi
d913f4fc30 feat(builder): B5 — share + promote-to-project wizard (t-paliad-350)
Litigation Builder slice B5 (m/paliad#153 PRD §2.4 + §2.5 + §5.4 + §10).

Backend (internal/services/scenario_builder_service.go):
- ListSharedWithMe — scenarios shared read-only with the caller (the
  "Geteilt mit mir" bucket).
- PromoteScenario — transactional promote-to-project (PRD §10, no partial
  promotions). One Postgres tx: INSERT paliad.projects ('case',
  origin_scenario_id, proceeding_type_id + scenario_flags from the primary
  triplet) → creator team lead + wizard-selected colleagues → parties →
  deadlines (filed→completed, planned→pending with computed/actual date,
  skipped→none) → flip scenario to 'promoted' + promoted_project_id. The
  primary top-level proceeding + its spawned descendants form the one case
  file; additional standalone proceedings are reported via
  ProceedingsSkipped and stay in the scenario. Planned dates come from the
  injected FristenrechnerService.Calculate; court-set/undated planned
  events are skipped + counted.
- NewScenarioBuilderService gains a *FristenrechnerService dep (wired in
  cmd/server/main.go; nil in tests that don't promote).

Handlers/routes:
- GET /api/builder/scenarios/shared, POST /api/builder/scenarios/{id}/promote.

Frontend:
- builder-shares.ts — share modal (HLC user picker + current-shares list +
  revoke).
- builder-promote.ts — 3-step wizard (Bestätigen → Parteien ergänzen →
  Akte-Metadaten) → POST /promote → navigate to /projects/{id}.
- builder.ts — bucketed side panel (Aktiv / Geteilt mit mir / Als Projekt
  angelegt / Archiviert), read-only chrome (watermark + locked affordances)
  for shared/promoted scenarios, wired share + promote buttons, deep-link
  auto-load now covers shared scenarios.
- procedures.tsx — enabled buttons, bucket containers, readonly watermark slot.
- global.css — modal scaffold, share UI, promote wizard, buckets, readonly
  state. i18n.ts + i18n-keys.ts — DE+EN keys.

Tests: TestScenarioBuilderPromote (live-DB) pins the transactional cascade
+ readonly-after-promote + re-promote rejection. go build/vet/test + bun
build clean. Verified end-to-end via Playwright: Journey E (share → 2nd
user read-only watermark + locked canvas, incl. deep-link) and Journey D
(promote wizard 3 steps → project created with party → navigate → scenario
flipped to promoted).
2026-05-29 20:37:05 +02:00
mAi
e091716f48 Merge: t-paliad-349 docforge slice 8 — neutral model + Markdown importer + Exporter iface (m/paliad#157)
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 18:11:39 +02:00
mAi
8763ab013c feat(docforge): slice 8 — neutral model + Markdown importer + Exporter iface (t-paliad-349)
The final slice: land the format-neutral document model with REAL consumers
and unify the Markdown parser — no duplication, byte-identical output.

Neutral model (pkg/docforge/model.go): Document / Block / InlineSpan.
BlockKind values are the stylemap keys. A hyperlink is a span with Link set
+ Children (the label's spans), preserving link boundaries so adjacent
same-URL links stay distinct — byte-exact with the pre-model walker.

Markdown importer (pkg/docforge/markdown): Import(md) → Document. The SINGLE
Markdown parser for docforge — block split, marker detection, inline
bold/italic/link tokenisation, {{placeholder}} pass-through (the b78a984
fix). Relocated out of the docx walker.

docx renderer (pkg/docforge/docx/markdown.go): now RENDERS a Document →
OOXML (RenderDocumentToOOXML); RenderMarkdownToOOXML[WithStyles] = render(
markdown.Import(md)). The shipped submission walker routes through the model,
so there is one parser, not two. The comprehensive byte-exact render tests
(RenderMarkdownToOOXML_*) all PASS unchanged = output identical.

Exporter interface (pkg/docforge/exporter.go, PRD §4 B4): Exporter{Format,
MIMEType, RenderBody(Document)} with the .docx impl (pkg/docforge/docx/
exporter.go). The seam a future PDF/HTML exporter slots into.

Tests: parser tests relocated to the markdown pkg (parseSpans/detectBlockMarker)
+ new importer Document tests + exporter conformance test.

Verification: go build/vet clean; gofmt clean; full NO-DB test suite GREEN
(authoritative — proves no regression); docforge byte-exact render oracle
PASS; composer live test renders through the rewired walker (PASS); bun build
+ bun test 274/274. The shared-DB live run fails ~85 tests across unrelated
services from a harness pq-42P08 $1-type seeding quirk + a stale
deadline_rules test — systemic/environmental (the no-DB run is clean), not
this change.

docforge train complete: 8 slices, the engine extracted + cleaned + a working
author→generate→export loop on uploaded templates, plus the neutral model +
importer + exporter seam for future formats/consumers.

m/paliad#157
2026-05-29 18:10:16 +02:00
mAi
e1e8db7fc9 Merge: t-paliad-349 docforge slice 7 — generation on uploaded templates (m/paliad#157)
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 17:57:30 +02:00
mAi
b746ec36c7 feat(docforge): slice 7 — generation on uploaded templates (t-paliad-349)
A submission draft can now render from an uploaded docforge template
instead of a legacy Gitea base. DB-VERIFIED against TEST_DATABASE_URL (the
head greenlit option C) before commit — not just compiled.

Schema: migration 159 adds submission_drafts.template_version_id (nullable,
FK template_versions ON DELETE SET NULL) — the snapshot pin (PRD A3). A
later template edit creates a new version; the pinned draft keeps rendering
its version.

Draft service: TemplateVersionID on the model + draftColumns + the JOIN
list + DraftPatch (two-level pointer like base_id) + Update SET. Column-sync
verified live (Create_seeds_section_rows + the new pin test both pass).

Export/preview (handlers): a template-version path checked FIRST — load the
carrier via TemplateStore.GetVersion, render via the existing Export/
RenderPreview (the carrier already carries {{slots}}; no Composer/sections
needed). Falls through to base_id / v1 if the pin is missing. Both preview
sites + the view assembly branch on it.

Store: TemplateMeta.VersionID exposes the current version's row id (slice-4
gap — a consumer needs it to pin); populated in List/Get/GetVersion + the
authoring JSON. New GET /api/templates (authenticated, firm-filtered) is the
picker list any lawyer reads; admin authoring endpoints stay gated.

Frontend: the submission editor's base picker now offers uploaded templates
as a 'tpl:<version_id>' optgroup; selecting one PATCHes template_version_id
(clearing base_id) and vice versa — mutually exclusive render paths.

Live test (submission_draft_template_live_test.go, gated): pin round-trips
Update→Get, the uploaded carrier renders ({{firm.name}}→HLC via Export), and
clearing nulls it — all PASS against real Postgres.

Verification: go build/vet/gofmt clean; bun build + bun test 274/274; slice-7
+ slice-4 store + draft/composer live tests PASS against TEST_DATABASE_URL.
Pre-existing env failures (approval/projection seed $1-type quirk,
migration136 stale deadline_rules table) are unrelated — confirmed my branch
touches none of that code.

m/paliad#157
2026-05-29 17:55:31 +02:00
mAi
28aaafeb05 Merge: t-paliad-349 docforge slice 6c — authoring page (frontend) (m/paliad#157)
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 16:08:46 +02:00
mAi
f9331e9bb9 Merge: t-paliad-349 docforge slice 6b — authoring HTTP endpoints (m/paliad#157) 2026-05-29 16:08:46 +02:00
mAi
e53bcf8cc2 Merge: t-paliad-349 docforge slice 6a — authoring core + TemplateStore wiring (m/paliad#157) 2026-05-29 16:08:46 +02:00
mAi
68fcbc6fbf feat(docforge): slice 6c — template authoring page (frontend) (t-paliad-349)
The WYSIWYG authoring surface at /admin/templates (admin-gated page route):
  - templates-authoring.tsx — page shell (upload form, template list,
    workspace: palette / run-addressable preview / placed slots).
  - client/templates-authoring.ts — hydrates it: lists templates, uploads a
    .docx (multipart), renders the run-span preview, builds the variable
    palette from the Go catalogue (GET /api/docforge/variables), and wires
    the select-then-pick gesture: select text within one .docforge-run, click
    a palette variable → POST the slot → re-render with the response. Reuses
    the docforge-editor lib (escapeHtml, catalogue client). Cross-run
    selections rejected with a hint (v1: single-run text slots).
  - build.ts emits dist/templates-authoring.html + bundles the client.
  - handleTemplatesAuthoringPage serves the shell; GET /admin/templates
    registered under adminGate.
  - 12 i18n keys (DE+EN) for the page; i18n-keys.ts regenerated (3079).

Verification: go build/vet/test green (13 pkgs); bun run build.ts clean
(i18n scan passes); bun test 274/274; gofmt-clean. The docx surgery + store
+ catalogue are unit/live-tested. VERIFICATION CEILING: the integrated live
flow (upload→render→select→inject→save in a browser) needs the app running
with DATABASE_URL + Supabase auth + Playwright — verified post-merge, not in
this env.

m/paliad#157
2026-05-29 16:07:43 +02:00
mAi
31e15d4b20 feat(docforge): slice 6b — template authoring HTTP endpoints (t-paliad-349)
Admin-gated authoring API over docforge.TemplateStore + the docx authoring
engine (handlers/templates.go, routes under adminGate):
  GET  /api/admin/templates            — catalog list
  POST /api/admin/templates            — multipart upload → ImportForAuthoring
                                          (validate + detect slots) → Create v1
  GET  /api/admin/templates/{id}       — authoring view (run-addressable
                                          preview + slots)
  POST /api/admin/templates/{id}/slots — InjectSlot at the selection →
                                          AddVersion (re-detect slots from the
                                          new carrier so template_slots mirror
                                          the carrier's actual {{tokens}})

docforge.ErrTemplateNotFound → 404; injection failures (bad selection/key)
→ 400 with the engine's message for the UI to surface. Upload capped at
10 MB. Slot placement creates a version per placement (immutable snapshot);
batching a session into one version on explicit save is a documented
refinement.

Verification: go build/vet clean, handlers test green, gofmt-clean. The docx
surgery + store are unit/live-tested; the integrated HTTP flow is verified
post-merge (needs DATABASE_URL + Supabase auth).

m/paliad#157
2026-05-29 16:03:07 +02:00
mAi
a111a82640 feat(docforge): slice 6a — docx authoring core + TemplateStore wiring (t-paliad-349)
The verifiable backend heart of the authoring surface, before the HTTP +
frontend layers.

pkg/docforge/docx/authoring.go:
  - ImportForAuthoring(carrier) → AuthoringView{PreviewHTML, Slots}: parses
    an uploaded .docx into a run-addressable preview (one
    <span class="docforge-run" data-run="N"> per <w:t>, document order)
    plus the {{placeholder}} slots already present.
  - InjectSlot(carrier, runIndex, selectedText, slotKey) → new carrier:
    replaces the selection inside run N with a {{slot_key}} token. Keys on
    the selected TEXT (not a byte/UTF-16 offset) so umlauts can't desync the
    client selection from the server slice; preview + injection walk runs in
    the identical paragraph→<w:t> order so data-run indices line up.
  - v1 scope: text slots in body paragraphs; out-of-run / cross-run / not-
    found selections return an error the UI turns into a hint.

6 unit tests cover run-addressable preview, slot detection, injection +
round-trip re-import, umlaut/run-targeting, and the error paths (selection
absent, out-of-range run, invalid slot key) — all passing.

Wired PgTemplateStore through the stack (main.go → handlers.Services →
dbServices) so the upcoming authoring endpoints can reach it.

Verification: go build/vet clean, full module test green (13 pkgs), new
files gofmt-clean. The HTTP endpoints + frontend authoring page land next;
their live flow needs the post-merge e2e/manual loop (DB+Supabase).

m/paliad#157
2026-05-29 16:00:27 +02:00
mAi
63a9bedf7e Merge: t-paliad-349 docforge slice 5 — editor pkg + variable catalogue SSOT (m/paliad#157)
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 15:51:38 +02:00
mAi
b8709b903d feat(docforge): slice 5 — docforge-editor pkg + variable catalogue SSOT (t-paliad-349)
Establish the shared frontend editor package and make the Go resolvers the
single source of truth for variable labels.

Go — catalogue SSOT:
  - VariableResolver gains Keys() []VariableKey; ResolverSet gains
    Catalogue(). The 7 submission resolvers implement Keys() with the
    bilingual labels ported from the TS VARIABLE_LABELS table (incl. the
    legacy rule.* aliases). Keys() is entity-independent, so
    SubmissionVariableCatalogue() builds a metadata-only ResolverSet.
  - GET /api/docforge/variables serves the catalogue (auth-gated, static).
  - Tests: docforge ResolverSet (BuildBag merge + Catalogue order) and the
    submission catalogue integrity (no dupes, labels present, spot-checks).

Frontend — frontend/src/lib/docforge-editor/ (new shared package):
  - dom.ts: escapeHtml + cssEscape (pure), with bun tests. Dedupes the two
    identical escapeHtml/escapeHTML copies + the cssEscape copy that lived
    in the submission editor.
  - catalogue.ts: fetchVariableCatalogue() + labelMap() — the client for
    the Go catalogue.
  - submission-draft.ts now imports escapeHtml/cssEscape from the lib and
    fetches the catalogue on boot into state.varLabels (labelFor reads it,
    falling back to the raw key if the fetch fails — graceful degrade). The
    hardcoded VARIABLE_LABELS table is removed; VARIABLE_GROUPS stays
    (presentation: which keys to show + how to section them, legitimately
    frontend).

Scope note: the DOM-coupled editor plumbing (wireDraftVars/focus
preservation/autosave debounce) is extracted in slice 6 alongside its first
reuse — the authoring page — rather than speculatively now (extract with the
consumer; same principle as slices 2-3). Slice 5 lands the pure utilities +
the catalogue, which the slice-6 authoring palette consumes.

Verification: go build/vet/test green (Go files gofmt-clean; handlers.go
pre-existing drift, added region clean); bun run build.ts clean;
bun test 274/274 (incl. 5 new docforge-editor tests).

m/paliad#157
2026-05-29 15:50:42 +02:00
mAi
938222d602 Merge: t-paliad-349 docforge slice 4 — template tables + TemplateStore (m/paliad#157)
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 15:36:46 +02:00
mAi
47deeaf5ed feat(docforge): slice 4 — template tables + TemplateStore (t-paliad-349)
Persistence foundation for authoring (slice 6) + generation-on-templates
(slice 7). docforge owns no tables — it defines the contract; paliad
implements it (litigationplanner pattern).

Migration 158_docforge_templates (additive, generic — NOT submission_*-named
so a second docforge consumer reuses it):
  - templates          — catalog row; current_version_id pins the live
                         version (FK added post-create to break the
                         templates<->versions cycle; ON DELETE SET NULL).
  - template_versions  — immutable snapshots; carrier .docx in a bytea
                         column (the TemplateStore bytea backend) + stylemap
                         jsonb. Versioning = snapshot-at-create (PRD A3).
  - template_slots     — variable slots per version; anchor = sentinel token
                         locating the slot in the carrier OOXML (PRD §5
                         lean), slot_key = the bound variable.
  RLS mirrors submission_bases: firm-shared SELECT for authenticated,
  mutations admin-only + gated in Go (no mutation policy = denied).

docforge root: TemplateStore interface + neutral types (TemplateMeta,
Template, TemplateSlot, *Input, TemplateFilter) + ErrTemplateNotFound.
CarrierBytes is format-opaque []byte so the root never imports the docx
adapter; the exporter wraps (CarrierBytes, Stylemap) into a docx.Carrier.

paliad: PgTemplateStore (sqlx, follows the submission_base_service pattern):
List / Get (current version) / GetVersion (pinned snapshot) / Create
(version 1 + pin) / AddVersion (next version + re-pin), all transactional.
Gated live round-trip test (TEST_DATABASE_URL) covers carrier+stylemap+slot
round-trip and the version bump. No handler wires this yet (PRD: no UI in
slice 4).

Verification: go build ./... clean, go vet clean, gofmt clean, full module
test green, migration NoDuplicateSlot structural test green.

m/paliad#157
2026-05-29 15:35:36 +02:00
mAi
a2da501917 Merge: t-paliad-349 docforge slice 3 — VariableResolver interface + ResolverSet (m/paliad#157)
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 15:27:23 +02:00
mAi
8ea78fd376 refactor(docforge): slice 3 — VariableResolver interface + ResolverSet (t-paliad-349)
Move the variable-bag contract (PlaceholderMap, MissingPlaceholderFn,
DefaultMissingMarker) up to the pkg/docforge root (placeholder.go) — it is
format-neutral, consumed by the resolver layer and any future exporter.
The {{key}} substitution grammar (placeholderRegex, PUA preview sentinels,
replacePlaceholders) stays in pkg/docforge/docx: it is the .docx renderer's
own machinery, not a root concern.

New at the root (vars.go):
  - VariableResolver{Namespace() string; Populate(bag PlaceholderMap)} —
    a PUSH interface, deliberately not pull Resolve(key): some namespaces
    emit a data-dependent key set (parties.claimant.0.name, .1.name, … one
    per party) that a fixed key-by-key pull can't enumerate.
  - ResolverSet + BuildBag() — composes resolvers into one bag, replacing
    the hard-coded addFooVars-then-addBarVars sequencing in Build.

paliad side (submission_vars_resolvers.go): seven resolver types wrap the
UNCHANGED addXxxVars push-builders (firm/today/user/procedural_event/
project/parties/deadline), each capturing the entity it needs. The builder
bodies are byte-for-byte untouched, so the bag is identical by
construction; SubmissionVarsService.Build now wires the applicable
resolvers and calls ResolverSet.BuildBag(). Resolvers stay in paliad
because they read paliad's domain model; a second docforge consumer plugs
its own resolvers into a ResolverSet the same way.

Keys()/Catalogue() (the static key list that will data-drive the authoring
palette + kill the hardcoded VARIABLE_GROUPS in submission-draft.ts) is
deferred to the UI slice that consumes it, sourced from the frontend's
existing labels — building it now, ahead of its consumer, would be
speculative (PRD §4 B3 principle).

Verification: go build ./... clean, go vet clean, full module test green.
Alias-parity (procedural_event ≡ rule) and party-form tests pass unchanged
= bag byte-identical.

m/paliad#157
2026-05-29 15:16:02 +02:00
mAi
e189d3fe6a Merge: t-paliad-349 docforge slice 2 — composer + Carrier to pkg/docforge/docx (m/paliad#157)
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 15:10:33 +02:00
mAi
58907554fc Merge: t-paliad-349 docforge slice 1 — extract .docx engine to pkg/docforge/docx (m/paliad#157) 2026-05-29 15:10:33 +02:00
mAi
9b8a865c5f Merge: t-paliad-349 — docforge PRD (m/paliad#157) 2026-05-29 15:10:33 +02:00
mAi
f8067c2fe5 refactor(docforge): slice 2 — composer to pkg/docforge/docx + Carrier (t-paliad-349)
Move the full compose pipeline (anchor-pair splicing, append-before-sectPr,
hyperlink-rels patching, zip split/repack, final placeholder pass) into
pkg/docforge/docx/compose.go, decoupled from paliad's DB row types. The
engine now owns the entire .docx assembly.

New neutral types in docx:
  - Carrier{Bytes, Stylemap} — the opaque base .docx, preserved
    byte-for-byte outside the spliced regions (the lossless docforge
    carrier for .docx).
  - Section{Key, OrderIndex, Included, ContentMDDE, ContentMDEN} — the
    format-neutral content input.
  - Composer / NewComposer / ComposeOptions on those neutral types.

internal/services keeps SubmissionComposer + ComposeOptions as a thin
mapping wrapper (SubmissionSection -> docx.Section, Base.SectionSpec.Stylemap
+ BaseBytes -> docx.Carrier). handlers + the comprehensive compose_test are
unchanged; the test drives the wrapper end-to-end and its byte-exact OOXML
assertions pass = behaviour preserved.

Retired the slice-1 docx.XMLAttrEscape wrapper + its services forwarder:
compose now calls the local xmlAttrEscape inside the docx package.

Sequencing note: the paragraph-level neutral model (Document/Block/Slot the
PRD §3.2 sketches) is deferred to slice 6, where the authoring importer +
format exporters consume it. Building it now, ahead of any consumer, would
be speculative and risk the byte-identical guarantee for no gain (PRD §4 B3
principle). Carrier is the part of the model that earns its keep this cycle.

Verification: go build ./... clean, go vet clean, full module test green.

m/paliad#157
2026-05-29 14:57:34 +02:00
mAi
78a30a7ee0 refactor(docforge): slice 1 — extract .docx engine to pkg/docforge/docx (t-paliad-349)
Relocate the in-house OOXML machinery out of internal/services into the
first docforge adapter, with zero behaviour change:

  submission_merge.go  -> pkg/docforge/docx/merge.go     (placeholder
                          substitution renderer + preview-HTML emitter)
  submission_md.go     -> pkg/docforge/docx/markdown.go  (Markdown->OOXML
                          walker incl. the b78a984 underscore-fix)
  submission_render.go -> pkg/docforge/docx/dotm.go      (.dotm->.docx)
  + their _test.go files (git-tracked renames, 84-99% identical)

internal/services keeps thin type-alias + forwarder shims
(docforge_shims.go) so every caller in services/handlers/main compiles
and behaves identically: PlaceholderMap, MissingPlaceholderFn,
SubmissionRenderer, HyperlinkAllocator (aliases); NewSubmissionRenderer,
DefaultMissingMarker, RenderMarkdownToOOXML[WithStyles], ConvertDotmToDocx,
SanitiseSubmissionFileName (forwarders). docx.XMLAttrEscape is exported so
submission_compose.go's hyperlink-rels inserts reuse the walker's escaping.

Three mis-filed pretty-printer tests (legalSourcePretty, ourSideDE/EN,
patentNumberUPC) that exercise the vars layer move back to
internal/services/submission_vars_pretty_test.go.

Placeholder grammar + PlaceholderMap stay co-located with the renderer in
docx for now; slice 3 hoists the format-neutral grammar to the docforge
root with the VariableResolver interface.

Verification: go build ./... clean, go vet clean, full module test green
(the byte-exact OOXML golden tests in merge/compose/render pass unchanged
= behaviour preserved). gofmt drift on the moved files is pre-existing
(72/169 services files already drift; no gofmt gate).

m/paliad#157
2026-05-29 14:51:59 +02:00
mAi
091804923a docs(docforge): PRD — modular doc-generator engine (t-paliad-349)
Extract the submission generator into pkg/docforge: neutral document model
+ opaque carrier (lossless .docx), VariableResolver interface per namespace,
pluggable importer/exporter (.docx first), WYSIWYG authoring page, generic
editor UI package. 8-slice train, extract-in-place migration that protects
the b78a984 underscore fix, the placeholderRegex + data-var contracts, and
the building-block/section model.

Includes all 13 of m's decisions (5 prose-grill metaphor + 8 structured).
upc-kommentar deferred as a live consumer (it is Bun/SvelteKit/TS, zero Go);
abstractions sized for a later HTTP veneer.

m/paliad#157
2026-05-29 14:33:26 +02:00
mAi
9201501941 Merge: t-paliad-348 — port engine semantics to TS calc + manuscript regen (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-28 11:03:58 +02:00
mAi
8d8a882f46 Merge: t-paliad-347 B4 — Akte mode + project-backed scenarios (m/paliad#153)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
2026-05-28 10:45:14 +02:00
mAi
9679a98666 feat(builder): B4 — Akte mode + project-backed scenarios (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
PRD §2.3 + §10. Implements the dual-write rule (load-bearing
complexity per PRD §10): project-backed scenarios mirror flag
toggles to paliad.projects.scenario_flags and filed event states
to paliad.deadlines, while kontextfrei scenarios continue writing
only to paliad.scenario_events. Visible affordances: page-header
Akte picker, enabled "Aus Akte" mode tab, Akte banner on the
project-backed canvas, cross-surface scenario-flag-changed
dispatch + listener for live peer-surface coherence.

Backend
- ScenarioBuilderService takes ProjectService + ScenarioFlagsService
  deps so dual-write hits live tables.
- CreateScenarioFromProject seeds a scenario from a project: copies
  proceeding_type_id + scenario_flags, normalises our_side to the
  builder's binary claimant|defendant axis, surfaces existing
  rule-bound deadlines as scenario_events (filed when completed,
  planned otherwise).
- PatchProceeding on a project-backed top-level triplet dual-writes
  scenario_flags to projects.scenario_flags via flagDeltaFromBuilder.
- PatchEvent transitioning to state='filed' on a project-backed
  scenario upserts paliad.deadlines (status='completed', completed_
  at, source='rule') inside the same tx as the scenario_events
  UPDATE — canvas and project surfaces never diverge mid-flight.
- POST /api/builder/scenarios/from-project handler wires the entry
  point.

Frontend
- builder-akte.ts: project list fetch + dropdown render, Akte
  banner, createScenarioFromProject POST helper.
- builder.ts: mode branching — picking an Akte (search hit or
  page-header pick) creates a project-backed scenario and loads it;
  loaded scenarios reflect their origin_project_id on the picker +
  banner; flag toggles on Akte-backed top-level triplets dispatch
  scenario-flag-changed so the Verfahrensablauf strip / project
  surfaces refresh; the builder listens to inbound scenario-flag-
  changed and refetches its scenario when the changed project
  matches origin_project_id.
- procedures.tsx: enable the previously-disabled Aus Akte tab.
- i18n + CSS: builder.akte.banner.prefix key (DE+EN); lime-tinted
  banner styling.

Tests
- TestScenarioBuilderAkteDualWrite (live DB) pins the dual-write
  contract: Akte flag toggle → projects.scenario_flags updated,
  Akte filed event → deadlines row inserted; kontextfrei flag
  toggle leaves projects.scenario_flags untouched, kontextfrei
  filed event leaves deadlines untouched.
- Existing TestScenarioBuilderService passes against the new
  signature (nil deps short-circuit dual-write paths).

Verification: go test ./... + go vet ./... + bun run build all
clean. Playwright smoke against the static dist build confirms
the Akte tab + picker render correctly, fetchAkteProjects fires
on mount, and the scenario-flag-changed CustomEvent dispatches +
receives without runtime errors.

t-paliad-347
2026-05-28 10:44:33 +02:00
115 changed files with 14848 additions and 6194 deletions

View File

@@ -174,6 +174,9 @@ func main() {
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
// t-paliad-315 Slice C — building-block library.
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
// t-paliad-349 docforge slice 4/6 — uploaded-template store
// (Postgres bytea) backing the authoring surface.
templateStoreSvc := services.NewPgTemplateStore(pool)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
@@ -190,6 +193,7 @@ func main() {
SubmissionSection: submissionSectionSvc,
SubmissionComposer: submissionComposerSvc,
SubmissionBuildingBlock: submissionBuildingBlockSvc,
TemplateStore: templateStoreSvc,
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
@@ -233,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
@@ -248,8 +253,11 @@ func main() {
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
// CRUD over the new normalised scenarios + scenario_proceedings
// + scenario_events + scenario_shares tables.
ScenarioBuilder: services.NewScenarioBuilderService(pool),
// + scenario_events + scenario_shares tables. B4 adds the
// Akte-mode dual-write: project-backed scenarios write through
// to paliad.projects.scenario_flags + paliad.deadlines via the
// injected project + scenarioFlags services.
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when

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,495 @@
# PRD — `docforge`: a modular document-generator engine
**Task:** t-paliad-349 (m/paliad#157) · **Author:** leibniz (inventor) · **Date:** 2026-05-29
**Status:** DESIGN — awaiting head's go/no-go on the coder shift.
**Supersedes nothing.** Extends and re-homes the submission generator designed in
`docs/design-submission-generator-2026-05-19.md`, `…-v2-2026-05-26.md`, and
`docs/design-submission-page-2026-05-22.md`.
---
## §0 Premises
### 0.1 What this is
m wants the paliad "doc generator" pulled apart into a clean, reusable engine.
Verbatim direction (2026-05-29):
> I want to be able to create and modify word documents, using variables inside
> the documents, "editing them live" and preview the results, export in the end.
> We should have all that modular to keep it clean. The editor is something else
> than the importing, exporting, variable exchange, data fetching etc.
>
> Currently I can't upload the base document to insert variables into to create a
> template — and then later I want to fill the template using data, modifying it
> manually where necessary, then exporting.
Two distinct user surfaces fall out of that:
- **Authoring** — upload a base `.docx` → place variable slots into it → save as a
reusable template. *This is the gap that does not exist today.*
- **Generation** — pick a template → bind variables to project data → manually edit
where needed (live editor + preview) → export `.docx`.
### 0.2 Today's state (audited 2026-05-29, verified against the live tree)
The current submission generator is ~250 KB of Go plus a 115 KB editor bundle:
- `internal/services/submission_vars.go` — variable resolution across **7 namespaces**
(`firm.*`, `today.*`, `user.*`, `project.*`, `parties.*`, `procedural_event.*`
+ `rule.*` legacy aliases, `deadline.*`). Resolution is a **push** model: each
namespace is a hardcoded `addXxxVars(bag PlaceholderMap, …)` function mutating a
shared `map[string]string`. There is **no interface and no registry** — adding a
namespace means hand-editing `Build` to call a new function.
- `internal/services/submission_merge.go` — placeholder substitution. The regex
(line 95, verified) is `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`.
Two-pass: single-run replace inside each `<w:t>`, then
cross-run merge for fragmented placeholders. HTML preview wraps `(key,value)` in
Private-Use-Area sentinels so `emitTextWithDraftVars` can reconstruct
`<span class="draft-var" data-var="key">…</span>` for click-to-jump.
- `internal/services/submission_md.go` — Markdown → OOXML runs. `parseInlineSpans`
(lines 393446) tokenises bold/italic and **preserves `{{…}}` verbatim**.
- `internal/services/submission_compose.go` — assembles the final `.docx`: unzip base,
render each included section's Markdown to OOXML, splice between
`{{#section:KEY}}…{{/section:KEY}}` anchors, patch hyperlink rels, repack, then run
the placeholder pass.
- `internal/services/submission_{draft,section,building_block,base}_service.go` — the
draft/section/building-block/base data model + CRUD.
- `internal/handlers/submission_{drafts,sections,building_blocks,bases}.go` — the HTTP
wire (the 53 KB `submission_drafts.go` is the bulk).
- `frontend/src/client/submission-draft.ts` — the editor UI (**one `.ts` bundle; there is
no `submission-draft.tsx`** — the brief was wrong on this point).
**OOXML approach (verified):** pure `archive/zip` + string manipulation of
`word/document.xml`. **No third-party docx library**`go.mod` has none.
`lukasjarosch/go-docx` appears *only in a comment* (`submission_merge.go:13`)
documenting why it was rejected (it refuses sibling placeholders in one run). The base
stays byte-for-byte identical outside the regions we touch.
**Reference model:** `pkg/litigationplanner/` (t-paliad-292). The package **owns its
types** and exposes **interfaces for stateful inputs** (`Catalog`, `HolidayCalendar`,
`CourtRegistry`); paliad implements them against Postgres, youpc.org against an embedded
JSON snapshot. `doc.go` is the package doc; `types_wire_test.go` locks the JSON contract.
**docforge mirrors this packaging discipline exactly.**
### 0.3 Premise correction (load-bearing)
The brief lists **two consumers in scope: paliad + upc-commentary**. Verified against the
live repo: **`UPCommentary/upc-kommentar` is Bun + SvelteKit + TypeScript + PLpgSQL —
zero Go.** A SvelteKit app cannot `import` a Go `pkg/`. m's resolution (2026-05-29):
**upc-kommentar is out of scope as a live consumer for now.** docforge is a pure Go
package; paliad imports it in-process like `litigationplanner`. The interfaces are
designed so an HTTP veneer (for a future TS consumer) is *addable later* without rework —
but none is built now. See §4 D-P1 and §8.
### 0.4 Locked constraints (m, confirmed)
- One Go module: `pkg/docforge`. Same packaging model as `pkg/litigationplanner`.
- docforge **owns no database tables** — data flows in via interfaces.
- `.docx` first; engine designed format-pluggable for `.pdf`/`.html`/`.md` later.
- Authoring and Generation are **distinct pages**, but share the engine + the generic
editor plumbing.
- Generation must support **minor manual content edits** (live editor, not just
data-binding).
- Editor stays per-consumer; the **generic UX plumbing** is extracted into a reusable UI
package now.
- The neutral model must be **lossless for our own `.docx`** (the uploaded base is an
opaque carrier, preserved byte-for-byte outside touched regions).
### 0.5 Contracts that MUST survive the refactor
These are invariants. The migration (§6) protects each by moving it *with its file and its
test*, unchanged:
1. **`placeholderRegex`** = `` `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}` `` — underscores
and dots legal in keys; whitespace inside braces trimmed; case-sensitive.
2. **Last night's underscore fix** (commit `b78a984`): `parseInlineSpans` short-circuits
the inline scanner on `{{` and copies the placeholder literally to `}}`, so
`{{project.case_number}}` is never mangled to `{{project.casenumber}}`.
3. **`data-var` contract** — `data-var="<key>"` on both `.draft-var` preview spans and
`.submission-draft-var-input` sidebar inputs; the click-to-jump and focus-highlight are
bijective across repaints.
4. **Missing-value markers**`[KEIN WERT: key]` (DE) / `[NO VALUE: key]` (EN) render
inline, never an error.
5. **Legacy aliases**`procedural_event.X ≡ rule.X` resolve identically
(`submission_vars_aliases_test.go`); party variables emit comma-joined, indexed, and
flat-legacy forms (`submission_vars_parties_test.go`).
6. **Section anchor syntax**`{{#section:KEY}}…{{/section:KEY}}`, `KEY` matched against
`[A-Za-z0-9_]+`.
7. **No binary retention** — exported `.docx` is regenerable from inputs; only audit rows
persist (`system_audit_log` `submission.exported` + `project_events`).
8. **V1 fallback path** — pre-Composer drafts (`base_id IS NULL`, no section rows) render
via the pure-placeholder path. No auto-upgrade.
9. **`{{…}}` pass-through** — the Markdown walker emits placeholders verbatim; the merge
pass substitutes them afterward. Order is load-bearing (substitution runs *inside*
compose, after section splicing).
---
## §1 Goals
**G1.** Extract the format-neutral document machinery (Markdown→OOXML walker, OOXML
merge/compose, placeholder engine, `.dotm``.docx`) into `pkg/docforge` with a clean
public surface and zero behavior change at the extraction step.
**G2.** Introduce a **neutral document/template model** so importers produce it, the engine
binds variables on it, and exporters render it out — with `.docx` as the first
importer+exporter pair, not the universe. Lossless for our own `.docx`.
**G3.** Replace the hardcoded `addXxxVars` push with a **`VariableResolver` interface per
namespace** + a `ResolverSet` that composes them, preserves aliases, and exposes the key
catalogue (label + group) so the frontend variable form/palette becomes data-driven
instead of hardcoded in TS.
**G4.** Build the **Authoring surface**: upload `.docx` → WYSIWYG render → click/select →
insert `{{slot}}` → save template. Closes the gap m named.
**G5.** Refactor **Generation** onto docforge + uploaded templates, preserving the live
editor, preview, manual-edit, and export — and every contract in §0.5.
**G6.** Extract the **generic editor UX** into `frontend/src/lib/docforge-editor/`,
consumed by both the generation and authoring shells.
**Non-goals (this PRD):** implementation, migration SQL, code. Formats beyond `.docx`
(interface only). Live upc-kommentar integration. Multi-user concurrent editing of one
draft. An HTTP service veneer.
---
## §2 User journeys
### 2.1 Authoring (new)
1. m opens **`/admin/templates`** (or `/templates/new`) and uploads a base `.docx`
(firm letterhead with caption layout, signature block, etc.).
2. docforge's `.docx` importer parses the upload into a **carrier** (opaque OOXML kept
intact) + a renderable preview. The page shows a **WYSIWYG-ish render** of the document.
3. m highlights a piece of text — e.g. `Az. 4c O 12/23` — and a **variable palette**
(sourced from the `ResolverSet.Keys()` catalogue, grouped DE/EN) lets him pick
`project.case_number`. The selection is **replaced with a `{{project.case_number}}`
slot**; a `template_slots` row records the slot key + its anchor position.
4. He repeats for every variable region, saves, and the template becomes pickable in
Generation. (Editing the template later creates a new **version** — see §4 D-A3.)
**Scope guard:** v1 authoring places **text-level slots in body paragraphs**. Slots in
headers/footers/tables/text-boxes are a flagged follow-up (§7 note), because the
click→OOXML-run mapping there is materially harder.
### 2.2 Generation (refactor of today)
1. Lawyer picks a template (uploaded template *or* a legacy Gitea base — both supported
during transition) for a submission code, optionally project-scoped.
2. A **draft** is created. Its template **structure is snapshotted** at create
(§4 D-A3) so later template edits don't shift an in-flight draft.
3. The sidebar shows the variable form (data-driven from `ResolverSet.Keys()`); the
resolved bag is merged with the lawyer's overrides; the live preview renders with
`data-var` click-to-jump; manual prose edits autosave (500 ms debounce).
4. Export → docforge binds the model + carrier + resolved variables → `.docx` bytes
stream as a download. Audit rows written. No binary retained.
### 2.3 upc-kommentar parallel journey (deferred — validates the abstractions)
Not built now, but the abstractions are sized for it: upc-kommentar authors work in
**Markdown** (and want to import **foreign doc/docx** as input — m, 2026-05-29 Q4). When
it becomes a consumer, it would: implement its own `VariableResolver`(s) over its Postgres
(commentary metadata), feed Markdown through docforge's **markdown importer** into the
neutral model, edit live in its own Svelte shell (reusing the *wire contract*, not Go
code), and export. The Go engine is reached over an HTTP veneer added at that point. This
journey is the litmus test for §3's seams: **a new consumer adds resolvers + a transport,
touches no engine internals.**
---
## §3 Module shape
### 3.1 Package tree
```
pkg/docforge/
doc.go // package doc (litigationplanner-style)
model.go // neutral model: Document, Block, InlineSpan, Slot
template.go // Template, TemplateSlot, Carrier
variables.go // VariableResolver interface, VariableKey, ResolverSet, alias registry
bind.go // binding engine: walk model, resolve slots, apply missing-marker policy
render.go // RenderHTML (preview w/ data-var spans) — format-neutral entry
importer.go // Importer interface
exporter.go // Exporter interface
store.go // TemplateStore interface (carrier bytes + slot persistence contract)
errors.go // sentinel errors (ErrUnknownTemplate, ErrUnboundSlot, …)
placeholder.go // placeholderRegex + substitution primitives (THE locked grammar)
types_wire_test.go // locks the JSON wire shape consumed by the TS editor
docx/ // the .docx adapter — first importer + exporter
importer.go // DocxImporter: parse .docx -> Carrier + detect/locate slots
exporter.go // DocxExporter: (model + carrier + vars) -> .docx bytes [today's compose+merge]
ooxml.go // archive/zip + document.xml manipulation [today's submission_merge/compose internals]
md_to_ooxml.go // Markdown -> OOXML runs [today's submission_md walker + the b78a984 fix]
dotm.go // ConvertDotmToDocx [today's pre-pass]
markdown/ // markdown importer (input content; foreign-docx import is a later sibling)
importer.go // parse Markdown -> neutral blocks
```
**What lives in docforge vs paliad:**
| Concern | Home | Why |
|---|---|---|
| Neutral model, binding, preview-render | `docforge` | format-neutral core |
| `VariableResolver` interface + `ResolverSet` | `docforge` | the seam m wants clean |
| Placeholder grammar + substitution | `docforge` | shared invariant (§0.5.1) |
| `.docx` importer + exporter, MD→OOXML walker | `docforge/docx` | first format adapter (ships *inside* the pkg, like litigationplanner's embedded snapshot) |
| Markdown importer | `docforge/markdown` | input-format adapter |
| Concrete resolvers (`project`, `parties`, `firm`, `user`, `today`, `deadline`, `procedural_event`) | **paliad** `internal/…` | they read paliad's DB/services |
| `TemplateStore` impl (Postgres bytea) | **paliad** | docforge owns no tables |
| Section / building-block model, submission codes | **paliad** | consumer-specific composition concepts |
| HTTP handlers, editor UI, authoring page | **paliad** | wire + per-consumer UI |
### 3.2 The neutral model + the carrier (resolving "intermediate, but lossless docx")
```go
// A Document is the format-neutral content model importers produce and exporters consume.
type Document struct {
Blocks []Block
}
type Block struct {
Kind BlockKind // paragraph | heading | list_item | blockquote | section_marker
Style string // logical style key (mapped to a base stylemap on export)
Spans []InlineSpan // text runs (bold/italic/link) + Slots
// …list level, section key, etc.
}
type InlineSpan struct {
Text string
Bold bool
Italic bool
Link string
Slot *Slot // non-nil => this span is a variable slot, not literal text
}
type Slot struct {
Key string // e.g. "project.case_number" — the placeholder grammar key
}
```
**The carrier keeps the lossless guarantee.** The uploaded `.docx` chrome
(letterhead, styles, caption, signature) is **never round-tripped through `Document`**.
It is held as an opaque `Carrier` (the original OOXML), and the exporter splices the
rendered neutral content into the carrier's named anchors, then substitutes slots — exactly
today's compose mechanism, now formalised:
```go
type Carrier struct {
Format string // "docx"
Bytes []byte // original upload, preserved byte-for-byte outside anchor regions
Anchors []Anchor // {{#section:KEY}}…{{/section:KEY}} positions + slot positions
}
```
So **two layers**: editable content = `Document` (neutral, format-pluggable); base chrome =
`Carrier` (opaque, lossless). Foreign-docx *import as input content* (Q4) does parse into
`Document` and **is inherently lossy** — flagged as a boundary (§8), distinct from the
lossless export of *our* templates.
### 3.3 The variable resolver seam (G3)
```go
// VariableResolver answers keys within one dotted namespace.
type VariableResolver interface {
Namespace() string // e.g. "project"
Resolve(key string) (value string, ok bool)// ok=false => unknown key => missing marker
Keys() []VariableKey // catalogue for the palette + sidebar form
}
type VariableKey struct {
Key, LabelDE, LabelEN, Group string
}
// ResolverSet composes namespaced resolvers, registers canonical<->legacy aliases,
// and offers BOTH a pull path (Resolve, used during binding) and a push path
// (BuildBag, preserving today's resolved_bag/merged_bag wire).
type ResolverSet struct{ /* … */ }
func (s *ResolverSet) Resolve(key string) (string, bool)
func (s *ResolverSet) BuildBag() map[string]string // == today's PlaceholderMap
func (s *ResolverSet) Catalogue() []VariableKey // drives the data-driven form/palette
func (s *ResolverSet) RegisterAlias(canonical, legacy string)
```
paliad's seven `addXxxVars` functions become seven resolver types implementing this
interface. `BuildBag()` reproduces today's flat map exactly (alias parity tests pin it).
`Catalogue()` kills the hardcoded `VARIABLE_GROUPS`/`VARIABLE_LABELS` in the TS bundle.
**Resolver model = hybrid** (pull-capable interface, push-driven `BuildBag` default —
inventor pick, §4 D-I1).
### 3.4 Wire contract (Go ↔ TS) — preserved, locked by test
The editor wire stays as-is; `types_wire_test.go` pins it:
- `GET draft``{ draft, resolved_bag, merged_bag, preview_html, rule, parties, sections }`
- preview HTML carries `<span class="draft-var" data-var="<key>">…</span>` (built by
docforge's `RenderHTML`, today's `emitTextWithDraftVars`).
- `PATCH draft``{ variables: PlaceholderMap, … }` (presence-tracked optional fields).
- export/preview endpoints unchanged.
- **New (authoring):** `POST /api/templates` (upload), `GET /api/templates/:id` (carrier
preview + slots), `POST /api/templates/:id/slots` (place slot), `GET /api/docforge/variables`
(the `Catalogue()`).
---
## §4 Decisions (m's picks, 2026-05-29)
### Prose-grill resolutions (core metaphor)
| # | Question | m's decision | Note |
|---|---|---|---|
| P1 | Cross-language sharing model | **Go pkg only; upc-kommentar out of scope for now, "reuse later somehow"** | Interfaces sized so an HTTP veneer is addable without rework. No service built. |
| P2 | Intermediate model? | **Yes — but lossless for our .docx** | → carrier (opaque OOXML) + neutral Document (editable content). §3.2. |
| P3 | Authoring slot mechanic | **(b) click-to-insert** | Upload → render → click/select → inject `{{…}}`. |
| P4 | Input formats | **Markdown primary; foreign doc/docx import later** | Markdown importer first; foreign-docx import is lossy (§8). |
| P5 | Editor sharing | **Build paliad's UI; extract generic UX into a UI package** | `frontend/src/lib/docforge-editor/`. |
### Structured decisions
| # | Decision | m's pick | Rationale / divergence |
|---|---|---|---|
| A1 | Authoring UX | **WYSIWYG inline** | Matches "insert variables into the document". Hardest part — render fidelity + click→run mapping — flagged §7. |
| A2 | Template storage | **Postgres bytea (interface-backed)** | m leans (1); flagged Supabase Storage as viable. Resolved: behind a `TemplateStore` interface, bytea impl now, Supabase Storage a one-impl swap later. No schema churn either way. |
| A3 | Versioning of existing drafts | **Snapshot at draft-create** | Lawyer's in-flight draft won't shift under them; matches today's section-seeding. |
| A4 | Migration strategy | **Extract-in-place, then extend** | Lowest risk to the recent fixes — they move with their files + tests; behavior identical at each step. |
| B1 | Package name | **`docforge`** | — |
| B2 | Schema scope | **New generic tables** (`templates`, `template_slots`, `template_versions`) | Authoring is domain-neutral; submission_bases (Gitea/section_spec) stays for legacy bases with a converge path. |
| B3 | UI package extraction | **Extract now** | Authoring reuses it this cycle — earns its keep, not speculative. |
| B4 | Exporter pluggability | **Interface now, docx-only impl** | Cheap insurance; matches "pluggable for later". |
### Inventor picks (m delegated — "whatever works best")
| # | Pick | Reasoning |
|---|---|---|
| I1 | `VariableResolver` = pull-capable interface, push `BuildBag()` default | Preserves today's flat-map wire while enabling on-demand resolution + the `Catalogue()` that data-drives the form. |
| I2 | `.docx` adapter ships **inside** `pkg/docforge/docx` | Mirrors litigationplanner shipping its embedded snapshot in-package; keeps the first adapter co-located with the engine it proves. |
| I3 | Carrier-vs-Document split (§3.2) | Only way to satisfy "intermediate model" AND "lossless our .docx" simultaneously. |
---
## §5 Data model deltas (paliad-side — docforge owns none)
**New tables** (additive; SQL drafted by the coder, not here):
- **`paliad.templates`** — `id`, `slug`, `name_de/en`, `kind` (`'submission'` | generic),
`source_format` (`'docx'`), `firm`, `is_active`, `created/updated_by`, timestamps,
`current_version_id` FK.
- **`paliad.template_versions`** — immutable snapshots: `id`, `template_id` FK,
`version` int, `carrier_blob` bytea (the `.docx`; or storage ref via `TemplateStore`),
`created_at`, `created_by`. Editing a template inserts a new version row.
- **`paliad.template_slots`** — `id`, `template_version_id` FK, `slot_key` (the variable
key, e.g. `project.case_number`), `anchor` (position encoding — see flag below),
`label`, `order_index`. Versioned alongside the carrier.
**Snapshot semantics (A3):** a draft pins `template_version_id`. Template edits create a
new version; existing drafts keep their pinned version. *(Flag for coder: pin
`template_version_id` on the draft vs. copy a `template_snapshot` jsonb onto the draft —
both satisfy A3; the version-table approach is preferred for auditability but the coder
picks based on query ergonomics.)*
**Touched existing tables:**
- `submission_drafts` — add nullable `template_version_id` for uploaded-template drafts;
**legacy `base_id` path preserved** (extract-in-place ⇒ no data migration of the 11
existing drafts; §0.5.8 fallback intact).
- `submission_bases`, `submission_sections`, `submission_building_blocks`**unchanged**.
They remain paliad consumer-specific concepts that map onto docforge's neutral model at
render time. submission_bases (Gitea-backed) coexists with the new uploaded-template
tables during transition; convergence is a later, separate task.
**Slot anchor encoding (flag for coder):** how a `template_slots.anchor` records *where*
in the carrier OOXML the slot sits (run index + offset, vs. a stable sentinel token
injected into the carrier at authoring time). The sentinel-token approach is likely
simpler and reuses the existing cross-run substitution machinery — resolve in
implementation chat.
---
## §6 Migration plan (protects working code + the recent fixes)
**Principle:** extract-in-place (A4). Each step **compiles, passes the moved tests, and
leaves observable behavior identical.** The recent fixes travel *with their files*:
- The **b78a984 underscore fix**`pkg/docforge/docx/md_to_ooxml.go` (was
`submission_md.go` `parseInlineSpans`), `submission_md_test.go` moves alongside.
- **`placeholderRegex`** → `pkg/docforge/placeholder.go`; its tests move.
- **`data-var` / `emitTextWithDraftVars`** → `pkg/docforge/render.go` (`RenderHTML`);
wire test moves and is pinned in `types_wire_test.go`.
- **Cross-run merge, `.dotm``.docx`, anchor splicing** → `pkg/docforge/docx/`; tests move.
- **Building-block + section model, submission codes, the 7 concrete resolvers** stay in
`internal/` (consumer-specific) — now calling into docforge.
**Safety rails per step:** (1) `go build ./...` green; (2) the moved test files green; (3)
a golden-export check — generate a known draft before and after the step, assert byte-equal
`.docx`; (4) the live preview HTML for a fixture draft is string-equal (the `data-var`
contract). No step ships until all four hold.
**What is explicitly NOT migrated:** the 11 pre-Composer drafts (`base_id IS NULL`) keep
the v1 fallback render path; no auto-upgrade (§0.5.8).
---
## §7 Slice train
Tracer-bullet vertical slices, each independently shippable. Slices 13 are pure
behavior-preserving refactors (the risky-to-working-code part, front-loaded under golden
checks); 47 build the new capability; 8 sets up the future.
1. **Extract the docx engine** — move MD→OOXML walker, OOXML merge/compose, placeholder
grammar, `.dotm``.docx` into `pkg/docforge/{placeholder.go, render.go, docx/}`.
paliad's `submission_*` services become thin adapters. Golden-export + preview checks
green. *Protects b78a984, the regex, the data-var contract.*
2. **Neutral model + binding** — introduce `Document`/`Block`/`Slot`/`Carrier` + `bind.go`;
refactor the docx exporter to consume the neutral model (sections → blocks → OOXML
spliced into carrier). Behavior identical (golden checks).
3. **`VariableResolver` interface** — refactor the 7 `addXxxVars` into resolver types +
`ResolverSet`; `BuildBag()` reproduces today's map (alias-parity tests pin it);
`Catalogue()` exposed. Frontend form switched to consume `Catalogue()` (kills hardcoded
`VARIABLE_GROUPS`).
4. **Template store + schema**`templates`/`template_versions`/`template_slots` +
Postgres-bytea `TemplateStore` impl. No UI yet. Additive migrations.
5. **UI package extraction** — pull generic plumbing (debounced autosave, data-var wiring,
preview/export round-trip, focus preservation, sticky collapse) into
`frontend/src/lib/docforge-editor/`; submission editor consumes it. Refactor, behavior
identical.
6. **Authoring page** — upload `.docx` → docforge docx-importer → WYSIWYG render → select
text → pick variable from `Catalogue()` palette → inject slot (writes
`template_slots` + new `template_version`). Reuses the UI package + docforge importer.
*(v1: body-paragraph text slots only.)*
7. **Generation on uploaded templates** — generation page picks an uploaded template
(`template_version_id` path) alongside legacy bases; snapshot-at-create; data-bind +
manual edit + export via docforge. Legacy base path still works.
8. **Markdown importer + exporter-interface finalisation**`docforge/markdown` importer
as input; `Exporter` interface locked (docx-only impl). Sets up future formats +
eventual upc-kommentar reuse.
**Flagged follow-ups (post-train, separate tasks):** slots in headers/footers/tables;
foreign-docx import fidelity; the HTTP veneer + a TS consumer; submission_bases →
templates convergence; auto-upgrade of pre-Composer drafts.
---
## §8 Out of scope
- **Implementation, migration SQL, code.** PRD only.
- **upc-kommentar as a live consumer** — deferred; abstractions sized for it, nothing built.
- **An HTTP service veneer** — addable later without engine rework; not now.
- **Formats beyond `.docx`** — `Exporter` interface defined (B4), only the docx impl built.
- **Lossless import of *foreign* `.docx`** — our own templates export losslessly via the
carrier; importing an arbitrary third-party Word doc as input content is best-effort and
inherently lossy. Distinct guarantee.
- **Multi-user concurrent editing** of one draft.
- **Re-proposing the current `submission_*.go` shape** — the point is to extract + clean it.
- **Slots outside body paragraphs** (headers/footers/tables/text-boxes) in authoring v1.
---
## Appendix — open flags for the coder (resolve in implementation chat)
1. **Slot anchor encoding** — run-index+offset vs. injected sentinel token (§5). Lean
sentinel.
2. **Snapshot mechanism** — pinned `template_version_id` vs. `template_snapshot` jsonb on
the draft (§5). Lean version-pin.
3. **Authoring render fidelity** — reuse the existing lossy `docXMLToHTML` preview for the
WYSIWYG surface, or invest in higher fidelity. Lean reuse for v1, accept that
complex layouts render approximately while slots still anchor correctly.
4. **Storage backend** — Postgres bytea now; Supabase Storage is a clean `TemplateStore`
swap if template volume/size grows.

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**:

View File

@@ -18,6 +18,7 @@ import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderProjectsChart } from "./src/projects-chart";
import { renderSubmissionDraft } from "./src/submission-draft";
import { renderTemplatesAuthoring } from "./src/templates-authoring";
import { renderSubmissionsIndex } from "./src/submissions-index";
import { renderSubmissionsNew } from "./src/submissions-new";
import { renderEvents } from "./src/events";
@@ -255,6 +256,7 @@ async function build() {
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/projects-chart.ts"),
join(import.meta.dir, "src/client/submission-draft.ts"),
join(import.meta.dir, "src/client/templates-authoring.ts"),
join(import.meta.dir, "src/client/submissions-index.ts"),
join(import.meta.dir, "src/client/submissions-new.ts"),
join(import.meta.dir, "src/client/events.ts"),
@@ -382,6 +384,7 @@ async function build() {
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
await Bun.write(join(DIST, "templates-authoring.html"), renderTemplatesAuthoring());
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
// t-paliad-115 — shared EventsPage at the canonical /events URL.

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

@@ -0,0 +1,262 @@
// Akte-mode wiring for the Litigation Builder (m/paliad#153 B4,
// t-paliad-347).
//
// PRD §2.3 + §3.1 + §3.2: the page-header Akte picker lists every
// project (`type='case'`) the user can see. Picking one POSTs to
// /api/builder/scenarios/from-project, which mints a project-backed
// scenario (origin_project_id pinned) seeded with the project's
// proceeding + scenario_flags + completed deadlines. Subsequent
// builder edits dual-write through to paliad.deadlines + projects.
// scenario_flags via the server-side dual-write hooks.
//
// The picker is its own module so the builder.ts orchestrator only
// has to expose two hooks:
//
// - `onProjectChosen(projectId)` — called when the user picks a
// project. Builder calls the from-project endpoint and loads the
// returned scenario.
// - `setSelectedProject(scenario)` — called after a scenario loads
// so the picker reflects the current Akte (or "— ohne —" for
// kontextfrei scenarios).
//
// Cross-surface scenario-flag-changed (mig 154 ssoT, m/paliad#149):
// the builder listens to the existing CustomEvent so any peer surface
// that PATCHes /api/projects/{id}/scenario-flags triggers a re-fetch
// on the builder's active proceeding when the projectId matches the
// scenario's origin_project_id. The dispatch direction is already
// covered by patchScenarioFlags inside scenario-flags.ts — the
// builder's own PATCH /api/projects/.../scenario-flags goes through
// that helper so peer surfaces stay in sync without a separate dispatch.
import { t } from "./i18n";
export interface AkteProjectMeta {
id: string;
title: string;
reference?: string | null;
case_number?: string | null;
proceeding_type_id?: number | null;
our_side?: string | null;
}
export type OnProjectChosen = (projectId: string) => void | Promise<void>;
interface State {
projects: AkteProjectMeta[];
loaded: boolean;
}
const state: State = {
projects: [],
loaded: false,
};
// fetchAkteProjects pulls every type=case project the caller can see.
// Visibility is enforced by /api/projects via the project_teams /
// can_see_project predicate. We filter client-side to projects with a
// proceeding_type_id — those are the ones the builder can render. We
// don't filter server-side because /api/projects' filter param doesn't
// accept proceeding_type_id_not_null and round-tripping for that one
// reason isn't worth a new endpoint.
export async function fetchAkteProjects(): Promise<AkteProjectMeta[]> {
try {
const resp = await fetch("/api/projects?type=case", {
headers: { Accept: "application/json" },
});
if (!resp.ok) {
console.warn("builder-akte: /api/projects", resp.status);
return [];
}
const rows = (await resp.json()) as Array<{
id: string;
title: string;
reference?: string | null;
case_number?: string | null;
proceeding_type_id?: number | null;
our_side?: string | null;
status?: string;
}>;
return rows
.filter((r) => r.proceeding_type_id != null && (r.status ?? "active") === "active")
.map((r) => ({
id: r.id,
title: r.title,
reference: r.reference ?? null,
case_number: r.case_number ?? null,
proceeding_type_id: r.proceeding_type_id ?? null,
our_side: r.our_side ?? null,
}));
} catch (e) {
console.error("builder-akte: fetch projects failed", e);
return [];
}
}
// formatProjectLabel renders the dropdown row for a project. Reference
// + title are the primary anchors; the case_number tail disambiguates
// when two cases share a reference family.
function formatProjectLabel(p: AkteProjectMeta): string {
const parts: string[] = [];
if (p.reference) parts.push(p.reference);
parts.push(p.title);
if (p.case_number) parts.push("(" + p.case_number + ")");
return parts.join(" · ");
}
// renderAktePicker fills the existing <select id="builder-akte-picker">
// with the project list + a "— ohne —" sentinel. Idempotent.
function renderAktePicker(selectedId: string | null): void {
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
if (!sel) return;
const none = t("builder.akte.none");
const opts: string[] = [`<option value="" data-i18n="builder.akte.none">${escHtml(none)}</option>`];
for (const p of state.projects) {
const selected = p.id === selectedId ? " selected" : "";
opts.push(
`<option value="${escAttr(p.id)}"${selected}>${escHtml(formatProjectLabel(p))}</option>`,
);
}
sel.innerHTML = opts.join("");
}
// mountAktePicker is the entry point. It fetches the project list once,
// wires the dropdown change event to the supplied callback, and
// returns a controller exposing setSelectedProject so the builder can
// keep the picker reflective of the active scenario's Akte.
//
// The picker re-enables itself the moment projects load. While
// loading, the existing `disabled` attribute (set in procedures.tsx)
// stays so users don't pick during the fetch — but if the user lands
// on the page after the catalog is cached this is essentially
// instantaneous.
export interface AktePickerHandle {
setSelectedProject: (projectId: string | null) => void;
isAkteMode: () => boolean;
reload: () => Promise<void>;
}
export async function mountAktePicker(onChosen: OnProjectChosen): Promise<AktePickerHandle> {
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
if (!sel) {
return {
setSelectedProject: () => {},
isAkteMode: () => false,
reload: async () => {},
};
}
// First load — fill the dropdown, enable it, wire change.
state.projects = await fetchAkteProjects();
state.loaded = true;
renderAktePicker(null);
sel.disabled = false;
sel.addEventListener("change", () => {
const id = sel.value;
if (!id) {
// "— ohne —" reset is intentional; the builder treats this as
// "leave the current scenario alone, just clear the picker".
// Switching the active scenario to a non-Akte one happens via
// the scenario picker, not by clicking the empty Akte option.
return;
}
void onChosen(id);
});
return {
setSelectedProject: (projectId: string | null) => {
const next = projectId ?? "";
// Renderless quick-sync when the option is present; otherwise
// re-render so the option appears (covers freshly created
// projects since this picker last loaded).
const optEl = sel.querySelector<HTMLOptionElement>(`option[value="${cssEscape(next)}"]`);
if (next && !optEl) {
renderAktePicker(next);
} else {
sel.value = next;
}
},
isAkteMode: () => sel.value !== "",
reload: async () => {
state.projects = await fetchAkteProjects();
renderAktePicker(sel.value || null);
},
};
}
// createScenarioFromProject posts to the B4 entry point. Returns the
// new scenario's deep payload on success (id + proceedings + events),
// null on failure. Caller is expected to load the returned scenario
// via the builder's existing fetchScenarioDeep / state.active path.
export async function createScenarioFromProject(projectId: string): Promise<{ id: string } | null> {
try {
const resp = await fetch("/api/builder/scenarios/from-project", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ project_id: projectId }),
});
if (!resp.ok) {
console.warn("builder-akte: from-project", resp.status, await resp.text().catch(() => ""));
return null;
}
const out = await resp.json();
return out && typeof out.id === "string" ? { id: out.id } : null;
} catch (e) {
console.error("builder-akte: from-project failed", e);
return null;
}
}
// renderAkteBanner toggles the "Aus Akte: <code>" badge next to the
// scenario picker. The badge is a <span class="builder-akte-banner">
// inserted/removed by this helper; CSS gives it a lime tint to match
// the Akte affordance throughout the app. Pass `null` (or omit
// projectId) to hide.
export function renderAkteBanner(projectId: string | null): void {
const host = document.querySelector(".builder-pageheader") as HTMLElement | null;
if (!host) return;
let badge = document.getElementById("builder-akte-banner");
if (!projectId) {
if (badge) badge.remove();
return;
}
const meta = state.projects.find((p) => p.id === projectId);
const label = meta ? formatProjectLabel(meta) : projectId.slice(0, 8);
const text =
t("builder.akte.banner.prefix") + " " + label;
if (!badge) {
badge = document.createElement("span");
badge.id = "builder-akte-banner";
badge.className = "builder-akte-banner";
badge.setAttribute("role", "note");
host.appendChild(badge);
}
badge.textContent = text;
}
// ────────────────────────────────────────────────────────────────────────────
// helpers
// ────────────────────────────────────────────────────────────────────────────
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// cssEscape is a small fallback for browsers that don't yet expose
// CSS.escape. UUIDs only contain [0-9a-f-] so even the naïve replacer
// keeps us safe; the function exists to make intent obvious.
function cssEscape(s: string): string {
if (typeof CSS !== "undefined" && typeof (CSS as { escape?: (s: string) => string }).escape === "function") {
return (CSS as { escape: (s: string) => string }).escape(s);
}
return s.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
}

View File

@@ -0,0 +1,370 @@
// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4
// + §5.4, B5).
//
// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party
// names) → Akte-Metadaten (title, reference, case number, our_side,
// litigation parent, team). Commit POSTs the merged payload to
// /api/builder/scenarios/{id}/promote — a single server-side transaction
// (no partial promotions) that creates the paliad.projects 'case' row,
// cascades deadlines, and flips the scenario to 'promoted'. On success
// the wizard navigates to /projects/{new-id}.
import { t } from "./i18n";
interface ProjectOption {
id: string;
title: string;
type: string;
reference?: string;
}
interface UserOption {
id: string;
email: string;
display_name?: string;
office?: string;
}
interface PartyRow {
name: string;
role: string;
representative: string;
}
export interface PromoteContext {
scenarioId: string;
ownerId?: string;
proceedingLabel: string;
filedCount: number;
plannedCount: number;
flagCount: number;
extraTopLevel: number;
defaultOurSide: "claimant" | "defendant" | null;
defaultTitle: string;
onSuccess: (projectId: string) => void;
}
export async function openPromoteWizard(ctx: PromoteContext): Promise<void> {
// Parallel fetch: litigation parents + HLC users (both optional pickers).
const [parents, users] = await Promise.all([
fetchProjects("litigation"),
fetchUsers(),
]);
let step = 1;
const parties: PartyRow[] = [];
const meta = {
title: ctx.defaultTitle || "",
reference: "",
caseNumber: "",
clientNumber: "",
ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant",
parentId: "",
teamIds: new Set<string>(),
};
const backdrop = document.createElement("div");
backdrop.className = "builder-modal-backdrop";
const modal = document.createElement("div");
modal.className = "builder-modal builder-promote-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-label", t("builder.promote.title"));
backdrop.appendChild(modal);
const close = () => {
document.removeEventListener("keydown", onEsc, true);
backdrop.remove();
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
document.addEventListener("keydown", onEsc, true);
function stepHeader(): string {
const steps = [
t("builder.promote.step1"),
t("builder.promote.step2"),
t("builder.promote.step3"),
];
const dots = steps.map((label, i) => {
const n = i + 1;
const cls = n === step ? " is-active" : n < step ? " is-done" : "";
return `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
}).join("");
return `<ol class="builder-promote-steps">${dots}</ol>`;
}
function renderStep1(): string {
const rows = [
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
].join("");
const extra = ctx.extraTopLevel > 0
? `<p class="builder-promote-note">${escHtml(
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
)}</p>`
: "";
return (
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
);
}
function renderStep2(): string {
const list = parties.length === 0
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
: parties.map((p, i) => (
`<div class="builder-promote-party" data-idx="${i}">` +
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
`</div>`
)).join("");
return (
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
`<div class="builder-promote-parties">${list}</div>` +
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
);
}
function renderStep3(): string {
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
.concat(parents.map((p) => {
const sel = p.id === meta.parentId ? " selected" : "";
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
})).join("");
const sideSel = (v: string) => (meta.ourSide === v ? " selected" : "");
const team = users
.filter((u) => u.id !== ctx.ownerId)
.slice(0, 40)
.map((u) => {
const checked = meta.teamIds.has(u.id) ? " checked" : "";
const label = (u.display_name || "").trim()
? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string)
: u.email;
return (
`<label class="builder-promote-team-item">` +
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
`<span>${escHtml(label)}</span></label>`
);
}).join("");
return (
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
`<div class="builder-promote-field-row">` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
`</div>` +
`<div class="builder-promote-field-row">` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
`<select class="builder-promote-ourside">` +
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
`</select></label>` +
`</div>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
`<div class="builder-promote-team">${team}</div></div>` +
`<p class="builder-promote-error" hidden></p>`
);
}
function render(): void {
let body = "";
if (step === 1) body = renderStep1();
else if (step === 2) body = renderStep2();
else body = renderStep3();
const backLabel = t("builder.promote.back");
const cancelLabel = t("builder.promote.cancel");
const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit");
modal.innerHTML = `
<header class="builder-modal-header">
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
</header>
${stepHeader()}
<div class="builder-promote-body">${body}</div>
<footer class="builder-promote-footer">
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
<span class="builder-promote-footer-spacer"></span>
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
</footer>`;
wire();
}
function captureStep2(): void {
modal.querySelectorAll<HTMLElement>(".builder-promote-party").forEach((row) => {
const idx = Number(row.getAttribute("data-idx"));
if (Number.isNaN(idx) || !parties[idx]) return;
parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value;
parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value;
parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value;
});
}
function captureStep3(): void {
const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? "";
meta.title = get(".builder-promote-title");
meta.reference = get(".builder-promote-reference");
meta.caseNumber = get(".builder-promote-casenumber");
meta.clientNumber = get(".builder-promote-clientnumber");
meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide;
meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || "";
meta.teamIds = new Set(
Array.from(modal.querySelectorAll<HTMLInputElement>(".builder-promote-team-cb:checked"))
.map((cb) => cb.getAttribute("data-user-id") || "")
.filter(Boolean),
);
}
function wire(): void {
modal.querySelector(".builder-modal-close")?.addEventListener("click", close);
modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close);
modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => {
if (step === 2) captureStep2();
if (step === 3) captureStep3();
step = Math.max(1, step - 1);
render();
});
modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => {
if (step === 2) captureStep2();
if (step < 3) {
step += 1;
render();
return;
}
captureStep3();
void commit();
});
if (step === 2) {
modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => {
captureStep2();
parties.push({ name: "", role: "", representative: "" });
render();
});
modal.querySelectorAll<HTMLElement>(".builder-promote-party-remove").forEach((btn) => {
btn.addEventListener("click", () => {
captureStep2();
const row = btn.closest(".builder-promote-party") as HTMLElement;
const idx = Number(row?.getAttribute("data-idx"));
if (!Number.isNaN(idx)) parties.splice(idx, 1);
render();
});
});
}
}
async function commit(): Promise<void> {
const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null;
const showErr = (msg: string) => {
if (errEl) {
errEl.textContent = msg;
errEl.hidden = false;
}
};
if (!meta.title.trim()) {
showErr(t("builder.promote.error.title_required"));
return;
}
const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null;
if (nextBtn) nextBtn.disabled = true;
const payload: Record<string, unknown> = {
title: meta.title.trim(),
reference: meta.reference.trim() || undefined,
case_number: meta.caseNumber.trim() || undefined,
client_number: meta.clientNumber.trim() || undefined,
our_side: meta.ourSide || undefined,
parent_id: meta.parentId || undefined,
parties: parties
.filter((p) => p.name.trim())
.map((p) => ({
name: p.name.trim(),
role: p.role.trim() || undefined,
representative: p.representative.trim() || undefined,
})),
team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })),
};
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!resp.ok) {
if (nextBtn) nextBtn.disabled = false;
showErr(t("builder.promote.error.generic"));
return;
}
const out = (await resp.json()) as { project_id: string };
const body = modal.querySelector(".builder-promote-body") as HTMLElement;
if (body) body.innerHTML = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
ctx.onSuccess(out.project_id);
} catch {
if (nextBtn) nextBtn.disabled = false;
showErr(t("builder.promote.error.generic"));
}
}
render();
document.body.appendChild(backdrop);
(modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus();
}
async function fetchProjects(type: string): Promise<ProjectOption[]> {
try {
const resp = await fetch("/api/projects?type=" + encodeURIComponent(type));
if (!resp.ok) return [];
const data = (await resp.json()) as ProjectOption[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async function fetchUsers(): Promise<UserOption[]> {
try {
const resp = await fetch("/api/users");
if (!resp.ok) return [];
const data = (await resp.json()) as UserOption[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,229 @@
// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5).
//
// "Teilen" opens a modal with an HLC user picker. Picking a colleague +
// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner
// stays sole editor. Existing shares are listed with a revoke affordance.
// The sharee sees the scenario in their "Geteilt mit mir" bucket (read-
// only) — that side is handled by builder.ts.
import { t } from "./i18n";
export interface ShareUser {
id: string;
email: string;
display_name?: string;
office?: string;
}
export interface BuilderShareRow {
id: string;
scenario_id: string;
shared_with_user_id: string;
created_by: string;
created_at: string;
}
interface ShareModalOpts {
scenarioId: string;
ownerId?: string;
currentShares: BuilderShareRow[];
// Called after a successful add/revoke with the fresh share list so the
// caller can update state.active.shares + re-render side panel buckets.
onChanged: (shares: BuilderShareRow[]) => void;
}
let allUsers: ShareUser[] | null = null;
async function fetchUsers(): Promise<ShareUser[]> {
if (allUsers) return allUsers;
try {
const resp = await fetch("/api/users");
if (!resp.ok) return [];
const data = (await resp.json()) as ShareUser[];
allUsers = Array.isArray(data) ? data : [];
return allUsers;
} catch {
return [];
}
}
function userLabel(u: ShareUser): string {
const name = (u.display_name || "").trim();
if (name) return u.office ? `${name} · ${u.office}` : name;
return u.email;
}
export async function openShareModal(opts: ShareModalOpts): Promise<void> {
const users = await fetchUsers();
let shares = [...opts.currentShares];
const backdrop = document.createElement("div");
backdrop.className = "builder-modal-backdrop";
backdrop.innerHTML = `
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
aria-label="${escAttr(t("builder.share.title"))}">
<header class="builder-modal-header">
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
</header>
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
<div class="builder-share-pickerbox">
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
</div>
<div class="builder-share-current">
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
<ul class="builder-share-current-list"></ul>
</div>
</div>`;
const close = () => {
document.removeEventListener("keydown", onEsc, true);
backdrop.remove();
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close);
document.addEventListener("keydown", onEsc, true);
const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement;
const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement;
const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement;
function renderCurrent(): void {
if (shares.length === 0) {
currentEl.innerHTML = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
return;
}
currentEl.innerHTML = shares.map((sh) => {
const u = users.find((x) => x.id === sh.shared_with_user_id);
const label = u ? userLabel(u) : sh.shared_with_user_id;
return (
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
`</li>`
);
}).join("");
currentEl.querySelectorAll<HTMLElement>(".builder-share-current-item").forEach((li) => {
const id = li.getAttribute("data-share-id");
if (!id) return;
li.querySelector(".builder-share-revoke")?.addEventListener("click", () => {
void revoke(id);
});
});
}
function renderResults(): void {
const q = searchEl.value.trim().toLowerCase();
const sharedIds = new Set(shares.map((s) => s.shared_with_user_id));
const matches = users
.filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id))
.filter((u) => {
if (!q) return true;
return (
(u.display_name || "").toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.office || "").toLowerCase().includes(q)
);
})
.slice(0, 12);
if (matches.length === 0) {
resultsEl.innerHTML = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
return;
}
resultsEl.innerHTML = matches.map((u) => (
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
`</li>`
)).join("");
resultsEl.querySelectorAll<HTMLElement>(".builder-share-result").forEach((li) => {
const uid = li.getAttribute("data-user-id");
if (!uid) return;
li.querySelector(".builder-share-add")?.addEventListener("click", () => {
void add(uid);
});
});
}
async function add(userId: string): Promise<void> {
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shared_with_user_id: userId }),
},
);
if (!resp.ok) {
flashError();
return;
}
const row = (await resp.json()) as BuilderShareRow;
shares = [...shares.filter((s) => s.id !== row.id), row];
searchEl.value = "";
renderResults();
renderCurrent();
opts.onChanged(shares);
} catch {
flashError();
}
}
async function revoke(shareId: string): Promise<void> {
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) +
"/shares/" + encodeURIComponent(shareId),
{ method: "DELETE" },
);
if (!resp.ok && resp.status !== 204) {
flashError();
return;
}
shares = shares.filter((s) => s.id !== shareId);
renderResults();
renderCurrent();
opts.onChanged(shares);
} catch {
flashError();
}
}
function flashError(): void {
const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement;
let err = box.querySelector(".builder-share-error") as HTMLElement | null;
if (!err) {
err = document.createElement("p");
err.className = "builder-share-error";
box.appendChild(err);
}
err.textContent = t("builder.share.error");
}
searchEl.addEventListener("input", renderResults);
renderResults();
renderCurrent();
document.body.appendChild(backdrop);
searchEl.focus();
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -33,6 +33,19 @@ import {
type ScenarioSearchHit,
type ProjectSearchHit,
} from "./builder-search";
import {
mountAktePicker,
createScenarioFromProject,
renderAkteBanner,
type AktePickerHandle,
} from "./builder-akte";
import {
onScenarioFlagsChanged,
SCENARIO_FLAG_CHANGED_EVENT,
type ScenarioFlagChangedDetail,
} from "./scenario-flags";
import { openShareModal, type BuilderShareRow } from "./builder-shares";
import { openPromoteWizard } from "./builder-promote";
// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
// parent proceeding, the builder auto-creates a child proceeding row
@@ -114,6 +127,14 @@ type EntryMode = "cold" | "event" | "akte";
interface State {
active: BuilderScenarioDeep | null;
list: BuilderScenario[];
// B5 — scenarios shared read-only with me (the "Geteilt mit mir"
// bucket). Disjoint from `list` (which is owner-scoped). readonly is
// true when the active scenario is one of these OR is promoted —
// either way every mutating affordance is disabled + a watermark shows.
shared: BuilderScenario[];
readonly: boolean;
// owner display-name cache for the read-only watermark.
ownerNameById: Map<string, string>;
procTypes: ProceedingTypeMeta[];
procTypesById: Map<number, ProceedingTypeMeta>;
procTypesByCode: Map<string, ProceedingTypeMeta>;
@@ -131,11 +152,18 @@ interface State {
mode: EntryMode;
anchorRuleID: string | null;
searchCtl: { focus: () => void; close: () => void } | null;
// B4 — Akte-mode picker handle. Null until the page mounts; once
// mountAktePicker resolves, the handle lets us keep the picker
// selection reflective of the active scenario (origin_project_id).
aktePicker: AktePickerHandle | null;
}
const state: State = {
active: null,
list: [],
shared: [],
readonly: false,
ownerNameById: new Map(),
procTypes: [],
procTypesById: new Map(),
procTypesByCode: new Map(),
@@ -145,6 +173,7 @@ const state: State = {
mode: "cold",
anchorRuleID: null,
searchCtl: null,
aktePicker: null,
};
// ────────────────────────────────────────────────────────────────────────────
@@ -168,10 +197,29 @@ async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T |
}
async function fetchScenarios(): Promise<BuilderScenario[]> {
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=active");
// B5 — pull every status so the side panel can bucket into Aktiv /
// Promoted / Archiviert. The picker + recent list filter to active.
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=all");
return Array.isArray(out) ? out : [];
}
async function fetchSharedScenarios(): Promise<BuilderScenario[]> {
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios/shared");
return Array.isArray(out) ? out : [];
}
// fetchOwnerNames lazily loads the user directory once so the read-only
// watermark can render "Geteilt von <Name>". Failures degrade to showing
// the owner uuid; the watermark is informational, not load-bearing.
async function ensureOwnerNames(): Promise<void> {
if (state.ownerNameById.size > 0) return;
const users = await fetchJSON<Array<{ id: string; display_name?: string; email: string }>>("/api/users");
if (!Array.isArray(users)) return;
for (const u of users) {
state.ownerNameById.set(u.id, (u.display_name || "").trim() || u.email);
}
}
async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
}
@@ -380,20 +428,32 @@ async function flushAutoSave(): Promise<void> {
// ────────────────────────────────────────────────────────────────────────────
async function refreshScenarioList(): Promise<void> {
state.list = await fetchScenarios();
// Owned (all statuses) + shared-with-me run in parallel.
const [owned, shared] = await Promise.all([fetchScenarios(), fetchSharedScenarios()]);
state.list = owned;
state.shared = shared;
renderScenarioList();
renderScenarioPicker();
}
function renderScenarioList(): void {
const ul = document.getElementById("builder-scenario-list-active");
// renderBucket paints one side-panel bucket UL + toggles its wrapper's
// hidden attribute when empty. The Aktiv bucket always renders (shows the
// empty hint); the others hide when they have no rows.
function renderBucket(listId: string, wrapId: string | null, scenarios: BuilderScenario[], alwaysShow: boolean): void {
const ul = document.getElementById(listId);
if (!ul) return;
if (state.list.length === 0) {
ul.innerHTML = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
if (wrapId) {
const wrap = document.getElementById(wrapId);
if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0;
}
if (scenarios.length === 0) {
ul.innerHTML = alwaysShow
? `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`
: "";
return;
}
const activeId = state.active?.id;
ul.innerHTML = state.list.map((sc) => {
ul.innerHTML = scenarios.map((sc) => {
const isActive = sc.id === activeId;
return (
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
@@ -412,12 +472,32 @@ function renderScenarioList(): void {
});
}
function renderScenarioList(): void {
renderBucket("builder-scenario-list-active", null,
state.list.filter((s) => s.status === "active"), true);
renderBucket("builder-scenario-list-shared", "builder-bucket-shared", state.shared, false);
renderBucket("builder-scenario-list-promoted", "builder-bucket-promoted",
state.list.filter((s) => s.status === "promoted"), false);
renderBucket("builder-scenario-list-archived", "builder-bucket-archived",
state.list.filter((s) => s.status === "archived"), false);
}
function renderScenarioPicker(): void {
const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
if (!sel) return;
const placeholderText = t("builder.picker.placeholder");
const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
for (const sc of state.list) {
// Picker shows openable scenarios: active owned + shared-with-me.
const pickable = [
...state.list.filter((s) => s.status === "active"),
...state.shared,
];
// Ensure the currently-active scenario is selectable even if promoted/
// archived (so the dropdown reflects reality when one is open).
if (state.active && !pickable.some((s) => s.id === state.active!.id)) {
pickable.unshift(state.active);
}
for (const sc of pickable) {
const selected = sc.id === state.active?.id ? " selected" : "";
opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
}
@@ -753,10 +833,44 @@ async function onFlagChange(
if (spawnChildCode) {
await syncSpawnChild(updated, spawnChildCode, enabled);
}
// B4 — Akte-backed flag toggles dual-write to projects.scenario_flags
// on the server. Mirror the cross-surface CustomEvent here so peer
// surfaces (Verfahrensablauf strip, project Verlauf, dashboard
// panes) listening via `scenario-flags.ts` re-sync without a fresh
// GET. The PATCH /api/builder/.../proceedings hop bypasses
// patchScenarioFlags (which is what normally dispatches), so we
// recreate the event here when the active scenario is project-
// backed AND the patched triplet is top-level.
if (
state.active.origin_project_id &&
!updated.parent_scenario_proceeding_id
) {
const detail: ScenarioFlagChangedDetail = {
projectId: state.active.origin_project_id,
flags: builderFlagsToBoolMap(updated.scenario_flags),
changedKeys: [flagKey],
};
document.dispatchEvent(new CustomEvent(SCENARIO_FLAG_CHANGED_EVENT, { detail }));
}
setSaveState("saved");
renderCanvas();
}
// builderFlagsToBoolMap converts the builder's loose
// Record<string, unknown> (the wire shape of scenario_proceedings.
// scenario_flags) into the strict Record<string, boolean> shape peer
// surfaces expect. Non-bool values are dropped — defensive parsing
// for the same reason the server's flagDeltaFromBuilder skips them.
function builderFlagsToBoolMap(flags: Record<string, unknown>): Record<string, boolean> {
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
if (typeof v === "boolean") out[k] = v;
}
return out;
}
async function syncSpawnChild(
parent: BuilderProceeding,
childCode: string,
@@ -927,13 +1041,25 @@ async function loadScenario(id: string): Promise<void> {
if (!Array.isArray(deep.shares)) deep.shares = [];
state.active = deep;
state.pending = {};
// B5 — read-only when the scenario is shared with me (I'm not the
// owner) or already promoted (server blocks mutations either way).
const isShared = state.shared.some((s) => s.id === id);
state.readonly = isShared || deep.status === "promoted";
writeScenarioToUrl(id);
setSaveState("saved");
// Sync header inputs to scenario state.
const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
if (rename) rename.disabled = false;
await applyScenarioChrome(deep, isShared);
// B4 — reflect the scenario's Akte link on the page-header picker
// + banner. Project-backed scenarios reveal the source project so
// the user knows the builder writes feed into that Akte; non-Akte
// (cold-open / event-triggered) scenarios reset the picker to
// "— ohne —" + hide the banner.
if (state.aktePicker) {
state.aktePicker.setSelectedProject(deep.origin_project_id ?? null);
}
renderAkteBanner(deep.origin_project_id ?? null);
renderScenarioPicker();
renderScenarioList();
renderCanvas();
@@ -981,6 +1107,114 @@ function openAddProceedingPicker(anchor: HTMLElement): void {
});
}
// applyScenarioChrome sets the page-header action buttons + read-only
// watermark + body class for the freshly-loaded scenario. Editable
// scenarios get rename / share / promote enabled; read-only ones (shared
// with me, or promoted) lock all three and show the watermark. The body
// class drives the CSS that neutralises in-canvas mutating affordances.
async function applyScenarioChrome(deep: BuilderScenarioDeep, isShared: boolean): Promise<void> {
document.body.classList.toggle("builder-readonly", state.readonly);
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
const share = document.getElementById("builder-share-btn") as HTMLButtonElement | null;
const promote = document.getElementById("builder-promote-btn") as HTMLButtonElement | null;
if (rename) rename.disabled = state.readonly;
if (share) share.disabled = state.readonly;
if (promote) promote.disabled = state.readonly;
const wm = document.getElementById("builder-readonly-watermark");
if (!wm) return;
if (!state.readonly) {
wm.hidden = true;
wm.textContent = "";
return;
}
if (isShared) {
await ensureOwnerNames();
const owner = (deep.owner_id && state.ownerNameById.get(deep.owner_id)) || deep.owner_id || "?";
wm.textContent = t("builder.readonly.watermark").replace("{owner}", owner);
} else {
// Promoted (owned) scenario — read-only reference.
wm.textContent = t("builder.bucket.promoted");
}
wm.hidden = false;
}
// resetScenarioChrome clears the page-header action state + watermark
// when no scenario is active (cold-open / picker cleared).
function resetScenarioChrome(): void {
document.body.classList.remove("builder-readonly");
for (const id of ["builder-rename-btn", "builder-share-btn", "builder-promote-btn"]) {
const b = document.getElementById(id) as HTMLButtonElement | null;
if (b) b.disabled = true;
}
const wm = document.getElementById("builder-readonly-watermark");
if (wm) {
wm.hidden = true;
wm.textContent = "";
}
}
// onShareClick opens the share modal for the active (owned, editable)
// scenario. PRD §2.5.
function onShareClick(): void {
if (!state.active || state.readonly) return;
void openShareModal({
scenarioId: state.active.id,
ownerId: state.active.owner_id,
currentShares: (state.active.shares as BuilderShareRow[]) ?? [],
onChanged: (shares) => {
if (state.active) state.active.shares = shares;
},
});
}
// onPromoteClick gathers the summary numbers for wizard step 1 and opens
// the promote-to-project wizard. PRD §2.4. The primary proceeding (lowest-
// ordinal top-level) + its spawned descendants are what the server
// promotes into one case file; additional standalone proceedings are
// reported in the summary as staying behind.
function onPromoteClick(): void {
if (!state.active || state.readonly) return;
const sc = state.active;
const topLevel = sc.proceedings
.filter((p) => !p.parent_scenario_proceeding_id)
.sort((a, b) => a.ordinal - b.ordinal);
const primary = topLevel[0];
if (!primary) {
setSaveState("error");
return;
}
// Collect primary + descendants to scope the event counts.
const subtree = new Set<string>([primary.id]);
for (let changed = true; changed; ) {
changed = false;
for (const p of sc.proceedings) {
if (p.parent_scenario_proceeding_id && subtree.has(p.parent_scenario_proceeding_id) && !subtree.has(p.id)) {
subtree.add(p.id);
changed = true;
}
}
}
const evs = sc.events.filter((e) => subtree.has(e.scenario_proceeding_id));
const meta = state.procTypesById.get(primary.proceeding_type_id);
const label = meta ? meta.name || meta.code : "?";
const defaultParty = (primary.primary_party as "claimant" | "defendant" | undefined) ?? null;
void openPromoteWizard({
scenarioId: sc.id,
ownerId: sc.owner_id,
proceedingLabel: label,
filedCount: evs.filter((e) => e.state === "filed").length,
plannedCount: evs.filter((e) => e.state === "planned").length,
flagCount: Object.values(primary.scenario_flags).filter((v) => v === true).length,
extraTopLevel: topLevel.length - 1,
defaultOurSide: defaultParty,
defaultTitle: sc.name && sc.name !== "Unbenanntes Szenario" ? sc.name : "",
onSuccess: (projectId) => {
window.location.href = "/projects/" + encodeURIComponent(projectId);
},
});
}
async function onRenameClick(): Promise<void> {
if (!state.active) return;
const current = state.active.name;
@@ -1073,17 +1307,32 @@ async function onPickScenarioFromSearch(hit: ScenarioSearchHit): Promise<void> {
await loadScenario(hit.id);
}
function onPickProjectFromSearch(hit: ProjectSearchHit): void {
// PRD §2.3 — Akte (project-backed) builder lands at B4. For now we
// surface a console hint and a visible save-state message so the
// user gets feedback rather than silence.
console.info("builder: project pick deferred to B4", hit);
setSaveState("idle");
const status = document.getElementById("builder-save-status");
if (status) {
const span = status.querySelector("span");
if (span) span.textContent = t("builder.search.hint.akte_b4");
async function onPickProjectFromSearch(hit: ProjectSearchHit): Promise<void> {
// PRD §2.3 — picking a project from universal search puts the
// builder into Akte mode and creates a project-backed scenario.
// Switch the mode chip + sync the page-header Akte picker so the
// user sees the consistent state.
if (state.mode !== "akte") setMode("akte");
await loadAkteScenario(hit.id);
}
// loadAkteScenario is the canonical path that turns a project_id into
// a live builder scenario. POST /api/builder/scenarios/from-project
// returns the new scenario id; we then reuse loadScenario() (which
// fetches the deep payload + sets state.active + paints the canvas).
// Refreshes the scenario list afterwards so the side panel + picker
// pick up the new row.
async function loadAkteScenario(projectId: string): Promise<void> {
setSaveState("saving");
const created = await createScenarioFromProject(projectId);
if (!created) {
setSaveState("error");
return;
}
await refreshScenarioList();
await loadScenario(created.id);
if (state.aktePicker) state.aktePicker.setSelectedProject(projectId);
renderAkteBanner(projectId);
}
function wireModeBar(): void {
@@ -1120,6 +1369,12 @@ function wirePageHeader(): void {
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
void onRenameClick();
});
document.getElementById("builder-share-btn")?.addEventListener("click", () => {
onShareClick();
});
document.getElementById("builder-promote-btn")?.addEventListener("click", () => {
onPromoteClick();
});
document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
void onNewScenarioClick();
});
@@ -1132,7 +1387,9 @@ function wirePageHeader(): void {
if (id) void loadScenario(id);
else {
state.active = null;
state.readonly = false;
writeScenarioToUrl(null);
resetScenarioChrome();
renderCanvas();
}
});
@@ -1142,10 +1399,111 @@ function wirePageHeader(): void {
});
}
// wireScenarioFlagsListener keeps the builder in sync with cross-
// surface scenario_flag mutations. When a peer surface (Verfahrensablauf
// strip, project-detail page, project Verlauf) PATCHes
// /api/projects/{id}/scenario-flags, scenario-flags.ts dispatches a
// CustomEvent on document. If the changed project is the active
// scenario's origin_project_id, we refetch the scenario deep payload
// so the builder's top-level triplet picks up the new flags + any
// spawn children re-render. Non-matching events (unrelated project,
// or scenario isn't Akte-backed) are ignored.
function wireScenarioFlagsListener(): void {
onScenarioFlagsChanged(async (detail: ScenarioFlagChangedDetail) => {
const sc = state.active;
if (!sc || !sc.origin_project_id) return;
if (sc.origin_project_id !== detail.projectId) return;
// Avoid feedback loops: the builder's own PATCH (via patchScenario
// Flags inside scenario-flags.ts) fires this event too. We can't
// distinguish our own dispatch from a peer's, but the refetch
// path is idempotent — fetchScenarioDeep returns the same data
// we already have on first paint, and the canvas re-render is
// cheap. The save state stays "saved" because no fields are
// dirty.
const fresh = await fetchScenarioDeep(sc.id);
if (!fresh) return;
if (!Array.isArray(fresh.proceedings)) fresh.proceedings = [];
if (!Array.isArray(fresh.events)) fresh.events = [];
if (!Array.isArray(fresh.shares)) fresh.shares = [];
state.active = fresh;
renderCanvas();
});
}
// ────────────────────────────────────────────────────────────────────────────
// B6 — mobile basic-read guard (PRD §10 + §7.1)
// ────────────────────────────────────────────────────────────────────────────
// Mutating affordances that get gated on narrow viewports. Reading
// (open a scenario from the side panel, switch via the picker, search,
// switch entry mode) stays fully functional — only scenario-mutating
// taps are intercepted.
const MOBILE_MUTATING_SELECTOR = [
"#builder-rename-btn",
"#builder-share-btn",
"#builder-promote-btn",
"#builder-new-scenario-btn",
"#builder-cta-new",
"#builder-add-proceeding-btn",
".builder-triplet-host button",
".builder-triplet-host input",
".builder-triplet-host select",
].join(",");
function isNarrowViewport(): boolean {
return typeof window.matchMedia === "function" &&
window.matchMedia("(max-width: 640px)").matches;
}
let mobileToastTimer: number | null = null;
function showMobileBlockedToast(): void {
let toast = document.getElementById("builder-mobile-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "builder-mobile-toast";
toast.className = "builder-mobile-toast";
toast.setAttribute("role", "status");
toast.setAttribute("aria-live", "polite");
document.body.appendChild(toast);
}
toast.textContent = t("builder.mobile.blocked");
toast.classList.add("is-visible");
if (mobileToastTimer !== null) window.clearTimeout(mobileToastTimer);
mobileToastTimer = window.setTimeout(() => {
document.getElementById("builder-mobile-toast")?.classList.remove("is-visible");
}, 2600);
}
// wireMobileGuard intercepts taps on mutating affordances when the
// viewport is narrow (<640px), surfacing the "Auf größerem Bildschirm
// öffnen" toast instead of running the action. Capture phase so it
// pre-empts the control's own (bubble-phase) handler; calling
// preventDefault on a checkbox click also blocks its toggle + change
// event. Desktop is untouched — the guard early-returns unless the media
// query matches, so the desktop interaction code paths stay identical
// (PRD §10).
function wireMobileGuard(): void {
document.addEventListener(
"click",
(e) => {
if (!isNarrowViewport()) return;
const target = e.target as HTMLElement | null;
if (!target || !target.closest(MOBILE_MUTATING_SELECTOR)) return;
e.preventDefault();
e.stopPropagation();
showMobileBlockedToast();
},
true,
);
}
export async function mountBuilder(): Promise<void> {
wirePageHeader();
wireModeBar();
wireSearch();
wireScenarioFlagsListener();
wireMobileGuard();
// Parallel boot — proceeding type catalog (Forum=UPC, Kind=proceeding)
// for the add-proceeding picker + scenario_flag_catalog for the
// per-triplet flag strip. PRD §0.4 — UPC v1.
@@ -1158,9 +1516,28 @@ export async function mountBuilder(): Promise<void> {
state.procTypesByCode = new Map(state.procTypes.map((p) => [p.code, p]));
state.flagCatalog = flagCatalog;
await refreshScenarioList();
// B4 — mount the Akte picker in parallel with the scenario load.
// The picker fetches /api/projects?type=case; the call is gated on
// requireDB so dev/test environments without a DB pool return [].
// Independent of scenario state — we mount it before deciding
// whether to load a scenario so the picker is interactive even if
// the requested scenario fails to load.
state.aktePicker = await mountAktePicker(async (projectId) => {
if (state.mode !== "akte") setMode("akte");
await loadAkteScenario(projectId);
});
const requested = readScenarioFromUrl();
if (requested && state.list.some((s) => s.id === requested)) {
await loadScenario(requested);
// Deep-link auto-load covers both owned scenarios and ones shared with
// me (so a "Geteilt mit mir" link opens straight into the read-only
// view, not the cold-open canvas). loadScenario derives read-only from
// state.shared, so the share watermark + locked affordances apply.
const isKnown =
requested != null &&
(state.list.some((s) => s.id === requested) || state.shared.some((s) => s.id === requested));
if (isKnown) {
await loadScenario(requested as string);
} else {
renderCanvas();
}

View File

@@ -1,507 +0,0 @@
// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1).
//
// Power-user surface: a filter strip (Forum / Verfahren / Was passierte /
// Partei) over a free-text search box over a result list of
// procedural_events. Clicking a row locks the event as the trigger and
// transitions to the shared result view (S2). Inbox channel chip lives
// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA
// / Postal auto-sets the Forum chip.
//
// Section-split visual hierarchy per m §11.Q3: filter strip on top
// ("Filter (eingrenzen)") with the four chip groups, search box and
// result list below — clicking a result row IS the qualifier action.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
import { mountResultView } from "./fristenrechner-result";
// Wire shape from GET /api/tools/fristenrechner/search?kind=events.
// Mirrors services.EventSearchResponse server-side.
interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
description?: string;
primary_party?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
follow_up_count: number;
concept_id?: string;
score: number;
}
interface EventSearchResponse {
query: string;
events: EventSearchHit[];
total: number;
}
interface ProceedingChip {
code: string;
name: string;
nameEN: string;
group: string;
}
// Module-local state — single Mode A surface at a time.
interface ModeAState {
jurisdiction: string; // "" = Alle
proc: string; // proceeding_types.code, "" = Alle
eventKind: string; // "" = Alle
party: string; // "" = Alle (Mode A's filter semantics, §11.Q8)
q: string; // free-text query
inbox: string; // CMS / bea / postal / "" — secondary, design §3.3
inboxOpen: boolean;
}
const state: ModeAState = {
jurisdiction: "",
proc: "",
eventKind: "",
party: "",
q: "",
inbox: "",
inboxOpen: false,
};
// Debounce token for search input — avoid hammering the server on
// every keystroke.
let searchSeq = 0;
let searchTimer: ReturnType<typeof setTimeout> | null = null;
// Chip data — static. Forum and event-kind are closed-set per design;
// party is closed-set with "Beide" option (Mode A is filter mode,
// §11.Q8). Inbox secondary chip set per §3.3.
const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const;
const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const;
const PARTIES = ["claimant", "defendant", "both"] as const;
// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE,
// Postal → no narrowing (postal arrives at every jurisdiction).
const INBOX_TO_FORUM: Record<string, string> = {
cms: "UPC",
bea: "DE",
postal: "",
};
// MODE_A_HOST_ID is the DOM id of the container Mode A renders into.
// The mode shell (fristenrechner-result.mountModeShell) creates this
// element under the overhaul root and hands it to Mode A; Mode A
// otherwise has no opinion about its placement on the page.
const MODE_A_HOST_ID = "fristen-overhaul-mode-host";
export function isModeASurfaceMounted(): boolean {
return !!document.getElementById("fristen-mode-a-root");
}
// mountModeA renders the Mode A surface into the overhaul root. Reads
// initial state from URL params so deep links restore the previous
// filter / search state.
export async function mountModeA(): Promise<void> {
const root = document.getElementById(MODE_A_HOST_ID);
if (!root) return;
// Hydrate state from URL.
const params = new URLSearchParams(window.location.search);
state.jurisdiction = (params.get("forum") || "").toUpperCase();
state.proc = params.get("pt") || "";
state.eventKind = params.get("kind") || "";
state.party = params.get("party") || "";
state.q = params.get("q") || "";
renderShell();
await loadProceedingChips();
void runSearch();
}
// renderShell builds the Mode A markup. Idempotent re-call from the
// boot path; row-level rewrites use renderResults / renderFilterStrip
// for finer-grained updates.
function renderShell(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.innerHTML = `
<div id="fristen-mode-a-root" class="fristen-mode-a-root">
<section class="fristen-mode-a-filters" aria-label="${escAttr(t("deadlines.overhaul.modea.filters.label"))}">
<header class="fristen-mode-a-filters-header">
<span class="fristen-mode-a-filters-title">${escHtml(t("deadlines.overhaul.modea.filters.heading"))}</span>
</header>
<div class="fristen-mode-a-chip-row" data-axis="forum">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.forum"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-forum"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="proc">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.proc"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-proc"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="kind">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.kind"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-kind"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="party">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.party"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-party"></div>
</div>
<details class="fristen-mode-a-inbox" ${state.inboxOpen ? "open" : ""}>
<summary class="fristen-mode-a-inbox-summary">${escHtml(t("deadlines.overhaul.modea.inbox.summary"))}</summary>
<div class="fristen-mode-a-chip-row" data-axis="inbox">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.inbox"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-inbox"></div>
</div>
</details>
</section>
<section class="fristen-mode-a-search" aria-label="${escAttr(t("deadlines.overhaul.modea.search.label"))}">
<div class="fristen-mode-a-search-input-wrap">
<svg class="fristen-mode-a-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="search" id="fristen-mode-a-search-input"
class="fristen-mode-a-search-input"
autocomplete="off" spellcheck="false"
data-i18n-placeholder="deadlines.overhaul.modea.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
value="${escAttr(state.q)}" />
</div>
</section>
<section class="fristen-mode-a-results" aria-label="${escAttr(t("deadlines.overhaul.modea.results.label"))}">
<header class="fristen-mode-a-results-header">
<span class="fristen-mode-a-results-title">${escHtml(t("deadlines.overhaul.modea.results.heading"))}</span>
<span class="fristen-mode-a-results-count" id="fristen-mode-a-results-count"></span>
</header>
<ul class="fristen-mode-a-result-list" id="fristen-mode-a-result-list" role="listbox" aria-live="polite"></ul>
</section>
</div>
`;
renderForumChips();
renderKindChips();
renderPartyChips();
renderInboxChips();
// Proceeding chips render later, after fetch.
// Wire search input.
const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null;
if (input) {
input.addEventListener("input", () => {
state.q = input.value;
scheduleSearch(180);
});
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
scheduleSearch(0);
}
});
}
}
// Filter-strip chip renderers ----------------------------------------
function renderForumChips(): void {
const host = document.getElementById("fristen-mode-a-chips-forum");
if (!host) return;
const chips = [
chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""),
...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.value || "";
state.jurisdiction = v;
// Forum change invalidates the proc pick if it falls outside.
state.proc = "";
syncUrl();
renderForumChips();
void loadProceedingChips();
scheduleSearch(0);
});
});
}
function renderKindChips(): void {
const host = document.getElementById("fristen-mode-a-chips-kind");
if (!host) return;
const chips = [
chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""),
...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.eventKind = btn.dataset.value || "";
syncUrl();
renderKindChips();
scheduleSearch(0);
});
});
}
function renderPartyChips(): void {
const host = document.getElementById("fristen-mode-a-chips-party");
if (!host) return;
const chips = [
chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""),
...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.party = btn.dataset.value || "";
syncUrl();
renderPartyChips();
scheduleSearch(0);
});
});
}
function renderInboxChips(): void {
const host = document.getElementById("fristen-mode-a-chips-inbox");
if (!host) return;
const opts = [
{ v: "", label: t("deadlines.overhaul.modea.chip.all") },
{ v: "cms", label: "CMS" },
{ v: "bea", label: "beA" },
{ v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") },
];
host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.value || "";
state.inbox = v;
// Auto-nudge forum from inbox per design §3.3.
const nudge = INBOX_TO_FORUM[v];
if (nudge !== undefined && nudge !== "") {
state.jurisdiction = nudge;
state.proc = "";
renderForumChips();
void loadProceedingChips();
}
renderInboxChips();
scheduleSearch(0);
});
});
}
// Proceeding chips — dynamic fetch.
let lastProcFetchKey = "";
async function loadProceedingChips(): Promise<void> {
const host = document.getElementById("fristen-mode-a-chips-proc");
if (!host) return;
const key = `j=${state.jurisdiction}`;
if (lastProcFetchKey === key) return; // cached for current jurisdiction
lastProcFetchKey = key;
host.innerHTML = `<span class="fristen-mode-a-chip-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</span>`;
const url = new URL("/api/tools/proceeding-types", window.location.origin);
url.searchParams.set("kind", "proceeding");
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
let chips: ProceedingChip[] = [];
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (resp.ok) {
const data = (await resp.json()) as ProceedingChip[] | null;
chips = data || [];
}
} catch {
// Soft-fail: chip strip just hides; search still runs without
// proceeding narrowing.
}
renderProceedingChips(chips);
}
function renderProceedingChips(chips: ProceedingChip[]): void {
const host = document.getElementById("fristen-mode-a-chips-proc");
if (!host) return;
const lang = getLang();
if (chips.length === 0) {
host.innerHTML = `<span class="fristen-mode-a-chip-empty">${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}</span>`;
return;
}
const rendered = [
chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""),
...chips.map((c) => {
const label = lang === "en" ? c.nameEN || c.name : c.name;
return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code);
}),
];
host.innerHTML = rendered.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.proc = btn.dataset.value || "";
syncUrl();
renderProceedingChips(chips);
scheduleSearch(0);
});
});
}
// Search ------------------------------------------------------------
function scheduleSearch(delayMs: number): void {
if (searchTimer !== null) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchTimer = null;
void runSearch();
}, delayMs);
}
async function runSearch(): Promise<void> {
searchSeq++;
const mySeq = searchSeq;
const list = document.getElementById("fristen-mode-a-result-list");
const count = document.getElementById("fristen-mode-a-results-count");
if (!list || !count) return;
list.innerHTML = `<li class="fristen-mode-a-result-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</li>`;
count.textContent = "";
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("kind", "events");
if (state.q) url.searchParams.set("q", state.q);
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
if (state.proc) url.searchParams.set("proc", state.proc);
if (state.eventKind) url.searchParams.set("event_kind", state.eventKind);
if (state.party) url.searchParams.set("party", state.party);
let data: EventSearchResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
if (mySeq === searchSeq) {
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
}
return;
}
data = (await resp.json()) as EventSearchResponse;
} catch {
if (mySeq === searchSeq) {
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
}
return;
}
if (mySeq !== searchSeq) return; // stale response
renderResults(data);
}
function renderResults(data: EventSearchResponse): void {
const list = document.getElementById("fristen-mode-a-result-list");
const count = document.getElementById("fristen-mode-a-results-count");
if (!list || !count) return;
count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total));
if (data.events.length === 0) {
list.innerHTML = `<li class="fristen-mode-a-result-empty">${escHtml(t("deadlines.overhaul.modea.no_results"))}</li>`;
return;
}
const lang = getLang();
list.innerHTML = data.events.map((e) => {
const name = lang === "en" ? e.name_en || e.name_de : e.name_de;
const pt = e.proceeding_type;
const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de;
const icon = eventKindIconForChip(e.event_kind);
const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count));
const juris = pt.jurisdiction || "";
return `
<li class="fristen-mode-a-result" data-event-code="${escAttr(e.code)}" tabindex="0" role="option">
<span class="fristen-mode-a-result-icon" aria-hidden="true">${icon}</span>
<div class="fristen-mode-a-result-body">
<div class="fristen-mode-a-result-title">${escHtml(name)}</div>
<div class="fristen-mode-a-result-meta">
<span class="fristen-mode-a-result-pt">${escHtml(pt.code)}</span>
<span class="fristen-mode-a-result-pt-name">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-mode-a-result-juris">${escHtml(juris)}</span>` : ""}
<span class="fristen-mode-a-result-followups">${escHtml(followUps)}</span>
</div>
</div>
<span class="fristen-mode-a-result-cta" aria-hidden="true">&rarr;</span>
</li>
`;
}).join("");
list.querySelectorAll<HTMLLIElement>(".fristen-mode-a-result").forEach((li) => {
li.addEventListener("click", () => commitEvent(li.dataset.eventCode || ""));
li.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key;
if (k === "Enter" || k === " ") {
e.preventDefault();
commitEvent(li.dataset.eventCode || "");
}
});
});
}
// Commit — user picked a result; lock the event as trigger and
// transition to the §4 result view (S2).
function commitEvent(code: string): void {
if (!code) return;
// Reflect in URL before re-mounting so the result view's deep link
// is consistent.
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", code);
// Preserve project / forum / kind filters so a back-navigation
// brings Mode A back with the same filters.
history.pushState(null, "", url.pathname + url.search + url.hash);
void mountResultView({
eventRef: code,
party: state.party || undefined,
});
}
// Helpers -----------------------------------------------------------
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
const t = title ? ` title="${escAttr(title)}"` : "";
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${t}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
}
function eventKindIconForChip(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;";
case "hearing": return "&#127963;&#65039;";
case "decision": return "&#9878;&#65039;";
case "order": return "&#128220;";
default: return "&#128269;";
}
}
// syncUrl writes the active filter set into the URL so the deep link
// restores Mode A in the same state.
function syncUrl(): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
setOrClear(url, "forum", state.jurisdiction);
setOrClear(url, "pt", state.proc);
setOrClear(url, "kind", state.eventKind);
setOrClear(url, "party", state.party);
setOrClear(url, "q", state.q);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
function setOrClear(url: URL, key: string, val: string): void {
if (val) url.searchParams.set(key, val);
else url.searchParams.delete(key);
}

View File

@@ -1,72 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
defaultChecked,
groupFollowUps,
type FollowUpRule,
} from "./fristenrechner-result";
// Pure helpers exercised here; the DOM-driven render path is covered
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
// entry-mode UIs in later slices).
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
return {
rule_id: "r" + Math.random().toString(36).slice(2, 8),
event_code: "evt",
title_de: "Frist",
title_en: "Deadline",
priority: "mandatory",
is_court_set: false,
is_spawn: false,
is_bilateral: false,
has_condition: false,
...partial,
};
}
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
test("groups by priority; conditional takes precedence over priority", () => {
const rows = [
mk({ priority: "mandatory" }),
mk({ priority: "recommended" }),
mk({ priority: "optional" }),
mk({ priority: "mandatory", has_condition: true }), // → conditional
mk({ priority: "optional", has_condition: true }), // → conditional
];
const g = groupFollowUps(rows);
expect(g.mandatory.length).toBe(1);
expect(g.recommended.length).toBe(1);
expect(g.optional.length).toBe(1);
expect(g.conditional.length).toBe(2);
});
test("unknown priority falls through to optional", () => {
const g = groupFollowUps([mk({ priority: "informational" })]);
expect(g.optional.length).toBe(1);
expect(g.mandatory.length).toBe(0);
});
});
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
test("mandatory rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
});
test("recommended rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
});
test("optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
});
test("conditional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
});
test("court-set rules unchecked even when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
});
test("spawned rules pre-checked when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
});
test("spawned optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
});
});

View File

@@ -1,693 +0,0 @@
// Fristenrechner overhaul — shared result view (design §4).
//
// Given a locked trigger event + a trigger date, this module renders
// the result surface: a sticky trigger card on top, then four priority
// groups (mandatory / recommended / optional / conditional) of follow-up
// rules with computed dates, then a write-back footer that calls the
// existing POST /api/projects/{id}/deadlines/bulk.
//
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
// wizard in S4) both land here once they've identified a trigger
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
// services.FollowUpsResponse server-side.
export interface FollowUpRule {
rule_id: string;
event_code: string;
title_de: string;
title_en: string;
priority: string;
primary_party?: string;
// m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's
// primary_party is the side opposite the perspective. Drives the
// Gegenseitig badge + muted style + unchecked default.
is_cross_party: boolean;
duration_value?: number;
duration_unit?: string;
timing?: string;
due_date?: string;
original_due_date?: string;
was_adjusted?: boolean;
is_court_set: boolean;
is_spawn: boolean;
is_bilateral: boolean;
has_condition: boolean;
rule_code?: string;
legal_source?: string;
legal_source_display?: string;
legal_source_url?: string;
notes_de?: string;
notes_en?: string;
spawn_label?: string;
spawn_proceeding_code?: string;
concept_id?: string;
}
export interface FollowUpsResponse {
trigger: {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
};
trigger_date: string;
party?: string;
follow_ups: FollowUpRule[];
}
// Per-rule UI state — checkbox, optional date override.
interface RuleSelection {
checked: boolean;
override?: string;
}
// Module-local state. Single result view at a time; the surface
// re-renders in place when the user changes the trigger date or
// re-locks a different event.
let currentResponse: FollowUpsResponse | null = null;
const selections = new Map<string, RuleSelection>();
let currentProjectId: string | null = null;
// Public API ----------------------------------------------------------
// isOverhaulMode reports whether the page is in overhaul mode.
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
// a two-week deprecation window. The `?overhaul=1` deep links from
// S2-S4 still work — they're now redundant with the default but kept
// alive so bookmarks don't 302 / lose state.
export function isOverhaulMode(): boolean {
return new URLSearchParams(window.location.search).get("legacy") !== "1";
}
// resolveProjectId reads the active Akte from the URL query string.
// Returns null when in kontextfrei mode (no project picked).
function resolveProjectId(): string | null {
const p = new URLSearchParams(window.location.search).get("project");
return p && p.length > 0 ? p : null;
}
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
// link path bypasses these (jumps straight to the result view via
// ?event=); the tabs appear when no event is locked yet.
export type ModeTab = "search" | "wizard";
// mountModeShell renders the mode-tab pair under the page header and
// hosts whichever mode panel is currently active. Called from the boot
// path when no `?event=` is present. S3 wires Mode A; S4 will add
// Mode B and the actual tab switching.
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
// Defer to the per-mode module to render into the root. The tab
// strip itself is a small header above the mode panel — for S3 we
// render the shell + Mode A in one shot.
// S4 will replace this with a real tab switcher.
const tabs = `
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
aria-selected="${activeTab === "search"}" data-tab="search">
<span class="fristen-mode-tab-icon" aria-hidden="true">&#9889;</span>
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.search"))}</span>
</button>
<button type="button" class="fristen-mode-tab${activeTab === "wizard" ? " is-active" : ""}" role="tab"
aria-selected="${activeTab === "wizard"}" data-tab="wizard">
<span class="fristen-mode-tab-icon" aria-hidden="true">&#129517;</span>
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.wizard"))}</span>
</button>
</nav>
<div id="fristen-overhaul-mode-host"></div>
`;
root.innerHTML = tabs;
// Wire tab switching. S3 only has Mode A wired; Mode B is a
// placeholder until S4.
root.querySelectorAll<HTMLButtonElement>(".fristen-mode-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab || "search") as ModeTab;
void mountModeShell(tab);
});
});
// Mount the active mode panel into the host. S3 only routes "search";
// "wizard" renders a placeholder until S4 lands.
const host = document.getElementById("fristen-overhaul-mode-host");
if (!host) return;
if (activeTab === "search") {
// Lazy import to keep the bundle layered and avoid a circular ref
// between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts.
const mod = await import("./fristenrechner-mode-a");
await mod.mountModeA();
} else {
const mod = await import("./fristenrechner-wizard");
await mod.mountWizard();
}
}
// MountOptions configures the surface entry. Both entry-mode paths
// (Mode A in S3, Mode B in S4) call mount() with the event reference
// that the user committed.
export interface MountOptions {
// eventRef is the procedural_event code OR its uuid OR the anchor
// sequencing_rule id. Resolved server-side; the wire returns the
// canonical code so the URL bookmark is stable.
eventRef: string;
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
triggerDate?: string;
// party is "claimant" | "defendant"; mode A may pass "both" or
// "court". When omitted, follow-ups are returned without party
// narrowing.
party?: string;
// courtId selects the holiday calendar for the per-rule date
// adjustment. Optional.
courtId?: string;
}
// mountResultView fetches /follow-ups and renders the result surface
// into the host container. Re-callable: replaces previous state.
export async function mountResultView(opts: MountOptions): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
const triggerDate = opts.triggerDate || todayIso();
currentProjectId = resolveProjectId();
// Show a quick "loading…" placeholder so the user sees something
// immediately, even on a cold fetch.
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", opts.eventRef);
url.searchParams.set("trigger_date", triggerDate);
if (opts.party) url.searchParams.set("party", opts.party);
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
let data: FollowUpsResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
return;
}
data = (await resp.json()) as FollowUpsResponse;
} catch (err) {
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
return;
}
currentResponse = data;
selections.clear();
for (const r of data.follow_ups) {
selections.set(r.rule_id, { checked: defaultChecked(r) });
}
renderSurface();
// Reflect the canonical event code + trigger date in the URL so the
// deep-link survives a reload.
syncUrlState(data.trigger.code, data.trigger_date);
}
// Render --------------------------------------------------------------
function renderSurface(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root || !currentResponse) return;
const lang = getLang();
const trig = currentResponse.trigger;
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
const juris = trig.proceeding_type.jurisdiction || "";
const kindIcon = eventKindIcon(trig.event_kind);
const triggerCard = `
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
<header class="fristen-overhaul-trigger-header">
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
</header>
<div class="fristen-overhaul-trigger-meta">
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
</div>
<div class="fristen-overhaul-trigger-date">
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
${escHtml(t("deadlines.overhaul.trigger.date"))}
</label>
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
value="${escAttr(currentResponse.trigger_date)}" />
</div>
</section>
`;
const groups = groupFollowUps(currentResponse.follow_ups);
const groupHtml = renderGroups(groups, lang);
const nudge = currentProjectId
? ""
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
const footer = currentProjectId
? renderFooter()
: "";
root.innerHTML = `
${triggerCard}
${nudge}
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
${groupHtml}
</section>
${footer}
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
`;
wireSurfaceEvents();
}
export interface GroupedFollowUps {
mandatory: FollowUpRule[];
recommended: FollowUpRule[];
optional: FollowUpRule[];
conditional: FollowUpRule[];
}
// groupFollowUps splits the wire list into the four visible groups per
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
// precedence over the priority bucket so a "nur wenn CCR" mandatory
// rule renders under Conditional with the gating language visible.
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
for (const r of rows) {
if (r.has_condition) {
out.conditional.push(r);
continue;
}
switch (r.priority) {
case "mandatory":
out.mandatory.push(r);
break;
case "recommended":
out.recommended.push(r);
break;
case "optional":
out.optional.push(r);
break;
default:
// unknown / informational — fold into optional so the row is at
// least visible. Future Phase 2 'informational' tier gets a
// dedicated bucket once seeded.
out.optional.push(r);
}
}
return out;
}
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
const blocks: string[] = [];
if (groups.mandatory.length > 0) {
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
}
if (groups.recommended.length > 0) {
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
}
if (groups.optional.length > 0) {
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
}
if (groups.conditional.length > 0) {
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
}
if (blocks.length === 0) {
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
}
return blocks.join("");
}
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
const items = rows.map((r) => renderRule(r, lang)).join("");
return `
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
<ul class="fristen-overhaul-rule-list">
${items}
</ul>
</div>
`;
}
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
const sel = selections.get(r.rule_id);
const checked = sel ? sel.checked : defaultChecked(r);
const dateOverride = sel?.override;
const computedDate = r.due_date || "";
const effectiveDate = dateOverride || computedDate;
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
const durationPhrase = formatDurationPhrase(r, lang);
const dateCell = r.is_court_set
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
: effectiveDate
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">&mdash;</span>`;
const partyBadge = r.primary_party
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
: "";
const sourceBadge = r.legal_source_display
? r.legal_source_url
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
: r.rule_code
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
: "";
const spawnBadge = r.is_spawn
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
: "";
const condBadge = r.has_condition
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
: "";
const crossPartyBadge = r.is_cross_party
? `<span class="fristen-overhaul-rule-crossparty" title="${escAttr(t("deadlines.overhaul.crossparty.tooltip"))}">${escHtml(t("deadlines.overhaul.crossparty.badge"))}</span>`
: "";
const notesHtml = notes
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
: "";
const editBtn = r.is_court_set || r.is_spawn || !computedDate
? ""
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
return `
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}${r.is_cross_party ? " is-cross-party" : ""}" data-rule-id="${escAttr(r.rule_id)}">
<label class="fristen-overhaul-rule-check">
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
</label>
<div class="fristen-overhaul-rule-body">
<div class="fristen-overhaul-rule-title-row">
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
${spawnBadge}
${condBadge}
${crossPartyBadge}
</div>
<div class="fristen-overhaul-rule-meta-row">
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
${partyBadge}
${sourceBadge}
</div>
${notesHtml}
</div>
<div class="fristen-overhaul-rule-date-cell">
${dateCell}
${editBtn}
</div>
</li>
`;
}
function renderFooter(): string {
const selectedCount = countSelected();
return `
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
</span>
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
id="fristen-overhaul-write-back"
${selectedCount === 0 ? "disabled" : ""}>
${escHtml(t("deadlines.overhaul.footer.cta"))}
</button>
</footer>
`;
}
// Event wiring --------------------------------------------------------
function wireSurfaceEvents(): void {
// Trigger-date change → re-fetch with new date.
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
if (dateInput && currentResponse) {
dateInput.addEventListener("change", () => {
if (!currentResponse) return;
const newDate = dateInput.value;
if (!newDate) return;
void mountResultView({
eventRef: currentResponse.trigger.code,
triggerDate: newDate,
party: currentResponse.party,
});
});
}
// Checkbox toggles → update selections + footer count.
const root = document.getElementById("fristen-overhaul-root");
if (root) {
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.ruleId || "";
const sel = selections.get(id) ?? { checked: cb.checked };
sel.checked = cb.checked;
selections.set(id, sel);
refreshFooterCount();
});
});
// Per-rule date override.
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
btn.addEventListener("click", () => editRuleDate(btn));
});
}
// Write-back CTA.
const cta = document.getElementById("fristen-overhaul-write-back");
if (cta) cta.addEventListener("click", () => void submitWriteBack());
}
function editRuleDate(btn: HTMLButtonElement): void {
const ruleId = btn.dataset.ruleId || "";
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
if (!rule) return;
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
const current = sel.override || rule.due_date || todayIso();
const dateCell = btn.parentElement;
if (!dateCell) return;
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
if (!dateSpan) return;
const input = document.createElement("input");
input.type = "date";
input.value = current;
input.className = "fristen-overhaul-rule-date-input";
dateSpan.replaceWith(input);
btn.disabled = true;
input.focus();
const commit = () => {
const newDate = input.value;
if (newDate && newDate !== current) {
sel.override = newDate;
selections.set(ruleId, sel);
}
renderSurface();
};
input.addEventListener("blur", commit, { once: true });
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
input.blur();
} else if ((e as KeyboardEvent).key === "Escape") {
renderSurface();
}
});
}
function refreshFooterCount(): void {
const countEl = document.getElementById("fristen-overhaul-footer-count");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const n = countSelected();
if (countEl) {
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
}
if (cta) cta.disabled = n === 0;
}
function countSelected(): number {
let n = 0;
if (!currentResponse) return 0;
for (const r of currentResponse.follow_ups) {
if (r.is_court_set) continue;
// Cross-party rows are unconditionally excluded from write-back
// (design §2.4). Even if the user manually checks the box, they
// describe what the opponent files — not Akte work for our side.
if (r.is_cross_party) continue;
const sel = selections.get(r.rule_id);
if (sel?.checked) n++;
}
return n;
}
// Write-back ----------------------------------------------------------
async function submitWriteBack(): Promise<void> {
if (!currentResponse) return;
if (!currentProjectId) return;
const msg = document.getElementById("fristen-overhaul-msg");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const lang = getLang();
const deadlines: Array<Record<string, unknown>> = [];
for (const r of currentResponse.follow_ups) {
const sel = selections.get(r.rule_id);
if (!sel?.checked) continue;
if (r.is_court_set) continue;
// Skip cross-party rows even if checked — they describe opposing-
// side filings and don't belong in our side's Akte deadline set
// (design §2.4, write-back exclusion).
if (r.is_cross_party) continue;
const dueDate = sel.override || r.due_date;
if (!dueDate) continue;
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
deadlines.push({
title,
rule_code: r.rule_code || undefined,
due_date: dueDate,
original_due_date: r.original_due_date || r.due_date || undefined,
source: "fristenrechner",
rule_id: r.rule_id,
notes: notes || undefined,
audit_reason: auditReason(),
});
}
if (deadlines.length === 0 || !msg || !cta) return;
cta.disabled = true;
msg.textContent = "";
msg.className = "fristen-overhaul-msg";
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deadlines }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = body.error || t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
return;
}
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
msg.className = "fristen-overhaul-msg form-msg-ok";
setTimeout(() => {
if (cta) cta.disabled = false;
}, 1500);
} catch {
msg.textContent = t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
}
}
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
function auditReason(): string {
if (!currentResponse) return "";
const name = currentResponse.trigger.name_de;
const date = currentResponse.trigger_date;
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
}
// Helpers -------------------------------------------------------------
export function defaultChecked(r: FollowUpRule): boolean {
// Cross-party rows are unchecked by default — they describe what the
// OTHER side files. They render to honestly show the workflow, but
// the Akte write-back excludes them unconditionally (design §2.4).
if (r.is_cross_party) return false;
if (r.is_court_set) return false;
if (r.is_spawn) return r.priority === "mandatory";
if (r.has_condition) return false;
return r.priority === "mandatory" || r.priority === "recommended";
}
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
if (!r.duration_value || !r.duration_unit) return "";
const unitDE: Record<string, string> = {
days: "Tage",
months: "Monate",
weeks: "Wochen",
years: "Jahre",
};
const unitEN: Record<string, string> = {
days: "days",
months: "months",
weeks: "weeks",
years: "years",
};
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
return `${r.duration_value} ${u}`;
}
function formatDateForLang(iso: string, lang: "de" | "en"): string {
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
if (!iso || iso.length < 10) return iso;
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return iso;
if (lang === "en") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const idx = parseInt(m, 10) - 1;
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
return `${parseInt(d, 10)} ${mn} ${y}`;
}
return `${d}.${m}.${y}`;
}
function eventKindIcon(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;"; // inbox/letter
case "hearing": return "&#127963;&#65039;"; // courthouse
case "decision": return "&#9878;&#65039;"; // scales
case "order": return "&#128220;"; // page
default: return "&#128197;"; // calendar
}
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function syncUrlState(eventCode: string, triggerDate: string): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", eventCode);
url.searchParams.set("trigger_date", triggerDate);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}

View File

@@ -1,47 +0,0 @@
import { describe, expect, test } from "bun:test";
import { followUpsDifferByParty } from "./fristenrechner-wizard";
describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => {
test("true when both claimant and defendant rules present", () => {
expect(followUpsDifferByParty([
{ primary_party: "claimant" },
{ primary_party: "defendant" },
])).toBe(true);
});
test("false when all claimant", () => {
expect(followUpsDifferByParty([
{ primary_party: "claimant" },
{ primary_party: "claimant" },
])).toBe(false);
});
test("false when all defendant", () => {
expect(followUpsDifferByParty([
{ primary_party: "defendant" },
])).toBe(false);
});
test("false when only 'both' rules", () => {
// "Both" rules are bilateral procedural moves (Vertraulichkeits-
// Erwiderung); they don't gate R5 because either party can be
// looking at them.
expect(followUpsDifferByParty([
{ primary_party: "both" },
{ primary_party: "both" },
])).toBe(false);
});
test("false when only court rules", () => {
expect(followUpsDifferByParty([
{ primary_party: "court" },
])).toBe(false);
});
test("true when mixed with both / court alongside the asymmetric pair", () => {
expect(followUpsDifferByParty([
{ primary_party: "both" },
{ primary_party: "claimant" },
{ primary_party: "court" },
{ primary_party: "defendant" },
])).toBe(true);
});
test("false on empty list", () => {
expect(followUpsDifferByParty([])).toBe(false);
});
});

View File

@@ -1,711 +0,0 @@
// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2).
//
// 3-5 question row stack that lands the user on one procedural_event
// (the trigger), then transitions to the shared §4 result view.
//
// R1 Was ist passiert? (event_kind) always asked
// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows
// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option
// R4 Welches Schriftstück? (procedural_event — land) always asked
// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ
//
// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier".
// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where
// perspective is a qualifier).
// Pre-fill + collapse rows from project (project.proceeding_type →
// R3 + R2 derived; project.our_side → R5). Preserve compatible
// downstream picks on back-navigation (§11.Q10).
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
import { mountResultView } from "./fristenrechner-result";
// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we
// need; kept local so the wizard doesn't depend on Mode A.
interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
follow_up_count: number;
}
interface EventSearchResponse {
events: EventSearchHit[];
total: number;
}
interface ProceedingChip {
code: string;
name: string;
nameEN: string;
group: string;
}
interface ProjectSummary {
id: string;
proceeding_type_id?: number | null;
our_side?: string | null;
}
type Forum = "UPC" | "DE" | "EPA" | "DPMA";
type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed";
type WizardParty = "claimant" | "defendant";
// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by
// fristenrechner-result.mountModeShell which creates the host element
// under the overhaul root.
const WIZARD_HOST_ID = "fristen-overhaul-mode-host";
// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists
// so re-grouping happens in one place.
const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"];
const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"];
// Single wizard state. Module-local; one wizard at a time.
interface WizardState {
// Picks. "" = not answered. R5 only set when the question is asked.
r1: EventKindRow | "";
r2: Forum | "";
r3: string; // proceeding_types.code
r4: string; // procedural_events.code
r5: WizardParty | "";
// Pre-fill provenance — when a pick came from the project context,
// the row renders with an "aus Akte" tag so the user notices.
r2FromProject: boolean;
r3FromProject: boolean;
r5FromProject: boolean;
// Implicit fills — R2 auto-derived from R1 when R1 narrows to one
// forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but
// if downstream R3 lookup returns a single forum we can mark R2 as
// implicit).
r2Implicit: boolean;
r3Implicit: boolean;
}
const state: WizardState = {
r1: "", r2: "", r3: "", r4: "", r5: "",
r2FromProject: false, r3FromProject: false, r5FromProject: false,
r2Implicit: false, r3Implicit: false,
};
// Loaded from the project (if any).
let projectSummary: ProjectSummary | null = null;
// Proceeding chip cache key: jurisdiction × event_kind.
let lastProcCacheKey = "";
let cachedProcChips: ProceedingChip[] = [];
// Event chip cache: keyed on R3 code + R1 event_kind.
let lastEventCacheKey = "";
let cachedEventChips: EventSearchHit[] = [];
// Public API ---------------------------------------------------------
export async function mountWizard(): Promise<void> {
const host = document.getElementById(WIZARD_HOST_ID);
if (!host) return;
// Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…).
const params = new URLSearchParams(window.location.search);
state.r1 = (params.get("kind") as EventKindRow) || "";
state.r2 = (params.get("forum") as Forum) || "";
state.r3 = params.get("pt") || "";
state.r4 = params.get("event") || "";
state.r5 = (params.get("party") as WizardParty) || "";
// Project prefills.
const projectId = params.get("project");
if (projectId) {
projectSummary = await fetchProject(projectId);
await applyProjectPrefills();
} else {
projectSummary = null;
}
renderShell();
void renderRows();
}
// applyProjectPrefills derives R2 + R3 + R5 from the project when they
// haven't been set explicitly. Project picks take precedence over
// unspecified state, but a user-supplied URL pick wins over the
// project default.
async function applyProjectPrefills(): Promise<void> {
if (!projectSummary) return;
// Map our_side → R5.
if (!state.r5) {
const side = projectSummary.our_side;
if (side === "claimant" || side === "applicant" || side === "appellant") {
state.r5 = "claimant";
state.r5FromProject = true;
} else if (side === "defendant" || side === "respondent") {
state.r5 = "defendant";
state.r5FromProject = true;
}
}
// Map proceeding_type_id → R3 + infer R2 jurisdiction.
if (projectSummary.proceeding_type_id && !state.r3) {
const pt = await fetchProceedingByID(projectSummary.proceeding_type_id);
if (pt) {
state.r3 = pt.code;
state.r3FromProject = true;
if (pt.group && !state.r2) {
state.r2 = pt.group as Forum;
state.r2FromProject = true;
}
}
}
}
// Render -------------------------------------------------------------
function renderShell(): void {
const host = document.getElementById(WIZARD_HOST_ID);
if (!host) return;
host.innerHTML = `
<div class="fristen-wizard-root">
<header class="fristen-wizard-header">
<h2 class="fristen-wizard-title">${escHtml(t("deadlines.overhaul.wizard.heading"))}</h2>
<p class="fristen-wizard-hint">${escHtml(t("deadlines.overhaul.wizard.hint"))}</p>
</header>
<div class="fristen-wizard-rows" id="fristen-wizard-rows" aria-live="polite"></div>
</div>
`;
}
async function renderRows(): Promise<void> {
const host = document.getElementById("fristen-wizard-rows");
if (!host) return;
// Resolve dynamic row prerequisites BEFORE building markup so chip
// sets are populated.
if (state.r1 && state.r2) {
await ensureProceedingChips(state.r2, state.r1);
// Auto-skip R3 when the narrowed pool has exactly one option.
if (!state.r3 && cachedProcChips.length === 1) {
state.r3 = cachedProcChips[0].code;
state.r3Implicit = true;
}
}
if (state.r1 && state.r3) {
await ensureEventChips(state.r3, state.r1);
}
const rows: string[] = [];
rows.push(rowR1());
if (shouldShowR2()) rows.push(rowR2());
if (shouldShowR3()) rows.push(rowR3());
if (shouldShowR4()) rows.push(rowR4());
if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading());
host.innerHTML = rows.join("");
wireRowEvents();
// R5 conditional check — fires after R4 picked. Inspects /follow-ups
// to see whether they actually differ by party. If yes, show R5. If
// no, or R5 already set, transition straight to result view.
if (state.r4) {
void maybeAdvanceFromR4();
}
}
// Should-show predicates --------------------------------------------
function shouldShowR2(): boolean {
// Skip R2 only when R1 narrows to a single forum — which today
// never happens for the closed event_kind set (every kind exists in
// multiple jurisdictions). Always show R2 until we have empirical
// evidence otherwise.
return state.r1 !== "" && state.r1 !== "missed";
}
function shouldShowR3(): boolean {
if (state.r1 === "" || state.r2 === "") return false;
if (state.r3 && state.r3Implicit) return true; // visible compact
return true;
}
function shouldShowR4(): boolean {
return state.r3 !== "" && state.r1 !== "";
}
// shouldShowR5Sync renders the placeholder row immediately; the actual
// asked-or-not decision happens after the async follow-ups probe in
// maybeAdvanceFromR4.
function shouldShowR5Sync(): boolean {
return state.r4 !== "";
}
// Row builders ------------------------------------------------------
function rowR1(): string {
const chips = EVENT_KINDS.map((k) => {
const label = t(`deadlines.overhaul.kind.${k}` as never);
const icon = eventKindIcon(k);
return chipHtml("r1", k, label, state.r1 === k, icon);
}).join("");
return rowShell({
n: 1,
badge: "filter",
label: t("deadlines.overhaul.wizard.r1.label"),
active: !state.r1,
answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR2(): string {
const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join("");
return rowShell({
n: 2,
badge: "filter",
label: t("deadlines.overhaul.wizard.r2.label"),
active: !state.r2,
fromProject: state.r2FromProject,
answeredText: state.r2 || "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR3(): string {
if (cachedProcChips.length === 0) {
return rowShell({
n: 3, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r3.label"),
active: true,
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}</div>`,
});
}
const lang = getLang();
const chips = cachedProcChips.map((p) => {
const label = lang === "en" ? p.nameEN || p.name : p.name;
return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code);
}).join("");
let answered = "";
if (state.r3) {
const hit = cachedProcChips.find((p) => p.code === state.r3);
if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name;
}
return rowShell({
n: 3,
badge: "qualifier",
label: t("deadlines.overhaul.wizard.r3.label"),
active: !state.r3,
fromProject: state.r3FromProject,
implicit: state.r3Implicit,
answeredText: answered,
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR4(): string {
if (cachedEventChips.length === 0) {
return rowShell({
n: 4, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r4.label"),
active: true,
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}</div>`,
});
}
const lang = getLang();
const chips = cachedEventChips.map((e) => {
const label = lang === "en" ? e.name_en || e.name_de : e.name_de;
return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow));
}).join("");
let answered = "";
if (state.r4) {
const hit = cachedEventChips.find((e) => e.code === state.r4);
if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de;
}
return rowShell({
n: 4,
badge: "qualifier",
label: t("deadlines.overhaul.wizard.r4.label"),
active: !state.r4,
answeredText: answered,
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR5Loading(): string {
// Placeholder while we probe whether R5 is needed. The async
// follow-ups probe replaces this with rowR5 chips or skips
// straight to the result view.
return rowShell({
n: 5, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r5.label"),
active: !state.r5,
fromProject: state.r5FromProject,
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
body: `<div class="fristen-wizard-probe">${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}</div>`,
});
}
function rowR5Chips(): string {
const chips = (["claimant", "defendant"] as const).map((p) =>
chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join("");
return rowShell({
n: 5, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r5.label"),
active: !state.r5,
fromProject: state.r5FromProject,
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
interface RowShellOpts {
n: number;
badge: "filter" | "qualifier";
label: string;
active: boolean;
body: string;
answeredText?: string;
fromProject?: boolean;
implicit?: boolean;
}
function rowShell(o: RowShellOpts): string {
const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` +
(o.active ? " is-active" : " is-answered") +
(o.fromProject ? " is-from-project" : "") +
(o.implicit ? " is-implicit" : "");
const badgeText = o.badge === "filter"
? t("deadlines.overhaul.wizard.badge.filter")
: t("deadlines.overhaul.wizard.badge.qualifier");
const annotations: string[] = [];
if (o.fromProject) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}</span>`);
if (o.implicit) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}</span>`);
const answered = o.answeredText
? `<span class="fristen-wizard-row-answer">${escHtml(o.answeredText)}</span>`
: "";
const edit = !o.active
? `<button type="button" class="fristen-wizard-row-edit" data-row="${o.n}">${escHtml(t("deadlines.overhaul.wizard.edit"))}</button>`
: "";
return `
<section class="${cls}" data-row="${o.n}">
<header class="fristen-wizard-row-header">
<span class="fristen-wizard-row-n">${o.n}</span>
<span class="fristen-wizard-row-badge fristen-wizard-row-badge--${o.badge}">${escHtml(badgeText)}</span>
<span class="fristen-wizard-row-label">${escHtml(o.label)}</span>
${annotations.join("")}
${answered}
${edit}
</header>
${o.active ? `<div class="fristen-wizard-row-body">${o.body}</div>` : ""}
</section>
`;
}
// Event wiring ------------------------------------------------------
function wireRowEvents(): void {
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const axis = btn.dataset.axis || "";
const value = btn.dataset.value || "";
handleChip(axis, value);
});
});
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row-edit").forEach((btn) => {
btn.addEventListener("click", () => {
const n = parseInt(btn.dataset.row || "0", 10);
handleEdit(n);
});
});
}
function handleChip(axis: string, value: string): void {
switch (axis) {
case "r1": {
if (state.r1 === value) return;
state.r1 = value as EventKindRow;
// R1 change resets R3/R4 (event-kind narrows the pools).
state.r3 = "";
state.r3Implicit = false;
state.r4 = "";
state.r5 = state.r5FromProject ? state.r5 : "";
cachedEventChips = [];
lastEventCacheKey = "";
cachedProcChips = [];
lastProcCacheKey = "";
break;
}
case "r2": {
if (state.r2 === value) return;
state.r2 = value as Forum;
state.r2FromProject = false;
state.r2Implicit = false;
// R2 change may invalidate R3 → reset.
state.r3 = "";
state.r3FromProject = false;
state.r3Implicit = false;
state.r4 = "";
cachedProcChips = [];
lastProcCacheKey = "";
cachedEventChips = [];
lastEventCacheKey = "";
break;
}
case "r3": {
if (state.r3 === value) return;
state.r3 = value;
state.r3FromProject = false;
state.r3Implicit = false;
state.r4 = "";
cachedEventChips = [];
lastEventCacheKey = "";
break;
}
case "r4": {
if (state.r4 === value) return;
state.r4 = value;
break;
}
case "r5": {
if (state.r5 === value) return;
state.r5 = value as WizardParty;
state.r5FromProject = false;
break;
}
}
syncUrl();
void renderRows();
}
function handleEdit(n: number): void {
switch (n) {
case 1:
state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : "";
cachedProcChips = []; lastProcCacheKey = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 2:
state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false;
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
state.r4 = "";
cachedProcChips = []; lastProcCacheKey = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 3:
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
state.r4 = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 4:
state.r4 = "";
state.r5 = state.r5FromProject ? state.r5 : "";
break;
case 5:
state.r5 = ""; state.r5FromProject = false;
break;
}
syncUrl();
void renderRows();
}
// maybeAdvanceFromR4 fetches /follow-ups for the picked event to
// decide whether R5 is needed. If R5 is already set OR the
// follow-ups don't differ by party, transition straight to the
// result view. Else swap the R5 loading row for the chip picker.
async function maybeAdvanceFromR4(): Promise<void> {
if (!state.r4) return;
if (state.r5) {
// R5 already answered (project prefill or explicit pick) → go.
void launchResult();
return;
}
// Probe follow-ups.
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", state.r4);
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
// Soft-fail → swap to R5 chips so the user can decide manually.
swapR5(rowR5Chips());
return;
}
const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> };
const differs = followUpsDifferByParty(data.follow_ups);
if (!differs) {
void launchResult();
return;
}
swapR5(rowR5Chips());
} catch {
swapR5(rowR5Chips());
}
}
function swapR5(html: string): void {
const host = document.getElementById("fristen-wizard-rows");
if (!host) return;
const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]');
if (!r5) {
host.insertAdjacentHTML("beforeend", html);
} else {
r5.outerHTML = html;
}
wireRowEvents();
}
function launchResult(): void {
// Hand off to the §4 result view. The URL already carries the
// picks via syncUrl(); add event= so the boot path treats this
// as a deep-link.
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", state.r4);
if (state.r5) url.searchParams.set("party", state.r5);
else url.searchParams.delete("party");
history.pushState(null, "", url.pathname + url.search + url.hash);
void mountResultView({ eventRef: state.r4, party: state.r5 || undefined });
}
export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean {
let hasClaimant = false, hasDefendant = false;
for (const r of rows) {
if (r.primary_party === "claimant") hasClaimant = true;
else if (r.primary_party === "defendant") hasDefendant = true;
if (hasClaimant && hasDefendant) return true;
}
return false;
}
// Fetches -----------------------------------------------------------
async function fetchProject(id: string): Promise<ProjectSummary | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } });
if (!resp.ok) return null;
return (await resp.json()) as ProjectSummary;
} catch {
return null;
}
}
async function fetchProceedingByID(id: number): Promise<ProceedingChip | null> {
// The proceeding-types endpoint returns codes, names, jurisdictions
// but doesn't carry the id (the wire shape FristenrechnerType is
// code-keyed). Walk the unfiltered list and pick by sort-order
// proximity / sort-fallback: we need the row whose id matches; since
// the wire doesn't expose id, fetch the projects detail to get the
// code directly. Cheap workaround: rely on /api/projects/{id}'s
// proceeding_type_id being matched against the proceeding-types list
// by jurisdiction round-trip is not possible without id. Instead
// expose the proceeding-types-by-id mapping via a follow-up endpoint
// later. For now hit the unfiltered list and assume the project's
// pick is in the active set.
//
// Pragmatic fallback: query the full list and return the only entry
// whose pseudo-id-via-sort-order matches. The lookup is unreliable
// until the wire shape includes id; for the project-prefill case the
// user can always re-pick R3 / R2 if the prefill misfires.
try {
const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } });
if (!resp.ok) return null;
const list = (await resp.json()) as ProceedingChip[] | null;
if (!list || list.length === 0) return null;
// Without id in the wire we cannot match by id. Skip the prefill
// silently — R3 stays unanswered and the user picks manually.
// (S5/follow-up can extend the wire shape to include id.)
void id;
return null;
} catch {
return null;
}
}
async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise<void> {
const key = `${forum}\x00${kind}`;
if (lastProcCacheKey === key) return;
lastProcCacheKey = key;
const url = new URL("/api/tools/proceeding-types", window.location.origin);
url.searchParams.set("kind", "proceeding");
url.searchParams.set("jurisdiction", forum);
if (kind !== "missed") url.searchParams.set("event_kind", kind);
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
cachedProcChips = [];
return;
}
const data = (await resp.json()) as ProceedingChip[] | null;
cachedProcChips = data || [];
} catch {
cachedProcChips = [];
}
}
async function ensureEventChips(procCode: string, kind: EventKindRow): Promise<void> {
const key = `${procCode}\x00${kind}`;
if (lastEventCacheKey === key) return;
lastEventCacheKey = key;
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("kind", "events");
url.searchParams.set("proc", procCode);
if (kind !== "missed") url.searchParams.set("event_kind", kind);
url.searchParams.set("limit", "100");
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
cachedEventChips = [];
return;
}
const data = (await resp.json()) as EventSearchResponse;
cachedEventChips = data.events || [];
} catch {
cachedEventChips = [];
}
}
// Helpers -----------------------------------------------------------
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
const tt = title ? ` title="${escAttr(title)}"` : "";
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${tt}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
}
function eventKindIcon(kind?: EventKindRow): string {
switch (kind) {
case "filing": return "&#128229;";
case "hearing": return "&#127963;&#65039;";
case "decision": return "&#9878;&#65039;";
case "order": return "&#128220;";
case "missed": return "&#9202;";
default: return "&#128197;";
}
}
function syncUrl(): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("mode", "wizard");
setOrClear(url, "kind", state.r1);
setOrClear(url, "forum", state.r2);
setOrClear(url, "pt", state.r3);
// event=… is set only on launchResult; the wizard URL carries the
// R4 candidate via r4= so back/forward navigates within the wizard.
setOrClear(url, "r4", state.r4);
setOrClear(url, "party", state.r5);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
function setOrClear(url: URL, key: string, val: string): void {
if (val) url.searchParams.set(key, val);
else url.searchParams.delete(key);
}

View File

@@ -221,6 +221,7 @@ const translations: Record<Lang, Record<string, string>> = {
"builder.header.stichtag": "Stichtag:",
"builder.header.search": "Suche:",
"builder.akte.none": "\u2014 ohne \u2014",
"builder.akte.banner.prefix": "Aus Akte:",
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
"builder.action.rename": "Benennen",
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
@@ -293,6 +294,62 @@ const translations: Record<Lang, Record<string, string>> = {
"builder.search.summary.projects.other": "{n} Akten",
"builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501",
// B5 \u2014 side-panel buckets, sharing, promote-to-project wizard.
"builder.bucket.shared": "Geteilt mit mir",
"builder.bucket.promoted": "Als Projekt angelegt",
"builder.bucket.archived": "Archiviert",
"builder.bucket.empty": "\u2014",
"builder.readonly.watermark": "Geteilt von {owner} \u00b7 schreibgesch\u00fctzt",
"builder.readonly.blocked": "Schreibgesch\u00fctzt \u2014 Bearbeiten ist nur f\u00fcr die Eigent\u00fcmer:in m\u00f6glich.",
"builder.share.title": "Szenario teilen",
"builder.share.subtitle": "Schreibgesch\u00fctzt mit HLC-Kolleg:innen teilen. Du bleibst alleinige Bearbeiter:in.",
"builder.share.search.placeholder": "Name oder E-Mail suchen \u2026",
"builder.share.button": "Schreibgesch\u00fctzt teilen",
"builder.share.current.title": "Bereits geteilt mit:",
"builder.share.current.empty": "Noch mit niemandem geteilt.",
"builder.share.revoke": "Entfernen",
"builder.share.close": "Schlie\u00dfen",
"builder.share.no_results": "Keine Nutzer:innen gefunden.",
"builder.share.error": "Teilen fehlgeschlagen. Erneut versuchen.",
"builder.promote.title": "Als Projekt anlegen",
"builder.promote.step1": "Best\u00e4tigen",
"builder.promote.step2": "Parteien erg\u00e4nzen",
"builder.promote.step3": "Akte-Metadaten",
"builder.promote.next": "Weiter",
"builder.promote.back": "Zur\u00fcck",
"builder.promote.commit": "Anlegen",
"builder.promote.cancel": "Abbrechen",
"builder.promote.summary.heading": "Das wird angelegt:",
"builder.promote.summary.proceeding": "Hauptverfahren",
"builder.promote.summary.events_filed": "eingereichte Ereignisse",
"builder.promote.summary.events_planned": "geplante Ereignisse",
"builder.promote.summary.flags": "aktive Optionen",
"builder.promote.summary.note_extra": "{n} weitere(s) eigenst\u00e4ndige(s) Verfahren bleibt im Szenario und wird nicht automatisch \u00fcbernommen.",
"builder.promote.parties.hint": "Trage die echten Parteinamen ein \u2014 oder erg\u00e4nze sie sp\u00e4ter in der Akte.",
"builder.promote.parties.add": "+ Partei hinzuf\u00fcgen",
"builder.promote.parties.name": "Name",
"builder.promote.parties.role": "Rolle (z. B. Kl\u00e4ger)",
"builder.promote.parties.representative": "Vertreter:in",
"builder.promote.parties.remove": "Entfernen",
"builder.promote.parties.empty": "Noch keine Parteien.",
"builder.promote.meta.title": "Aktentitel / Mandat",
"builder.promote.meta.title.placeholder": "z. B. Becker ./. X \u2014 UPC Verletzung",
"builder.promote.meta.reference": "Referenz (optional)",
"builder.promote.meta.case_number": "Aktenzeichen (optional)",
"builder.promote.meta.client_number": "Mandantennummer (optional)",
"builder.promote.meta.our_side": "Unsere Seite",
"builder.promote.meta.our_side.claimant": "Kl\u00e4ger",
"builder.promote.meta.our_side.defendant": "Beklagter",
"builder.promote.meta.our_side.none": "\u2014 offen \u2014",
"builder.promote.meta.parent": "\u00dcbergeordnetes Verfahren (optional)",
"builder.promote.meta.parent.none": "\u2014 keines \u2014",
"builder.promote.meta.team": "Team (optional)",
"builder.promote.meta.team.hint": "Du wirst automatisch als Lead hinzugef\u00fcgt.",
"builder.promote.error.title_required": "Bitte einen Aktentitel eingeben.",
"builder.promote.error.generic": "Anlegen fehlgeschlagen. Erneut versuchen.",
"builder.promote.success": "Akte angelegt \u2014 Weiterleitung \u2026",
"builder.mobile.blocked": "Auf gr\u00f6\u00dferem Bildschirm \u00f6ffnen, um zu bearbeiten.",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step2.perspective": "Perspektive und Datum",
@@ -342,10 +399,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.epa.opp.opd": "Einspruchsverfahren",
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
"deadlines.party.claimant": "Kl\u00e4ger",
"deadlines.party.defendant": "Beklagter",
"deadlines.party.court": "Gericht",
"deadlines.party.both": "Beide",
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
@@ -1071,6 +1124,7 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.view.month": "Monat",
"cal.view.week": "Woche",
"cal.view.day": "Tag",
"cal.today": "Heute",
"cal.month.prev": "Vorheriger Monat",
"cal.month.next": "Nächster Monat",
"cal.week.prev": "Vorherige Woche",
@@ -1496,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:",
@@ -1691,11 +1763,28 @@ 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.",
"submissions.draft.sections.title": "Abschnitte",
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
"templates.authoring.title": "Vorlagen — Paliad",
"templates.authoring.heading": "Vorlagen",
"templates.authoring.intro": "Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.",
"templates.authoring.upload.title": "Neue Vorlage hochladen",
"templates.authoring.upload.file": "Word-Datei (.docx)",
"templates.authoring.upload.name_de": "Name (DE)",
"templates.authoring.upload.name_en": "Name (EN)",
"templates.authoring.upload.firm": "Kanzlei (optional)",
"templates.authoring.upload.submit": "Hochladen",
"templates.authoring.list.title": "Vorhandene Vorlagen",
"templates.authoring.workspace.hint": "Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.",
"templates.authoring.slots.title": "Platzhalter",
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
"admin.building_blocks.title": "Bausteine — Paliad",
"admin.building_blocks.heading": "Bausteine",
@@ -3504,6 +3593,7 @@ const translations: Record<Lang, Record<string, string>> = {
"builder.header.stichtag": "Anchor:",
"builder.header.search": "Search:",
"builder.akte.none": "— none —",
"builder.akte.banner.prefix": "From matter:",
"builder.search.placeholder": "Event, scenario, matter …",
"builder.action.rename": "Name it",
"builder.action.rename.prompt": "Name for this scenario:",
@@ -3576,6 +3666,62 @@ const translations: Record<Lang, Record<string, string>> = {
"builder.search.summary.projects.other": "{n} matters",
"builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━",
// B5 — side-panel buckets, sharing, promote-to-project wizard.
"builder.bucket.shared": "Shared with me",
"builder.bucket.promoted": "Promoted to project",
"builder.bucket.archived": "Archived",
"builder.bucket.empty": "—",
"builder.readonly.watermark": "Shared by {owner} · read-only",
"builder.readonly.blocked": "Read-only — only the owner can edit.",
"builder.share.title": "Share scenario",
"builder.share.subtitle": "Share read-only with HLC colleagues. You remain the sole editor.",
"builder.share.search.placeholder": "Search name or email …",
"builder.share.button": "Share read-only",
"builder.share.current.title": "Already shared with:",
"builder.share.current.empty": "Not shared with anyone yet.",
"builder.share.revoke": "Remove",
"builder.share.close": "Close",
"builder.share.no_results": "No users found.",
"builder.share.error": "Sharing failed. Please try again.",
"builder.promote.title": "Create as project",
"builder.promote.step1": "Confirm",
"builder.promote.step2": "Add parties",
"builder.promote.step3": "Case metadata",
"builder.promote.next": "Next",
"builder.promote.back": "Back",
"builder.promote.commit": "Create",
"builder.promote.cancel": "Cancel",
"builder.promote.summary.heading": "What will be created:",
"builder.promote.summary.proceeding": "Primary proceeding",
"builder.promote.summary.events_filed": "filed events",
"builder.promote.summary.events_planned": "planned events",
"builder.promote.summary.flags": "active options",
"builder.promote.summary.note_extra": "{n} further standalone proceeding(s) stay in the scenario and are not carried over automatically.",
"builder.promote.parties.hint": "Enter the real party names — or add them later in the case file.",
"builder.promote.parties.add": "+ Add party",
"builder.promote.parties.name": "Name",
"builder.promote.parties.role": "Role (e.g. claimant)",
"builder.promote.parties.representative": "Representative",
"builder.promote.parties.remove": "Remove",
"builder.promote.parties.empty": "No parties yet.",
"builder.promote.meta.title": "Case title / matter",
"builder.promote.meta.title.placeholder": "e.g. Becker v. X — UPC infringement",
"builder.promote.meta.reference": "Reference (optional)",
"builder.promote.meta.case_number": "Case number (optional)",
"builder.promote.meta.client_number": "Client number (optional)",
"builder.promote.meta.our_side": "Our side",
"builder.promote.meta.our_side.claimant": "Claimant",
"builder.promote.meta.our_side.defendant": "Defendant",
"builder.promote.meta.our_side.none": "— open —",
"builder.promote.meta.parent": "Parent litigation (optional)",
"builder.promote.meta.parent.none": "— none —",
"builder.promote.meta.team": "Team (optional)",
"builder.promote.meta.team.hint": "You are added as lead automatically.",
"builder.promote.error.title_required": "Please enter a case title.",
"builder.promote.error.generic": "Creation failed. Please try again.",
"builder.promote.success": "Case created — redirecting …",
"builder.mobile.blocked": "Open on a larger screen to edit.",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step2.perspective": "Perspective and Date",
@@ -3625,10 +3771,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.epa.opp.opd": "Opposition",
"deadlines.epa.opp.boa": "Appeal",
"deadlines.epa.grant.exa": "Grant Procedure",
"deadlines.party.claimant": "Claimant",
"deadlines.party.defendant": "Defendant",
"deadlines.party.court": "Court",
"deadlines.party.both": "Both",
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
@@ -4760,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:",
@@ -4950,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",
@@ -4960,6 +5124,19 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
"submissions.draft.sections.title": "Sections",
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
"templates.authoring.title": "Templates — Paliad",
"templates.authoring.heading": "Templates",
"templates.authoring.intro": "Upload a Word template, highlight spots and insert variables.",
"templates.authoring.upload.title": "Upload a new template",
"templates.authoring.upload.file": "Word file (.docx)",
"templates.authoring.upload.name_de": "Name (DE)",
"templates.authoring.upload.name_en": "Name (EN)",
"templates.authoring.upload.firm": "Firm (optional)",
"templates.authoring.upload.submit": "Upload",
"templates.authoring.list.title": "Existing templates",
"templates.authoring.workspace.hint": "Highlight text, then pick a variable to place a placeholder.",
"templates.authoring.slots.title": "Placeholders",
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
"admin.building_blocks.title": "Building blocks — Paliad",
"admin.building_blocks.heading": "Building blocks",

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

@@ -1,5 +1,7 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
// t-paliad-238 Slice A — client bundle for the dedicated
// Submissions/Schriftsätze editor at
@@ -33,6 +35,9 @@ interface SubmissionDraftJSON {
// path stays the fallback). composer_meta carries the seed-time
// section order in later slices.
base_id?: string | null;
// t-paliad-349 slice 7 — pinned uploaded docforge template version.
// Mutually exclusive with base_id in practice (export checks this first).
template_version_id?: string | null;
composer_meta?: Record<string, unknown>;
created_at: string;
updated_at: string;
@@ -69,6 +74,17 @@ interface SubmissionBaseRow {
section_count: number;
}
// t-paliad-349 slice 7 — an uploaded docforge template offered in the
// picker for generation. version_id is what a draft pins.
interface PickerTemplate {
id: string;
name_de: string;
name_en: string;
firm?: string | null;
version: number;
version_id?: string;
}
interface AvailablePartyJSON {
id: string;
name: string;
@@ -153,19 +169,16 @@ function isEN(): boolean {
return document.documentElement.lang === "en";
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the
// shared editor utilities); the local copies were removed in slice 5.
// ─────────────────────────────────────────────────────────────────────
// Variable contract — DE/EN labels per dotted-path placeholder.
// Mirrors the same shape the email-template variables sidebar uses;
// keeps the lawyer's mental model anchored on the same vocabulary.
// Labels come from the Go-side catalogue (GET /api/docforge/variables),
// fetched once on boot into state.varLabels. The frontend keeps only the
// presentation grouping (VARIABLE_GROUPS) — which keys to show and how to
// section them — not the label data itself, so labels can't drift from the
// resolvers that produce the values (t-paliad-349 slice 5).
// ─────────────────────────────────────────────────────────────────────
interface VariableLabel {
@@ -186,71 +199,6 @@ interface VariableGroup {
collapsedByDefault?: boolean;
}
const VARIABLE_LABELS: Record<string, VariableLabel> = {
"firm.name": { de: "Kanzlei", en: "Firm" },
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
"today": { de: "Heute", en: "Today" },
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
"user.display_name": { de: "Bearbeiter", en: "Author" },
"user.email": { de: "E-Mail", en: "Email" },
"user.office": { de: "Büro", en: "Office" },
"project.title": { de: "Projekttitel", en: "Project title" },
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
"project.court": { de: "Gericht", en: "Court" },
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
"project.our_side": { de: "Unsere Seite", en: "Our side" },
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
"project.instance_level": { de: "Instanz", en: "Instance" },
"project.client_number": { de: "Mandantennummer", en: "Client number" },
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
// Procedural-event namespace (t-paliad-262 Slice A, design doc
// docs/design-procedural-events-model-2026-05-25.md). The canonical
// placeholder names are below; the `rule.*` aliases that follow are
// @deprecated but kept forever per m's Q7 lock — existing Word
// templates and saved drafts authored with the old names keep
// merging identically.
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
};
// t-paliad-287 — variable groups restructured into four lawyer-facing
// sections: Mandant/Verfahren up top (the case identity), then Parteien
@@ -341,7 +289,7 @@ const VARIABLE_GROUPS: VariableGroup[] = [
];
function labelFor(key: string): string {
const entry = VARIABLE_LABELS[key];
const entry = state.varLabels[key];
if (!entry) return key;
return isEN() ? entry.en : entry.de;
}
@@ -373,6 +321,15 @@ interface State {
// completes) keeps the picker hidden permanently for this load.
bases: SubmissionBaseRow[];
basesLoaded: boolean;
// t-paliad-349 slice 7 — uploaded templates offered in the picker.
templates: PickerTemplate[];
templatesLoaded: boolean;
// t-paliad-349 slice 5 — variable labels fetched once on boot from the
// Go catalogue (GET /api/docforge/variables), the single source of
// truth. Empty until the fetch lands; labelFor falls back to the raw
// key, so a failed fetch degrades gracefully rather than breaking the
// form.
varLabels: Record<string, VariableLabel>;
}
type PartySide = "claimant" | "defendant" | "other";
@@ -401,6 +358,9 @@ const state: State = {
addPartyBusy: false,
bases: [],
basesLoaded: false,
templates: [],
templatesLoaded: false,
varLabels: {},
};
// ─────────────────────────────────────────────────────────────────────
@@ -425,6 +385,21 @@ async function boot(): Promise<void> {
console.warn("submission-draft: base catalog fetch failed", err);
state.basesLoaded = true;
});
// t-paliad-349 slice 7 — uploaded-template catalog for the picker.
loadTemplates().catch(err => {
console.warn("submission-draft: template catalog fetch failed", err);
state.templatesLoaded = true;
});
// t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
// before the first paint so the sidebar form labels render. Awaited
// because labelFor needs it at paint time; a failure leaves varLabels
// empty and labelFor falls back to the raw key (degraded but usable).
try {
state.varLabels = labelMap(await fetchVariableCatalogue());
} catch (err) {
console.warn("submission-draft: variable catalogue fetch failed", err);
}
try {
if (parsed.mode === "global") {
@@ -528,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) {
@@ -583,6 +558,7 @@ function paint(): void {
paintPartyPicker();
paintLanguageRow();
paintLanguageFallback();
paintKeywordRow();
paintVariables();
paintSectionList();
paintPreview();
@@ -1059,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;
@@ -1217,29 +1240,46 @@ async function loadBases(): Promise<void> {
if (state.view) paintBasePicker();
}
// loadTemplates fetches the firm-shared uploaded-template catalog
// (t-paliad-349 slice 7). Failure leaves the list empty — the picker
// simply offers no uploaded templates, the editor stays usable.
async function loadTemplates(): Promise<void> {
const res = await fetch("/api/templates", { credentials: "include" });
if (!res.ok) {
throw new Error("template list HTTP " + res.status);
}
const body = await res.json() as { templates?: PickerTemplate[] };
state.templates = (body.templates ?? []).filter(t => !!t.version_id);
state.templatesLoaded = true;
if (state.view) paintBasePicker();
}
function paintBasePicker(): void {
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
if (!row || !sel || !state.view) return;
// Hide the picker until the catalog has loaded AND the catalog has
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
// keeps the picker hidden indefinitely so the editor stays usable.
if (!state.basesLoaded || state.bases.length === 0) {
// Hide the picker only when BOTH catalogs are loaded-but-empty. As long
// as bases OR uploaded templates exist, the picker is useful. A failed
// fetch leaves the respective list empty; the editor stays usable.
const hasBases = state.basesLoaded && state.bases.length > 0;
const hasTemplates = state.templatesLoaded && state.templates.length > 0;
if (!hasBases && !hasTemplates) {
row.style.display = "none";
return;
}
row.style.display = "";
// Rebuild the <option> list each paint so language toggles + base
// catalog updates flow through.
// Rebuild the <option> list each paint so language toggles + catalog
// updates flow through.
sel.innerHTML = "";
const currentBaseID = state.view.draft.base_id ?? "";
const currentTplVersion = state.view.draft.template_version_id ?? "";
// "Keine Vorlagenbasis" only listed when the draft is currently in
// that state (pre-Composer / cleared). Avoids tempting the lawyer
// to clear after they've already picked one.
if (!currentBaseID) {
// that state (no base, no template). Avoids tempting the lawyer to
// clear after they've already picked one.
if (!currentBaseID && !currentTplVersion) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
@@ -1252,6 +1292,21 @@ function paintBasePicker(): void {
if (b.id === currentBaseID) opt.selected = true;
sel.appendChild(opt);
}
// t-paliad-349 slice 7 — uploaded templates as a separate optgroup.
// The value is "tpl:<version_id>" so onBaseChange can route it to the
// template_version_id PATCH instead of base_id.
if (hasTemplates) {
const group = document.createElement("optgroup");
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
for (const tmpl of state.templates) {
const opt = document.createElement("option");
opt.value = "tpl:" + tmpl.version_id;
opt.textContent = isEN() ? tmpl.name_en : tmpl.name_de;
if (tmpl.version_id === currentTplVersion) opt.selected = true;
group.appendChild(opt);
}
sel.appendChild(group);
}
// Wire change handler once per paint. Removing then re-adding
// keeps the binding consistent across repaints (e.g. after
@@ -1259,12 +1314,17 @@ function paintBasePicker(): void {
sel.onchange = () => { onBaseChange(sel.value); };
}
async function onBaseChange(newBaseID: string): Promise<void> {
async function onBaseChange(newValue: string): Promise<void> {
if (!state.view) return;
const payload: Record<string, unknown> = {
// Empty string in the picker maps to null = clear.
base_id: newBaseID === "" ? null : newBaseID,
};
// The picker mixes legacy bases (plain uuid) and uploaded templates
// ("tpl:<version_id>"). Route to the matching field and clear the other
// so the two render paths stay mutually exclusive. Empty = clear both.
let payload: Record<string, unknown>;
if (newValue.startsWith("tpl:")) {
payload = { template_version_id: newValue.slice(4), base_id: null };
} else {
payload = { base_id: newValue === "" ? null : newValue, template_version_id: null };
}
try {
const res = await fetch(
`/api/submission-drafts/${state.view.draft.id}`,
@@ -1985,11 +2045,11 @@ function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
row.innerHTML = `
<div class="submission-bb-picker-row-head">
<strong>${escapeHTML(title)}</strong>
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
<strong>${escapeHtml(title)}</strong>
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
</div>
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
<pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
row.addEventListener("click", () => {
void insertBlockIntoSection(b.id, sec.id, overlay);
});
@@ -2019,15 +2079,6 @@ async function insertBlockIntoSection(blockID: string, sectionID: string, overla
}
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
try {
const draftID = state.view?.draft.id;
@@ -2104,17 +2155,6 @@ function findVarInput(key: string): HTMLInputElement | null {
);
}
function cssEscape(s: string): string {
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
// older browsers may lack it; defensive fallback escapes characters
// CSS treats as special. Placeholder keys never carry whitespace or
// quotes so escaping is straightforward.
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(s);
}
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
function onDraftVarClick(key: string, ev: Event): void {
const input = findVarInput(key);
if (!input) return;

View File

@@ -0,0 +1,314 @@
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import { escapeHtml } from "../lib/docforge-editor/dom";
import { fetchVariableCatalogue, type VariableEntry } from "../lib/docforge-editor/catalogue";
// t-paliad-349 docforge slice 6 — client for the template authoring page.
//
// Flow: list templates → upload a .docx (or open one) → the carrier renders
// as run spans (<span class="docforge-run" data-run="N">) → the admin
// selects text within one run, then clicks a variable in the palette → the
// server injects {{slot}} at the selection and returns the updated view.
//
// The select-then-pick gesture keys on the run index (data-run) + the
// selected text, matching the server's text-based InjectSlot so umlauts
// can't desync the selection from the slice. Selections that span more than
// one run are rejected with a hint (v1 scope: single-run text slots).
interface TemplateMeta {
id: string;
slug?: string;
name_de: string;
name_en: string;
kind: string;
source_format: string;
firm?: string;
is_active: boolean;
version: number;
}
interface TemplateSlot {
key: string;
anchor: string;
label?: string;
order_index: number;
}
interface AuthoringView {
template: TemplateMeta;
preview_html: string;
slots: TemplateSlot[];
}
interface Selection1Run {
runIndex: number;
text: string;
}
interface State {
catalogue: VariableEntry[];
openID: string | null;
activeSlotKey: string | null;
selection: Selection1Run | null;
}
const state: State = {
catalogue: [],
openID: null,
activeSlotKey: null,
selection: null,
};
function isEN(): boolean {
return (document.documentElement.lang || "de").toLowerCase().startsWith("en");
}
function labelOf(e: VariableEntry): string {
return isEN() ? e.label_en : e.label_de;
}
async function boot(): Promise<void> {
initI18n();
initSidebar();
try {
state.catalogue = await fetchVariableCatalogue();
} catch (err) {
console.warn("templates-authoring: catalogue fetch failed", err);
}
wireUploadForm();
await loadList();
}
async function loadList(): Promise<void> {
const host = document.getElementById("docforge-template-list");
if (!host) return;
let metas: TemplateMeta[] = [];
try {
const res = await fetch("/api/admin/templates", { headers: { Accept: "application/json" } });
if (res.ok) {
const body = (await res.json()) as { templates: TemplateMeta[] };
metas = body.templates ?? [];
}
} catch (err) {
console.warn("templates-authoring: list fetch failed", err);
}
if (metas.length === 0) {
host.innerHTML = `<li class="docforge-template-empty">${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}</li>`;
return;
}
host.innerHTML = metas
.map((m) => {
const name = isEN() ? m.name_en : m.name_de;
const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : "";
return `<li class="docforge-template-row" data-template-id="${escapeHtml(m.id)}">
<span class="docforge-template-name">${escapeHtml(name)}</span>
<span class="docforge-template-meta">v${m.version}${firm}</span>
</li>`;
})
.join("");
host.querySelectorAll<HTMLLIElement>(".docforge-template-row").forEach((li) => {
li.addEventListener("click", () => {
const id = li.dataset.templateId;
if (id) void openTemplate(id);
});
});
}
function wireUploadForm(): void {
const form = document.getElementById("docforge-upload-form") as HTMLFormElement | null;
if (!form) return;
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const status = document.getElementById("docforge-upload-status");
const data = new FormData(form);
setText(status, isEN() ? "Uploading…" : "Lädt hoch…");
try {
const res = await fetch("/api/admin/templates", { method: "POST", body: data });
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
setText(status, (isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
return;
}
const view = (await res.json()) as AuthoringView;
setText(status, "");
form.reset();
await loadList();
openView(view);
} catch (err) {
setText(status, (isEN() ? "Error: " : "Fehler: ") + String(err));
}
});
}
async function openTemplate(id: string): Promise<void> {
try {
const res = await fetch(`/api/admin/templates/${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return;
openView((await res.json()) as AuthoringView);
} catch (err) {
console.warn("templates-authoring: open failed", err);
}
}
function openView(view: AuthoringView): void {
state.openID = view.template.id;
state.activeSlotKey = null;
state.selection = null;
const workspace = document.getElementById("docforge-workspace");
if (workspace) workspace.hidden = false;
const title = document.getElementById("docforge-workspace-title");
if (title) {
const name = isEN() ? view.template.name_en : view.template.name_de;
title.textContent = `${name} · v${view.template.version}`;
}
renderPreview(view.preview_html);
renderSlots(view.slots);
renderPalette();
setWorkspaceStatus("");
}
function renderPreview(html: string): void {
const host = document.getElementById("docforge-preview");
if (!host) return;
host.innerHTML = html;
host.addEventListener("mouseup", onPreviewSelect);
}
// onPreviewSelect captures a selection that lies entirely within one run
// span; otherwise it clears the pending selection and hints.
function onPreviewSelect(): void {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
state.selection = null;
return;
}
const text = sel.toString();
if (text === "") {
state.selection = null;
return;
}
const anchorRun = closestRun(sel.anchorNode);
const focusRun = closestRun(sel.focusNode);
if (!anchorRun || anchorRun !== focusRun) {
state.selection = null;
setWorkspaceStatus(isEN()
? "Select within a single text span."
: "Bitte innerhalb einer Textstelle markieren.");
return;
}
const runIndex = Number(anchorRun.dataset.run);
if (Number.isNaN(runIndex)) {
state.selection = null;
return;
}
state.selection = { runIndex, text };
setWorkspaceStatus(state.activeSlotKey
? (isEN() ? `Click to bind “${text}” → ${state.activeSlotKey}` : `Variable wählen, um „${text}“ zu setzen`)
: (isEN() ? `Selected “${text}” — now pick a variable.` : `${text}" markiert — jetzt Variable wählen.`));
}
function closestRun(node: Node | null): HTMLElement | null {
let el: Node | null = node;
while (el && el !== document.body) {
if (el instanceof HTMLElement && el.classList.contains("docforge-run")) return el;
el = el.parentNode;
}
return null;
}
// renderPalette groups catalogue entries by their namespace group and wires
// each as a click-to-place control.
function renderPalette(): void {
const host = document.getElementById("docforge-palette");
if (!host) return;
if (state.catalogue.length === 0) {
host.innerHTML = `<p class="docforge-palette-empty">${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}</p>`;
return;
}
const groups = new Map<string, VariableEntry[]>();
for (const e of state.catalogue) {
const arr = groups.get(e.group) ?? [];
arr.push(e);
groups.set(e.group, arr);
}
let html = `<h3>${escapeHtml(isEN() ? "Variables" : "Variablen")}</h3>`;
for (const [group, entries] of groups) {
html += `<div class="docforge-palette-group"><h4>${escapeHtml(group)}</h4>`;
for (const e of entries) {
html += `<button type="button" class="docforge-palette-var" data-slot-key="${escapeHtml(e.key)}" title="{{${escapeHtml(e.key)}}}">${escapeHtml(labelOf(e))}</button>`;
}
html += `</div>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLButtonElement>(".docforge-palette-var").forEach((btn) => {
btn.addEventListener("click", () => onPaletteClick(btn.dataset.slotKey ?? "", btn));
});
}
function onPaletteClick(slotKey: string, btn: HTMLButtonElement): void {
state.activeSlotKey = slotKey;
const host = document.getElementById("docforge-palette");
host?.querySelectorAll(".docforge-palette-var--active").forEach((el) => el.classList.remove("docforge-palette-var--active"));
btn.classList.add("docforge-palette-var--active");
if (state.selection) {
void placeSlot(state.selection.runIndex, state.selection.text, slotKey);
} else {
setWorkspaceStatus(isEN()
? `${slotKey} selected — now highlight the text to replace.`
: `${slotKey} gewählt — jetzt den zu ersetzenden Text markieren.`);
}
}
async function placeSlot(runIndex: number, selectedText: string, slotKey: string): Promise<void> {
if (!state.openID) return;
setWorkspaceStatus(isEN() ? "Placing…" : "Setze…");
try {
const res = await fetch(`/api/admin/templates/${encodeURIComponent(state.openID)}/slots`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_index: runIndex, selected_text: selectedText, slot_key: slotKey }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
return;
}
openView((await res.json()) as AuthoringView);
} catch (err) {
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + String(err));
}
}
function renderSlots(slots: TemplateSlot[]): void {
const host = document.getElementById("docforge-slot-list");
if (!host) return;
if (slots.length === 0) {
host.innerHTML = `<li class="docforge-slot-empty">${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}</li>`;
return;
}
host.innerHTML = slots
.map((s) => `<li class="docforge-slot-row" data-slot="${escapeHtml(s.key)}"><code>{{${escapeHtml(s.key)}}}</code></li>`)
.join("");
}
function setWorkspaceStatus(msg: string): void {
setText(document.getElementById("docforge-workspace-status"), msg);
}
function setText(el: Element | null, msg: string): void {
if (el) el.textContent = msg;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => void boot());
} else {
void boot();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,320 +0,0 @@
// Per-event-card choice popover + chip indicator (t-paliad-265 /
// m/paliad#96).
//
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
// button on cards that carry a non-empty `choices_offered` declaration
// and an inert chip span next to the title. This module:
//
// 1. Wires a delegated click handler on the result container so the
// caret opens a popover with the offered choice-kinds.
// 2. Commits the user's pick — either by POSTing to the project-
// bound endpoint or by mutating the in-memory state for the
// unbound (no-project) case.
// 3. Rehydrates the chip on every render + after every commit so the
// glanceable indicator matches the active state.
//
// Two consumer pages — /tools/verfahrensablauf (unbound) and
// /tools/fristenrechner (project-bound) — both wire this module
// once at boot via attachEventCardChoices().
import { escAttr, escHtml } from "./verfahrensablauf-core";
import { t } from "../i18n";
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
export interface EventChoice {
submission_code: string;
choice_kind: ChoiceKind;
choice_value: string;
}
// State surface — the page passes in callbacks that own persistence.
// commit / remove must trigger a recalc on the page side (the popover
// only owns its own visual state).
export interface EventCardChoicesOpts {
container: HTMLElement;
// Initial state: a list of choices. The page seeds this from the
// server response (project-bound) or from URL params (unbound).
initial: EventChoice[];
// commit gets called for an UPSERT. The page POSTs to the API (or
// mutates URL state) AND triggers a recalc.
commit: (choice: EventChoice) => Promise<void> | void;
// remove gets called when the user resets a choice.
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
}
// One mutable bag per attach() call. The current implementation is a
// single-page singleton — paginated views (admin tables) are not in
// scope. Last-write-wins on the in-memory state.
interface AttachedState {
opts: EventCardChoicesOpts;
// active: submission_code → kind → value. Rebuilt from `initial`
// on every reseed() call.
active: Map<string, Map<ChoiceKind, string>>;
popover: HTMLDivElement | null;
}
const states = new WeakMap<HTMLElement, AttachedState>();
// attachEventCardChoices wires the delegated click + popover lifecycle
// to the given container. Call once per page after mount; safe to call
// again with a fresh container.
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
const state: AttachedState = {
opts,
active: new Map(),
popover: null,
};
for (const c of opts.initial) {
if (!state.active.has(c.submission_code)) {
state.active.set(c.submission_code, new Map());
}
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
}
states.set(opts.container, state);
opts.container.addEventListener("click", (e) => {
const targetEl = e.target as HTMLElement | null;
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
if (caret) {
e.stopPropagation();
openPopover(state, caret);
return;
}
// Outside-click closes the popover.
if (state.popover && !state.popover.contains(e.target as Node)) {
closePopover(state);
}
});
// ESC also closes.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.popover) {
closePopover(state);
}
});
// Repaint chips on every renderResults() call. The page is
// responsible for calling reseedChips() after re-render so the chip
// dom node (re-created by the renderer) picks the active state up.
reseedChips(opts.container);
}
// reseedChips walks every chip span in the container and re-renders
// its content from the active state map. Idempotent.
export function reseedChips(container: HTMLElement): void {
const state = states.get(container);
if (!state) return;
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const kinds = state.active.get(code);
if (!kinds || kinds.size === 0) {
chip.innerHTML = "";
chip.dataset.empty = "true";
return;
}
chip.dataset.empty = "false";
chip.innerHTML = renderChip(kinds);
});
// Skipped rows fade out via a class on the card-item ancestor.
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const skipped = state.active.get(code)?.get("skip") === "true";
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
});
}
function renderChip(kinds: Map<ChoiceKind, string>): string {
const parts: string[] = [];
if (kinds.get("skip") === "true") {
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
}
const ap = kinds.get("appellant");
if (ap && ap !== "" ) {
let label = "";
switch (ap) {
case "claimant": label = t("choices.appellant.claimant"); break;
case "defendant": label = t("choices.appellant.defendant"); break;
case "both": label = t("choices.appellant.both"); break;
case "none": label = t("choices.appellant.none"); break;
}
if (label) {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
}
}
if (kinds.get("include_ccr") === "true") {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
}
return parts.join(" ");
}
function openPopover(state: AttachedState, caret: HTMLElement): void {
closePopover(state);
const code = caret.dataset.submissionCode || "";
if (!code) return;
let offered: Record<string, unknown> = {};
try {
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
} catch {
return;
}
const isHidden = caret.dataset.isHidden === "1";
const pop = document.createElement("div");
pop.className = "event-card-choices-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("choices.caret.title"));
const blocks: string[] = [];
// t-paliad-293: hidden-card prominence. When the user opens the
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
// most likely intent — surface it as a single high-contrast action
// at the top of the popover (rather than burying it under the skip
// toggle's reset link). Clicking it clears the `skip` choice, which
// is the same wire effect as the legacy inline chip from t-paliad-290.
if (isHidden) {
blocks.push(renderUnhideBlock());
}
if (Array.isArray(offered.appellant)) {
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
}
if (Array.isArray(offered.include_ccr)) {
blocks.push(renderToggleBlock(state, code, "include_ccr"));
}
if (Array.isArray(offered.skip)) {
blocks.push(renderToggleBlock(state, code, "skip"));
}
pop.innerHTML = blocks.join("");
document.body.appendChild(pop);
state.popover = pop;
positionPopover(pop, caret);
pop.addEventListener("click", async (e) => {
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
if (!btn) return;
e.stopPropagation();
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
const value = btn.dataset.choiceValue || "";
const action = btn.dataset.choiceAction;
if (!kind) return;
try {
if (action === "set") {
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
if (!state.active.has(code)) state.active.set(code, new Map());
state.active.get(code)!.set(kind, value);
} else if (action === "clear") {
await state.opts.remove(code, kind);
state.active.get(code)?.delete(kind);
}
reseedChips(state.opts.container);
closePopover(state);
} catch (err) {
console.error("event card choice commit failed", err);
// Surface a soft inline error inside the popover; do NOT close.
const errEl = document.createElement("div");
errEl.className = "event-card-choices-error";
errEl.textContent = t("choices.commit.error");
pop.appendChild(errEl);
}
});
}
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
const current = state.active.get(code)?.get("appellant") || "";
const buttons = values
.filter((v): v is string => typeof v === "string")
.map((v) => {
const labelKey = `choices.appellant.${v}` as const;
const isActive = v === current;
return `<button type="button"
data-choice-action="set"
data-choice-kind="appellant"
data-choice-value="${escAttr(v)}"
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
})
.join("");
const reset = current
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
<div class="event-card-choices-options">${buttons}</div>
${reset}
</div>`;
}
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
const current = state.active.get(code)?.get(kind) || "false";
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
data-choice-action="set"
data-choice-kind="${kind}"
data-choice-value="${v}"
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
const reset = state.active.get(code)?.has(kind)
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
<div class="event-card-choices-options">
${opt("true", trueKey)}
${opt("false", falseKey)}
</div>
${reset}
</div>`;
}
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
// action — surfaced only when the caret is opened on a re-surfaced
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
// the same `clear` action as the skip-block reset link below, but
// labelled in the user's terms ("restore this card" rather than
// "reset skip choice"). Drops out of the popover automatically on
// non-hidden cards so the popover stays minimal. (t-paliad-293)
function renderUnhideBlock(): string {
const label = t("choices.unhide.chip");
return `<div class="event-card-choices-block event-card-choices-block--unhide">
<button type="button"
data-choice-action="clear"
data-choice-kind="skip"
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
</div>`;
}
function closePopover(state: AttachedState): void {
if (state.popover) {
state.popover.remove();
state.popover = null;
}
}
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
const rect = caret.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
pop.style.position = "absolute";
pop.style.top = `${rect.bottom + scrollY + 4}px`;
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
pop.style.zIndex = "1000";
}
// Returns the current in-memory choice list for the given container —
// used by the unbound /tools/verfahrensablauf page to keep the URL
// param in sync.
export function currentChoices(container: HTMLElement): EventChoice[] {
const state = states.get(container);
if (!state) return [];
const out: EventChoice[] = [];
state.active.forEach((kinds, code) => {
kinds.forEach((value, kind) => {
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
});
});
return out;
}

View File

@@ -1,309 +0,0 @@
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
//
// The contract:
// 1. URL params (proceeding, side, target, trigger_date) define which
// timeline kind the user is looking at — paste-able, shareable,
// refresh-resistant.
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
// per-user scenario tweaks (event_choices, court_id, flags,
// show_hidden) — these never leak into a shared link.
// 3. On hydrate, URL wins. localStorage fills the rest.
import { describe, expect, test } from "bun:test";
import {
APPEAL_TARGETS,
SCENARIO_KEYS,
SCENARIO_PREFIX,
URL_KEYS,
applyFiltersToSearch,
hydrate,
makeMemoryStorage,
parseAppealTargetFromSearch,
parseProceedingFromSearch,
parseSideFromSearch,
parseTriggerDateFromSearch,
readBoolFlag,
readCourtId,
readEventChoices,
readScenario,
writeBoolFlag,
writeCourtId,
writeEventChoices,
} from "./verfahrensablauf-state";
describe("URL parsers — filter chips", () => {
test("parseProceedingFromSearch returns empty string when absent", () => {
expect(parseProceedingFromSearch("")).toBe("");
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
});
test("parseProceedingFromSearch echoes the raw value", () => {
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
});
test("parseSideFromSearch validates the enum", () => {
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
expect(parseSideFromSearch("?side=neither")).toBe(null);
expect(parseSideFromSearch("")).toBe(null);
});
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
for (const t of APPEAL_TARGETS) {
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
}
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
expect(parseAppealTargetFromSearch("")).toBe("");
});
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
});
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
expect(parseTriggerDateFromSearch("")).toBe("");
});
});
describe("URL encoder — applyFiltersToSearch", () => {
test("empty filters preserve the existing query string", () => {
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
});
test("setting a filter writes the canonical key", () => {
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
});
test("setting null / empty / undefined deletes the key", () => {
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
});
test("invalid trigger_date is deleted (never written as-is)", () => {
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
});
test("setting all four filters together emits all four keys", () => {
const out = applyFiltersToSearch("", {
proceeding: "upc.apl.unified",
side: "defendant",
target: "endentscheidung",
triggerDate: "2026-05-26",
});
expect(out).toContain("proceeding=upc.apl.unified");
expect(out).toContain("side=defendant");
expect(out).toContain("target=endentscheidung");
expect(out).toContain("trigger_date=2026-05-26");
});
test("other params (project, view) are preserved", () => {
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
expect(out).toContain("project=abc");
expect(out).toContain("view=timeline");
expect(out).toContain("side=claimant");
});
test("absent keys in the filter object don't touch existing URL values", () => {
// Only updating side — proceeding should be untouched.
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
expect(out).toContain("proceeding=upc.inf.cfi");
expect(out).toContain("side=claimant");
});
});
describe("URL round-trip — encode then parse yields the same value", () => {
test("proceeding", () => {
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
});
test("side", () => {
const enc = applyFiltersToSearch("", { side: "defendant" });
expect(parseSideFromSearch(enc)).toBe("defendant");
});
test("target", () => {
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
});
test("trigger_date", () => {
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
});
});
describe("Scenario localStorage helpers", () => {
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
for (const key of Object.values(SCENARIO_KEYS)) {
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
}
});
test("readEventChoices returns [] on empty storage", () => {
const s = makeMemoryStorage();
expect(readEventChoices(s)).toEqual([]);
});
test("writeEventChoices + readEventChoices round-trip", () => {
const s = makeMemoryStorage();
const choices = [
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
];
writeEventChoices(s, choices);
expect(readEventChoices(s)).toEqual(choices);
});
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
const s = makeMemoryStorage();
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
writeEventChoices(s, []);
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
});
test("readEventChoices ignores unknown choice_kind values", () => {
const s = makeMemoryStorage();
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
expect(readEventChoices(s)).toEqual([
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
]);
});
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
const s = makeMemoryStorage();
expect(readCourtId(s)).toBe("");
writeCourtId(s, "UPC-LD-MUC");
expect(readCourtId(s)).toBe("UPC-LD-MUC");
});
test("writeCourtId('') removes the key", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
writeCourtId(s, "");
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
});
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
const s = makeMemoryStorage();
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
});
test("readScenario returns all fields defaulted on empty storage", () => {
const s = makeMemoryStorage();
expect(readScenario(s)).toEqual({
eventChoices: [],
courtId: "",
ccr: false,
infAmend: false,
revAmend: false,
revCci: false,
showHidden: false,
});
});
});
describe("Hydration order — URL wins, localStorage fills the rest", () => {
test("URL fills filter chips, localStorage fills scenario state", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
const out = hydrate(
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
s,
);
// URL-sourced
expect(out.proceeding).toBe("upc.inf.cfi");
expect(out.side).toBe("defendant");
expect(out.target).toBe("endentscheidung");
expect(out.triggerDate).toBe("2026-05-26");
// localStorage-sourced
expect(out.courtId).toBe("UPC-LD-MUC");
expect(out.showHidden).toBe(true);
expect(out.ccr).toBe(true);
});
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
const out = hydrate("", s);
expect(out.proceeding).toBe("");
expect(out.side).toBe(null);
expect(out.target).toBe("");
expect(out.triggerDate).toBe("");
expect(out.courtId).toBe("UPC-LD-MUC");
});
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
const s = makeMemoryStorage();
const out = hydrate(
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
s,
);
expect(out.proceeding).toBe("upc.apl.unified");
expect(out.side).toBe("claimant");
expect(out.target).toBe("anordnung");
expect(out.triggerDate).toBe("2026-07-01");
expect(out.courtId).toBe("");
expect(out.eventChoices).toEqual([]);
expect(out.showHidden).toBe(false);
});
test("a shared link doesn't leak the recipient's scenario state in", () => {
// Two storages: m's (loaded with court + flags) and a recipient's
// (empty). The same URL should reproduce filter chips identically
// but leave each user's scenario state untouched.
const mStorage = makeMemoryStorage();
writeCourtId(mStorage, "UPC-LD-MUC");
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
const recipientStorage = makeMemoryStorage();
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
const mView = hydrate(sharedURL, mStorage);
const recipientView = hydrate(sharedURL, recipientStorage);
// Filter chips identical
expect(mView.proceeding).toBe(recipientView.proceeding);
expect(mView.side).toBe(recipientView.side);
expect(mView.triggerDate).toBe(recipientView.triggerDate);
// Scenario state diverges — recipient sees defaults
expect(mView.courtId).toBe("UPC-LD-MUC");
expect(recipientView.courtId).toBe("");
expect(mView.ccr).toBe(true);
expect(recipientView.ccr).toBe(false);
});
});
describe("URL key constants match the documented contract", () => {
test("URL_KEYS uses the spec'd snake_case names", () => {
expect(URL_KEYS.proceeding).toBe("proceeding");
expect(URL_KEYS.side).toBe("side");
expect(URL_KEYS.target).toBe("target");
expect(URL_KEYS.triggerDate).toBe("trigger_date");
});
});

View File

@@ -1,263 +0,0 @@
// /tools/verfahrensablauf URL + scenario-localStorage state contract
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
// two namespaces:
//
// URL params (filter chips — the timeline kind the user is looking
// at; paste-able, shareable, refresh-resistant):
// proceeding, side, target, trigger_date
//
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
// scenario inputs — the noisy parts that don't belong in a URL):
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
// show_hidden
//
// Hydration order: URL wins. On page load, URL fills the filter chips;
// localStorage fills the rest. Filter-chip changes write to URL only.
// Scenario changes write to localStorage only. A shared link from a
// colleague reproduces the timeline kind (proceeding + side + target +
// trigger_date) but never leaks the recipient's court / flag /
// event_choices state in.
//
// All helpers in this module are pure: they take a search string (or a
// StorageLike) and return values, no DOM. The wiring in
// ../verfahrensablauf.ts mounts them onto window.location +
// window.localStorage at runtime.
import type { EventChoice, ChoiceKind } from "./event-card-choices";
// ----- URL params (filter chips) ----------------------------------
export type Side = "claimant" | "defendant" | null;
export const APPEAL_TARGETS = [
"endentscheidung",
"kostenentscheidung",
"anordnung",
"schadensbemessung",
"bucheinsicht",
] as const;
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
export const URL_KEYS = {
proceeding: "proceeding",
side: "side",
target: "target",
triggerDate: "trigger_date",
} as const;
// parseProceedingFromSearch extracts the proceeding code. Returns ""
// if absent. No validation against the proceeding registry — that's
// the caller's job (an unknown code from a stale link should leave
// the first-tile auto-select fallback running).
export function parseProceedingFromSearch(search: string): string {
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
return v ?? "";
}
export function parseSideFromSearch(search: string): Side {
const raw = new URLSearchParams(search).get(URL_KEYS.side);
return raw === "claimant" || raw === "defendant" ? raw : null;
}
export function parseAppealTargetFromSearch(search: string): AppealTarget {
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
return raw as AppealTarget;
}
return "";
}
// parseTriggerDateFromSearch validates the ISO-date shape so a
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
// only. Round-tripped against Date to reject 2026-02-30 etc.
export function parseTriggerDateFromSearch(search: string): string {
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
const d = new Date(raw + "T00:00:00Z");
if (Number.isNaN(d.getTime())) return "";
if (d.toISOString().slice(0, 10) !== raw) return "";
return raw;
}
// applyFiltersToSearch produces the canonical query string for the
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
// preserved verbatim. Empty values are deleted, never written as
// empty string, so the URL stays clean on the default.
export function applyFiltersToSearch(
search: string,
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
): string {
const params = new URLSearchParams(search);
if ("proceeding" in filters) {
if (filters.proceeding && filters.proceeding !== "") {
params.set(URL_KEYS.proceeding, filters.proceeding);
} else {
params.delete(URL_KEYS.proceeding);
}
}
if ("side" in filters) {
if (filters.side === "claimant" || filters.side === "defendant") {
params.set(URL_KEYS.side, filters.side);
} else {
params.delete(URL_KEYS.side);
}
}
if ("target" in filters) {
if (filters.target && filters.target !== "") {
params.set(URL_KEYS.target, filters.target);
} else {
params.delete(URL_KEYS.target);
}
}
if ("triggerDate" in filters) {
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
params.set(URL_KEYS.triggerDate, filters.triggerDate);
} else {
params.delete(URL_KEYS.triggerDate);
}
}
const s = params.toString();
return s ? `?${s}` : "";
}
// ----- localStorage (scenario state) ------------------------------
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
export const SCENARIO_KEYS = {
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
courtId: `${SCENARIO_PREFIX}.court_id`,
ccr: `${SCENARIO_PREFIX}.ccr`,
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
revCci: `${SCENARIO_PREFIX}.rev_cci`,
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
} as const;
// StorageLike is the tiny subset of the Web Storage API the scenario
// helpers actually use. Lets the tests pass a Map-backed fake without
// pulling in a full localStorage polyfill.
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
// readEventChoices is forgiving: malformed tuples or unknown
// choice_kinds are dropped silently. Same shape as the legacy URL
// codec (comma-separated `submission_code:kind=value`).
export function readEventChoices(storage: StorageLike): EventChoice[] {
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
if (!raw) return [];
const out: EventChoice[] = [];
for (const tuple of raw.split(",")) {
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
if (!m) continue;
const kind = m[2] as ChoiceKind;
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
}
return out;
}
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
if (choices.length === 0) {
storage.removeItem(SCENARIO_KEYS.eventChoices);
return;
}
const enc = choices
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
.join(",");
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
}
// readCourtId / writeCourtId — empty string == no court picked. The
// "" value is stored as a removed key, not an empty string entry, so
// reading it back yields null rather than "".
export function readCourtId(storage: StorageLike): string {
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
}
export function writeCourtId(storage: StorageLike, courtId: string): void {
if (courtId === "") {
storage.removeItem(SCENARIO_KEYS.courtId);
return;
}
storage.setItem(SCENARIO_KEYS.courtId, courtId);
}
// Boolean flags — "1" / "0" string encoding, removeItem on default
// (false for flags, also false for show_hidden) so the storage stays
// uncluttered on a fresh page.
export function readBoolFlag(storage: StorageLike, key: string): boolean {
return storage.getItem(key) === "1";
}
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
if (on) storage.setItem(key, "1");
else storage.removeItem(key);
}
// Read all scenario state in one call — convenience for the page's
// load-time hydration. Caller decides whether to apply each field
// (e.g. court_id is proceeding-specific; the page may discard the
// stored value if the active proceeding doesn't expose a court row).
export interface ScenarioState {
eventChoices: EventChoice[];
courtId: string;
ccr: boolean;
infAmend: boolean;
revAmend: boolean;
revCci: boolean;
showHidden: boolean;
}
export function readScenario(storage: StorageLike): ScenarioState {
return {
eventChoices: readEventChoices(storage),
courtId: readCourtId(storage),
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
};
}
// ----- URL → localStorage hydration order -------------------------
// The page's load-time contract: read URL filters, then read
// scenario state from localStorage. URL wins on conflict — but the
// only field that can conflict is none of them today (URL owns
// proceeding/side/target/trigger_date; localStorage owns the rest).
// The order matters for one edge case: if a future field migrates
// from URL → localStorage with overlap, the URL value MUST be honored.
export interface HydratedState extends ScenarioState {
proceeding: string;
side: Side;
target: AppealTarget;
triggerDate: string;
}
export function hydrate(search: string, storage: StorageLike): HydratedState {
const scenario = readScenario(storage);
return {
proceeding: parseProceedingFromSearch(search),
side: parseSideFromSearch(search),
target: parseAppealTargetFromSearch(search),
triggerDate: parseTriggerDateFromSearch(search),
...scenario,
};
}
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
// Not used by the runtime page (which mounts real localStorage), but
// kept here so test files have one well-known import.
export function makeMemoryStorage(): StorageLike {
const store = new Map<string, string>();
return {
getItem: (k) => (store.has(k) ? store.get(k)! : null),
setItem: (k, v) => { store.set(k, v); },
removeItem: (k) => { store.delete(k); },
};
}

View File

@@ -1,293 +0,0 @@
import { h } from "../jsx";
interface ProceedingDef {
code: string;
i18nKey: string;
name: string;
}
function proceedingBtn(p: ProceedingDef): string {
return (
<button type="button" className="proceeding-btn" data-code={p.code}>
<strong data-i18n={p.i18nKey}>{p.name}</strong>
</button>
);
}
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
// unified "Berufung" tile (upc.apl). After picking it, the user
// selects which decision the appeal is directed AT via the
// .appeal-target-row chip group below — the engine then filters
// rules whose applies_to_target contains the picked slug.
const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
];
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
// Shared Verfahrensablauf wizard body. Renders the proceeding picker,
// perspective + date inputs, scenario flag rows, detail-mode toggle,
// view toggle, and the timeline-container that client/verfahrensablauf.ts
// (via initVerfahrensablauf()) wires against. Used by both
// /tools/verfahrensablauf (legacy) and /tools/procedures (unified).
export function VerfahrensablaufBody({ todayIso }: { todayIso: string }): string {
return (
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
<div className="wizard-step" id="step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<div className="proceeding-group" data-forum="upc">
<h4 data-i18n="deadlines.upc">UPC</h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
<div className="proceeding-group" data-forum="epa">
<h4 data-i18n="deadlines.epa">EPA</h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
<strong className="proceeding-summary-name" id="proceeding-summary-name">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;hlen
</button>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
</h3>
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="side-radio-cluster" id="side-radio-cluster">
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
</label>
</div>
<span className="side-hint" id="side-hint"
data-i18n="deadlines.side.hint">
W&auml;hlen Sie eine Seite, um die Spalten zu fokussieren.
</span>
</div>
<div className="side-chip" id="side-chip" style="display:none">
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
<strong className="side-chip-value" id="side-chip-value">&mdash;</strong>
<button type="button" className="side-chip-override" id="side-chip-override"
data-i18n="deadlines.side.override">
Andere Seite w&auml;hlen
</button>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="endentscheidung" checked />
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="kostenentscheidung" />
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="anordnung" />
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="schadensbemessung" />
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="bucheinsicht" />
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
<label className="fristen-view-option">
<input type="checkbox" id="show-hidden-toggle" />
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
</label>
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite">&nbsp;</span>
</div>
</div>
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
<div className="date-input-group">
<div className="date-field-row">
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={todayIso} />
</div>
<div className="date-field-row" id="court-picker-row" style="display:none">
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
<span data-i18n="deadlines.step3">Ergebnis</span>
</h3>
<div className="verfahrensablauf-detail-toggle" id="verfahrensablauf-detail-toggle"
role="radiogroup" aria-label="Detail">
<span className="fristen-view-label" data-i18n="deadlines.detail.label">Anzeige:</span>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="mandatory_only" />
<span data-i18n="deadlines.detail.mandatory_only">Nur Pflicht</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="selected" checked />
<span data-i18n="deadlines.detail.selected">Gewählt</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="all_options" />
<span data-i18n="deadlines.detail.all_options">Alle Optionen</span>
</label>
</div>
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="columns" checked />
<span data-i18n="deadlines.view.columns">Spalten</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="verfahrensablauf-durations-show" />
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
</label>
</div>
<div id="timeline-container">
</div>
<div className="fristen-result-actions">
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -732,8 +732,13 @@ export type I18nKey =
| "builder.action.rename"
| "builder.action.rename.prompt"
| "builder.action.share"
| "builder.akte.banner.prefix"
| "builder.akte.none"
| "builder.bucket.active"
| "builder.bucket.archived"
| "builder.bucket.empty"
| "builder.bucket.promoted"
| "builder.bucket.shared"
| "builder.canvas.add_proceeding"
| "builder.empty.cta"
| "builder.empty.headline"
@@ -753,6 +758,7 @@ export type I18nKey =
| "builder.header.scenario"
| "builder.header.search"
| "builder.header.stichtag"
| "builder.mobile.blocked"
| "builder.mode.akte"
| "builder.mode.cold"
| "builder.mode.event"
@@ -767,6 +773,45 @@ export type I18nKey =
| "builder.picker.future_jurisdiction"
| "builder.picker.placeholder"
| "builder.picker.title"
| "builder.promote.back"
| "builder.promote.cancel"
| "builder.promote.commit"
| "builder.promote.error.generic"
| "builder.promote.error.title_required"
| "builder.promote.meta.case_number"
| "builder.promote.meta.client_number"
| "builder.promote.meta.our_side"
| "builder.promote.meta.our_side.claimant"
| "builder.promote.meta.our_side.defendant"
| "builder.promote.meta.our_side.none"
| "builder.promote.meta.parent"
| "builder.promote.meta.parent.none"
| "builder.promote.meta.reference"
| "builder.promote.meta.team"
| "builder.promote.meta.team.hint"
| "builder.promote.meta.title"
| "builder.promote.meta.title.placeholder"
| "builder.promote.next"
| "builder.promote.parties.add"
| "builder.promote.parties.empty"
| "builder.promote.parties.hint"
| "builder.promote.parties.name"
| "builder.promote.parties.remove"
| "builder.promote.parties.representative"
| "builder.promote.parties.role"
| "builder.promote.step1"
| "builder.promote.step2"
| "builder.promote.step3"
| "builder.promote.success"
| "builder.promote.summary.events_filed"
| "builder.promote.summary.events_planned"
| "builder.promote.summary.flags"
| "builder.promote.summary.heading"
| "builder.promote.summary.note_extra"
| "builder.promote.summary.proceeding"
| "builder.promote.title"
| "builder.readonly.blocked"
| "builder.readonly.watermark"
| "builder.save.error"
| "builder.save.idle"
| "builder.save.saved"
@@ -788,6 +833,16 @@ export type I18nKey =
| "builder.search.summary.projects.other"
| "builder.search.summary.scenarios.one"
| "builder.search.summary.scenarios.other"
| "builder.share.button"
| "builder.share.close"
| "builder.share.current.empty"
| "builder.share.current.title"
| "builder.share.error"
| "builder.share.no_results"
| "builder.share.revoke"
| "builder.share.search.placeholder"
| "builder.share.subtitle"
| "builder.share.title"
| "builder.subtitle"
| "builder.triplet.collapse"
| "builder.triplet.detailgrad.all_options"
@@ -1707,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"
@@ -1749,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"
@@ -2787,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"
@@ -2886,6 +2962,18 @@ export type I18nKey =
| "team.selection.toggle_card"
| "team.subtitle"
| "team.title"
| "templates.authoring.heading"
| "templates.authoring.intro"
| "templates.authoring.list.title"
| "templates.authoring.slots.title"
| "templates.authoring.title"
| "templates.authoring.upload.file"
| "templates.authoring.upload.firm"
| "templates.authoring.upload.name_de"
| "templates.authoring.upload.name_en"
| "templates.authoring.upload.submit"
| "templates.authoring.upload.title"
| "templates.authoring.workspace.hint"
| "theme.toggle.auto"
| "theme.toggle.cycle.auto"
| "theme.toggle.cycle.dark"

View File

@@ -0,0 +1,43 @@
// docforge-editor — the variable catalogue client.
//
// The catalogue (key + bilingual label + namespace group) is served by the
// Go backend at GET /api/docforge/variables, built from the resolvers'
// Keys() as the single source of truth. A consumer fetches it once and uses
// labelMap() to label its sidebar form + authoring palette, instead of
// hard-coding a parallel label table that can drift from the resolvers.
export interface VariableEntry {
key: string;
label_de: string;
label_en: string;
group: string;
}
interface VariablesResponse {
variables: VariableEntry[];
}
// fetchVariableCatalogue loads the catalogue from the backend. Throws on a
// non-2xx response so the caller can decide how to degrade.
export async function fetchVariableCatalogue(): Promise<VariableEntry[]> {
const res = await fetch("/api/docforge/variables", {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`docforge variables: HTTP ${res.status}`);
}
const body = (await res.json()) as VariablesResponse;
return body.variables ?? [];
}
// labelMap turns a catalogue into a key → {de, en} lookup for a label
// function. Keys absent from the map fall back to the raw key at the call
// site, so a failed fetch degrades to dotted-key labels rather than a
// broken form.
export function labelMap(catalogue: VariableEntry[]): Record<string, { de: string; en: string }> {
const out: Record<string, { de: string; en: string }> = {};
for (const e of catalogue) {
out[e.key] = { de: e.label_de, en: e.label_en };
}
return out;
}

View File

@@ -0,0 +1,26 @@
import { test, expect } from "bun:test";
import { escapeHtml, cssEscape } from "./dom";
test("escapeHtml escapes the five HTML-significant characters", () => {
expect(escapeHtml(`<a href="x" title='y'>& z</a>`)).toBe(
"&lt;a href=&quot;x&quot; title=&#39;y&#39;&gt;&amp; z&lt;/a&gt;",
);
});
test("escapeHtml is a no-op on plain text", () => {
expect(escapeHtml("Aktenzeichen 4c O 12/23")).toBe("Aktenzeichen 4c O 12/23");
});
test("escapeHtml escapes & first to avoid double-encoding", () => {
expect(escapeHtml("&lt;")).toBe("&amp;lt;");
});
test("cssEscape backslash-escapes the dots in a placeholder key", () => {
// Both CSS.escape and the regex fallback escape '.' the same way, so the
// result is stable across environments (bun has no CSS global → fallback).
expect(cssEscape("project.case_number")).toBe("project\\.case_number");
});
test("cssEscape leaves identifier-safe characters untouched", () => {
expect(cssEscape("today")).toBe("today");
});

View File

@@ -0,0 +1,32 @@
// docforge-editor — shared, framework-agnostic editor utilities.
//
// Slice 5 of the docforge train (t-paliad-349 / m/paliad#157) begins
// extracting the generic editor plumbing out of the submission-specific
// client bundle so a second consumer (and the slice-6 authoring page) can
// reuse it. This module holds the pure DOM-string helpers — no DOM
// mutation, no editor state — so they unit-test cleanly under bun.
// escapeHtml escapes the five HTML-significant characters for safe
// insertion into element text or an attribute value. Matches the
// server-side emitTextWithDraftVars/htmlEscape contract so preview markup
// round-trips identically.
export function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// cssEscape escapes a string for use inside a CSS attribute selector
// (e.g. `[data-var="${cssEscape(key)}"]`). Prefers the native CSS.escape
// and falls back to escaping CSS-special characters for older runtimes.
// Placeholder keys ([A-Za-z][A-Za-z0-9_.]*) never carry whitespace or
// quotes, so the fallback is straightforward.
export function cssEscape(s: string): string {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(s);
}
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}

View File

@@ -68,12 +68,10 @@ export function renderProcedures(): string {
<button type="button" id="builder-share-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.share">Teilen</button>
<button type="button" id="builder-promote-btn"
className="builder-action-btn builder-action-btn--primary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.promote">Als Projekt anlegen</button>
</div>
<div className="builder-pageheader-row">
@@ -123,9 +121,7 @@ export function renderProcedures(): string {
role="tab"
aria-selected="false"
data-mode="akte"
id="builder-mode-akte"
disabled
title="In B4 verf&uuml;gbar">
id="builder-mode-akte">
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
</button>
</nav>
@@ -143,10 +139,28 @@ export function renderProcedures(): string {
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
</div>
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
{/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert.
Each bucket hides itself when empty (builder.ts toggles
the hidden attribute). */}
<div className="builder-sidepanel-bucket" data-bucket="shared" id="builder-bucket-shared" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.shared">Geteilt mit mir</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-shared" aria-label="Mit mir geteilte Szenarien"></ul>
</div>
<div className="builder-sidepanel-bucket" data-bucket="promoted" id="builder-bucket-promoted" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.promoted">Als Projekt angelegt</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-promoted" aria-label="Promotete Szenarien"></ul>
</div>
<div className="builder-sidepanel-bucket" data-bucket="archived" id="builder-bucket-archived" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.archived">Archiviert</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-archived" aria-label="Archivierte Szenarien"></ul>
</div>
</aside>
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
{/* B5 — read-only watermark for shared / promoted scenarios.
builder.ts fills + unhides it when the active scenario
is not editable by the current user. */}
<div id="builder-readonly-watermark" className="builder-readonly-watermark" hidden></div>
<div id="builder-canvas" className="builder-canvas">
{/* Cold-open placeholder — replaced by triplet stack once a
scenario is loaded. */}

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;
@@ -19894,6 +20017,23 @@ a.fristen-overhaul-rule-source {
.builder-save-status[data-state="saved"] { color: var(--color-accent-strong-fg); }
.builder-save-status[data-state="error"] { color: var(--status-red-fg, #c5503a); }
/* B4 (m/paliad#153) — Akte-mode banner. Lime tint matches the Paliad
accent palette; positioned on the page header so it's visible the
whole time the user works in Akte mode. */
.builder-akte-banner {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
border-radius: 0.3rem;
background: var(--color-accent);
color: var(--color-accent-dark);
font-size: 0.85rem;
font-weight: 500;
margin-top: 0.3rem;
align-self: flex-start;
}
.builder-action-btn {
font: inherit;
padding: 0.35rem 0.85rem;
@@ -20631,3 +20771,414 @@ a.fristen-overhaul-rule-source {
width: 100%;
}
}
/* ===================================================================
B5 — side-panel buckets, read-only watermark, share modal,
promote-to-project wizard (m/paliad#153 PRD §2.4 + §2.5).
=================================================================== */
.builder-sidepanel-bucket + .builder-sidepanel-bucket {
margin-top: 0.85rem;
padding-top: 0.65rem;
border-top: 1px solid var(--color-border);
}
.builder-bucket-label {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
/* Read-only watermark banner above the canvas. */
.builder-readonly-watermark {
display: block;
margin-bottom: 0.6rem;
padding: 0.4rem 0.75rem;
border-radius: 0.4rem;
background: repeating-linear-gradient(
45deg,
var(--color-surface-muted),
var(--color-surface-muted) 10px,
var(--color-surface-2) 10px,
var(--color-surface-2) 20px
);
border: 1px dashed var(--color-border);
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 500;
}
/* Read-only mode: neutralise every mutating affordance in the canvas
while keeping text selectable + read interactions working. PRD §2.5 /
§10 — pointer-events:none on the controls, not the cards. */
body.builder-readonly .builder-triplet-host button,
body.builder-readonly .builder-triplet-host input,
body.builder-readonly .builder-triplet-host select,
body.builder-readonly .builder-add-proceeding-btn {
pointer-events: none;
opacity: 0.5;
}
/* Generic modal scaffold (shared by share modal + promote wizard). */
.builder-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.4);
}
.builder-modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 540px;
max-height: calc(100vh - 2rem);
overflow: auto;
padding: 1.1rem 1.25rem 1.25rem;
}
.builder-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.builder-modal-title {
margin: 0;
font-size: 1.1rem;
}
.builder-modal-close {
font: inherit;
font-size: 1.4rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
color: var(--color-text-muted);
padding: 0 0.3rem;
}
.builder-modal-subtitle {
margin: 0 0 0.85rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
/* Share modal. */
.builder-share-pickerbox {
position: relative;
}
.builder-share-search {
width: 100%;
font: inherit;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.35rem;
background: var(--color-surface-2);
color: var(--color-text);
}
.builder-share-results {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
max-height: 220px;
overflow: auto;
}
.builder-share-result,
.builder-share-current-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0.4rem;
border-radius: 0.3rem;
}
.builder-share-result:hover {
background: var(--color-surface-muted);
}
.builder-share-result-empty,
.builder-share-current-empty {
padding: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted);
list-style: none;
}
.builder-share-add,
.builder-share-revoke {
font: inherit;
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
border-radius: 0.3rem;
cursor: pointer;
border: 1px solid var(--color-border);
background: var(--color-surface-2);
color: var(--color-text);
white-space: nowrap;
}
.builder-share-add {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-share-current {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
.builder-share-current-title {
margin: 0 0 0.4rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
}
.builder-share-current-list {
list-style: none;
margin: 0;
padding: 0;
}
.builder-share-error {
margin: 0.5rem 0 0;
font-size: 0.82rem;
color: #c0392b;
}
/* Promote wizard. */
.builder-promote-modal {
max-width: 600px;
}
.builder-promote-steps {
display: flex;
list-style: none;
margin: 0 0 1rem;
padding: 0;
gap: 0.5rem;
}
.builder-promote-step {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--color-text-muted);
flex: 1;
}
.builder-promote-step-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
border: 1px solid var(--color-border);
font-size: 0.78rem;
flex: 0 0 auto;
}
.builder-promote-step.is-active {
color: var(--color-text);
font-weight: 600;
}
.builder-promote-step.is-active .builder-promote-step-n {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
.builder-promote-step.is-done .builder-promote-step-n {
background: var(--color-surface-muted);
}
.builder-promote-body {
min-height: 160px;
}
.builder-promote-section-title {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.builder-promote-summary {
list-style: none;
margin: 0;
padding: 0;
}
.builder-promote-summary li {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.builder-promote-summary li span {
color: var(--color-text-muted);
}
.builder-promote-note {
margin: 0.75rem 0 0;
padding: 0.5rem 0.65rem;
border-radius: 0.35rem;
background: var(--color-surface-muted);
font-size: 0.82rem;
color: var(--color-text-muted);
}
.builder-promote-hint,
.builder-promote-empty,
.builder-promote-team-hint {
font-size: 0.82rem;
color: var(--color-text-muted);
margin: 0 0 0.6rem;
}
.builder-promote-parties {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.builder-promote-party {
display: grid;
grid-template-columns: 1.3fr 1fr 1fr auto;
gap: 0.35rem;
align-items: center;
}
.builder-promote-party input,
.builder-promote-field input,
.builder-promote-field select {
font: inherit;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 0.3rem;
background: var(--color-surface-2);
color: var(--color-text);
width: 100%;
}
.builder-promote-party-remove {
font: inherit;
font-size: 1.1rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
color: var(--color-text-muted);
}
.builder-promote-party-add {
font: inherit;
font-size: 0.85rem;
padding: 0.3rem 0.7rem;
border-radius: 0.3rem;
cursor: pointer;
border: 1px dashed var(--color-border);
background: transparent;
color: var(--color-text);
}
.builder-promote-field {
display: block;
margin-bottom: 0.7rem;
}
.builder-promote-field > span {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.2rem;
}
.builder-promote-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.builder-promote-team {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-height: 160px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 0.35rem;
padding: 0.4rem;
}
.builder-promote-team-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
}
.builder-promote-error {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: #c0392b;
}
.builder-promote-success {
text-align: center;
padding: 2rem 0;
font-size: 1rem;
color: var(--color-text);
}
.builder-promote-footer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px solid var(--color-border);
}
.builder-promote-footer-spacer {
flex: 1;
}
.builder-promote-cancel,
.builder-promote-backbtn,
.builder-promote-nextbtn {
font: inherit;
padding: 0.4rem 0.95rem;
border-radius: 0.35rem;
cursor: pointer;
border: 1px solid var(--color-border);
background: var(--color-surface-2);
color: var(--color-text);
}
.builder-promote-nextbtn {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-promote-nextbtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 640px) {
.builder-promote-party,
.builder-promote-field-row {
grid-template-columns: 1fr;
}
/* B6 — modals go full-bleed on phones so the wizard/share UI is at
least legible if reached; entry is gated by the mobile guard, but
keep it readable for the read-only case. */
.builder-modal {
max-width: 100%;
border-radius: 0.4rem;
}
}
/* B6 — mobile basic-read guard toast (PRD §7.1 + §10). Shown when a
mutating affordance is tapped on a narrow viewport. */
.builder-mobile-toast {
position: fixed;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%) translateY(1rem);
z-index: 1100;
max-width: calc(100vw - 2rem);
padding: 0.6rem 1rem;
border-radius: 0.5rem;
background: var(--color-accent-dark, #0b1f33);
color: #fff;
font-size: 0.88rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.builder-mobile-toast.is-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}

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,112 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-349 docforge slice 6 — template authoring page at
// /admin/templates.
//
// Admin uploads a base .docx, sees it rendered as run-addressable text,
// selects a span + a variable from the palette to drop a {{slot}}, and the
// result saves as a reusable docforge template. Pure shell:
// client/templates-authoring.ts hydrates the list, upload form, preview,
// palette, and slot list after load. The palette labels come from the Go
// variable catalogue (GET /api/docforge/variables, the SSOT from slice 5).
//
// Design ref: docs/plans/prd-docforge-2026-05-29.md §2.1.
export function renderTemplatesAuthoring(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="templates.authoring.title">Vorlagen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-templates-authoring">
<Sidebar currentPath="/admin" />
<BottomNav currentPath="/admin" />
<main>
<section className="tool-page docforge-templates-page">
<div className="container">
<header className="docforge-templates-header">
<h1 data-i18n="templates.authoring.heading">Vorlagen</h1>
<p
className="docforge-templates-intro"
data-i18n="templates.authoring.intro">
Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.
</p>
</header>
{/* Upload a new base .docx */}
<section className="docforge-upload" id="docforge-upload">
<h2 data-i18n="templates.authoring.upload.title">Neue Vorlage hochladen</h2>
<form id="docforge-upload-form" className="entity-form">
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.file">Word-Datei (.docx)</span>
<input type="file" name="file" accept=".docx,.dotx,.docm,.dotm" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.name_de">Name (DE)</span>
<input type="text" name="name_de" className="entity-form-input" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.name_en">Name (EN)</span>
<input type="text" name="name_en" className="entity-form-input" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.firm">Kanzlei (optional)</span>
<input type="text" name="firm" className="entity-form-input" />
</label>
<button type="submit" className="btn-primary" data-i18n="templates.authoring.upload.submit">
Hochladen
</button>
<span className="docforge-upload-status" id="docforge-upload-status" />
</form>
</section>
{/* Existing templates */}
<section className="docforge-template-list-wrap">
<h2 data-i18n="templates.authoring.list.title">Vorhandene Vorlagen</h2>
<ul className="entity-table docforge-template-list" id="docforge-template-list" />
</section>
{/* Authoring workspace — hidden until a template is opened. */}
<section className="docforge-workspace" id="docforge-workspace" hidden>
<header className="docforge-workspace-header">
<h2 id="docforge-workspace-title" />
<span className="docforge-workspace-hint" data-i18n="templates.authoring.workspace.hint">
Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.
</span>
<span className="docforge-workspace-status" id="docforge-workspace-status" />
</header>
<div className="docforge-workspace-grid">
{/* Variable palette (left) — populated from the catalogue. */}
<aside className="docforge-palette" id="docforge-palette" />
{/* Run-addressable preview (center) — selection target. */}
<div className="docforge-preview" id="docforge-preview" />
{/* Placed slots (right). */}
<aside className="docforge-slots">
<h3 data-i18n="templates.authoring.slots.title">Platzhalter</h3>
<ul className="docforge-slot-list" id="docforge-slot-list" />
</aside>
</div>
</section>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/templates-authoring.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,12 @@
-- t-paliad-349: revert docforge template authoring tables.
--
-- Drop the FK first so the templates ↔ template_versions cycle unwinds,
-- then the tables (template_slots + template_versions cascade from their
-- parents, but drop explicitly for clarity and order-independence).
ALTER TABLE IF EXISTS paliad.templates
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
DROP TABLE IF EXISTS paliad.template_slots;
DROP TABLE IF EXISTS paliad.template_versions;
DROP TABLE IF EXISTS paliad.templates;

View File

@@ -0,0 +1,127 @@
-- t-paliad-349 (m/paliad#157): docforge slice 4 — template authoring tables.
--
-- These three tables are the persistence home for the docforge authoring
-- flow (upload a base .docx → place variable slots → save as a reusable
-- template) and the generation flow (pick a template → bind data →
-- export). They are paliad's implementation of the docforge.TemplateStore
-- contract; docforge itself owns no tables (the litigationplanner pattern).
--
-- Generic on purpose (NOT submission_*-named): authoring is a
-- domain-neutral capability, so the eventual second docforge consumer can
-- reuse the same shape. submission_bases (Gitea-backed, section_spec) stays
-- for the legacy base catalog during the transition; convergence is a
-- later, separate task.
--
-- paliad.templates — one row per template (the catalog entry).
-- paliad.template_versions — immutable snapshots; editing a template
-- inserts a new version. The carrier .docx
-- bytes live here (bytea) — the TemplateStore
-- bytea backend. A draft pins a version
-- (snapshot-at-create, PRD §4 A3) so later
-- edits don't shift an in-flight draft.
-- paliad.template_slots — the variable slots placed in a version's
-- carrier. anchor is the sentinel token the
-- authoring surface injects into the carrier
-- OOXML to locate the slot (PRD §5 lean);
-- slot_key is the variable bound there.
--
-- Visibility: the template catalog is shared firm-wide (every
-- authenticated user generates from it), so SELECT is open to
-- authenticated, mirroring submission_bases. Mutations (upload, edit) are
-- admin-only and gated in Go at the handler layer — no INSERT/UPDATE/DELETE
-- RLS path means RLS denies them by default.
--
-- Slice 4 ships the schema + the TemplateStore only; no rows are seeded and
-- no UI writes here yet (authoring is slice 6, generation-on-templates is
-- slice 7).
CREATE TABLE IF NOT EXISTS paliad.templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text UNIQUE,
name_de text NOT NULL,
name_en text NOT NULL,
kind text NOT NULL DEFAULT 'submission',
source_format text NOT NULL DEFAULT 'docx',
firm text,
is_active bool NOT NULL DEFAULT true,
current_version_id uuid,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT templates_source_format_check CHECK (source_format IN ('docx'))
);
CREATE TABLE IF NOT EXISTS paliad.template_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
template_id uuid NOT NULL REFERENCES paliad.templates(id) ON DELETE CASCADE,
version int NOT NULL,
carrier_blob bytea NOT NULL,
stylemap jsonb NOT NULL DEFAULT '{}'::jsonb,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT template_versions_unique_per_template UNIQUE (template_id, version)
);
CREATE TABLE IF NOT EXISTS paliad.template_slots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
template_version_id uuid NOT NULL REFERENCES paliad.template_versions(id) ON DELETE CASCADE,
slot_key text NOT NULL,
anchor text NOT NULL,
label text,
order_index int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT template_slots_unique_anchor UNIQUE (template_version_id, anchor)
);
-- current_version_id FK is added after template_versions exists to avoid a
-- circular CREATE-TABLE dependency. ON DELETE SET NULL: dropping the
-- pinned version detaches it rather than cascading the template away.
ALTER TABLE paliad.templates
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
ALTER TABLE paliad.templates
ADD CONSTRAINT templates_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS templates_firm_kind_idx
ON paliad.templates (firm, kind) WHERE is_active;
CREATE INDEX IF NOT EXISTS template_versions_template_idx
ON paliad.template_versions (template_id, version);
CREATE INDEX IF NOT EXISTS template_slots_version_idx
ON paliad.template_slots (template_version_id, order_index);
ALTER TABLE paliad.templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.template_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.template_slots ENABLE ROW LEVEL SECURITY;
-- Firm-shared catalog: any authenticated user reads. Mutations are
-- admin-only, gated in Go (no mutation RLS policy = RLS denies by default).
DROP POLICY IF EXISTS templates_select ON paliad.templates;
CREATE POLICY templates_select
ON paliad.templates FOR SELECT TO authenticated
USING (true);
DROP POLICY IF EXISTS template_versions_select ON paliad.template_versions;
CREATE POLICY template_versions_select
ON paliad.template_versions FOR SELECT TO authenticated
USING (true);
DROP POLICY IF EXISTS template_slots_select ON paliad.template_slots;
CREATE POLICY template_slots_select
ON paliad.template_slots FOR SELECT TO authenticated
USING (true);
DROP TRIGGER IF EXISTS templates_set_updated_at ON paliad.templates;
CREATE TRIGGER templates_set_updated_at
BEFORE UPDATE ON paliad.templates
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.templates IS
't-paliad-349: docforge template catalog. One row per uploaded template; current_version_id pins the live version.';
COMMENT ON TABLE paliad.template_versions IS
't-paliad-349: immutable docforge template snapshots. carrier_blob holds the base .docx bytes (TemplateStore bytea backend).';
COMMENT ON TABLE paliad.template_slots IS
't-paliad-349: variable slots placed in a template version. anchor = sentinel token locating the slot in the carrier OOXML; slot_key = the bound variable.';

View File

@@ -0,0 +1,6 @@
-- t-paliad-349: revert the template-version pin on submission drafts.
DROP INDEX IF EXISTS paliad.submission_drafts_template_version_idx;
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS template_version_id;

View File

@@ -0,0 +1,28 @@
-- t-paliad-349 (m/paliad#157): docforge slice 7 — pin an uploaded template
-- version onto a submission draft (generation-on-uploaded-templates).
--
-- A draft can now source its document from a docforge uploaded template
-- (paliad.template_versions) instead of a legacy Gitea base. template_version_id
-- is the snapshot pin (PRD §4 A3): the draft renders the exact carrier of the
-- version it was bound to, so a later template edit (which creates a new
-- version) doesn't shift an in-flight draft.
--
-- Nullable + additive: existing drafts keep template_version_id NULL and
-- render via their existing path (Composer base_id, or the v1 fallback).
-- The three sources are mutually exclusive in practice; the export path
-- checks template_version_id first, then base_id, then v1.
--
-- ON DELETE SET NULL: if the pinned version is removed, the draft detaches
-- and falls back rather than failing — same posture as base_id's
-- ON DELETE SET NULL.
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS template_version_id uuid
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS submission_drafts_template_version_idx
ON paliad.submission_drafts (template_version_id)
WHERE template_version_id IS NOT NULL;
COMMENT ON COLUMN paliad.submission_drafts.template_version_id IS
't-paliad-349: pinned docforge template version (snapshot-at-create). NULL = render via base_id Composer path or v1 fallback.';

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

@@ -0,0 +1,48 @@
package handlers
// docforge variable catalogue handler (t-paliad-349 slice 5).
//
// Endpoint: GET /api/docforge/variables → the full variable catalogue
// (key + bilingual label + namespace group) the sidebar form and the
// authoring palette render. The catalogue is the Go-side single source of
// truth, built from the submission resolvers' Keys(); it replaces the
// duplicated TS VARIABLE_LABELS table so labels can't drift between the
// resolver that produces a value and the form that labels it.
//
// Static — no DB call, no per-user state. Auth-gated only (anonymous 401);
// the catalogue is the same for every authenticated user.
import (
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
type docforgeVariablesResponse struct {
Variables []variableEntry `json:"variables"`
}
type variableEntry struct {
Key string `json:"key"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Group string `json:"group"`
}
// handleDocforgeVariables backs GET /api/docforge/variables.
func handleDocforgeVariables(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
cat := services.SubmissionVariableCatalogue()
out := make([]variableEntry, 0, len(cat))
for _, e := range cat {
out = append(out, variableEntry{
Key: e.Key,
LabelDE: e.LabelDE,
LabelEN: e.LabelEN,
Group: e.Group,
})
}
writeJSON(w, http.StatusOK, docforgeVariablesResponse{Variables: out})
}

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
@@ -128,6 +133,10 @@ type Services struct {
// editor. Per Q2: paste sources only, no lineage on sections.
SubmissionBuildingBlock *services.BuildingBlockService
// t-paliad-349 docforge slice 4/6 — uploaded-template store backing
// the authoring surface.
TemplateStore *services.PgTemplateStore
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
@@ -207,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,
@@ -215,6 +225,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
submissionSection: svc.SubmissionSection,
submissionComposer: svc.SubmissionComposer,
submissionBuildingBlock: svc.SubmissionBuildingBlock,
templateStore: svc.TemplateStore,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
scenarioFlags: svc.ScenarioFlags,
@@ -455,6 +466,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// the sidebar picker. Wide-open SELECT (any authenticated user);
// admin mutations are not exposed yet (Slice C).
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)
// t-paliad-349 slice 7 — firm-shared template picker list for
// generation (any authenticated lawyer; admin authoring stays gated).
protected.HandleFunc("GET /api/templates", handlePickerTemplates)
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
// for inline editor autosave. URL keyed on draft_id + section_id;
// owner-scoped via SubmissionDraftService.Get.
@@ -491,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)
@@ -527,6 +553,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// retires the legacy routes.
protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList)
protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate)
// m/paliad#153 B4 — Akte mode entry point. Creates a project-backed
// scenario from a paliad.projects row; subsequent edits dual-write
// through to paliad.deadlines + paliad.projects.scenario_flags.
protected.HandleFunc("POST /api/builder/scenarios/from-project", handleBuilderScenarioFromProject)
// m/paliad#153 B5 — "Geteilt mit mir" bucket. Literal segment wins
// over {id} in Go 1.22+ ServeMux precedence, so this never shadows GET .../{id}.
protected.HandleFunc("GET /api/builder/scenarios/shared", handleBuilderScenariosShared)
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
@@ -537,6 +570,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
// m/paliad#153 B5 — transactional promote-to-project wizard commit.
protected.HandleFunc("POST /api/builder/scenarios/{id}/promote", handleBuilderScenarioPromote)
// m/paliad#153 B2 — read-only passthrough so the builder can render
// per-triplet flag toggles without a per-project round-trip.
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
@@ -743,6 +778,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
// t-paliad-349 docforge slice 6 — template authoring surface
// (upload base .docx → place variable slots → save). Admin-only,
// firm-shared catalog like submission_bases.
protected.HandleFunc("GET /admin/templates", adminGate(users, gateOnboarded(handleTemplatesAuthoringPage)))
protected.HandleFunc("GET /api/admin/templates", adminGate(users, handleListTemplates))
protected.HandleFunc("POST /api/admin/templates", adminGate(users, handleUploadTemplate))
protected.HandleFunc("GET /api/admin/templates/{id}", adminGate(users, handleGetTemplateAuthoring))
protected.HandleFunc("POST /api/admin/templates/{id}/slots", adminGate(users, handlePlaceTemplateSlot))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
@@ -754,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).
@@ -77,6 +81,9 @@ type dbServices struct {
submissionComposer *services.SubmissionComposer
submissionBuildingBlock *services.BuildingBlockService
// t-paliad-349 docforge slice 4/6 — uploaded-template store.
templateStore *services.PgTemplateStore
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService
@@ -403,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

@@ -52,6 +52,51 @@ func writeBuilderError(w http.ResponseWriter, err error) {
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------------------------------------------------------------------------
// Akte mode (B4, t-paliad-347)
// ---------------------------------------------------------------------------
// handleBuilderScenarioFromProject — POST /api/builder/scenarios/from-project
//
// Body: {"project_id": "<uuid>"}
//
// Creates a fresh project-backed scenario by snapshotting the project's
// proceeding_type_id + our_side + scenario_flags into one top-level
// triplet, and seeds scenario_events from every existing
// paliad.deadlines row tied to a sequencing_rule. The new scenario's
// origin_project_id pins the Akte link so subsequent edits dual-write
// through to paliad.deadlines + paliad.projects.scenario_flags (PRD §2.3).
//
// Visibility: caller must be able to see the project. Bad input
// (missing proceeding_type_id, invisible project) returns 400 / 404
// via the standard service-error mapping.
func handleBuilderScenarioFromProject(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var body struct {
ProjectID uuid.UUID `json:"project_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
if body.ProjectID == uuid.Nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id ist erforderlich"})
return
}
out, err := dbSvc.scenarioBuilder.CreateScenarioFromProject(r.Context(), uid, body.ProjectID)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// ---------------------------------------------------------------------------
// Scenario CRUD
// ---------------------------------------------------------------------------
@@ -388,6 +433,62 @@ func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Shared-with-me + Promote (B5, m/paliad#153)
// ---------------------------------------------------------------------------
// handleBuilderScenariosShared — GET /api/builder/scenarios/shared
//
// Lists scenarios shared read-only with the caller (the "Geteilt mit mir"
// side-panel bucket, PRD §2.5). The caller's own scenarios are excluded.
func handleBuilderScenariosShared(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
out, err := dbSvc.scenarioBuilder.ListSharedWithMe(r.Context(), uid)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioPromote — POST /api/builder/scenarios/{id}/promote
//
// Body: PromoteScenarioInput (wizard steps 2 + 3). Promotes the scenario
// into a real paliad.projects 'case' row transactionally (PRD §10 — no
// partial promotions) and returns PromoteResult with the new project id
// the wizard navigates to (/projects/{project_id}).
func handleBuilderScenarioPromote(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var input services.PromoteScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PromoteScenario(r.Context(), uid, sid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// ---------------------------------------------------------------------------
// Scenario flag catalog passthrough (m/paliad#153 B2)
// ---------------------------------------------------------------------------

View File

@@ -30,6 +30,8 @@ package handlers
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -44,6 +46,8 @@ 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.
@@ -115,10 +119,14 @@ type submissionDraftJSON struct {
// pre-Composer drafts; the editor sidebar surfaces this in the
// base picker. PATCH accepts {"base_id": "<uuid>"} or
// {"base_id": null} to set or clear.
BaseID *uuid.UUID `json:"base_id"`
ComposerMeta map[string]any `json:"composer_meta"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
BaseID *uuid.UUID `json:"base_id"`
// TemplateVersionID — pinned uploaded docforge template version
// (t-paliad-349 slice 7). NULL = base_id/v1 path. The editor's picker
// surfaces this; PATCH accepts {"template_version_id": "<uuid>"} | null.
TemplateVersionID *uuid.UUID `json:"template_version_id"`
ComposerMeta map[string]any `json:"composer_meta"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// submissionSectionJSON is the on-the-wire row for each per-draft
@@ -126,15 +134,15 @@ type submissionDraftJSON struct {
// section stack but doesn't yet edit prose. Slice B makes content_md_*
// editable + adds the PATCH endpoint.
type submissionSectionJSON struct {
ID uuid.UUID `json:"id"`
SectionKey string `json:"section_key"`
OrderIndex int `json:"order_index"`
Kind string `json:"kind"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Included bool `json:"included"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
ID uuid.UUID `json:"id"`
SectionKey string `json:"section_key"`
OrderIndex int `json:"order_index"`
Kind string `json:"kind"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Included bool `json:"included"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
}
type submissionRuleSummary struct {
@@ -170,6 +178,16 @@ type submissionDraftPatchInput struct {
// admin-recovery flows).
BaseID *uuid.UUID `json:"base_id,omitempty"`
BaseIDSet bool `json:"-"`
// TemplateVersionID pins an uploaded docforge template version
// (t-paliad-349 slice 7). Same three-state presence contract as
// 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
@@ -193,6 +211,9 @@ func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
if _, ok := raw["base_id"]; ok {
p.BaseIDSet = true
}
if _, ok := raw["template_version_id"]; ok {
p.TemplateVersionIDSet = true
}
return nil
}
@@ -433,10 +454,17 @@ 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
}
if input.TemplateVersionIDSet {
if !validateTemplateVersionPin(w, r.Context(), input.TemplateVersionID) {
return
}
patch.TemplateVersionID = &input.TemplateVersionID
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -517,7 +545,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
tplBytes, err := previewTemplateBytes(ctx, d)
if err != nil {
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -573,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.
@@ -597,6 +625,48 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
}
}
// validateTemplateVersionPin checks that a non-nil template-version pin
// refers to an existing version (404 otherwise), so a PATCH can't bind a
// draft to a vanished template. A nil pin (clear) is always valid. Returns
// true when the patch may proceed; writes the error response otherwise.
func validateTemplateVersionPin(w http.ResponseWriter, ctx context.Context, pin *uuid.UUID) bool {
if pin == nil {
return true
}
if dbSvc.templateStore == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
return false
}
if _, err := dbSvc.templateStore.GetVersion(ctx, pin.String()); err != nil {
if errors.Is(err, docforge.ErrTemplateNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template version not found"})
} else {
writeServiceError(w, err)
}
return false
}
return true
}
// previewTemplateBytes returns the carrier bytes to render a draft's
// preview: the pinned uploaded-template version's carrier when set
// (t-paliad-349 slice 7), otherwise the resolved upstream submission
// template (v1/legacy path). A missing pinned version falls through to the
// upstream resolution rather than failing.
func previewTemplateBytes(ctx context.Context, d *services.SubmissionDraft) ([]byte, error) {
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
if err == nil {
return tmpl.CarrierBytes, nil
}
if !errors.Is(err, docforge.ErrTemplateNotFound) {
return nil, err
}
}
b, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
return b, err
}
// exportSubmissionDraft is the shared render entry point used by both
// the project-scoped and global export handlers (t-paliad-313 Slice B).
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
@@ -607,6 +677,27 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
//
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
// t-paliad-349 slice 7 — uploaded-template path, checked first. The
// pinned version's carrier already carries {{slots}}; Export resolves
// the bag + substitutes them via the same renderer the v1 path uses
// (no Composer/sections — the uploaded doc IS the document). A missing
// pinned version falls through to the base_id / v1 paths.
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
switch {
case err == nil:
docx, resolved, rerr := dbSvc.submissionDraft.Export(ctx, d, tmpl.CarrierBytes)
if rerr != nil {
return nil, nil, "", false, fmt.Errorf("render: %w", rerr)
}
return docx, resolved, "", false, nil
case errors.Is(err, docforge.ErrTemplateNotFound):
log.Printf("submission_drafts: pinned template version missing (draft=%s version=%s) — falling back", d.ID, *d.TemplateVersionID)
default:
return nil, nil, "", false, fmt.Errorf("template version lookup: %w", err)
}
}
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
switch {
@@ -853,16 +944,26 @@ type globalDraftPatchInput struct {
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
BaseID *uuid.UUID `json:"base_id,omitempty"`
baseIDProvided bool
// TemplateVersionID + provided flag — uploaded-template pin
// (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 {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
BaseID *uuid.UUID `json:"base_id,omitempty"`
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
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 {
@@ -874,14 +975,17 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
g.ProjectID = a.ProjectID
g.SelectedParties = a.SelectedParties
g.BaseID = a.BaseID
// Detect whether "project_id" / "base_id" were present in the JSON
// object.
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
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
_, g.projectIDProvided = raw["project_id"]
_, g.baseIDProvided = raw["base_id"]
_, g.templateVersionIDProvided = raw["template_version_id"]
return nil
}
@@ -917,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
@@ -926,6 +1031,13 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
bid := in.BaseID // may be nil → clear
patch.BaseID = &bid
}
if in.templateVersionIDProvided {
if !validateTemplateVersionPin(w, r.Context(), in.TemplateVersionID) {
return
}
tv := in.TemplateVersionID // may be nil → clear
patch.TemplateVersionID = &tv
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
@@ -1045,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()
@@ -1155,6 +1267,23 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
}
// t-paliad-349 slice 7 — uploaded-template draft: render the pinned
// carrier. The Gitea tier / language-fallback notions don't apply (they
// describe the upstream fallback chain), so they stay at their zero
// values. A missing pinned version falls through to upstream resolution.
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
if tmpl, terr := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String()); terr == nil {
html, rerr := dbSvc.submissionDraft.RenderPreview(ctx, d, tmpl.CarrierBytes)
if rerr != nil {
return nil, rerr
}
view.PreviewHTML = html
return view, nil
} else if !errors.Is(terr, docforge.ErrTemplateNotFound) {
return nil, terr
}
}
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
@@ -1184,16 +1313,21 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
type submissionTemplateTier string
const (
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
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.
@@ -1202,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
@@ -1231,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
@@ -1260,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
}
@@ -1306,21 +1458,22 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
meta = map[string]any{}
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
SelectedParties: selected,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
LastImportedAt: d.LastImportedAt,
BaseID: d.BaseID,
ComposerMeta: meta,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
SelectedParties: selected,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
LastImportedAt: d.LastImportedAt,
BaseID: d.BaseID,
TemplateVersionID: d.TemplateVersionID,
ComposerMeta: meta,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
}

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,306 @@
package handlers
// docforge template authoring handlers (t-paliad-349 slice 6).
//
// The admin-only authoring surface: upload a base .docx, see it rendered as
// run-addressable text, place {{variable}} slots into it, and save the
// result as a reusable template. Backed by docforge.TemplateStore
// (Postgres bytea carrier) + the docx authoring engine
// (ImportForAuthoring / InjectSlot).
//
// Endpoints (all under adminGate — templates are firm-shared, admin-
// authored, like submission_bases):
// GET /api/admin/templates — catalog list
// POST /api/admin/templates — multipart upload → create v1
// GET /api/admin/templates/{id} — authoring view (preview+slots)
// POST /api/admin/templates/{id}/slots — place a slot → new version
//
// Slot placement creates a new template version (immutable snapshot) per
// placement. That keeps the snapshot guarantee simple; batching a whole
// authoring session into one version on an explicit "save" is a documented
// future refinement (it trades the version-per-slot churn for a client- or
// session-held draft carrier).
//
// VERIFICATION CEILING: the live upload→render→select→inject→save flow
// needs the app running with DATABASE_URL + Supabase auth + Playwright; it
// is verified post-merge. The docx surgery (ImportForAuthoring/InjectSlot)
// and the store are unit/live-tested independently.
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
)
// maxTemplateUpload bounds an uploaded .docx. Templates are firm letterhead
// + chrome — tens of KB in practice; 10 MB is a generous ceiling.
const maxTemplateUpload = 10 << 20
type templateMetaJSON struct {
ID string `json:"id"`
Slug string `json:"slug,omitempty"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Kind string `json:"kind"`
SourceFormat string `json:"source_format"`
Firm string `json:"firm,omitempty"`
IsActive bool `json:"is_active"`
Version int `json:"version"`
VersionID string `json:"version_id,omitempty"`
}
type templateSlotJSON struct {
Key string `json:"key"`
Anchor string `json:"anchor"`
Label string `json:"label,omitempty"`
OrderIndex int `json:"order_index"`
}
type authoringViewJSON struct {
Template templateMetaJSON `json:"template"`
PreviewHTML string `json:"preview_html"`
Slots []templateSlotJSON `json:"slots"`
}
func metaJSON(m docforge.TemplateMeta) templateMetaJSON {
return templateMetaJSON{
ID: m.ID, Slug: m.Slug, NameDE: m.NameDE, NameEN: m.NameEN,
Kind: m.Kind, SourceFormat: m.SourceFormat, Firm: m.Firm,
IsActive: m.IsActive, Version: m.Version, VersionID: m.VersionID,
}
}
func slotsJSON(slots []docforge.TemplateSlot) []templateSlotJSON {
out := make([]templateSlotJSON, 0, len(slots))
for _, s := range slots {
out = append(out, templateSlotJSON{Key: s.Key, Anchor: s.Anchor, Label: s.Label, OrderIndex: s.OrderIndex})
}
return out
}
// writeTemplateError maps docforge's not-found sentinel to 404 and falls
// back to the shared service-error mapper.
func writeTemplateError(w http.ResponseWriter, err error) {
if errors.Is(err, docforge.ErrTemplateNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"})
return
}
writeServiceError(w, err)
}
func requireTemplateStore(w http.ResponseWriter) bool {
if !requireDB(w) {
return false
}
if dbSvc.templateStore == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
return false
}
return true
}
// handleTemplatesAuthoringPage serves the authoring page shell. The client
// bundle hydrates the list, upload, preview, palette, and slots.
func handleTemplatesAuthoringPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/templates-authoring.html")
}
// handleListTemplates backs GET /api/admin/templates.
func handleListTemplates(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
metas, err := dbSvc.templateStore.List(r.Context(), docforge.TemplateFilter{ActiveOnly: true})
if err != nil {
writeTemplateError(w, err)
return
}
out := make([]templateMetaJSON, 0, len(metas))
for _, m := range metas {
out = append(out, metaJSON(m))
}
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
}
// handlePickerTemplates backs GET /api/templates — the firm-shared catalog
// any authenticated lawyer reads to pick an uploaded template for
// generation (t-paliad-349 slice 7). Unlike the admin list it filters by
// firm (the deployment's branding firm + firm-agnostic templates), matching
// the submission_bases picker contract. Metadata only — no carrier bytes.
func handlePickerTemplates(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
metas, err := dbSvc.templateStore.List(r.Context(),
docforge.TemplateFilter{Firm: branding.Name, ActiveOnly: true})
if err != nil {
writeTemplateError(w, err)
return
}
out := make([]templateMetaJSON, 0, len(metas))
for _, m := range metas {
out = append(out, metaJSON(m))
}
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
}
// handleUploadTemplate backs POST /api/admin/templates (multipart). Reads
// the uploaded .docx, validates it parses, detects any slots already in it,
// and creates the template at version 1.
func handleUploadTemplate(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if err := r.ParseMultipartForm(maxTemplateUpload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
return
}
defer file.Close()
carrier, err := io.ReadAll(io.LimitReader(file, maxTemplateUpload))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not read uploaded file"})
return
}
nameDE := r.FormValue("name_de")
nameEN := r.FormValue("name_en")
if nameDE == "" || nameEN == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name_de and name_en required"})
return
}
// Validate + detect existing slots before persisting.
view, err := docx.ImportForAuthoring(carrier)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "not a parseable .docx: " + err.Error()})
return
}
tmpl, err := dbSvc.templateStore.Create(r.Context(),
docforge.TemplateMetaInput{
Slug: r.FormValue("slug"),
NameDE: nameDE,
NameEN: nameEN,
Firm: r.FormValue("firm"),
CreatedBy: uid.String(),
},
docforge.TemplateVersionInput{
CarrierBytes: carrier,
Slots: view.Slots,
CreatedBy: uid.String(),
})
if err != nil {
writeTemplateError(w, err)
return
}
writeJSON(w, http.StatusCreated, authoringViewJSON{
Template: metaJSON(tmpl.TemplateMeta),
PreviewHTML: view.PreviewHTML,
Slots: slotsJSON(tmpl.Slots),
})
}
// handleGetTemplateAuthoring backs GET /api/admin/templates/{id} — the
// authoring view: current carrier rendered run-addressable + its slots.
func handleGetTemplateAuthoring(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
tmpl, err := dbSvc.templateStore.Get(r.Context(), r.PathValue("id"))
if err != nil {
writeTemplateError(w, err)
return
}
view, err := docx.ImportForAuthoring(tmpl.CarrierBytes)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "stored carrier failed to parse: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, authoringViewJSON{
Template: metaJSON(tmpl.TemplateMeta),
PreviewHTML: view.PreviewHTML,
Slots: slotsJSON(view.Slots),
})
}
type placeSlotInput struct {
RunIndex int `json:"run_index"`
SelectedText string `json:"selected_text"`
SlotKey string `json:"slot_key"`
}
// handlePlaceTemplateSlot backs POST /api/admin/templates/{id}/slots —
// inject a slot at the selection and persist as a new version.
func handlePlaceTemplateSlot(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var in placeSlotInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
id := r.PathValue("id")
tmpl, err := dbSvc.templateStore.Get(r.Context(), id)
if err != nil {
writeTemplateError(w, err)
return
}
newCarrier, err := docx.InjectSlot(tmpl.CarrierBytes, in.RunIndex, in.SelectedText, in.SlotKey)
if err != nil {
// Injection failures are client-fixable (bad selection / key).
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Re-detect slots from the new carrier so template_slots mirrors the
// carrier's actual {{tokens}} (single source of truth).
newView, err := docx.ImportForAuthoring(newCarrier)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("post-inject parse: %v", err)})
return
}
updated, err := dbSvc.templateStore.AddVersion(r.Context(), id,
docforge.TemplateVersionInput{
CarrierBytes: newCarrier,
Stylemap: tmpl.Stylemap,
Slots: newView.Slots,
CreatedBy: uid.String(),
})
if err != nil {
writeTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, authoringViewJSON{
Template: metaJSON(updated.TemplateMeta),
PreviewHTML: newView.PreviewHTML,
Slots: slotsJSON(updated.Slots),
})
}

View File

@@ -0,0 +1,66 @@
package services
// Shims bridging the submission generator to the extracted docforge .docx
// adapter (pkg/docforge/docx). Slice 1 of the docforge train
// (t-paliad-349 / m/paliad#157) relocated the Markdown→OOXML walker, the
// placeholder substitution engine, and the .dotm→.docx converter into
// pkg/docforge/docx with no behaviour change. These type aliases and
// forwarders keep every existing caller in internal/services and
// internal/handlers compiling and behaving identically — the names,
// signatures, and semantics are unchanged; only the implementation moved.
//
// Later slices retire these shims as the submission services are
// refactored to call docforge directly through the neutral model and the
// VariableResolver interface.
import (
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
)
// PlaceholderMap is the variable bag (dotted-key → substituted value),
// built by SubmissionVarsService and consumed by the renderer. The
// canonical type lives in the docforge root (the format-neutral
// variable-bag contract).
type PlaceholderMap = docforge.PlaceholderMap
// MissingPlaceholderFn translates an unbound placeholder key into the
// in-document marker token.
type MissingPlaceholderFn = docforge.MissingPlaceholderFn
// SubmissionRenderer renders a .docx template by substituting
// {{placeholder}} tokens. Stateless; safe for concurrent use.
type SubmissionRenderer = docx.SubmissionRenderer
// HyperlinkAllocator hands the Markdown walker a rId for each external
// URL it encounters in [label](url) inline links.
type HyperlinkAllocator = docx.HyperlinkAllocator
// NewSubmissionRenderer constructs the renderer.
func NewSubmissionRenderer() *SubmissionRenderer { return docx.NewSubmissionRenderer() }
// DefaultMissingMarker returns the standard missing-value marker for the
// given UI language ("[KEIN WERT: <key>]" / "[NO VALUE: <key>]").
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
return docforge.DefaultMissingMarker(lang)
}
// RenderMarkdownToOOXML renders Markdown source into OOXML paragraph
// elements using a single paragraph style.
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
return docx.RenderMarkdownToOOXML(md, paragraphStyle)
}
// RenderMarkdownToOOXMLWithStyles is the full rich-prose entry point
// (headings, lists, blockquote, inline hyperlinks via the allocator).
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
return docx.RenderMarkdownToOOXMLWithStyles(md, stylemap, links)
}
// ConvertDotmToDocx rewrites a .dotm/.docm/.dotx zip into a clean .docx
// zip. Idempotent on a zip that is already a plain .docx.
func ConvertDotmToDocx(dotmBytes []byte) ([]byte, error) { return docx.ConvertDotmToDocx(dotmBytes) }
// SanitiseSubmissionFileName cleans a string for use inside a download
// filename (strips path separators / quotes, ASCII-folds DE umlauts).
func SanitiseSubmissionFileName(s string) string { return docx.SanitiseSubmissionFileName(s) }

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

@@ -24,13 +24,37 @@ import (
// fall-through) and at the row level via the migration-157 RLS policies.
// The application-level check is the load-bearing one — the service
// connects with the service-role credential, which bypasses RLS.
//
// B4 (t-paliad-347 / m/paliad#153) adds the Akte-mode dual-write:
// project-backed scenarios (origin_project_id IS NOT NULL) write flag
// toggles through to paliad.projects.scenario_flags and "filed" event
// toggles through to paliad.deadlines, so the project's Verlauf / Frist
// rail reflect builder activity without a separate sync step. The
// scenario row itself records canvas view-state (ordinal, collapsed,
// per-card horizon, notes); the SSoT for project-bound actuals stays
// paliad.deadlines / paliad.projects.scenario_flags (PRD §2.3 + §10).
type ScenarioBuilderService struct {
db *sqlx.DB
db *sqlx.DB
projects *ProjectService
flags *ScenarioFlagsService
// fristenrechner computes planned-deadline due dates during the B5
// promote-to-project cascade (PRD §5.4 — "due_date=computed"). nil in
// test setups that don't exercise promotion; the promote path then
// skips planned events that have no actual_date (it can't assert a
// date it didn't compute) and reports them via DeadlinesSkipped.
fristenrechner *FristenrechnerService
}
// NewScenarioBuilderService wires the service to the shared pool.
func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db}
// NewScenarioBuilderService wires the service to the shared pool plus
// the project + scenario-flags services it leans on for the Akte-mode
// dual-write, and the Fristenrechner calc service the B5 promote path
// uses to compute planned-deadline dates. projects / flags / frist are
// optional in test setups (nil → the dual-write + promote-compute hooks
// short-circuit), but a production wiring should always pass them so
// Akte-backed scenarios stay in sync with project surfaces and
// promotion cascades real dates.
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService, frist *FristenrechnerService) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db, projects: projects, flags: flags, fristenrechner: frist}
}
// ErrScenarioBuilderNotVisible is returned when the caller is neither
@@ -427,8 +451,19 @@ type PatchProceedingInput struct {
}
// PatchProceeding updates fields on one proceeding row.
//
// Dual-write (B4): when the parent scenario is project-backed
// (scenarios.origin_project_id IS NOT NULL) and the patched proceeding
// is the top-level triplet (parent_scenario_proceeding_id IS NULL) and
// the patch includes scenario_flags, the merged flag delta also lands on
// paliad.projects.scenario_flags via ScenarioFlagsService.Patch. Top-
// level only because child triplets (CCR child etc.) represent spawned
// sub-proceedings whose flags don't belong on the parent project row;
// the spawned proceeding will get its own project record when (and if)
// the scenario is promoted via the B5 wizard.
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
@@ -491,7 +526,7 @@ func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, sc
created_at, updated_at`,
strings.Join(sets, ", "), len(args)-1, len(args))
var out BuilderProceeding
err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
err = s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
})
if err != nil {
@@ -501,9 +536,55 @@ func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, sc
}
return nil, fmt.Errorf("patch proceeding: %w", err)
}
// B4 dual-write: if the scenario is Akte-backed and we just
// changed scenario_flags on the top-level triplet, mirror the
// merged delta onto paliad.projects.scenario_flags. The PATCH
// fires after the scenario_proceedings UPDATE commits — a failure
// here logs but doesn't roll back the builder write (the builder
// state is the user-visible canvas; the project mirror is a
// convenience).
if sc.OriginProjectID != nil && out.ParentScenarioProceedingID == nil &&
len(input.ScenarioFlags) > 0 && s.flags != nil {
if delta, derr := flagDeltaFromBuilder(input.ScenarioFlags); derr == nil && len(delta) > 0 {
if _, perr := s.flags.Patch(ctx, userID, *sc.OriginProjectID, delta); perr != nil {
// Don't fail the builder PATCH — log via the audit
// reason that landed in the tx and surface the
// error through fmt so callers can still inspect.
return nil, fmt.Errorf("dual-write to project scenario_flags: %w", perr)
}
}
}
return &out, nil
}
// flagDeltaFromBuilder converts the builder's scenario_flags jsonb
// (Record<string, unknown>) into the partial delta shape expected by
// ScenarioFlagsService.Patch (map[string]*bool, where nil deletes the
// key). Non-bool values are skipped; the builder only writes booleans
// through its UI but defensive parsing keeps the dual-write honest if
// a stray null sneaks in.
func flagDeltaFromBuilder(raw json.RawMessage) (map[string]*bool, error) {
if len(raw) == 0 {
return nil, nil
}
var src map[string]any
if err := json.Unmarshal(raw, &src); err != nil {
return nil, fmt.Errorf("decode flag delta: %w", err)
}
out := make(map[string]*bool, len(src))
for k, v := range src {
switch val := v.(type) {
case bool:
b := val
out[k] = &b
case nil:
out[k] = nil
}
}
return out, nil
}
// DeleteProceeding removes a proceeding (and cascades to events + children).
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
@@ -618,8 +699,20 @@ type PatchEventInput struct {
// PatchEvent updates fields on one event card. The card's parent
// proceeding must belong to the addressed scenario.
//
// Dual-write (B4): when the parent scenario is project-backed
// (scenarios.origin_project_id IS NOT NULL), the event's sequencing
// rule is set, and the patch transitions the card to state='filed'
// with an actual_date, the same fact lands on paliad.deadlines
// (status='completed', completed_at=actual_date). If a deadline row
// already exists for the (project_id, sequencing_rule_id) pair it's
// updated in place; otherwise a fresh row is inserted carrying the
// rule's display name + due_date=actual_date. The dual-write runs in
// the same transaction as the scenario_events UPDATE so canvas and
// project surfaces never diverge mid-flight.
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
@@ -667,8 +760,24 @@ func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenari
horizon_optional, created_at, updated_at`,
strings.Join(sets, ", "), len(args))
var out BuilderEvent
err := s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
err = s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
if err := tx.GetContext(ctx, &out, q, args...); err != nil {
return err
}
// B4 dual-write: project-backed scenarios reflect "filed"
// transitions on paliad.deadlines so the project's Verlauf /
// Frist rail picks them up without a separate writer. We
// only act when state explicitly flipped to 'filed' on this
// patch — earlier rows that were already filed don't get
// re-stamped.
if sc.OriginProjectID != nil && input.State != nil && *input.State == "filed" &&
out.SequencingRuleID != nil && out.ActualDate != nil {
if err := s.dualWriteFiledDeadlineTx(ctx, tx, *sc.OriginProjectID,
*out.SequencingRuleID, *out.ActualDate); err != nil {
return fmt.Errorf("dual-write filed deadline: %w", err)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("patch event: %w", err)
@@ -676,6 +785,82 @@ func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenari
return &out, nil
}
// dualWriteFiledDeadlineTx upserts a paliad.deadlines row for the
// (project_id, sequencing_rule_id) pair so a builder-filed event
// surfaces on the project's deadline rail. If a row exists, it's
// flipped to status='completed' + completed_at; otherwise a fresh row
// is inserted with the rule's display name, due_date=actual_date, and
// source='litigation_builder'. The whole thing runs inside the caller
// transaction so the canvas event and the deadline never diverge.
func (s *ScenarioBuilderService) dualWriteFiledDeadlineTx(ctx context.Context, tx *sqlx.Tx, projectID, ruleID uuid.UUID, actualDate time.Time) error {
// Try update first — keeps any existing approval / event_type
// hydration intact for deadlines created via the regular Akten
// path. We touch only the columns the builder owns:
// status / completed_at / updated_at.
res, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET status = 'completed',
completed_at = $1,
updated_at = now()
WHERE project_id = $2
AND sequencing_rule_id = $3
AND status <> 'completed'`,
actualDate, projectID, ruleID)
if err != nil {
return fmt.Errorf("update existing deadline: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
return nil
}
// Already-completed rows: leave them alone, the builder isn't
// reopening anything. Detect via a count probe so we don't
// double-insert.
var existing int
if err := tx.GetContext(ctx, &existing,
`SELECT COUNT(*) FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id = $2`,
projectID, ruleID); err != nil {
return fmt.Errorf("probe deadline row: %w", err)
}
if existing > 0 {
return nil
}
// No existing row — insert a fresh deadline. The title comes from
// paliad.procedural_events.name joined via sequencing_rules.
// procedural_event_id (sequencing_rules itself doesn't carry a
// display label — the name lives on the procedural_event row).
// rule_code falls back when the event has no name; the literal
// "Litigation-Builder Event" is the last resort for rules that
// have no procedural_event_id either. source='rule' (already
// allowed by deadlines_source_check) since the row is rule-backed
// — the Litigation Builder doesn't get its own source bucket; the
// audit_reason on the surrounding tx tells the audit log who
// inserted it.
var title string
if err := tx.GetContext(ctx, &title,
`SELECT COALESCE(NULLIF(pe.name, ''), NULLIF(sr.rule_code, ''), 'Litigation-Builder Event')
FROM paliad.sequencing_rules sr
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.id = $1`, ruleID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
title = "Litigation-Builder Event"
} else {
return fmt.Errorf("load rule name: %w", err)
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(project_id, title, due_date, sequencing_rule_id, status, completed_at, source, approval_status)
VALUES ($1, $2, $3::date, $4, 'completed', $5::timestamptz, 'rule', 'legacy')`,
projectID, title, actualDate, ruleID, actualDate); err != nil {
return fmt.Errorf("insert builder deadline: %w", err)
}
return nil
}
// DeleteEvent removes one event card.
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
@@ -751,6 +936,438 @@ func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenar
return nil
}
// -----------------------------------------------------------------------------
// Shared-with-me listing (B5)
// -----------------------------------------------------------------------------
// ListSharedWithMe returns scenarios shared read-only with the caller
// (a paliad.scenario_shares row exists for shared_with_user_id = caller).
// The caller's own scenarios are excluded — they live in ListMyScenarios.
// Sorted by the share's created_at desc so the most-recently-shared sits
// on top. Promoted scenarios stay visible (read-only reference) just like
// in the owner's own list.
func (s *ScenarioBuilderService) ListSharedWithMe(ctx context.Context, userID uuid.UUID) ([]BuilderScenario, error) {
out := []BuilderScenario{}
if err := s.db.SelectContext(ctx, &out,
`SELECT sc.id, sc.owner_id, sc.name, sc.status, sc.origin_project_id,
sc.promoted_project_id, sc.stichtag, sc.notes,
sc.project_id, sc.description, sc.created_by,
sc.created_at, sc.updated_at
FROM paliad.scenarios sc
JOIN paliad.scenario_shares sh ON sh.scenario_id = sc.id
WHERE sh.shared_with_user_id = $1
AND (sc.owner_id IS NULL OR sc.owner_id <> $1)
ORDER BY sh.created_at DESC`, userID); err != nil {
return nil, fmt.Errorf("list shared scenarios: %w", err)
}
return out, nil
}
// -----------------------------------------------------------------------------
// Promote-to-project (B5, PRD §2.4 + §5.4 + §10)
// -----------------------------------------------------------------------------
// PromotePartyInput is one party row the wizard's "Parteien ergänzen"
// step contributes. Mirrors CreatePartyInput minus contact_info (the
// wizard collects names + roles; full contact data is filled in the Akte
// later).
type PromotePartyInput struct {
Name string `json:"name"`
Role *string `json:"role,omitempty"`
Representative *string `json:"representative,omitempty"`
}
// PromoteTeamMemberInput grants a colleague access to the new project at
// promote time. Responsibility defaults to 'member' when blank.
type PromoteTeamMemberInput struct {
UserID uuid.UUID `json:"user_id"`
Responsibility string `json:"responsibility,omitempty"`
}
// PromoteScenarioInput is the POST /api/builder/scenarios/{id}/promote
// body — the merged payload from wizard steps 2 (Parteien) + 3
// (Akte-Metadaten). The procedural shape (proceeding type, flags,
// perspective) + event states come from the scenario itself; the wizard
// only supplies the client-bound metadata the scenario can't know.
type PromoteScenarioInput struct {
Title string `json:"title"`
Reference *string `json:"reference,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ClientNumber *string `json:"client_number,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
Parties []PromotePartyInput `json:"parties,omitempty"`
TeamMembers []PromoteTeamMemberInput `json:"team_members,omitempty"`
}
// PromoteResult is the outcome the wizard navigates on.
type PromoteResult struct {
ProjectID uuid.UUID `json:"project_id"`
DeadlinesCreated int `json:"deadlines_created"`
DeadlinesSkipped int `json:"deadlines_skipped"`
PartiesCreated int `json:"parties_created"`
ProceedingsSkipped int `json:"proceedings_skipped"`
}
// PromoteScenario turns a scenario into a real paliad.projects 'case' row
// in a single transaction (PRD §10 — no partial promotions). It promotes
// the scenario's primary proceeding (the lowest-ordinal top-level
// triplet) plus its spawned descendants (the CCR child etc., whose rules
// fold into the primary's timeline under the active flags). Additional
// unrelated top-level proceedings are left in the scenario and reported
// via ProceedingsSkipped — v1 promotes one case file per call, matching
// the singular acceptance criterion (one project, navigate to one id);
// the scenario stays visible as 'promoted' for historical reference and
// can seed a second promotion later.
//
// The cascade, all inside the tx:
// 1. INSERT paliad.projects (type='case', client metadata from the
// wizard, proceeding_type_id + scenario_flags from the primary
// triplet, origin_scenario_id = scenario.id).
// 2. INSERT the creator as team lead + any wizard-selected colleagues.
// 3. INSERT parties from the wizard's step-2 payload.
// 4. For each event under the promoted proceedings: filed → a completed
// deadline (due_date + completed_at = actual_date); planned → an open
// ('pending') deadline with the computed due_date; skipped → no row.
// Planned events with no computable date (court-set / conditional /
// no actual_date) are skipped and counted.
// 5. UPDATE the scenario: status='promoted', promoted_project_id = new.
//
// Any error rolls the whole transaction back.
func (s *ScenarioBuilderService) PromoteScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PromoteScenarioInput) (*PromoteResult, error) {
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
if sc.Status == "promoted" {
return nil, fmt.Errorf("%w: scenario is already promoted", ErrInvalidInput)
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
}
for i := range input.Parties {
if strings.TrimSpace(input.Parties[i].Name) == "" {
return nil, fmt.Errorf("%w: party %d has a blank name", ErrInvalidInput, i+1)
}
}
for _, tm := range input.TeamMembers {
if tm.UserID == uuid.Nil {
return nil, fmt.Errorf("%w: team member has an empty user_id", ErrInvalidInput)
}
if tm.Responsibility != "" && !IsValidResponsibility(tm.Responsibility) {
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, tm.Responsibility)
}
}
// Parent visibility (mirrors ProjectService.Create): a litigation
// parent the caller can't see would leak the new sub-tree.
if input.ParentID != nil && s.projects != nil {
if _, perr := s.projects.GetByID(ctx, userID, *input.ParentID); perr != nil {
return nil, fmt.Errorf("%w: litigation parent not visible", ErrForbidden)
}
}
// Load the proceeding + event tree.
proceedings := []BuilderProceeding{}
if err := s.db.SelectContext(ctx, &proceedings, `
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed, created_at, updated_at
FROM paliad.scenario_proceedings
WHERE scenario_id = $1
ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
return nil, fmt.Errorf("load proceedings: %w", err)
}
if len(proceedings) == 0 {
return nil, fmt.Errorf("%w: scenario has no proceedings to promote", ErrInvalidInput)
}
// Primary = first top-level proceeding (lowest ordinal). Collect it +
// its spawned descendants; those form the one case file we promote.
var primary *BuilderProceeding
for i := range proceedings {
if proceedings[i].ParentScenarioProceedingID == nil {
primary = &proceedings[i]
break
}
}
if primary == nil {
return nil, fmt.Errorf("%w: scenario has no top-level proceeding", ErrInvalidInput)
}
promoteSet := collectProceedingSubtree(proceedings, primary.ID)
topLevelCount := 0
for i := range proceedings {
if proceedings[i].ParentScenarioProceedingID == nil {
topLevelCount++
}
}
// Resolve the primary proceeding's catalog code (the calc engine keys
// off code, not id).
var primaryCode string
if err := s.db.GetContext(ctx, &primaryCode,
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, primary.ProceedingTypeID); err != nil {
return nil, fmt.Errorf("resolve proceeding code: %w", err)
}
// Resolve our_side: explicit wizard value wins; otherwise fold the
// primary triplet's perspective down to the project axis.
ourSide := input.OurSide
if ourSide == nil {
ourSide = primary.PrimaryParty
}
// Compute the primary proceeding's timeline so planned events get real
// dates. The CCR child's rules fold into this timeline under the
// primary's flags (sub-track routing), so one calc covers the whole
// promoted subtree. Keyed by lowercased rule id → display name/code/date.
type computed struct {
name string
code string
dueDate string
}
timelineByRule := map[string]computed{}
if s.fristenrechner != nil {
stichtag := promoteStichtag(primary, sc)
opts := CalcOptions{Flags: scenarioFlagsTruthyKeys(primary.ScenarioFlags)}
tl, cerr := s.fristenrechner.Calculate(ctx, primaryCode, stichtag, opts)
if cerr != nil {
// A calc failure is not fatal — filed events still carry their
// own actual_date. Planned events then fall to DeadlinesSkipped.
tl = nil
}
if tl != nil {
for _, e := range tl.Deadlines {
if e.RuleID == "" {
continue
}
timelineByRule[strings.ToLower(e.RuleID)] = computed{
name: e.Name, code: e.Code, dueDate: e.DueDate,
}
}
}
}
// Load events for the promoted proceedings only.
events := []BuilderEvent{}
if err := s.db.SelectContext(ctx, &events, `
SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
e.procedural_event_id, e.custom_label, e.state, e.actual_date,
e.skip_reason, e.notes, e.horizon_optional, e.created_at, e.updated_at
FROM paliad.scenario_events e
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
WHERE sp.scenario_id = $1
ORDER BY e.created_at ASC`, scenarioID); err != nil {
return nil, fmt.Errorf("load events: %w", err)
}
result := &PromoteResult{
ProceedingsSkipped: topLevelCount - 1,
}
newProjectID := uuid.New()
err = s.withAuditTx(ctx, "scenario_builder: promote scenario", func(tx *sqlx.Tx) error {
now := time.Now().UTC()
// 1. Project row. path is filled by the BEFORE INSERT trigger
// (projects_sync_path); '' satisfies the NOT NULL constraint.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, reference, status, created_by,
case_number, client_number, proceeding_type_id, our_side,
scenario_flags, origin_scenario_id, metadata, created_at, updated_at)
VALUES ($1, 'case', $2, '', $3, $4, 'active', $5,
$6, $7, $8, $9, $10::jsonb, $11, '{}'::jsonb, $12, $12)`,
newProjectID, input.ParentID, title, input.Reference, userID,
nullableTrimmed(stringPtrOrNil(input.CaseNumber)),
nullableTrimmed(input.ClientNumber),
primary.ProceedingTypeID, nullableOurSide(ourSide),
[]byte(primary.ScenarioFlags), scenarioID, now); err != nil {
return fmt.Errorf("insert project: %w", err)
}
// 2a. Creator as team lead (RLS visibility, matches Create).
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`, newProjectID, userID); err != nil {
return fmt.Errorf("insert creator team row: %w", err)
}
// 2b. Wizard-selected colleagues.
for _, tm := range input.TeamMembers {
if tm.UserID == userID {
continue // creator already added as lead
}
resp := tm.Responsibility
if resp == "" {
resp = ResponsibilityMember
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, $3, $4, false, $5)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role, responsibility = EXCLUDED.responsibility`,
newProjectID, tm.UserID, legacyRoleFromResponsibility(resp), resp, userID); err != nil {
return fmt.Errorf("insert team member: %w", err)
}
}
// 3. Parties.
for _, p := range input.Parties {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.parties (project_id, name, role, representative, contact_info)
VALUES ($1, $2, $3, $4, '{}'::jsonb)`,
newProjectID, strings.TrimSpace(p.Name), p.Role, p.Representative); err != nil {
return fmt.Errorf("insert party: %w", err)
}
result.PartiesCreated++
}
// 4. Deadlines from the promoted proceedings' events.
for _, ev := range events {
if !promoteSet[ev.ScenarioProceedingID] {
continue
}
if ev.State == "skipped" {
continue
}
if ev.SequencingRuleID == nil {
// Free-form / procedural-event-only cards have no rule to
// anchor a deadline on in v1 — skip (counts as skipped only
// when it was a dated plan; here just leave it out).
continue
}
ruleKey := strings.ToLower(ev.SequencingRuleID.String())
comp := timelineByRule[ruleKey]
title := comp.name
if strings.TrimSpace(title) == "" {
title = "Litigation-Builder Frist"
}
ruleCode := comp.code
if ev.State == "filed" && ev.ActualDate != nil {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(project_id, title, rule_code, due_date, sequencing_rule_id,
status, completed_at, source, approval_status)
VALUES ($1, $2, $3, $4::date, $5, 'completed', $6::timestamptz, 'rule', 'legacy')`,
newProjectID, title, nullableTrimmed(&ruleCode), *ev.ActualDate,
*ev.SequencingRuleID, *ev.ActualDate); err != nil {
return fmt.Errorf("insert filed deadline: %w", err)
}
result.DeadlinesCreated++
continue
}
// planned — need a date. Prefer an explicit actual_date
// (court-set override the user pinned), else the computed date.
var dueDate *time.Time
if ev.ActualDate != nil {
dueDate = ev.ActualDate
} else if comp.dueDate != "" {
if d, perr := time.Parse("2006-01-02", comp.dueDate); perr == nil {
dueDate = &d
}
}
if dueDate == nil {
result.DeadlinesSkipped++
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(project_id, title, rule_code, due_date, sequencing_rule_id,
status, source, approval_status)
VALUES ($1, $2, $3, $4::date, $5, 'pending', 'rule', 'legacy')`,
newProjectID, title, nullableTrimmed(&ruleCode), *dueDate,
*ev.SequencingRuleID); err != nil {
return fmt.Errorf("insert planned deadline: %w", err)
}
result.DeadlinesCreated++
}
// 5. Flip the scenario to promoted.
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.scenarios
SET status = 'promoted', promoted_project_id = $1, updated_at = now()
WHERE id = $2`, newProjectID, scenarioID); err != nil {
return fmt.Errorf("mark scenario promoted: %w", err)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("promote scenario: %w", err)
}
result.ProjectID = newProjectID
return result, nil
}
// collectProceedingSubtree returns the set of proceeding ids rooted at
// rootID (inclusive), walking parent_scenario_proceeding_id downwards.
func collectProceedingSubtree(all []BuilderProceeding, rootID uuid.UUID) map[uuid.UUID]bool {
set := map[uuid.UUID]bool{rootID: true}
// Iterate to a fixpoint; depth is tiny (<=2 today) so a few passes suffice.
for changed := true; changed; {
changed = false
for i := range all {
p := &all[i]
if p.ParentScenarioProceedingID != nil && set[*p.ParentScenarioProceedingID] && !set[p.ID] {
set[p.ID] = true
changed = true
}
}
}
return set
}
// promoteStichtag picks the calc anchor for the promote timeline: the
// primary proceeding's own stichtag, else the scenario default, else today.
func promoteStichtag(primary *BuilderProceeding, sc *BuilderScenario) string {
if primary.Stichtag != nil {
return primary.Stichtag.Format("2006-01-02")
}
if sc.Stichtag != nil {
return sc.Stichtag.Format("2006-01-02")
}
return time.Now().UTC().Format("2006-01-02")
}
// scenarioFlagsTruthyKeys returns the flag keys set to boolean true in the
// builder's scenario_flags jsonb — the array shape the calc engine's
// CalcOptions.Flags consumes.
func scenarioFlagsTruthyKeys(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil
}
out := []string{}
for k, v := range m {
if b, ok := v.(bool); ok && b {
out = append(out, k)
}
}
return out
}
// stringPtrOrNil normalises a *string so an all-whitespace value becomes
// nil before nullableTrimmed sees it (case_number empty → NULL column).
func stringPtrOrNil(p *string) *string {
if p == nil {
return nil
}
if strings.TrimSpace(*p) == "" {
return nil
}
return p
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------
@@ -916,6 +1533,189 @@ func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context,
return nil, ErrScenarioBuilderNotVisible
}
// -----------------------------------------------------------------------------
// Akte mode — project-backed scenarios (B4, t-paliad-347)
// -----------------------------------------------------------------------------
// CreateScenarioFromProject builds a fresh project-backed scenario from
// a paliad.projects row: the scenario's origin_project_id points at the
// project, one top-level proceeding mirrors the project's
// proceeding_type_id + our_side + scenario_flags, and every existing
// paliad.deadlines row with a sequencing_rule_id surfaces as a
// scenario_events row (state='filed' when the deadline is completed,
// 'planned' otherwise).
//
// The scenario is the canvas view-state; paliad.projects.scenario_flags
// + paliad.deadlines remain the SSoT for project-bound actuals (PRD
// §2.3 + §10). Subsequent PatchProceeding / PatchEvent calls on this
// scenario route their writes through to those SSoT tables via the
// dual-write hooks below.
//
// Visibility: the caller must be able to see the project; the project's
// type must be 'case' (it's the proceeding-bearing project rung) and
// must have a proceeding_type_id set (otherwise there's nothing to seed
// the builder with). Returns ErrInvalidInput when those preconditions
// don't hold.
func (s *ScenarioBuilderService) CreateScenarioFromProject(ctx context.Context, userID, projectID uuid.UUID) (*BuilderScenarioDeep, error) {
if s.projects == nil {
return nil, fmt.Errorf("%w: project service not wired", ErrInvalidInput)
}
proj, err := s.projects.GetByID(ctx, userID, projectID)
if err != nil {
return nil, err
}
if proj == nil {
return nil, ErrNotVisible
}
if proj.ProceedingTypeID == nil || *proj.ProceedingTypeID <= 0 {
return nil, fmt.Errorf("%w: project %s has no proceeding_type_id — Akte-mode requires one", ErrInvalidInput, projectID)
}
// Read the project's persisted scenario_flags. The column is jsonb
// NOT NULL DEFAULT '{}' (mig 154) so an empty map is always safe.
var rawFlags []byte
if err := s.db.GetContext(ctx, &rawFlags,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
return nil, fmt.Errorf("read project scenario_flags: %w", err)
}
if len(rawFlags) == 0 {
rawFlags = []byte(`{}`)
}
// Pull every active+published sequencing_rule deadline row on the
// project so the canvas can render filed/planned actuals as event
// cards from first paint. CCR sub-projects are reached separately
// when the user toggles with_ccr; the seed only covers the addressed
// project's deadlines.
type deadlineRow struct {
ID uuid.UUID `db:"id"`
SequencingRuleID *uuid.UUID `db:"sequencing_rule_id"`
Status string `db:"status"`
DueDate time.Time `db:"due_date"`
CompletedAt *time.Time `db:"completed_at"`
}
var deadlines []deadlineRow
if err := s.db.SelectContext(ctx, &deadlines,
`SELECT id, sequencing_rule_id, status, due_date, completed_at
FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id IS NOT NULL`,
projectID); err != nil {
return nil, fmt.Errorf("read project deadlines: %w", err)
}
// Derive the builder-side primary_party from the project's
// our_side. The Project.OurSide column accepts the wider sub-role
// set (claimant / applicant / appellant; defendant / respondent;
// third_party / other) but the builder triplet has a binary
// claimant|defendant axis per PRD §3.3 — fold the wider set down,
// drop third_party / other to NULL (no perspective preselected).
primaryParty := mapProjectOurSideToTripletParty(proj.OurSide)
name := strings.TrimSpace(proj.Title)
if name == "" {
name = "Akte"
}
deep := &BuilderScenarioDeep{
Proceedings: []BuilderProceeding{},
Events: []BuilderEvent{},
Shares: []BuilderShare{},
}
err = s.withAuditTx(ctx, "scenario_builder: create from project", func(tx *sqlx.Tx) error {
// 1. Insert the scenario header. origin_project_id pins the
// Akte link; promotion later overwrites promoted_project_id
// independently.
if err := tx.GetContext(ctx, &deep.BuilderScenario,
`INSERT INTO paliad.scenarios
(owner_id, name, status, origin_project_id)
VALUES ($1, $2, 'active', $3)
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at`,
userID, name, projectID); err != nil {
return fmt.Errorf("insert scenario row: %w", err)
}
// 2. Insert one top-level proceeding mirroring the project's
// procedural shape + flags. scenario_flags is copied
// verbatim from the project — subsequent toggles on the
// builder propagate back via PatchProceeding's dual-write.
var proc BuilderProceeding
if err := tx.GetContext(ctx, &proc,
`INSERT INTO paliad.scenario_proceedings
(scenario_id, proceeding_type_id, primary_party, scenario_flags, ordinal, detailgrad)
VALUES ($1, $2, $3, $4::jsonb, 0, 'selected')
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed,
created_at, updated_at`,
deep.BuilderScenario.ID, *proj.ProceedingTypeID, primaryParty, rawFlags); err != nil {
return fmt.Errorf("insert seed proceeding: %w", err)
}
deep.Proceedings = append(deep.Proceedings, proc)
// 3. One scenario_events row per project deadline. Filed
// deadlines render with state='filed' + actual_date =
// completed_at (falling back to due_date when the column
// was never set). Pending / approved deadlines render
// planned. Skipped is not derivable from the deadline row
// shape; users mark skip on the canvas via PatchEvent.
for _, d := range deadlines {
state := "planned"
var actualDate *time.Time
if d.Status == "completed" {
state = "filed"
if d.CompletedAt != nil {
actualDate = d.CompletedAt
} else {
due := d.DueDate
actualDate = &due
}
}
var ev BuilderEvent
if err := tx.GetContext(ctx, &ev,
`INSERT INTO paliad.scenario_events
(scenario_proceeding_id, sequencing_rule_id, state, actual_date)
VALUES ($1, $2, $3, $4)
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes,
horizon_optional, created_at, updated_at`,
proc.ID, *d.SequencingRuleID, state, actualDate); err != nil {
return fmt.Errorf("insert seed event: %w", err)
}
deep.Events = append(deep.Events, ev)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("create scenario from project: %w", err)
}
return deep, nil
}
// mapProjectOurSideToTripletParty folds paliad.projects.our_side (which
// allows the wider claimant/applicant/appellant + defendant/respondent
// + third_party/other set, mig 112) down to the builder triplet's
// binary claimant|defendant axis (PRD §3.3). Returns nil when the
// project hasn't picked a side or the role doesn't map (third_party /
// other) — the canvas shows both columns equally in that case.
func mapProjectOurSideToTripletParty(side *string) *string {
if side == nil {
return nil
}
switch *side {
case "claimant", "applicant", "appellant":
s := "claimant"
return &s
case "defendant", "respondent":
s := "defendant"
return &s
}
return nil
}
// withAuditTx opens a transaction, stamps paliad.audit_reason via
// set_config(..., true) so the reason persists for the duration of the
// tx (matching the mig-079 audit-trigger pattern used by event_choice_

View File

@@ -6,6 +6,7 @@ import (
"errors"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
@@ -82,7 +83,7 @@ func TestScenarioBuilderService(t *testing.T) {
t.Fatalf("look up upc.inf id: %v", err)
}
svc := NewScenarioBuilderService(pool)
svc := NewScenarioBuilderService(pool, nil, nil, nil)
// 1. Create a scenario for the owner. Empty name should default.
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
@@ -216,5 +217,442 @@ func TestScenarioBuilderService(t *testing.T) {
}
}
// TestScenarioBuilderAkteDualWrite pins B4's load-bearing contract
// (m/paliad#153 / t-paliad-347 / PRD §2.3 + §10):
//
// - PatchProceeding on a project-backed scenario (origin_project_id
// IS NOT NULL) MUST mirror scenario_flags onto
// paliad.projects.scenario_flags;
// - PatchEvent flipping state→'filed' on a project-backed scenario
// MUST upsert a paliad.deadlines row (status='completed',
// completed_at=actual_date);
// - PatchProceeding/PatchEvent on a non-Akte (kontextfrei) scenario
// MUST NOT touch paliad.projects.scenario_flags or
// paliad.deadlines.
//
// Skipped without TEST_DATABASE_URL. Mirrors the live-DB pattern used
// by TestScenarioBuilderService above.
func TestScenarioBuilderAkteDualWrite(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()
owner := uuid.New()
cleanup := func() {
pool.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM paliad.projects WHERE created_by = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM paliad.users WHERE id = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM auth.users WHERE id = $1`, owner)
}
cleanup()
defer cleanup()
// Seed owner.
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
owner, "builder-akte-test@hlc.com"); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, lang, global_role)
VALUES ($1, $2, $3, 'munich', 'de', 'global_admin')`,
owner, "builder-akte-test@hlc.com", "Builder Akte Owner"); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Look up a real proceeding_type_id + a sequencing_rule_id on that
// proceeding so the deadline upsert has a real rule to point at.
var ptID int
if err := pool.GetContext(ctx, &ptID,
`SELECT id FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true LIMIT 1`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up upc.inf id: %v", err)
}
var ruleID uuid.UUID
if err := pool.GetContext(ctx, &ruleID,
`SELECT id FROM paliad.sequencing_rules
WHERE proceeding_type_id = $1
AND is_active = true
AND lifecycle_state = 'published'
ORDER BY sequence_order NULLS LAST, id LIMIT 1`, ptID); err != nil {
t.Fatalf("look up sequencing_rule: %v", err)
}
// Seed a paliad.projects (type='case') row pinned to that
// proceeding_type. our_side='defendant' so the builder triplet's
// primary_party derives from it.
projectID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, title, status, proceeding_type_id, our_side, created_by)
VALUES ($1, 'case', 'Builder Akte Test Project', 'active', $2, 'defendant', $3)`,
projectID, ptID, owner); err != nil {
t.Fatalf("seed project: %v", err)
}
// Place the owner on the project team so visibility checks pass.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited)
VALUES ($1, $2, 'lead', 'lead', false)`, projectID, owner); err != nil {
t.Fatalf("seed project_teams: %v", err)
}
// Wire up the service with the real project + flag deps so dual-
// write hits live tables. NewProjectService + NewScenarioFlags
// match the production wiring in cmd/server/main.go.
userSvc := NewUserService(pool)
projSvc := NewProjectService(pool, userSvc)
flagsSvc := NewScenarioFlagsService(pool, projSvc)
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
// ──────────────────────────────────────────────────────────────────
// Phase A — Akte-backed scenario writes through to project tables.
// ──────────────────────────────────────────────────────────────────
akte, err := svc.CreateScenarioFromProject(ctx, owner, projectID)
if err != nil {
t.Fatalf("CreateScenarioFromProject: %v", err)
}
if akte.OriginProjectID == nil || *akte.OriginProjectID != projectID {
t.Fatalf("origin_project_id = %v, want %v", akte.OriginProjectID, projectID)
}
if len(akte.Proceedings) != 1 {
t.Fatalf("seed proceedings = %d, want 1", len(akte.Proceedings))
}
procID := akte.Proceedings[0].ID
if akte.Proceedings[0].PrimaryParty == nil || *akte.Proceedings[0].PrimaryParty != "defendant" {
t.Errorf("primary_party = %v, want defendant", akte.Proceedings[0].PrimaryParty)
}
// Toggle with_ccr=true via PatchProceeding. Dual-write should land
// the same key on projects.scenario_flags.
if _, err := svc.PatchProceeding(ctx, owner, akte.ID, procID, PatchProceedingInput{
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
}); err != nil {
t.Fatalf("PatchProceeding (Akte): %v", err)
}
var rawProjFlags []byte
if err := pool.GetContext(ctx, &rawProjFlags,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
t.Fatalf("read project scenario_flags: %v", err)
}
var projFlags map[string]any
if err := json.Unmarshal(rawProjFlags, &projFlags); err != nil {
t.Fatalf("decode project scenario_flags: %v", err)
}
if v, ok := projFlags["with_ccr"].(bool); !ok || !v {
t.Errorf("after Akte PatchProceeding, projects.scenario_flags.with_ccr = %v, want true", projFlags["with_ccr"])
}
// Add an event card backed by a real sequencing rule, then PATCH
// state='filed' with actual_date. Dual-write should insert a
// paliad.deadlines row (status='completed', completed_at=actual_date).
ev, err := svc.AddEvent(ctx, owner, akte.ID, procID, AddEventInput{
SequencingRuleID: &ruleID,
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent (Akte): %v", err)
}
filedDate := mustDate(t, "2026-04-15")
if _, err := svc.PatchEvent(ctx, owner, akte.ID, ev.ID, PatchEventInput{
State: ptrString("filed"),
ActualDate: &filedDate,
}); err != nil {
t.Fatalf("PatchEvent filed (Akte): %v", err)
}
var deadlineCount int
if err := pool.GetContext(ctx, &deadlineCount,
`SELECT COUNT(*) FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id = $2
AND status = 'completed'`,
projectID, ruleID); err != nil {
t.Fatalf("count deadlines: %v", err)
}
if deadlineCount != 1 {
t.Errorf("after Akte PatchEvent filed, deadlines rows = %d, want 1", deadlineCount)
}
// ──────────────────────────────────────────────────────────────────
// Phase B — kontextfrei scenario does NOT touch project surfaces.
// ──────────────────────────────────────────────────────────────────
// Capture project scenario_flags + deadline count before the
// kontextfrei mutations so we can assert no change.
var beforeFlagsRaw []byte
_ = pool.GetContext(ctx, &beforeFlagsRaw,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
var beforeDeadlines int
_ = pool.GetContext(ctx, &beforeDeadlines,
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID)
kf, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
if err != nil {
t.Fatalf("CreateScenario (kontextfrei): %v", err)
}
if kf.OriginProjectID != nil {
t.Fatalf("kontextfrei origin_project_id = %v, want nil", kf.OriginProjectID)
}
kfProc, err := svc.AddProceeding(ctx, owner, kf.ID, AddProceedingInput{
ProceedingTypeID: ptID,
PrimaryParty: ptrString("claimant"),
})
if err != nil {
t.Fatalf("AddProceeding (kontextfrei): %v", err)
}
// Flag toggle on a kontextfrei scenario MUST NOT mutate the
// project's scenario_flags.
if _, err := svc.PatchProceeding(ctx, owner, kf.ID, kfProc.ID, PatchProceedingInput{
ScenarioFlags: json.RawMessage(`{"with_amend": true}`),
}); err != nil {
t.Fatalf("PatchProceeding (kontextfrei): %v", err)
}
var afterFlagsRaw []byte
if err := pool.GetContext(ctx, &afterFlagsRaw,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
t.Fatalf("re-read project scenario_flags: %v", err)
}
if string(beforeFlagsRaw) != string(afterFlagsRaw) {
t.Errorf("kontextfrei PatchProceeding leaked into projects.scenario_flags: before=%s after=%s",
beforeFlagsRaw, afterFlagsRaw)
}
// Filed-state event on a kontextfrei scenario MUST NOT touch
// paliad.deadlines.
kfEv, err := svc.AddEvent(ctx, owner, kf.ID, kfProc.ID, AddEventInput{
SequencingRuleID: &ruleID,
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent (kontextfrei): %v", err)
}
kfDate := mustDate(t, "2026-04-16")
if _, err := svc.PatchEvent(ctx, owner, kf.ID, kfEv.ID, PatchEventInput{
State: ptrString("filed"),
ActualDate: &kfDate,
}); err != nil {
t.Fatalf("PatchEvent filed (kontextfrei): %v", err)
}
var afterDeadlines int
if err := pool.GetContext(ctx, &afterDeadlines,
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID); err != nil {
t.Fatalf("re-count deadlines: %v", err)
}
if afterDeadlines != beforeDeadlines {
t.Errorf("kontextfrei PatchEvent filed leaked into deadlines: before=%d after=%d",
beforeDeadlines, afterDeadlines)
}
}
// TestScenarioBuilderPromote pins B5's load-bearing contract
// (m/paliad#153 / t-paliad-350 / PRD §2.4 + §5.4 + §10): PromoteScenario
// creates a paliad.projects 'case' row transactionally, cascades parties
// + deadlines, flips the scenario to 'promoted' with a back-ref, and
// makes the original scenario read-only afterwards.
func TestScenarioBuilderPromote(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()
owner := uuid.New()
var createdProjectID uuid.UUID
cleanup := func() {
if createdProjectID != uuid.Nil {
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, createdProjectID)
}
pool.ExecContext(ctx, `DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, owner)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, owner)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'promote-owner-test@hlc.com')`, owner); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, lang)
VALUES ($1, 'promote-owner-test@hlc.com', 'Promote Owner', 'munich', 'de')`, owner); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
var ptID int
if err := pool.GetContext(ctx, &ptID,
`SELECT id FROM paliad.proceeding_types WHERE code = $1 AND is_active = true LIMIT 1`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up upc.inf id: %v", err)
}
// Two distinct rule ids: one filed, one planned (with an explicit
// actual_date so the planned deadline lands even without a calc engine).
var ruleIDs []uuid.UUID
if err := pool.SelectContext(ctx, &ruleIDs,
`SELECT id FROM paliad.sequencing_rules
WHERE proceeding_type_id = $1 AND is_active = true AND lifecycle_state = 'published'
ORDER BY sequence_order NULLS LAST, id LIMIT 2`, ptID); err != nil {
t.Fatalf("look up sequencing_rules: %v", err)
}
if len(ruleIDs) < 2 {
t.Skipf("need >=2 published rules for upc.inf; got %d", len(ruleIDs))
}
userSvc := NewUserService(pool)
projSvc := NewProjectService(pool, userSvc)
flagsSvc := NewScenarioFlagsService(pool, projSvc)
// fristenrechner nil — planned events carry an explicit actual_date in
// this test, so the cascade doesn't need computed dates.
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{Name: "Promote-Test"})
if err != nil {
t.Fatalf("CreateScenario: %v", err)
}
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
ProceedingTypeID: ptID,
PrimaryParty: ptrString("defendant"),
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
})
if err != nil {
t.Fatalf("AddProceeding: %v", err)
}
filedDate := mustDate(t, "2026-01-15")
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
SequencingRuleID: &ruleIDs[0], State: ptrString("filed"), ActualDate: &filedDate,
}); err != nil {
t.Fatalf("AddEvent filed: %v", err)
}
plannedDate := mustDate(t, "2026-04-01")
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
SequencingRuleID: &ruleIDs[1], State: ptrString("planned"), ActualDate: &plannedDate,
}); err != nil {
t.Fatalf("AddEvent planned: %v", err)
}
res, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{
Title: "Becker ./. X — UPC",
CaseNumber: ptrString("UPC_CFI_123/2026"),
OurSide: ptrString("defendant"),
Parties: []PromotePartyInput{
{Name: "Becker GmbH", Role: ptrString("defendant")},
{Name: "X Corp", Role: ptrString("claimant")},
},
})
if err != nil {
t.Fatalf("PromoteScenario: %v", err)
}
createdProjectID = res.ProjectID
if res.ProjectID == uuid.Nil {
t.Fatal("PromoteScenario returned nil project id")
}
if res.PartiesCreated != 2 {
t.Errorf("PartiesCreated = %d, want 2", res.PartiesCreated)
}
if res.DeadlinesCreated != 2 {
t.Errorf("DeadlinesCreated = %d, want 2 (1 filed + 1 planned-with-date)", res.DeadlinesCreated)
}
// Project row exists, is a 'case', carries origin_scenario_id + flags.
var proj struct {
Type string `db:"type"`
OriginScenario *uuid.UUID `db:"origin_scenario_id"`
ProceedingType *int `db:"proceeding_type_id"`
OurSide *string `db:"our_side"`
ScenarioFlags json.RawMessage `db:"scenario_flags"`
CaseNumber *string `db:"case_number"`
}
if err := pool.GetContext(ctx, &proj,
`SELECT type, origin_scenario_id, proceeding_type_id, our_side, scenario_flags, case_number
FROM paliad.projects WHERE id = $1`, res.ProjectID); err != nil {
t.Fatalf("load promoted project: %v", err)
}
if proj.Type != "case" {
t.Errorf("project type = %q, want case", proj.Type)
}
if proj.OriginScenario == nil || *proj.OriginScenario != sc.ID {
t.Errorf("origin_scenario_id = %v, want %v", proj.OriginScenario, sc.ID)
}
if proj.ProceedingType == nil || *proj.ProceedingType != ptID {
t.Errorf("proceeding_type_id = %v, want %d", proj.ProceedingType, ptID)
}
if proj.OurSide == nil || *proj.OurSide != "defendant" {
t.Errorf("our_side = %v, want defendant", proj.OurSide)
}
// Scenario flipped to promoted with the back-ref.
var after BuilderScenario
if err := pool.GetContext(ctx, &after,
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes, project_id, description, created_by, created_at, updated_at
FROM paliad.scenarios WHERE id = $1`, sc.ID); err != nil {
t.Fatalf("reload scenario: %v", err)
}
if after.Status != "promoted" {
t.Errorf("scenario status = %q, want promoted", after.Status)
}
if after.PromotedProjectID == nil || *after.PromotedProjectID != res.ProjectID {
t.Errorf("promoted_project_id = %v, want %v", after.PromotedProjectID, res.ProjectID)
}
// Deadlines + parties physically present.
var deadlineCount, partyCount int
pool.GetContext(ctx, &deadlineCount,
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, res.ProjectID)
pool.GetContext(ctx, &partyCount,
`SELECT COUNT(*) FROM paliad.parties WHERE project_id = $1`, res.ProjectID)
if deadlineCount != 2 {
t.Errorf("deadlines in DB = %d, want 2", deadlineCount)
}
if partyCount != 2 {
t.Errorf("parties in DB = %d, want 2", partyCount)
}
// Promoted scenario is now read-only: further PATCH is rejected.
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Name: ptrString("rename-after-promote"),
}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("PatchScenario after promote = %v, want ErrInvalidInput", err)
}
// Re-promoting is rejected.
if _, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{Title: "again"}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("re-promote = %v, want ErrInvalidInput", err)
}
}
// mustDate parses an ISO date or fails the test. Helper for the
// dual-write test above.
func mustDate(t *testing.T, s string) time.Time {
t.Helper()
d, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatalf("parse date %q: %v", s, err)
}
return d
}
// (Note: ptrString lives in rule_editor_service_test.go in this package
// and is reused here. No second declaration needed.)

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

@@ -1,93 +1,73 @@
package services
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
// §9.2). Assembles a base .docx and a draft's section rows into a
// merged .docx ready for export.
// Composer wrapper — bridges paliad's submission draft model
// (SubmissionSection + SubmissionBase) to the format-neutral docforge
// .docx composer (pkg/docforge/docx), extracted in slice 2 of the
// docforge train (t-paliad-349 / m/paliad#157).
//
// Pipeline (high-level):
// The full splice/assembly pipeline now lives in pkg/docforge/docx
// (compose.go): macro pre-pass, anchor-pair splicing, append-before-sectPr,
// hyperlink-rels patching, zip repack, and the final placeholder pass. This
// wrapper does the one thing the engine must not know about — mapping
// paliad's DB row types onto the neutral docx.Section / docx.Carrier
// inputs. Behaviour is byte-identical to the pre-extraction composer; the
// in-package compose_test still drives this wrapper end-to-end.
//
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
// 3. For each section in the draft (order_index ASC, included=true):
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
// base.section_spec.stylemap.paragraph.
// 4. Splice the rendered OOXML into the base body. Two splice modes:
// - Anchor mode: when the body carries `{{#section:KEY}}` /
// `{{/section:KEY}}` marker pairs, replace the slot's content
// (including the anchor paragraphs themselves) with the rendered
// section.
// - Append mode: when no anchor pair is found for a section, the
// rendered OOXML appends at the end of the body, just before any
// `<w:sectPr>` element. Sections with `included=false` are
// dropped silently.
// 5. Strip any leftover unmatched anchor paragraphs.
// 6. Re-pack the document.xml into the zip, leaving every other part
// untouched.
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
// so `{{path}}` placeholders inside section content (and inside
// the base's untouched chrome) get substituted by the merged bag.
// Cross-run merge in pass 2 handles autocorrect-fragmented
// placeholders the same as v1.
//
// Result: a fully-merged .docx. No new third-party Go dep — reuses
// archive/zip + the existing SubmissionRenderer.
// Slice note: the paragraph-level neutral document model (Document / Block
// / Slot) the PRD §3.2 sketches lands in slice 6, where the authoring
// importer and the format exporters actually consume it. Building it now,
// ahead of any consumer, would be speculative and would put the
// byte-identical guarantee at risk for no gain (PRD §4 B3 principle:
// extractions earn their keep this cycle).
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"regexp"
"sort"
"strings"
"time"
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
)
// SubmissionComposer assembles base + sections into a final .docx.
// Stateless; safe for concurrent use.
// SubmissionComposer assembles a base + a draft's sections into a final
// .docx. Stateless; safe for concurrent use.
type SubmissionComposer struct {
renderer *SubmissionRenderer
inner *docx.Composer
}
// NewSubmissionComposer wires the composer. The renderer is required —
// a nil renderer is a programmer error and the composer panics at
// NewSubmissionComposer wires the composer. The renderer is required — a
// nil renderer is a programmer error and the composer panics at
// construction.
func NewSubmissionComposer(renderer *SubmissionRenderer) *SubmissionComposer {
if renderer == nil {
panic("submission composer: renderer required")
}
return &SubmissionComposer{renderer: renderer}
return &SubmissionComposer{inner: docx.NewComposer(renderer)}
}
// ComposeOptions carries the per-call composition inputs.
// ComposeOptions carries the per-call composition inputs in paliad's own
// terms (SubmissionSection rows + the SubmissionBase chrome).
type ComposeOptions struct {
// Sections are the draft's section rows in display order. The
// composer renders included sections; excluded rows are dropped.
// Caller is responsible for visibility — by the time the composer
// runs, the section rows have already been gated through
// SubmissionDraftService.Get + can_see_project.
// Sections are the draft's section rows in display order. Included
// sections render; excluded rows are dropped. The caller is
// responsible for visibility — by the time the composer runs the rows
// have already been gated through SubmissionDraftService.Get +
// can_see_project.
Sections []SubmissionSection
// Base supplies the document chrome (.docx body host) plus the
// stylemap for the MD walker. Must not be nil.
// Base supplies the document chrome plus the stylemap for the MD
// walker. Must not be nil.
Base *SubmissionBase
// BaseBytes is the raw .docx bytes for the base. Typically fetched
// BaseBytes is the raw .docx bytes for the base, typically fetched
// from Gitea via the existing template cache.
BaseBytes []byte
// Lang ('de' or 'en') selects which content_md_* column the
// composer reads per section. Defaults to 'de' if empty.
// Lang ('de' or 'en') selects which content_md_* column the composer
// reads per section. Defaults to 'de' if empty.
Lang string
// Vars is the merged placeholder bag the v1 renderer pass
// substitutes after the composer assembly. Passed straight through
// to SubmissionRenderer.Render.
// Vars is the merged placeholder bag the renderer pass substitutes
// after assembly.
Vars PlaceholderMap
// Missing translates an unbound placeholder key into the marker
// the lawyer sees in Word. Passed straight to the renderer.
// Missing translates an unbound placeholder key into the marker the
// lawyer sees in Word.
Missing MissingPlaceholderFn
}
@@ -96,512 +76,24 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
if opts.Base == nil {
return nil, fmt.Errorf("submission compose: base required")
}
_ = ctx // reserved for cancellation propagation in later slices
sections := opts.Sections
// Pre-pass: strip macros so the base reads as a plain .docx zip.
cleanBytes, err := ConvertDotmToDocx(opts.BaseBytes)
if err != nil {
return nil, fmt.Errorf("submission compose: convert base: %w", err)
}
// Locate + extract word/document.xml so we can splice in-place.
documentXML, otherParts, err := splitBaseZip(cleanBytes)
if err != nil {
return nil, err
}
// Per-compose hyperlink allocator. Each unique URL gets a fresh
// rId outside the base's existing namespace. The post-pass
// (patchDocumentXMLRels) writes the matching Relationship rows
// before the zip is repacked. Slice D adds inline `[label](url)`
// hyperlink support.
linkAlloc := newComposerLinkAllocator()
// Build the rendered-section map: section_key → OOXML span.
stylemap := opts.Base.SectionSpec.Stylemap
rendered := make(map[string]string, len(sections))
keptSections := make([]SubmissionSection, 0, len(sections))
for _, sec := range sections {
if !sec.Included {
continue
secs := make([]docx.Section, len(opts.Sections))
for i, s := range opts.Sections {
secs[i] = docx.Section{
Key: s.SectionKey,
OrderIndex: s.OrderIndex,
Included: s.Included,
ContentMDDE: s.ContentMDDE,
ContentMDEN: s.ContentMDEN,
}
md := sec.ContentMDDE
if strings.EqualFold(opts.Lang, "en") {
md = sec.ContentMDEN
}
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
keptSections = append(keptSections, sec)
}
// Stable order — already sorted ascending by ListForDraft, but
// belt-and-braces in case the caller swaps the ordering policy
// later.
sort.SliceStable(keptSections, func(i, j int) bool {
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
return c.inner.Compose(ctx, docx.ComposeOptions{
Sections: secs,
Carrier: docx.Carrier{
Bytes: opts.BaseBytes,
Stylemap: opts.Base.SectionSpec.Stylemap,
},
Lang: opts.Lang,
Vars: opts.Vars,
Missing: opts.Missing,
})
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
// for inline `[label](url)` links, the base's
// word/_rels/document.xml.rels needs matching <Relationship>
// entries so Word can resolve the rIds. Mutates one zip part in
// otherParts (or appends if missing).
if linkAlloc.HasLinks() {
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
if err != nil {
return nil, err
}
otherParts = updatedParts
}
// Re-pack into a zip with the assembled document.xml. All other
// parts (styles, fonts, headers, footers, theme, settings) pass
// through bit-for-bit at their original mtime + compression.
repacked, err := repackBaseZip(otherParts, assembledBody)
if err != nil {
return nil, err
}
// Final pass: substitute placeholders against the merged bag. The
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
// alias contract, and the missing-marker emission. Reusing it
// guarantees v1's placeholder grammar stays intact inside section
// content + base chrome.
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
if err != nil {
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
}
return merged, nil
}
// ─────────────────────────────────────────────────────────────────────
// Section splicing
// ─────────────────────────────────────────────────────────────────────
// Anchor markers as they appear inside a <w:t> text node. We don't
// need a full XML parse — finding the marker text inside the body is
// sufficient because:
// - {{ and }} are never legitimate document content (placeholders
// follow the same convention everywhere else in paliad).
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
// special characters.
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
// exactly one <w:r>...</w:r>, which lives in exactly one
// <w:p>...</w:p>. We expand from the marker outward to find the
// enclosing <w:p> span and drop the entire paragraph as part of
// the splice.
//
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
// implemented as manual byte-index search around the marker hit
// (anchorParagraphSpan below) rather than a single regex pattern.
const (
anchorOpenPrefix = "{{#section:"
anchorClosePrefix = "{{/section:"
anchorSuffix = "}}"
)
// anchorKeyRegex validates that the captured anchor key is a clean
// identifier. Keys that include other characters (which can't actually
// appear in our authored .docx) are treated as no match.
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
// anchorPair records the byte span of one matched anchor pair inside
// the body — from the start of the opening anchor's <w:p> element
// through the end of the closing anchor's </w:p>.
type anchorPair struct {
key string
openStart int // start of <w:p> for the opening anchor
closeEnd int // index just past </w:p> for the closing anchor
}
// findAllAnchorPairs scans the body for matched open/close anchor
// pairs. Unbalanced markers (open without close, or vice versa) are
// dropped from the result. Returns pairs in body-order; each pair's
// span is non-overlapping.
func findAllAnchorPairs(body string) []anchorPair {
type marker struct {
key string
paraStart int
paraEnd int
isOpen bool
}
var markers []marker
collect := func(prefix string, isOpen bool) {
offset := 0
for {
idx := strings.Index(body[offset:], prefix)
if idx < 0 {
return
}
start := offset + idx
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
if suffixIdx < 0 {
return
}
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
if !anchorKeyRegex.MatchString(key) {
offset = start + len(prefix)
continue
}
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
if !ok {
offset = markerEnd
continue
}
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
offset = pEnd
}
}
collect(anchorOpenPrefix, true)
collect(anchorClosePrefix, false)
// Walk markers in body-order, matching each open with the next
// close that carries the same key.
sort.SliceStable(markers, func(i, j int) bool {
return markers[i].paraStart < markers[j].paraStart
})
var pairs []anchorPair
openStack := map[string]marker{}
for _, m := range markers {
if m.isOpen {
openStack[m.key] = m
continue
}
o, ok := openStack[m.key]
if !ok {
continue
}
pairs = append(pairs, anchorPair{
key: m.key,
openStart: o.paraStart,
closeEnd: m.paraEnd,
})
delete(openStack, m.key)
}
return pairs
}
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
// element that fully contains the byte range [markerStart, markerEnd).
// Returns false when the byte range doesn't sit inside a single
// paragraph (which would mean the marker survived a cross-paragraph
// edit — defensive guard, shouldn't happen in well-formed input).
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
// Walk backwards to find the nearest unclosed <w:p ... > opening.
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
// the enclosing paragraph's opening tag.
pStart := -1
cursor := markerStart
for cursor > 0 {
idx := strings.LastIndex(body[:cursor], "<w:p")
if idx < 0 {
break
}
// Confirm this is a paragraph open, not a different
// w:p-prefixed tag (e.g. <w:pPr>).
if idx+4 <= len(body) {
after := body[idx+4]
if after == ' ' || after == '>' || after == '/' {
// <w:p ...> or <w:p>; not <w:pPr>.
close := strings.Index(body[idx:], ">")
if close < 0 {
return 0, 0, false
}
pStart = idx
break
}
}
cursor = idx
}
if pStart < 0 {
return 0, 0, false
}
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
// the next </w:p> after the marker is the close.
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
if pEndIdx < 0 {
return 0, 0, false
}
pEnd := markerEnd + pEndIdx + len("</w:p>")
return pStart, pEnd, true
}
// spliceSections replaces anchor slots with rendered sections and
// appends any unanchored sections before sectPr. Returns the assembled
// document.xml body.
func spliceSections(documentXML []byte, rendered map[string]string, kept []SubmissionSection, all []SubmissionSection) []byte {
body := string(documentXML)
pairs := findAllAnchorPairs(body)
// Build a lookup of kept section keys for quick membership tests.
keptByKey := map[string]int{}
for i, sec := range kept {
keptByKey[sec.SectionKey] = i
}
allByKey := map[string]int{}
for i, sec := range all {
allByKey[sec.SectionKey] = i
}
matchedKeys := map[string]bool{}
// Walk pairs in REVERSE body-order so slice mutations don't shift
// later offsets.
sort.SliceStable(pairs, func(i, j int) bool {
return pairs[i].openStart > pairs[j].openStart
})
for _, p := range pairs {
replacement := ""
if idx, ok := keptByKey[p.key]; ok {
replacement = rendered[p.key]
matchedKeys[p.key] = true
_ = idx
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
// Anchor matches an excluded section on the draft — drop
// the entire slot.
replacement = ""
} else {
// Anchor doesn't match any section on this draft — drop
// to leave the base's chrome unbroken.
replacement = ""
}
body = body[:p.openStart] + replacement + body[p.closeEnd:]
}
// Append unanchored sections before sectPr in order_index ASC.
var unanchored strings.Builder
for _, sec := range kept {
if matchedKeys[sec.SectionKey] {
continue
}
unanchored.WriteString(rendered[sec.SectionKey])
}
if unanchored.Len() > 0 {
body = appendBeforeSectPr(body, unanchored.String())
}
return []byte(body)
}
// appendBeforeSectPr inserts content immediately before the first
// `<w:sectPr` element in the body, or at the end of the body if there
// is none. Word documents conventionally close the body with a sectPr
// describing page setup; we want to land sections before that element
// so they show up on the actual pages.
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
func appendBeforeSectPr(body, content string) string {
loc := sectPrRegex.FindStringIndex(body)
if loc == nil {
// No sectPr → append before `</w:body>` if present, else at
// the very end.
idx := strings.LastIndex(body, "</w:body>")
if idx < 0 {
return body + content
}
return body[:idx] + content + body[idx:]
}
return body[:loc[0]] + content + body[loc[0]:]
}
// ─────────────────────────────────────────────────────────────────────
// Zip plumbing
// ─────────────────────────────────────────────────────────────────────
// baseZipPart captures one zip entry we kept aside while extracting
// document.xml.
type baseZipPart struct {
name string
method uint16
modTime int64 // wall seconds; converted back to time.Time on repack
body []byte
}
// splitBaseZip extracts document.xml and returns it alongside every
// other zip entry, ready for repacking.
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
if err != nil {
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
}
var documentXML []byte
parts := make([]baseZipPart, 0, len(zr.File))
for _, f := range zr.File {
body, err := readZipEntry(f)
if err != nil {
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
}
if f.Name == "word/document.xml" {
documentXML = body
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
continue
}
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
}
if documentXML == nil {
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
}
return documentXML, parts, nil
}
// repackBaseZip rebuilds the zip, swapping document.xml for the
// assembled body and leaving every other part untouched.
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
var out bytes.Buffer
zw := zip.NewWriter(&out)
for _, p := range parts {
hdr := &zip.FileHeader{
Name: p.name,
Method: p.method,
}
if p.modTime > 0 {
hdr.Modified = time.Unix(p.modTime, 0)
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
}
body := p.body
if p.name == "word/document.xml" {
body = assembledBody
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
}
return out.Bytes(), nil
}
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// ─────────────────────────────────────────────────────────────────────
// Slice D — hyperlink wiring
// ─────────────────────────────────────────────────────────────────────
// composerLinkAllocator hands out fresh rIds for inline hyperlink
// targets discovered by the MD walker. Each unique URL gets one rId
// (deduped — repeated links to the same URL share one Relationship).
// Allocations land outside the base's rId namespace by prefixing with
// "rIdComposer" so they can't collide with existing relationships.
type composerLinkAllocator struct {
next int
byURL map[string]string
order []string // URLs in allocation order
}
func newComposerLinkAllocator() *composerLinkAllocator {
return &composerLinkAllocator{byURL: map[string]string{}}
}
// Alloc returns the rId for url, allocating one on first sight.
func (a *composerLinkAllocator) Alloc(url string) string {
if rid, ok := a.byURL[url]; ok {
return rid
}
a.next++
rid := fmt.Sprintf("rIdComposer%d", a.next)
a.byURL[url] = rid
a.order = append(a.order, url)
return rid
}
// HasLinks reports whether any links were allocated during this compose.
func (a *composerLinkAllocator) HasLinks() bool {
return len(a.order) > 0
}
// Pairs returns the (rId, URL) pairs in allocation order. The
// document.xml.rels patcher consumes this to emit <Relationship>
// elements.
func (a *composerLinkAllocator) Pairs() [][2]string {
pairs := make([][2]string, 0, len(a.order))
for _, url := range a.order {
pairs = append(pairs, [2]string{a.byURL[url], url})
}
return pairs
}
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
// in `parts` to append the given (rId, URL) pairs as hyperlink
// relationships. If the rels part doesn't exist (some bases omit it
// when the body has no relationships), this function appends a fresh
// part with the minimal Relationships wrapper.
//
// Idempotent on (rId, URL) pairs already present (e.g. when a base
// already references the URL for some other reason).
//
// Returns the (possibly extended) parts slice — callers must overwrite
// their reference because the append in the no-rels-yet case grows the
// backing array.
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
const path = "word/_rels/document.xml.rels"
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
existingIdx := -1
for i := range parts {
if parts[i].name == path {
existingIdx = i
break
}
}
var body string
if existingIdx >= 0 {
body = string(parts[existingIdx].body)
} else {
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
}
var inserts strings.Builder
for _, p := range pairs {
rid := p[0]
url := p[1]
if strings.Contains(body, `Id="`+rid+`"`) {
continue
}
inserts.WriteString(`<Relationship Id="`)
inserts.WriteString(xmlAttrEscape(rid))
inserts.WriteString(`" Type="`)
inserts.WriteString(hyperlinkType)
inserts.WriteString(`" Target="`)
inserts.WriteString(xmlAttrEscape(url))
inserts.WriteString(`" TargetMode="External"/>`)
}
if inserts.Len() == 0 {
return parts, nil
}
closeIdx := strings.LastIndex(body, "</Relationships>")
if closeIdx < 0 {
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
}
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
if existingIdx >= 0 {
parts[existingIdx].body = []byte(patched)
return parts, nil
}
parts = append(parts, baseZipPart{
name: path,
method: zip.Deflate,
modTime: time.Now().Unix(),
body: []byte(patched),
})
return parts, nil
}

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

@@ -63,12 +63,17 @@ type SubmissionDraft struct {
// ON DELETE SET NULL keeps a draft renderable if its base is
// removed; the lawyer picks a new one via the sidebar.
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
// TemplateVersionID pins an uploaded docforge template version
// (t-paliad-349 slice 7). NULL = render via base_id Composer path or
// the v1 fallback; non-NULL = render the pinned version's carrier.
// The export/preview path checks this first. ON DELETE SET NULL.
TemplateVersionID *uuid.UUID `db:"template_version_id" json:"template_version_id,omitempty"`
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
// Slice A: empty default. Future slices populate section_order,
// hidden_sections, etc.
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// Variables is the decoded overrides map; populated on read by the
// service so callers don't have to unmarshal manually.
@@ -170,6 +175,22 @@ type DraftPatch struct {
// content is unaffected — the base swap is render-side only.
// t-paliad-313.
BaseID **uuid.UUID
// TemplateVersionID pins (or clears) an uploaded docforge template
// version. Same three-state two-level pointer as BaseID:
// nil → no change
// *p == nil → clear (back to base_id / v1)
// **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
@@ -186,7 +207,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
variables, selected_parties,
last_exported_at, last_exported_sha,
last_imported_at,
base_id, composer_meta,
base_id, template_version_id, composer_meta,
created_at, updated_at`
// List returns every draft for (project, submission_code, user)
@@ -239,7 +260,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
d.variables, d.selected_parties,
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
d.base_id, d.composer_meta,
d.base_id, d.template_version_id, d.composer_meta,
d.created_at, d.updated_at,
p.title AS project_title,
p.reference AS project_reference
@@ -343,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
}
@@ -418,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 {
@@ -446,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
@@ -567,6 +796,40 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.TemplateVersionID != nil {
newTV := *patch.TemplateVersionID // *uuid.UUID — nil means clear
// Existence is enforced by the FK + validated at the handler via
// TemplateStore.GetVersion (clean 404); here we just set it.
setParts = append(setParts, fmt.Sprintf("template_version_id = $%d", idx))
args = append(args, newTV)
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
}
@@ -878,7 +1141,6 @@ func normalizeDraftLanguage(lang string) string {
return "de"
}
// Compile-time guard: ensure the *models.User reference in the import
// graph doesn't get optimised away by linters. The service doesn't
// dereference User directly — that happens in SubmissionVarsService —

View File

@@ -0,0 +1,184 @@
package services
// Live-DB test for generation-on-uploaded-templates (t-paliad-349 slice 7).
// Skipped without TEST_DATABASE_URL. Verifies the shipped draft-service
// change end-to-end against real Postgres:
// 1. submission_drafts.template_version_id round-trips through
// Update → Get (the column-sync + patch path), and clears to NULL.
// 2. An uploaded template's carrier renders via the v1 Export path:
// {{firm.name}} in the carrier substitutes to the branding name.
//
// This is the verification the head greenlit (option C) before the
// shipped-code change is committed.
import (
"archive/zip"
"bytes"
"context"
"io"
"os"
"strings"
"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/pkg/docforge"
)
func TestSubmissionDraft_TemplateVersionPin(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 := "tplpin-" + 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.templates WHERE created_by = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
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, 'Tpl Pin', '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)
store := NewPgTemplateStore(pool)
// Uploaded template whose carrier carries a {{firm.name}} slot.
carrier := minimalDocxWithBody(t, `<w:p><w:r><w:t>Von {{firm.name}}</w:t></w:r></w:p>`)
tmpl, err := store.Create(ctx,
docforge.TemplateMetaInput{NameDE: "Pin-Test", NameEN: "Pin test", CreatedBy: userID.String()},
docforge.TemplateVersionInput{CarrierBytes: carrier, CreatedBy: userID.String()})
if err != nil {
t.Fatalf("store.Create: %v", err)
}
if tmpl.VersionID == "" {
t.Fatalf("template VersionID empty — generation can't pin it")
}
versionID := uuid.MustParse(tmpl.VersionID)
// Project-less draft on a code that has a published rule (so Build
// resolves). No composer attached → plain draft.
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("drafts.Create: %v", err)
}
if d.TemplateVersionID != nil {
t.Errorf("fresh draft has a template pin: %v", d.TemplateVersionID)
}
// --- Pin the version via Update, read it back via Get.
pin := &versionID
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &pin}); err != nil {
t.Fatalf("Update(pin): %v", err)
}
got, err := drafts.Get(ctx, userID, d.ID)
if err != nil {
t.Fatalf("Get after pin: %v", err)
}
if got.TemplateVersionID == nil || *got.TemplateVersionID != versionID {
t.Fatalf("pinned template_version_id = %v; want %s", got.TemplateVersionID, versionID)
}
// --- The uploaded carrier renders via Export: {{firm.name}} → "HLC".
out, _, err := drafts.Export(ctx, got, carrier)
if err != nil {
t.Fatalf("Export: %v", err)
}
doc := unzipDocumentXML(t, out)
if strings.Contains(doc, "{{firm.name}}") {
t.Errorf("placeholder not substituted; doc=%s", doc)
}
if !strings.Contains(doc, "HLC") {
t.Errorf("firm.name did not resolve to HLC; doc=%s", doc)
}
// --- Clearing the pin sets it back to NULL.
var nilPin *uuid.UUID
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &nilPin}); err != nil {
t.Fatalf("Update(clear): %v", err)
}
cleared, err := drafts.Get(ctx, userID, d.ID)
if err != nil {
t.Fatalf("Get after clear: %v", err)
}
if cleared.TemplateVersionID != nil {
t.Errorf("template_version_id = %v after clear; want nil", cleared.TemplateVersionID)
}
}
// minimalDocxWithBody builds a tiny valid .docx (zip) whose document.xml
// body is the given inner XML.
func minimalDocxWithBody(t *testing.T, inner string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
add("[Content_Types].xml",
`<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`)
add("word/document.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
`<w:body>`+inner+`</w:body></w:document>`)
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
func unzipDocumentXML(t *testing.T, b []byte) 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 != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open document.xml: %v", err)
}
defer rc.Close()
data, _ := io.ReadAll(rc)
return string(data)
}
t.Fatal("document.xml not found in output")
return ""
}

View File

@@ -1,503 +0,0 @@
package services
// Markdown → OOXML walker for Composer section content (t-paliad-313
// Slice B, design doc §9.2).
//
// Scope per the head's Slice B brief: paragraphs + inline bold/italic
// only. Headings, lists, blockquote, links land in Slice D's rich-prose
// pass. This walker is intentionally minimal — every Markdown construct
// it doesn't recognise is rendered as a plain paragraph so the lawyer's
// prose round-trips losslessly even when they hit Markdown the walker
// doesn't yet understand.
//
// The output uses the base's stylemap.paragraph entry for the
// <w:pStyle> on each paragraph so the styling matches the base's
// typography (HLpat-Body-B0 on the HLC base, Normal on the neutral
// base, etc.).
//
// Placeholders ({{path.dot.notation}}) are preserved verbatim — they
// pass through the walker untouched and get substituted by the v1
// SubmissionRenderer's placeholder pass after the composer assembly.
//
// Grammar supported:
//
// - Blank line → paragraph break
// - `**bold**` → <w:r><w:rPr><w:b/></w:rPr><w:t>…</w:t></w:r>
// - `*italic*` or `_italic_` → <w:r><w:rPr><w:i/></w:rPr>…</w:r>
// - Otherwise → plain text run
import (
"fmt"
"strings"
)
// HyperlinkAllocator hands the walker a `rId` for each external URL
// it encounters in `[label](url)` inline links. The composer's
// post-pass uses these allocations to mutate
// `word/_rels/document.xml.rels` so the emitted `<w:hyperlink
// r:id="…">` elements resolve correctly. Pass nil to drop links to
// plain text (the label survives, the URL doesn't render).
//
// t-paliad-316 Slice D.
type HyperlinkAllocator func(url string) string
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
// when paragraphStyle is non-empty.
//
// Slice B shipped paragraphs + bold/italic. Slice D extends to
// headings (h1/h2/h3), bullet/numbered lists, blockquote, and inline
// hyperlinks via the optional HyperlinkAllocator.
//
// stylemap supplies the paragraph-style names for each kind:
// stylemap["paragraph"] — default body
// stylemap["heading_1/2/3"] — heading levels
// stylemap["list_bullet"] — bullet list paragraph style
// stylemap["list_numbered"] — numbered list paragraph style
// stylemap["blockquote"] — blockquote
// Missing entries fall back to the "paragraph" style.
//
// Empty input renders one empty paragraph so the splice site is
// well-formed even when the lawyer hasn't typed anything in this
// section.
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
}
// RenderMarkdownToOOXMLWithStyles is the full Slice-D-aware entry
// point. Slice B's RenderMarkdownToOOXML is a wrapper for back-compat.
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
defaultStyle := stylemap["paragraph"]
if md == "" {
return emptyParagraph(defaultStyle)
}
blocks := splitMarkdownBlocks(md)
if len(blocks) == 0 {
return emptyParagraph(defaultStyle)
}
// Numbered-list counter resets on every non-numbered block so
// "1. A\n2. B\n\n1. C" renders as 1./2./1. (the lawyer's input
// determined the ordinal, the walker just renders).
numberedCounter := 0
var b strings.Builder
for _, blk := range blocks {
style := stylemap[blk.styleKey]
if style == "" {
style = defaultStyle
}
if blk.styleKey == "list_numbered" {
numberedCounter++
} else {
numberedCounter = 0
}
b.WriteString(renderBlockParagraph(blk, style, links, numberedCounter))
}
return b.String()
}
// mdBlock is one rendered paragraph: a kind (paragraph / heading_*
// / list_bullet / list_numbered / blockquote) and the inline content
// text. List markers, heading hashes, blockquote `> ` etc. are
// stripped from the text before storage.
type mdBlock struct {
styleKey string // "paragraph" | "heading_1" | "heading_2" | "heading_3" | "list_bullet" | "list_numbered" | "blockquote"
text string
}
// splitMarkdownBlocks parses the source into a sequence of blocks,
// detecting heading / list / blockquote prefixes line-by-line. Blank
// lines split paragraph runs (same semantics as splitMarkdownParagraphs)
// but each line is also tagged with its block kind.
//
// Lines that look like block markers don't merge with their neighbours
// even across blank lines — every list / heading / blockquote line is
// its own block in the output. A run of unmarked lines collapses into
// one "paragraph" block (so soft line breaks inside a paragraph still
// concatenate).
//
// CRLF normalised to LF before parsing.
func splitMarkdownBlocks(md string) []mdBlock {
normalised := strings.ReplaceAll(md, "\r\n", "\n")
lines := strings.Split(normalised, "\n")
var blocks []mdBlock
var pendingPara []string
blankRun := 0
flushPara := func() {
if len(pendingPara) > 0 {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
pendingPara = nil
}
}
for _, raw := range lines {
line := raw
if strings.TrimSpace(line) == "" {
if len(pendingPara) > 0 {
flushPara()
blankRun = 1
continue
}
blankRun++
continue
}
// Detect heading / list / blockquote markers BEFORE we accumulate
// into the paragraph buffer.
kind, payload, ok := detectBlockMarker(line)
if ok {
flushPara()
// Emit spacing paragraphs equivalent to (blankRun - 1) extra.
for i := 1; i < blankRun; i++ {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
}
blankRun = 0
blocks = append(blocks, mdBlock{styleKey: kind, text: payload})
continue
}
// Plain paragraph line.
if len(pendingPara) == 0 {
// Starting a new paragraph after a blank run — emit
// (blankRun-1) extra empty paragraphs for vertical spacing.
for i := 1; i < blankRun; i++ {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
}
}
blankRun = 0
pendingPara = append(pendingPara, line)
}
flushPara()
return blocks
}
// detectBlockMarker classifies a single line. Returns (styleKey,
// payload-with-marker-stripped, true) for recognised markers; false
// for plain paragraph lines.
//
// Recognised markers (Slice D):
// # Heading → heading_1
// ## Heading → heading_2
// ### Heading → heading_3
// - item / * item → list_bullet
// 1. item / 2. item ... → list_numbered (any positive integer)
// > quote → blockquote
//
// Leading whitespace inside the line is tolerated up to 3 spaces (per
// CommonMark) so the lawyer's contentEditable indentation doesn't
// hide the marker.
func detectBlockMarker(line string) (string, string, bool) {
trimmed := strings.TrimLeft(line, " ")
// Cap to 3 spaces of leading indent — beyond that, treat as a
// regular paragraph line (matches CommonMark).
if len(line)-len(trimmed) > 3 {
return "", "", false
}
if strings.HasPrefix(trimmed, "### ") {
return "heading_3", strings.TrimSpace(trimmed[4:]), true
}
if strings.HasPrefix(trimmed, "## ") {
return "heading_2", strings.TrimSpace(trimmed[3:]), true
}
if strings.HasPrefix(trimmed, "# ") {
return "heading_1", strings.TrimSpace(trimmed[2:]), true
}
if strings.HasPrefix(trimmed, "> ") {
return "blockquote", strings.TrimSpace(trimmed[2:]), true
}
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
}
// Numbered: "N. " where N is one or more digits.
if i := indexOfNumberedMarker(trimmed); i > 0 {
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
}
return "", "", false
}
// indexOfNumberedMarker checks for "N. " or "N) " at the start of the
// trimmed line; returns the byte index just past the marker, or -1 if
// no marker present.
func indexOfNumberedMarker(s string) int {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 {
return -1
}
if i >= len(s) {
return -1
}
if s[i] != '.' && s[i] != ')' {
return -1
}
if i+1 >= len(s) || s[i+1] != ' ' {
return -1
}
return i + 2
}
// renderBlockParagraph emits one `<w:p>` for a block. List blocks
// keep the same paragraph style as a default paragraph (the Slice D
// design's contract — list styles come from the base's stylemap and
// Word's numbering.xml is honoured by adding a leading bullet/number
// prefix in the rendered text). This keeps the composer free of
// numbering.xml mutations.
func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(xmlAttrEscape(paragraphStyle))
b.WriteString(`"/></w:pPr>`)
}
if blk.text == "" {
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
b.WriteString(`</w:p>`)
return b.String()
}
text := blk.text
// List blocks emit a visible "• " / "N. " prefix run. The
// stylemap entry handles paragraph indentation if the base
// defines a list paragraph style; otherwise the prefix at least
// surfaces the structure in plain Word. Lawyers who want Word's
// auto-numbering reapply a list style post-export.
switch blk.styleKey {
case "list_bullet":
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
case "list_numbered":
ordinal := numberedOrdinal
if ordinal <= 0 {
ordinal = 1
}
b.WriteString(`<w:r><w:t xml:space="preserve">`)
b.WriteString(fmt.Sprintf("%d. ", ordinal))
b.WriteString(`</w:t></w:r>`)
}
for _, run := range parseInlineRuns(text, links) {
b.WriteString(run)
}
b.WriteString(`</w:p>`)
return b.String()
}
// parseInlineRuns extracts inline spans + hyperlink runs and serialises
// each to OOXML. Hyperlinks become `<w:hyperlink r:id="RID">…runs…</w:hyperlink>`
// where RID comes from the HyperlinkAllocator.
func parseInlineRuns(text string, links HyperlinkAllocator) []string {
// Phase 1: find all hyperlink spans `[label](url)` and split the
// text around them.
type segment struct {
text string
isLink bool
url string
}
var segs []segment
rest := text
for {
idx := strings.Index(rest, "[")
if idx < 0 {
if rest != "" {
segs = append(segs, segment{text: rest})
}
break
}
// Find matching closing bracket, then a "(" right after.
closeBracket := strings.Index(rest[idx:], "](")
if closeBracket < 0 {
segs = append(segs, segment{text: rest})
break
}
closeParen := strings.Index(rest[idx+closeBracket:], ")")
if closeParen < 0 {
segs = append(segs, segment{text: rest})
break
}
// idx = start of "["
// idx+closeBracket = position of "]"
// idx+closeBracket+1 = position of "("
// idx+closeBracket+closeParen = position of ")"
label := rest[idx+1 : idx+closeBracket]
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
if idx > 0 {
segs = append(segs, segment{text: rest[:idx]})
}
segs = append(segs, segment{text: label, isLink: true, url: url})
rest = rest[idx+closeBracket+closeParen+1:]
}
var runs []string
for _, seg := range segs {
if seg.isLink && links != nil {
rid := links(seg.url)
if rid != "" {
var hb strings.Builder
hb.WriteString(`<w:hyperlink r:id="`)
hb.WriteString(xmlAttrEscape(rid))
hb.WriteString(`">`)
for _, span := range parseInlineSpans(seg.text) {
hb.WriteString(renderRunWithLinkStyle(span))
}
hb.WriteString(`</w:hyperlink>`)
runs = append(runs, hb.String())
continue
}
}
for _, span := range parseInlineSpans(seg.text) {
runs = append(runs, renderRun(span))
}
}
return runs
}
// renderRunWithLinkStyle emits a hyperlink child run. Same B/I support
// as renderRun, but additionally tags the run with the "Hyperlink"
// character style (Word's built-in) so the link renders in the
// document's hyperlink colour + underline.
func renderRunWithLinkStyle(span inlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
if span.Bold {
b.WriteString(`<w:b/>`)
}
if span.Italic {
b.WriteString(`<w:i/>`)
}
b.WriteString(`</w:rPr><w:t xml:space="preserve">`)
b.WriteString(xmlTextEscape(span.Text))
b.WriteString(`</w:t></w:r>`)
return b.String()
}
// inlineSpan is one piece of inline content: a text payload plus
// formatting flags. Bold and italic are independent — `***both***`
// produces one span with both flags set.
type inlineSpan struct {
Text string
Bold bool
Italic bool
}
// parseInlineSpans tokenises Markdown inline formatting into runs of
// (text, bold, italic). The grammar is intentionally narrow:
//
// - `**…**` → bold
// - `__…__` → bold (Markdown alternate)
// - `*…*` → italic
// - `_…_` → italic (Markdown alternate)
// - Anything else flows through as plain text.
//
// Unbalanced delimiters fall through as literal characters — the
// walker never errors on malformed Markdown. Nested formatting (e.g.
// `**bold *bold-italic* bold**`) toggles flags as it walks.
func parseInlineSpans(text string) []inlineSpan {
var out []inlineSpan
var cur strings.Builder
bold := false
italic := false
flush := func() {
if cur.Len() == 0 {
return
}
out = append(out, inlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
cur.Reset()
}
i := 0
n := len(text)
for i < n {
// Preserve {{...}} placeholders verbatim. Underscores and
// other Markdown-significant chars inside a placeholder key
// (e.g. {{project.case_number}}) must not be interpreted as
// bold/italic delimiters — otherwise the key gets stripped of
// its underscores and the v1 placeholder pass looks up the
// wrong key, surfacing [KEIN WERT: project.casenumber] in the
// preview.
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
rel := strings.Index(text[i+2:], "}}")
if rel >= 0 {
end := i + 2 + rel + 2
cur.WriteString(text[i:end])
i = end
continue
}
// Unmatched {{ — fall through to plain character handling.
}
// Bold delimiters first (longer match wins over italic).
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
flush()
bold = !bold
i += 2
continue
}
if text[i] == '*' || text[i] == '_' {
flush()
italic = !italic
i++
continue
}
cur.WriteByte(text[i])
i++
}
flush()
if len(out) == 0 {
out = append(out, inlineSpan{Text: ""})
}
return out
}
// renderRun emits one `<w:r>` element for an inline span. Empty text
// spans render as empty runs (Word accepts them; they're harmless).
func renderRun(span inlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r>`)
if span.Bold || span.Italic {
b.WriteString(`<w:rPr>`)
if span.Bold {
b.WriteString(`<w:b/>`)
}
if span.Italic {
b.WriteString(`<w:i/>`)
}
b.WriteString(`</w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlTextEscape(span.Text))
b.WriteString(`</w:t></w:r>`)
return b.String()
}
// emptyParagraph returns one empty `<w:p>` with the given style. Used
// when a section's content_md is empty so the splice site stays
// well-formed.
func emptyParagraph(paragraphStyle string) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(xmlAttrEscape(paragraphStyle))
b.WriteString(`"/></w:pPr>`)
}
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
return b.String()
}
// xmlTextEscape escapes the five XML-significant characters for safe
// insertion into <w:t> content. & first to avoid double-encoding.
func xmlTextEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
// Quotes and apostrophes are legal inside element text content;
// no need to escape them here.
return s
}
// xmlAttrEscape escapes for safe insertion into an attribute value
// (e.g. `<w:pStyle w:val="…"/>`).
func xmlAttrEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}

View File

@@ -47,6 +47,7 @@ import (
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// SubmissionVarsService assembles the placeholder map.
@@ -151,17 +152,20 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
if lang == "" {
lang = "de"
}
bag := PlaceholderMap{}
addFirmVars(bag)
addTodayVars(bag, time.Now())
addUserVars(bag, user)
addRuleVars(bag, rule, lang)
// firm / today / user / procedural_event apply to every render,
// project-bound or not. Each resolver wraps the matching addXxxVars
// builder (unchanged); ResolverSet.BuildBag runs them into one bag.
resolvers := []docforge.VariableResolver{
firmResolver{},
todayResolver{now: time.Now()},
userResolver{user: user},
proceduralEventResolver{rule: rule, lang: lang},
}
out := &SubmissionVarsResult{
Placeholders: bag,
User: user,
Rule: rule,
Lang: lang,
User: user,
Rule: rule,
Lang: lang,
}
if in.ProjectID == nil {
@@ -169,6 +173,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
// deadline state to resolve. The lawyer's overrides will fill
// the placeholder map; missing keys render as
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
return out, nil
}
@@ -195,14 +200,18 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return nil, err
}
addProjectVars(bag, project, pt, lang)
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties))
addDeadlineVars(bag, next, project, lang)
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},
)
out.Project = project
out.ProceedingType = pt
out.Parties = parties
out.NextDeadline = next
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
return out, nil
}
@@ -310,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
@@ -360,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 {
@@ -370,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.
//
@@ -404,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,55 @@
package services
import "testing"
// The variable catalogue is the single source of truth for the sidebar
// form + authoring palette labels (t-paliad-349 slice 5). These checks
// pin its integrity so a resolver Keys() edit can't silently ship a
// malformed entry or a duplicate key.
func TestSubmissionVariableCatalogue(t *testing.T) {
cat := SubmissionVariableCatalogue()
if len(cat) == 0 {
t.Fatal("catalogue is empty")
}
seen := map[string]bool{}
for _, e := range cat {
if e.Key == "" || e.LabelDE == "" || e.LabelEN == "" || e.Group == "" {
t.Errorf("incomplete catalogue entry: %+v", e)
}
if seen[e.Key] {
t.Errorf("duplicate catalogue key: %q", e.Key)
}
seen[e.Key] = true
}
// Spot-check one key per namespace resolves with the expected label.
want := map[string]struct{ group, de string }{
"firm.name": {"firm", "Kanzlei"},
"today.long_de": {"today", "Heute (DE lang)"},
"user.display_name": {"user", "Bearbeiter"},
"project.case_number": {"project", "Aktenzeichen (Gericht)"},
"parties.claimant.name": {"parties", "Klägerin"},
"procedural_event.legal_source_pretty": {"procedural_event", "Rechtsgrundlage"},
"deadline.due_date": {"deadline", "Frist (ISO)"},
}
byKey := map[string]struct{ group, de string }{}
for _, e := range cat {
byKey[e.Key] = struct{ group, de string }{e.Group, e.LabelDE}
}
for k, exp := range want {
got, ok := byKey[k]
if !ok {
t.Errorf("catalogue missing expected key %q", k)
continue
}
if got.group != exp.group || got.de != exp.de {
t.Errorf("catalogue[%q] = {%q, %q}; want {%q, %q}", k, got.group, got.de, exp.group, exp.de)
}
}
// The legacy rule.* aliases must be present for labelFor coverage.
if !seen["rule.name"] || !seen["rule.legal_source_pretty"] {
t.Error("legacy rule.* aliases missing from catalogue")
}
}

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

@@ -0,0 +1,81 @@
package services
// Pretty-printer tests for the variable-resolution layer (legalSourcePretty,
// ourSideDE/EN, patentNumberUPC). These live with submission_vars.go;
// they were relocated out of the docx engine test suite when the
// .docx renderer moved to pkg/docforge/docx (t-paliad-349 slice 1).
import "testing"
func TestLegalSourcePretty(t *testing.T) {
tests := []struct {
src, lang, want string
}{
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
{"DE.ZPO.253", "de", "§ 253 ZPO"},
{"DE.ZPO.253", "en", "Section 253 ZPO"},
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
{"DE.PatG.83", "de", "§ 83 PatG"},
{"EPC.123", "de", "Art. 123 EPÜ"},
{"EPC.123", "en", "Art. 123 EPC"},
{"FOO.BAR.123", "de", "FOO.BAR.123"},
{"", "de", ""},
}
for _, tc := range tests {
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
got := legalSourcePretty(tc.src, tc.lang)
if got != tc.want {
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
}
})
}
}
func TestOurSideTranslations(t *testing.T) {
cases := []struct {
in, wantDE, wantEN string
}{
{"claimant", "Klägerin", "Claimant"},
{"defendant", "Beklagte", "Defendant"},
{"court", "Gericht", "Court"},
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
{"", "", ""},
{"unknown", "", ""},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := ourSideDE(tc.in); got != tc.wantDE {
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
}
if got := ourSideEN(tc.in); got != tc.wantEN {
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
}
})
}
}
func TestPatentNumberUPC(t *testing.T) {
tests := []struct {
in, want string
}{
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
{"EP 1 234 567", "EP 1 234 567"},
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
{"", ""},
{"WO/2023/123456", "WO/2023/123456"},
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
got := patentNumberUPC(tc.in)
if got != tc.want {
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,228 @@
package services
// Variable resolvers — the paliad-side implementations of
// docforge.VariableResolver (t-paliad-349 slice 3). Each wraps one of the
// addXxxVars push-builders, capturing the entity it needs, so the proven
// builder bodies stay byte-for-byte unchanged while the composition moves
// behind the docforge.ResolverSet seam. SubmissionVarsService.Build wires
// the applicable resolvers and calls ResolverSet.BuildBag().
//
// These live in paliad (not docforge) because they read paliad's domain
// model — branding, user, project, parties, deadline_rules, deadlines. A
// second docforge consumer implements its own resolvers against its own
// data and plugs them into a ResolverSet the same way.
import (
"time"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// Compile-time conformance: each resolver satisfies docforge.VariableResolver.
var (
_ docforge.VariableResolver = firmResolver{}
_ docforge.VariableResolver = todayResolver{}
_ docforge.VariableResolver = userResolver{}
_ docforge.VariableResolver = proceduralEventResolver{}
_ docforge.VariableResolver = projectResolver{}
_ docforge.VariableResolver = captionResolver{}
_ docforge.VariableResolver = partiesResolver{}
_ docforge.VariableResolver = deadlineResolver{}
)
// vk is a terse constructor for a catalogue entry in the given group.
func vk(group, key, de, en string) docforge.VariableKey {
return docforge.VariableKey{Key: key, LabelDE: de, LabelEN: en, Group: group}
}
// SubmissionVariableCatalogue returns the full variable catalogue for the
// submission resolvers — every (key, DE/EN label, namespace) the sidebar
// form and the authoring palette can offer. Built from the resolvers'
// Keys() with no entity state, so it needs no DB call. This is the single
// source of truth for variable labels, replacing the duplicated TS
// VARIABLE_LABELS table (t-paliad-349 slice 5).
func SubmissionVariableCatalogue() []docforge.VariableKey {
return docforge.NewResolverSet(
firmResolver{},
todayResolver{},
userResolver{},
proceduralEventResolver{},
projectResolver{},
captionResolver{},
partiesResolver{},
deadlineResolver{},
).Catalogue()
}
// firmResolver populates firm.* from process-wide branding.
type firmResolver struct{}
func (firmResolver) Namespace() string { return "firm" }
func (firmResolver) Populate(bag PlaceholderMap) { addFirmVars(bag) }
func (firmResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("firm", "firm.name", "Kanzlei", "Firm"),
vk("firm", "firm.signature_block", "Signatur-Block", "Signature block"),
}
}
// todayResolver populates today.* from the build-time clock.
type todayResolver struct{ now time.Time }
func (todayResolver) Namespace() string { return "today" }
func (r todayResolver) Populate(bag PlaceholderMap) { addTodayVars(bag, r.now) }
func (todayResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("today", "today", "Heute", "Today"),
vk("today", "today.iso", "Heute (ISO)", "Today (ISO)"),
vk("today", "today.long_de", "Heute (DE lang)", "Today (DE long)"),
vk("today", "today.long_en", "Heute (EN lang)", "Today (EN long)"),
}
}
// userResolver populates user.* from the caller's row.
type userResolver struct{ user *models.User }
func (userResolver) Namespace() string { return "user" }
func (r userResolver) Populate(bag PlaceholderMap) { addUserVars(bag, r.user) }
func (userResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("user", "user.display_name", "Bearbeiter", "Author"),
vk("user", "user.email", "E-Mail", "Email"),
vk("user", "user.office", "Büro", "Office"),
}
}
// proceduralEventResolver populates procedural_event.* and the legacy
// rule.* alias from the published deadline_rule.
type proceduralEventResolver struct {
rule *models.DeadlineRule
lang string
}
func (proceduralEventResolver) Namespace() string { return "procedural_event" }
func (r proceduralEventResolver) Populate(bag PlaceholderMap) { addRuleVars(bag, r.rule, r.lang) }
func (proceduralEventResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("procedural_event", "procedural_event.code", "Code (Verfahrensschritt)", "Code (procedural event)"),
vk("procedural_event", "procedural_event.name", "Verfahrensschritt", "Procedural event"),
vk("procedural_event", "procedural_event.name_de", "Verfahrensschritt (DE)", "Procedural event (DE)"),
vk("procedural_event", "procedural_event.name_en", "Verfahrensschritt (EN)", "Procedural event (EN)"),
vk("procedural_event", "procedural_event.legal_source", "Rechtsgrundlage (Code)", "Legal source (code)"),
vk("procedural_event", "procedural_event.legal_source_pretty", "Rechtsgrundlage", "Legal source"),
vk("procedural_event", "procedural_event.primary_party", "Partei (typisch)", "Primary party"),
vk("procedural_event", "procedural_event.event_kind", "Art des Verfahrensschritts", "Procedural-event kind"),
// Legacy rule.* aliases — @deprecated, kept forever (m/paliad#93 Q7).
vk("procedural_event", "rule.submission_code", "Schriftsatz-Code (legacy)", "Submission code (legacy)"),
vk("procedural_event", "rule.name", "Schriftsatz (legacy)", "Submission (legacy)"),
vk("procedural_event", "rule.name_de", "Schriftsatz (DE, legacy)", "Submission (DE, legacy)"),
vk("procedural_event", "rule.name_en", "Schriftsatz (EN, legacy)", "Submission (EN, legacy)"),
vk("procedural_event", "rule.legal_source", "Rechtsgrundlage (Code, legacy)", "Legal source (code, legacy)"),
vk("procedural_event", "rule.legal_source_pretty", "Rechtsgrundlage (legacy)", "Legal source (legacy)"),
vk("procedural_event", "rule.primary_party", "Partei (typisch, legacy)", "Primary party (legacy)"),
vk("procedural_event", "rule.event_type", "Schriftsatz-Typ (legacy)", "Event type (legacy)"),
}
}
// projectResolver populates project.* from the project + its proceeding type.
type projectResolver struct {
project *models.Project
pt *models.ProceedingType
lang string
}
func (projectResolver) Namespace() string { return "project" }
func (r projectResolver) Populate(bag PlaceholderMap) { addProjectVars(bag, r.project, r.pt, r.lang) }
func (projectResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("project", "project.title", "Projekttitel", "Project title"),
vk("project", "project.reference", "Aktenzeichen (intern)", "Internal reference"),
vk("project", "project.case_number", "Aktenzeichen (Gericht)", "Court case number"),
vk("project", "project.court", "Gericht", "Court"),
vk("project", "project.patent_number", "Patentnummer", "Patent number"),
vk("project", "project.patent_number_upc", "Patentnummer (UPC-Format)", "Patent number (UPC format)"),
vk("project", "project.filing_date", "Anmeldedatum", "Filing date"),
vk("project", "project.grant_date", "Erteilungsdatum", "Grant date"),
vk("project", "project.our_side", "Unsere Seite", "Our side"),
vk("project", "project.our_side_de", "Unsere Seite (DE)", "Our side (DE)"),
vk("project", "project.our_side_en", "Unsere Seite (EN)", "Our side (EN)"),
vk("project", "project.instance_level", "Instanz", "Instance"),
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 }
func (partiesResolver) Namespace() string { return "parties" }
func (r partiesResolver) Populate(bag PlaceholderMap) { addPartyVars(bag, r.parties) }
// Keys returns the flat, user-facing party forms (the power-user override
// rows the sidebar shows). The indexed (parties.claimant.0.name) and
// joined (parties.claimants) forms Populate also emits are not catalogue
// entries — they're resolved into the bag but not offered in the palette.
func (partiesResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("parties", "parties.claimant.name", "Klägerin", "Claimant"),
vk("parties", "parties.claimant.representative", "Klägerin-Vertreter", "Claimant representative"),
vk("parties", "parties.defendant.name", "Beklagte", "Defendant"),
vk("parties", "parties.defendant.representative", "Beklagten-Vertreter", "Defendant representative"),
vk("parties", "parties.other.name", "Weitere Partei", "Other party"),
vk("parties", "parties.other.representative", "Weitere-Partei-Vertreter", "Other party representative"),
}
}
// deadlineResolver populates deadline.* from the next pending deadline.
type deadlineResolver struct {
deadline *models.Deadline
project *models.Project
lang string
}
func (deadlineResolver) Namespace() string { return "deadline" }
func (r deadlineResolver) Populate(bag PlaceholderMap) {
addDeadlineVars(bag, r.deadline, r.project, r.lang)
}
func (deadlineResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("deadline", "deadline.due_date", "Frist (ISO)", "Deadline (ISO)"),
vk("deadline", "deadline.due_date_long_de", "Frist (DE lang)", "Deadline (DE long)"),
vk("deadline", "deadline.due_date_long_en", "Frist (EN lang)", "Deadline (EN long)"),
vk("deadline", "deadline.original_due_date", "Ursprüngliche Frist", "Original deadline"),
vk("deadline", "deadline.computed_from", "Frist berechnet aus", "Deadline computed from"),
vk("deadline", "deadline.title", "Frist-Titel", "Deadline title"),
vk("deadline", "deadline.source", "Frist-Quelle", "Deadline source"),
}
}

View File

@@ -0,0 +1,397 @@
package services
// PgTemplateStore — paliad's Postgres implementation of
// docforge.TemplateStore (t-paliad-349 slice 4). The carrier .docx bytes
// live in a bytea column (paliad.template_versions.carrier_blob); the
// stylemap is jsonb; slots are rows in paliad.template_slots. Versioning is
// snapshot-at-create: Create makes version 1 and pins it as current,
// AddVersion inserts the next version and re-points current.
//
// docforge owns the interface + the neutral types; this is the paliad-side
// data binding. No handler wires this yet — the authoring surface (slice 6)
// and generation-on-templates (slice 7) are the consumers.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// PgTemplateStore implements docforge.TemplateStore against Postgres.
type PgTemplateStore struct {
db *sqlx.DB
}
// compile-time conformance.
var _ docforge.TemplateStore = (*PgTemplateStore)(nil)
// NewPgTemplateStore wires the store.
func NewPgTemplateStore(db *sqlx.DB) *PgTemplateStore {
return &PgTemplateStore{db: db}
}
// templateMetaRow scans the catalog metadata + the current version number
// (via LEFT JOIN, 0 when no version pinned yet).
type templateMetaRow struct {
ID uuid.UUID `db:"id"`
Slug *string `db:"slug"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
Kind string `db:"kind"`
SourceFormat string `db:"source_format"`
Firm *string `db:"firm"`
IsActive bool `db:"is_active"`
Version int `db:"version"`
VersionID *uuid.UUID `db:"version_id"`
}
func (r templateMetaRow) toMeta() docforge.TemplateMeta {
m := docforge.TemplateMeta{
ID: r.ID.String(),
Slug: derefString(r.Slug),
NameDE: r.NameDE,
NameEN: r.NameEN,
Kind: r.Kind,
SourceFormat: r.SourceFormat,
Firm: derefString(r.Firm),
IsActive: r.IsActive,
Version: r.Version,
}
if r.VersionID != nil {
m.VersionID = r.VersionID.String()
}
return m
}
const templateMetaColumns = `t.id, t.slug, t.name_de, t.name_en, t.kind,
t.source_format, t.firm, t.is_active,
COALESCE(v.version, 0) AS version,
v.id AS version_id`
const templateMetaFrom = `FROM paliad.templates t
LEFT JOIN paliad.template_versions v
ON v.id = t.current_version_id`
// List returns catalog metadata for matching templates, without carrier
// bytes.
func (s *PgTemplateStore) List(ctx context.Context, f docforge.TemplateFilter) ([]docforge.TemplateMeta, error) {
q := `SELECT ` + templateMetaColumns + ` ` + templateMetaFrom + ` WHERE 1=1`
var args []any
if f.ActiveOnly {
q += ` AND t.is_active`
}
if f.Firm != "" {
args = append(args, f.Firm)
q += fmt.Sprintf(` AND (t.firm = $%d OR t.firm IS NULL)`, len(args))
}
if f.Kind != "" {
args = append(args, f.Kind)
q += fmt.Sprintf(` AND t.kind = $%d`, len(args))
}
q += ` ORDER BY COALESCE(t.firm, ''), t.name_de`
var rows []templateMetaRow
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
return nil, fmt.Errorf("list templates: %w", err)
}
out := make([]docforge.TemplateMeta, len(rows))
for i := range rows {
out[i] = rows[i].toMeta()
}
return out, nil
}
// Get resolves a template to its current version.
func (s *PgTemplateStore) Get(ctx context.Context, id string) (*docforge.Template, error) {
tid, err := uuid.Parse(id)
if err != nil {
return nil, docforge.ErrTemplateNotFound
}
var meta templateMetaRow
err = s.db.GetContext(ctx, &meta,
`SELECT `+templateMetaColumns+` `+templateMetaFrom+` WHERE t.id = $1`, tid)
if errors.Is(err, sql.ErrNoRows) {
return nil, docforge.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("get template: %w", err)
}
tmpl := &docforge.Template{TemplateMeta: meta.toMeta()}
if meta.Version == 0 {
// No version pinned yet — return metadata only (carrier empty).
return tmpl, nil
}
if err := s.loadCurrentVersionContent(ctx, tid, tmpl); err != nil {
return nil, err
}
return tmpl, nil
}
// GetVersion resolves a template to a specific version id — the path a
// draft uses to render its pinned snapshot.
func (s *PgTemplateStore) GetVersion(ctx context.Context, versionID string) (*docforge.Template, error) {
vid, err := uuid.Parse(versionID)
if err != nil {
return nil, docforge.ErrTemplateNotFound
}
var vr struct {
TemplateID uuid.UUID `db:"template_id"`
Version int `db:"version"`
Carrier []byte `db:"carrier_blob"`
Stylemap []byte `db:"stylemap"`
}
err = s.db.GetContext(ctx, &vr,
`SELECT template_id, version, carrier_blob, stylemap
FROM paliad.template_versions WHERE id = $1`, vid)
if errors.Is(err, sql.ErrNoRows) {
return nil, docforge.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("get template version: %w", err)
}
var meta templateMetaRow
err = s.db.GetContext(ctx, &meta,
`SELECT t.id, t.slug, t.name_de, t.name_en, t.kind, t.source_format,
t.firm, t.is_active, $2 AS version
FROM paliad.templates t WHERE t.id = $1`, vr.TemplateID, vr.Version)
if errors.Is(err, sql.ErrNoRows) {
return nil, docforge.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("get template version meta: %w", err)
}
tmpl := &docforge.Template{TemplateMeta: meta.toMeta(), CarrierBytes: vr.Carrier}
tmpl.VersionID = vid.String() // the resolved version is the one requested
tmpl.Stylemap = decodeStylemap(vr.Stylemap)
slots, err := s.loadSlots(ctx, vid)
if err != nil {
return nil, err
}
tmpl.Slots = slots
return tmpl, nil
}
// loadCurrentVersionContent fills carrier + stylemap + slots from the
// template's current_version_id.
func (s *PgTemplateStore) loadCurrentVersionContent(ctx context.Context, templateID uuid.UUID, tmpl *docforge.Template) error {
var vr struct {
ID uuid.UUID `db:"id"`
Carrier []byte `db:"carrier_blob"`
Stylemap []byte `db:"stylemap"`
}
err := s.db.GetContext(ctx, &vr,
`SELECT v.id, v.carrier_blob, v.stylemap
FROM paliad.template_versions v
JOIN paliad.templates t ON t.current_version_id = v.id
WHERE t.id = $1`, templateID)
if errors.Is(err, sql.ErrNoRows) {
return docforge.ErrTemplateNotFound
}
if err != nil {
return fmt.Errorf("load current version: %w", err)
}
tmpl.CarrierBytes = vr.Carrier
tmpl.Stylemap = decodeStylemap(vr.Stylemap)
slots, err := s.loadSlots(ctx, vr.ID)
if err != nil {
return err
}
tmpl.Slots = slots
return nil
}
// loadSlots returns the slots placed in a version, ordered.
func (s *PgTemplateStore) loadSlots(ctx context.Context, versionID uuid.UUID) ([]docforge.TemplateSlot, error) {
var rows []struct {
SlotKey string `db:"slot_key"`
Anchor string `db:"anchor"`
Label *string `db:"label"`
OrderIndex int `db:"order_index"`
}
err := s.db.SelectContext(ctx, &rows,
`SELECT slot_key, anchor, label, order_index
FROM paliad.template_slots
WHERE template_version_id = $1
ORDER BY order_index, slot_key`, versionID)
if err != nil {
return nil, fmt.Errorf("load template slots: %w", err)
}
out := make([]docforge.TemplateSlot, len(rows))
for i, r := range rows {
out[i] = docforge.TemplateSlot{
Key: r.SlotKey,
Anchor: r.Anchor,
Label: derefString(r.Label),
OrderIndex: r.OrderIndex,
}
}
return out, nil
}
// Create inserts a new template + its first version (version 1) and pins
// that version as current.
func (s *PgTemplateStore) Create(ctx context.Context, meta docforge.TemplateMetaInput, first docforge.TemplateVersionInput) (*docforge.Template, error) {
createdBy, err := uuid.Parse(meta.CreatedBy)
if err != nil {
return nil, fmt.Errorf("create template: invalid created_by: %w", err)
}
kind := meta.Kind
if kind == "" {
kind = "submission"
}
format := meta.SourceFormat
if format == "" {
format = "docx"
}
var versionID uuid.UUID
err = s.inTx(ctx, func(tx *sqlx.Tx) error {
var templateID uuid.UUID
if err := tx.GetContext(ctx, &templateID,
`INSERT INTO paliad.templates
(slug, name_de, name_en, kind, source_format, firm, created_by)
VALUES (NULLIF($1, ''), $2, $3, $4, $5, NULLIF($6, ''), $7)
RETURNING id`,
meta.Slug, meta.NameDE, meta.NameEN, kind, format, meta.Firm, createdBy); err != nil {
return fmt.Errorf("insert template: %w", err)
}
var verr error
versionID, verr = insertTemplateVersion(ctx, tx, templateID, 1, first)
if verr != nil {
return verr
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.templates SET current_version_id = $1, updated_at = now() WHERE id = $2`,
versionID, templateID); err != nil {
return fmt.Errorf("pin current version: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return s.GetVersion(ctx, versionID.String())
}
// AddVersion inserts the next version for an existing template and
// re-points current_version to it.
func (s *PgTemplateStore) AddVersion(ctx context.Context, templateID string, v docforge.TemplateVersionInput) (*docforge.Template, error) {
tid, err := uuid.Parse(templateID)
if err != nil {
return nil, docforge.ErrTemplateNotFound
}
var versionID uuid.UUID
err = s.inTx(ctx, func(tx *sqlx.Tx) error {
var exists bool
if err := tx.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM paliad.templates WHERE id = $1)`, tid); err != nil {
return fmt.Errorf("check template exists: %w", err)
}
if !exists {
return docforge.ErrTemplateNotFound
}
var nextVersion int
if err := tx.GetContext(ctx, &nextVersion,
`SELECT COALESCE(MAX(version), 0) + 1 FROM paliad.template_versions WHERE template_id = $1`,
tid); err != nil {
return fmt.Errorf("next version: %w", err)
}
var verr error
versionID, verr = insertTemplateVersion(ctx, tx, tid, nextVersion, v)
if verr != nil {
return verr
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.templates SET current_version_id = $1, updated_at = now() WHERE id = $2`,
versionID, tid); err != nil {
return fmt.Errorf("pin current version: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return s.GetVersion(ctx, versionID.String())
}
// insertTemplateVersion inserts a version row + its slots inside tx and
// returns the new version id.
func insertTemplateVersion(ctx context.Context, tx *sqlx.Tx, templateID uuid.UUID, version int, v docforge.TemplateVersionInput) (uuid.UUID, error) {
createdBy, err := uuid.Parse(v.CreatedBy)
if err != nil {
return uuid.Nil, fmt.Errorf("insert template version: invalid created_by: %w", err)
}
stylemap := v.Stylemap
if stylemap == nil {
stylemap = map[string]string{}
}
smJSON, err := json.Marshal(stylemap)
if err != nil {
return uuid.Nil, fmt.Errorf("marshal stylemap: %w", err)
}
var versionID uuid.UUID
if err := tx.GetContext(ctx, &versionID,
`INSERT INTO paliad.template_versions
(template_id, version, carrier_blob, stylemap, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
templateID, version, v.CarrierBytes, smJSON, createdBy); err != nil {
return uuid.Nil, fmt.Errorf("insert template version: %w", err)
}
for i, slot := range v.Slots {
order := slot.OrderIndex
if order == 0 {
order = i
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.template_slots
(template_version_id, slot_key, anchor, label, order_index)
VALUES ($1, $2, $3, NULLIF($4, ''), $5)`,
versionID, slot.Key, slot.Anchor, slot.Label, order); err != nil {
return uuid.Nil, fmt.Errorf("insert template slot %q: %w", slot.Key, err)
}
}
return versionID, nil
}
// inTx runs fn inside a transaction, committing on success and rolling
// back on error or panic.
func (s *PgTemplateStore) inTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
// decodeStylemap unmarshals the stylemap jsonb; an empty/invalid value
// yields an empty map so callers never deref nil.
func decodeStylemap(raw []byte) map[string]string {
out := map[string]string{}
if len(raw) == 0 {
return out
}
_ = json.Unmarshal(raw, &out)
return out
}

View File

@@ -0,0 +1,146 @@
package services
// Live-DB integration tests for PgTemplateStore (t-paliad-349 slice 4).
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB
// tests. Exercises the full round-trip: Create (version 1) → Get →
// GetVersion → AddVersion (version 2, current re-pointed) → List, asserting
// the carrier bytes, stylemap, and slots persist and resolve intact.
import (
"bytes"
"context"
"errors"
"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/pkg/docforge"
)
func TestPgTemplateStore_RoundTrip(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()
store := NewPgTemplateStore(pool)
author := uuid.NewString()
carrierV1 := []byte("PK\x03\x04 fake docx carrier v1")
tmpl, err := store.Create(ctx,
docforge.TemplateMetaInput{
NameDE: "Test-Vorlage",
NameEN: "Test template",
Firm: "HLC",
CreatedBy: author,
},
docforge.TemplateVersionInput{
CarrierBytes: carrierV1,
Stylemap: map[string]string{"paragraph": "Normal", "heading_1": "Heading 1"},
Slots: []docforge.TemplateSlot{
{Key: "project.case_number", Anchor: "{{project.case_number}}", Label: "Aktenzeichen", OrderIndex: 0},
{Key: "parties.claimant.0.name", Anchor: "{{parties.claimant.0.name}}", OrderIndex: 1},
},
CreatedBy: author,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
// Clean up the row (cascades to versions + slots) regardless of outcome.
defer func() {
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.templates WHERE id = $1`, tmpl.ID)
}()
// --- Create assertions: version 1, defaults applied, content intact.
if tmpl.Version != 1 {
t.Errorf("Create version = %d; want 1", tmpl.Version)
}
if tmpl.Kind != "submission" || tmpl.SourceFormat != "docx" {
t.Errorf("defaults: kind=%q format=%q; want submission/docx", tmpl.Kind, tmpl.SourceFormat)
}
if !bytes.Equal(tmpl.CarrierBytes, carrierV1) {
t.Errorf("carrier round-trip mismatch: got %q", tmpl.CarrierBytes)
}
if tmpl.Stylemap["heading_1"] != "Heading 1" {
t.Errorf("stylemap[heading_1] = %q; want 'Heading 1'", tmpl.Stylemap["heading_1"])
}
if len(tmpl.Slots) != 2 {
t.Fatalf("len(slots) = %d; want 2", len(tmpl.Slots))
}
if tmpl.Slots[0].Key != "project.case_number" || tmpl.Slots[0].Label != "Aktenzeichen" {
t.Errorf("slot[0] = %+v; want project.case_number/Aktenzeichen", tmpl.Slots[0])
}
// --- Get by template id resolves the current version.
got, err := store.Get(ctx, tmpl.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Version != 1 || !bytes.Equal(got.CarrierBytes, carrierV1) || len(got.Slots) != 2 {
t.Errorf("Get current version mismatch: v=%d slots=%d", got.Version, len(got.Slots))
}
// --- AddVersion bumps to 2 and re-points current.
carrierV2 := []byte("PK\x03\x04 fake docx carrier v2 edited")
v2, err := store.AddVersion(ctx, tmpl.ID, docforge.TemplateVersionInput{
CarrierBytes: carrierV2,
Stylemap: map[string]string{"paragraph": "HLpat-Body-B0"},
Slots: []docforge.TemplateSlot{{Key: "today", Anchor: "{{today}}", OrderIndex: 0}},
CreatedBy: author,
})
if err != nil {
t.Fatalf("AddVersion: %v", err)
}
if v2.Version != 2 {
t.Errorf("AddVersion version = %d; want 2", v2.Version)
}
if !bytes.Equal(v2.CarrierBytes, carrierV2) || len(v2.Slots) != 1 || v2.Slots[0].Key != "today" {
t.Errorf("AddVersion content mismatch: carrier/slots wrong")
}
// Get now resolves version 2 (current re-pointed).
cur, err := store.Get(ctx, tmpl.ID)
if err != nil {
t.Fatalf("Get after AddVersion: %v", err)
}
if cur.Version != 2 || !bytes.Equal(cur.CarrierBytes, carrierV2) {
t.Errorf("Get after AddVersion = v%d; want v2 with new carrier", cur.Version)
}
// --- List reflects the current version number, filtered by firm.
metas, err := store.List(ctx, docforge.TemplateFilter{Firm: "HLC", ActiveOnly: true})
if err != nil {
t.Fatalf("List: %v", err)
}
var found *docforge.TemplateMeta
for i := range metas {
if metas[i].ID == tmpl.ID {
found = &metas[i]
break
}
}
if found == nil {
t.Fatalf("List did not return the created template")
}
if found.Version != 2 {
t.Errorf("List version = %d; want 2 (current)", found.Version)
}
// --- Unknown id → ErrTemplateNotFound.
if _, err := store.Get(ctx, uuid.NewString()); !errors.Is(err, docforge.ErrTemplateNotFound) {
t.Errorf("Get(unknown) err = %v; want ErrTemplateNotFound", err)
}
}

24
pkg/docforge/doc.go Normal file
View File

@@ -0,0 +1,24 @@
// Package docforge is paliad's modular document-generator engine — the
// format-neutral core that turns templates + variables into rendered
// documents, with format-specific adapters living in sub-packages.
//
// The package is being extracted from the in-tree submission generator
// (internal/services/submission_*.go) per the PRD in
// docs/plans/prd-docforge-2026-05-29.md (t-paliad-349 / m/paliad#157).
// The extraction follows the same packaging discipline as
// pkg/litigationplanner: docforge owns its types and exposes interfaces
// for the stateful inputs (variable resolution, template storage); the
// consuming application (paliad) implements those interfaces against its
// own database, and a future second consumer reaches the engine over an
// HTTP veneer rather than importing it.
//
// Slice 1 (this commit) relocates the .docx adapter — the Markdown→OOXML
// walker, the placeholder substitution engine, and the .dotm→.docx
// converter — into pkg/docforge/docx with no behaviour change. paliad's
// internal/services package keeps thin type-alias + forwarder shims so
// the submission generator and its HTTP surface compile and behave
// identically. Later slices introduce the neutral document model,
// hoist the format-neutral placeholder grammar up to this root package,
// and add the VariableResolver interface, the TemplateStore, the
// authoring surface, and the pluggable Exporter.
package docforge

View File

@@ -0,0 +1,172 @@
package docx
// Authoring support — the .docx side of the docforge authoring surface
// (t-paliad-349 slice 6). Two operations back the "upload a base .docx →
// place variable slots" flow:
//
// ImportForAuthoring — parse an uploaded .docx into a run-addressable
// preview (one <span data-run="N"> per <w:t>, in document order) plus
// the slots already present in the carrier.
// InjectSlot — replace a selected piece of text inside run N with a
// {{slot_key}} placeholder, returning the new carrier bytes. The
// placeholder is the sentinel that locates the slot (PRD §5 lean) and
// the same token the generation-time renderer substitutes.
//
// Both walk runs in the same order (paragraphs, then <w:t> within), so the
// data-run indices the preview emits address exactly the runs InjectSlot
// targets. Injection keys on the selected text
// (not a byte/UTF-16 offset) so umlauts in German prose can't desync the
// client's selection from the server's slice.
//
// v1 scope (PRD §2.1): text-level slots inside body paragraphs. A run is a
// <w:t> within a <w:p>; selections spanning runs or sitting in
// headers/footers/tables are out of scope and surface as an error the UI
// turns into "select within a single text span".
import (
"bytes"
"fmt"
"strconv"
"strings"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// AuthoringView is the parsed, run-addressable form of an uploaded
// template, ready for the authoring editor.
type AuthoringView struct {
// PreviewHTML is the body rendered as paragraphs of run spans:
// <p>…<span class="docforge-run" data-run="N">text</span>…</p>.
// The client attaches selection handling to the run spans; data-run
// is the index InjectSlot expects.
PreviewHTML string
// Slots are the {{placeholder}} tokens already present in the
// carrier (so re-opening a saved template shows its slots).
Slots []docforge.TemplateSlot
}
// ImportForAuthoring parses carrierBytes (any .docx/.dotm/...) into an
// AuthoringView. Runs the .dotm→.docx pre-pass so macro templates import
// cleanly.
func ImportForAuthoring(carrierBytes []byte) (*AuthoringView, error) {
clean, err := ConvertDotmToDocx(carrierBytes)
if err != nil {
return nil, fmt.Errorf("authoring import: convert: %w", err)
}
documentXML, _, err := splitBaseZip(clean)
if err != nil {
return nil, fmt.Errorf("authoring import: %w", err)
}
return &AuthoringView{
PreviewHTML: authoringPreviewHTML(documentXML),
Slots: detectSlots(documentXML),
}, nil
}
// authoringPreviewHTML renders the body as run-addressable HTML. One <p>
// per <w:p>; one <span class="docforge-run" data-run="N"> per <w:t>, with
// the decoded run text HTML-escaped. N is the global run index in
// document-then-paragraph order — the same order InjectSlot walks.
func authoringPreviewHTML(documentXML []byte) string {
var out bytes.Buffer
runIdx := 0
paras := wParagraphRegex.FindAll(documentXML, -1)
for _, para := range paras {
out.WriteString("<p>")
for _, m := range wTextNodeRegex.FindAllSubmatch(para, -1) {
text := xmlDecode(string(m[2]))
out.WriteString(`<span class="docforge-run" data-run="`)
out.WriteString(strconv.Itoa(runIdx))
out.WriteString(`">`)
out.WriteString(htmlEscape(text))
out.WriteString(`</span>`)
runIdx++
}
out.WriteString("</p>\n")
}
if out.Len() == 0 {
return "<p></p>"
}
return out.String()
}
// detectSlots returns the distinct {{placeholder}} tokens present in the
// document body, in first-appearance order.
func detectSlots(documentXML []byte) []docforge.TemplateSlot {
seen := map[string]bool{}
var slots []docforge.TemplateSlot
// Match against decoded text so a placeholder split by an entity is
// still found the same way the renderer would substitute it.
for _, m := range wTextNodeRegex.FindAllSubmatch(documentXML, -1) {
text := xmlDecode(string(m[2]))
for _, pm := range placeholderRegex.FindAllStringSubmatch(text, -1) {
key := pm[1]
if seen[key] {
continue
}
seen[key] = true
slots = append(slots, docforge.TemplateSlot{
Key: key,
Anchor: "{{" + key + "}}",
OrderIndex: len(slots),
})
}
}
return slots
}
// InjectSlot replaces the first occurrence of selectedText inside run
// runIndex with a {{slotKey}} placeholder and returns the new carrier
// bytes. Errors when the run is out of range or selectedText isn't found
// in that run (a render/selection desync, or a cross-run selection).
func InjectSlot(carrierBytes []byte, runIndex int, selectedText, slotKey string) ([]byte, error) {
if selectedText == "" {
return nil, fmt.Errorf("authoring inject: empty selection")
}
if !placeholderRegex.MatchString("{{" + slotKey + "}}") {
return nil, fmt.Errorf("authoring inject: invalid slot key %q", slotKey)
}
clean, err := ConvertDotmToDocx(carrierBytes)
if err != nil {
return nil, fmt.Errorf("authoring inject: convert: %w", err)
}
documentXML, parts, err := splitBaseZip(clean)
if err != nil {
return nil, fmt.Errorf("authoring inject: %w", err)
}
runIdx := 0
injected := false
newDoc := wParagraphRegex.ReplaceAllFunc(documentXML, func(para []byte) []byte {
return wTextNodeRegex.ReplaceAllFunc(para, func(tnode []byte) []byte {
idx := runIdx
runIdx++
if injected || idx != runIndex {
return tnode
}
sub := wTextNodeRegex.FindSubmatch(tnode)
attrs := string(sub[1])
content := xmlDecode(string(sub[2]))
before, after, found := strings.Cut(content, selectedText)
if !found {
return tnode // not found here — reported after the walk
}
newContent := before + "{{" + slotKey + "}}" + after
if !strings.Contains(attrs, "xml:space") &&
(strings.HasPrefix(newContent, " ") || strings.HasSuffix(newContent, " ")) {
attrs += ` xml:space="preserve"`
}
injected = true
return []byte(`<w:t` + attrs + `>` + xmlEncode(newContent) + `</w:t>`)
})
})
if !injected {
return nil, fmt.Errorf("authoring inject: selection %q not found in run %d", selectedText, runIndex)
}
repacked, err := repackBaseZip(parts, newDoc)
if err != nil {
return nil, fmt.Errorf("authoring inject: %w", err)
}
return repacked, nil
}

View File

@@ -0,0 +1,111 @@
package docx
import (
"strings"
"testing"
)
// docBody wraps a <w:body> inner string into a full document.xml.
func docBody(inner string) string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
`<w:body>` + inner + `</w:body></w:document>`
}
func TestImportForAuthoring_PreviewIsRunAddressable(t *testing.T) {
body := docBody(
`<w:p><w:r><w:t>Az. 4c O 12/23</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Klägerin</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
if err != nil {
t.Fatalf("ImportForAuthoring: %v", err)
}
// Three <w:t> → three run spans, indexed 0,1,2 in document order.
for i, want := range []string{`data-run="0"`, `data-run="1"`, `data-run="2"`} {
if !strings.Contains(view.PreviewHTML, want) {
t.Errorf("preview missing %s (run %d); html=%s", want, i, view.PreviewHTML)
}
}
if !strings.Contains(view.PreviewHTML, "Az. 4c O 12/23") {
t.Errorf("preview missing run text; html=%s", view.PreviewHTML)
}
// Two paragraphs.
if n := strings.Count(view.PreviewHTML, "<p>"); n != 2 {
t.Errorf("paragraph count = %d; want 2", n)
}
if len(view.Slots) != 0 {
t.Errorf("fresh doc should have no slots; got %v", view.Slots)
}
}
func TestImportForAuthoring_DetectsExistingSlots(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Az. {{project.case_number}} vor {{project.court}}</w:t></w:r></w:p>`)
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
if err != nil {
t.Fatalf("ImportForAuthoring: %v", err)
}
if len(view.Slots) != 2 {
t.Fatalf("slots = %d; want 2 (%v)", len(view.Slots), view.Slots)
}
if view.Slots[0].Key != "project.case_number" || view.Slots[0].Anchor != "{{project.case_number}}" {
t.Errorf("slot[0] = %+v; want project.case_number", view.Slots[0])
}
if view.Slots[1].Key != "project.court" {
t.Errorf("slot[1].Key = %q; want project.court", view.Slots[1].Key)
}
}
func TestInjectSlot_ReplacesSelectionWithPlaceholder(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Az. 4c O 12/23 vor dem LG</w:t></w:r></w:p>`)
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "4c O 12/23", "project.case_number")
if err != nil {
t.Fatalf("InjectSlot: %v", err)
}
doc := readMergeDocumentXML(t, out)
if !strings.Contains(doc, "Az. {{project.case_number}} vor dem LG") {
t.Errorf("injected doc wrong; got %s", doc)
}
// Round-trips: re-importing finds the new slot.
view, err := ImportForAuthoring(out)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if len(view.Slots) != 1 || view.Slots[0].Key != "project.case_number" {
t.Errorf("re-imported slots = %v; want [project.case_number]", view.Slots)
}
}
func TestInjectSlot_TargetsTheNamedRun(t *testing.T) {
// "GmbH" appears in run 1 only; "Müller" (with umlaut) in run 0.
body := docBody(
`<w:p><w:r><w:t>Müller</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Müller", "parties.claimant.name")
if err != nil {
t.Fatalf("InjectSlot: %v", err)
}
doc := readMergeDocumentXML(t, out)
if !strings.Contains(doc, "{{parties.claimant.name}}") {
t.Errorf("umlaut selection not replaced; got %s", doc)
}
if !strings.Contains(doc, " GmbH") {
t.Errorf("run 1 should be untouched; got %s", doc)
}
}
func TestInjectSlot_ErrorsWhenSelectionNotInRun(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Goodbye", "firm.name"); err == nil {
t.Error("expected error when selection absent from run; got nil")
}
// Out-of-range run index.
if _, err := InjectSlot(minimalMergeDOCX(t, body), 9, "Hello", "firm.name"); err == nil {
t.Error("expected error for out-of-range run index; got nil")
}
}
func TestInjectSlot_RejectsInvalidSlotKey(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Hello", "9bad-key!"); err == nil {
t.Error("expected error for invalid slot key; got nil")
}
}

View File

@@ -0,0 +1,636 @@
package docx
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
// §9.2). Assembles a base .docx and a draft's section rows into a
// merged .docx ready for export.
//
// Pipeline (high-level):
//
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
// 3. For each section in the draft (order_index ASC, included=true):
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
// base.section_spec.stylemap.paragraph.
// 4. Splice the rendered OOXML into the base body. Two splice modes:
// - Anchor mode: when the body carries `{{#section:KEY}}` /
// `{{/section:KEY}}` marker pairs, replace the slot's content
// (including the anchor paragraphs themselves) with the rendered
// section.
// - Append mode: when no anchor pair is found for a section, the
// rendered OOXML appends at the end of the body, just before any
// `<w:sectPr>` element. Sections with `included=false` are
// dropped silently.
// 5. Strip any leftover unmatched anchor paragraphs.
// 6. Re-pack the document.xml into the zip, leaving every other part
// untouched.
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
// so `{{path}}` placeholders inside section content (and inside
// the base's untouched chrome) get substituted by the merged bag.
// Cross-run merge in pass 2 handles autocorrect-fragmented
// placeholders the same as v1.
//
// Result: a fully-merged .docx. No new third-party Go dep — reuses
// archive/zip + the existing SubmissionRenderer.
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"regexp"
"sort"
"strings"
"time"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// Composer assembles base + sections into a final .docx.
// Stateless; safe for concurrent use.
type Composer struct {
renderer *SubmissionRenderer
}
// NewComposer wires the composer. The renderer is required —
// a nil renderer is a programmer error and the composer panics at
// construction.
func NewComposer(renderer *SubmissionRenderer) *Composer {
if renderer == nil {
panic("submission composer: renderer required")
}
return &Composer{renderer: renderer}
}
// Carrier is the opaque base document the composer splices rendered
// content into. Its bytes are preserved verbatim outside the regions the
// splice touches — the {{#section:KEY}} anchor paragraphs and the
// {{placeholder}} tokens — so the firm's letterhead, styles, headers, and
// footers survive a compose byte-for-byte. This is the docforge "carrier"
// for the .docx format: the lossless host for editable content.
type Carrier struct {
// Bytes is the raw base .docx. May be a .dotm/.docm/.dotx; Compose
// runs ConvertDotmToDocx on it first (idempotent on a plain .docx).
Bytes []byte
// Stylemap maps a logical block kind (paragraph, heading_1/2/3,
// list_bullet, list_numbered, blockquote) to the Word paragraph
// style name the base defines for it. Drives the Markdown walker's
// <w:pStyle>. Missing entries fall back to the "paragraph" style.
Stylemap map[string]string
}
// Section is one editable content block the composer renders and splices.
// It is the format-neutral input the docforge engine consumes; the
// consuming application maps its own row type onto it (paliad maps
// SubmissionSection → Section).
type Section struct {
// Key matches a {{#section:KEY}} anchor in the carrier, or — when no
// anchor matches — marks an append-mode section.
Key string
// OrderIndex sets append-mode ordering (ascending).
OrderIndex int
// Included=false drops the section entirely.
Included bool
// ContentMDDE / ContentMDEN are the bilingual Markdown sources; Lang
// selects which one renders.
ContentMDDE string
ContentMDEN string
}
// ComposeOptions carries the per-call composition inputs.
type ComposeOptions struct {
// Sections are the draft's section rows in display order. The
// composer renders included sections; excluded rows are dropped.
// Caller is responsible for visibility — by the time the composer
// runs, the section rows have already been gated by the caller.
Sections []Section
// Carrier is the base .docx chrome plus its stylemap. Required.
Carrier Carrier
// Lang ('de' or 'en') selects which content_md_* column the
// composer reads per section. Defaults to 'de' if empty.
Lang string
// Vars is the merged placeholder bag the v1 renderer pass
// substitutes after the composer assembly. Passed straight through
// to SubmissionRenderer.Render.
Vars docforge.PlaceholderMap
// Missing translates an unbound placeholder key into the marker
// the lawyer sees in Word. Passed straight to the renderer.
Missing docforge.MissingPlaceholderFn
}
// Compose runs the full pipeline and returns the merged .docx bytes.
func (c *Composer) Compose(ctx context.Context, opts ComposeOptions) ([]byte, error) {
_ = ctx // reserved for cancellation propagation in later slices
sections := opts.Sections
// Pre-pass: strip macros so the base reads as a plain .docx zip.
cleanBytes, err := ConvertDotmToDocx(opts.Carrier.Bytes)
if err != nil {
return nil, fmt.Errorf("submission compose: convert base: %w", err)
}
// Locate + extract word/document.xml so we can splice in-place.
documentXML, otherParts, err := splitBaseZip(cleanBytes)
if err != nil {
return nil, err
}
// Per-compose hyperlink allocator. Each unique URL gets a fresh
// rId outside the base's existing namespace. The post-pass
// (patchDocumentXMLRels) writes the matching Relationship rows
// before the zip is repacked. Slice D adds inline `[label](url)`
// hyperlink support.
linkAlloc := newComposerLinkAllocator()
// Build the rendered-section map: section_key → OOXML span.
stylemap := opts.Carrier.Stylemap
rendered := make(map[string]string, len(sections))
keptSections := make([]Section, 0, len(sections))
for _, sec := range sections {
if !sec.Included {
continue
}
md := sec.ContentMDDE
if strings.EqualFold(opts.Lang, "en") {
md = sec.ContentMDEN
}
rendered[sec.Key] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
keptSections = append(keptSections, sec)
}
// Stable order — already sorted ascending by ListForDraft, but
// belt-and-braces in case the caller swaps the ordering policy
// later.
sort.SliceStable(keptSections, func(i, j int) bool {
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
})
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
// for inline `[label](url)` links, the base's
// word/_rels/document.xml.rels needs matching <Relationship>
// entries so Word can resolve the rIds. Mutates one zip part in
// otherParts (or appends if missing).
if linkAlloc.HasLinks() {
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
if err != nil {
return nil, err
}
otherParts = updatedParts
}
// Re-pack into a zip with the assembled document.xml. All other
// parts (styles, fonts, headers, footers, theme, settings) pass
// through bit-for-bit at their original mtime + compression.
repacked, err := repackBaseZip(otherParts, assembledBody)
if err != nil {
return nil, err
}
// Final pass: substitute placeholders against the merged bag. The
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
// alias contract, and the missing-marker emission. Reusing it
// guarantees v1's placeholder grammar stays intact inside section
// content + base chrome.
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
if err != nil {
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
}
return merged, nil
}
// ─────────────────────────────────────────────────────────────────────
// Section splicing
// ─────────────────────────────────────────────────────────────────────
// Anchor markers as they appear inside a <w:t> text node. We don't
// need a full XML parse — finding the marker text inside the body is
// sufficient because:
// - {{ and }} are never legitimate document content (placeholders
// follow the same convention everywhere else in paliad).
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
// special characters.
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
// exactly one <w:r>...</w:r>, which lives in exactly one
// <w:p>...</w:p>. We expand from the marker outward to find the
// enclosing <w:p> span and drop the entire paragraph as part of
// the splice.
//
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
// implemented as manual byte-index search around the marker hit
// (anchorParagraphSpan below) rather than a single regex pattern.
const (
anchorOpenPrefix = "{{#section:"
anchorClosePrefix = "{{/section:"
anchorSuffix = "}}"
)
// anchorKeyRegex validates that the captured anchor key is a clean
// identifier. Keys that include other characters (which can't actually
// appear in our authored .docx) are treated as no match.
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
// anchorPair records the byte span of one matched anchor pair inside
// the body — from the start of the opening anchor's <w:p> element
// through the end of the closing anchor's </w:p>.
type anchorPair struct {
key string
openStart int // start of <w:p> for the opening anchor
closeEnd int // index just past </w:p> for the closing anchor
}
// findAllAnchorPairs scans the body for matched open/close anchor
// pairs. Unbalanced markers (open without close, or vice versa) are
// dropped from the result. Returns pairs in body-order; each pair's
// span is non-overlapping.
func findAllAnchorPairs(body string) []anchorPair {
type marker struct {
key string
paraStart int
paraEnd int
isOpen bool
}
var markers []marker
collect := func(prefix string, isOpen bool) {
offset := 0
for {
idx := strings.Index(body[offset:], prefix)
if idx < 0 {
return
}
start := offset + idx
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
if suffixIdx < 0 {
return
}
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
if !anchorKeyRegex.MatchString(key) {
offset = start + len(prefix)
continue
}
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
if !ok {
offset = markerEnd
continue
}
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
offset = pEnd
}
}
collect(anchorOpenPrefix, true)
collect(anchorClosePrefix, false)
// Walk markers in body-order, matching each open with the next
// close that carries the same key.
sort.SliceStable(markers, func(i, j int) bool {
return markers[i].paraStart < markers[j].paraStart
})
var pairs []anchorPair
openStack := map[string]marker{}
for _, m := range markers {
if m.isOpen {
openStack[m.key] = m
continue
}
o, ok := openStack[m.key]
if !ok {
continue
}
pairs = append(pairs, anchorPair{
key: m.key,
openStart: o.paraStart,
closeEnd: m.paraEnd,
})
delete(openStack, m.key)
}
return pairs
}
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
// element that fully contains the byte range [markerStart, markerEnd).
// Returns false when the byte range doesn't sit inside a single
// paragraph (which would mean the marker survived a cross-paragraph
// edit — defensive guard, shouldn't happen in well-formed input).
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
// Walk backwards to find the nearest unclosed <w:p ... > opening.
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
// the enclosing paragraph's opening tag.
pStart := -1
cursor := markerStart
for cursor > 0 {
idx := strings.LastIndex(body[:cursor], "<w:p")
if idx < 0 {
break
}
// Confirm this is a paragraph open, not a different
// w:p-prefixed tag (e.g. <w:pPr>).
if idx+4 <= len(body) {
after := body[idx+4]
if after == ' ' || after == '>' || after == '/' {
// <w:p ...> or <w:p>; not <w:pPr>.
close := strings.Index(body[idx:], ">")
if close < 0 {
return 0, 0, false
}
pStart = idx
break
}
}
cursor = idx
}
if pStart < 0 {
return 0, 0, false
}
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
// the next </w:p> after the marker is the close.
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
if pEndIdx < 0 {
return 0, 0, false
}
pEnd := markerEnd + pEndIdx + len("</w:p>")
return pStart, pEnd, true
}
// spliceSections replaces anchor slots with rendered sections and
// appends any unanchored sections before sectPr. Returns the assembled
// document.xml body.
func spliceSections(documentXML []byte, rendered map[string]string, kept []Section, all []Section) []byte {
body := string(documentXML)
pairs := findAllAnchorPairs(body)
// Build a lookup of kept section keys for quick membership tests.
keptByKey := map[string]int{}
for i, sec := range kept {
keptByKey[sec.Key] = i
}
allByKey := map[string]int{}
for i, sec := range all {
allByKey[sec.Key] = i
}
matchedKeys := map[string]bool{}
// Walk pairs in REVERSE body-order so slice mutations don't shift
// later offsets.
sort.SliceStable(pairs, func(i, j int) bool {
return pairs[i].openStart > pairs[j].openStart
})
for _, p := range pairs {
replacement := ""
if idx, ok := keptByKey[p.key]; ok {
replacement = rendered[p.key]
matchedKeys[p.key] = true
_ = idx
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
// Anchor matches an excluded section on the draft — drop
// the entire slot.
replacement = ""
} else {
// Anchor doesn't match any section on this draft — drop
// to leave the base's chrome unbroken.
replacement = ""
}
body = body[:p.openStart] + replacement + body[p.closeEnd:]
}
// Append unanchored sections before sectPr in order_index ASC.
var unanchored strings.Builder
for _, sec := range kept {
if matchedKeys[sec.Key] {
continue
}
unanchored.WriteString(rendered[sec.Key])
}
if unanchored.Len() > 0 {
body = appendBeforeSectPr(body, unanchored.String())
}
return []byte(body)
}
// appendBeforeSectPr inserts content immediately before the first
// `<w:sectPr` element in the body, or at the end of the body if there
// is none. Word documents conventionally close the body with a sectPr
// describing page setup; we want to land sections before that element
// so they show up on the actual pages.
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
func appendBeforeSectPr(body, content string) string {
loc := sectPrRegex.FindStringIndex(body)
if loc == nil {
// No sectPr → append before `</w:body>` if present, else at
// the very end.
idx := strings.LastIndex(body, "</w:body>")
if idx < 0 {
return body + content
}
return body[:idx] + content + body[idx:]
}
return body[:loc[0]] + content + body[loc[0]:]
}
// ─────────────────────────────────────────────────────────────────────
// Zip plumbing
// ─────────────────────────────────────────────────────────────────────
// baseZipPart captures one zip entry we kept aside while extracting
// document.xml.
type baseZipPart struct {
name string
method uint16
modTime int64 // wall seconds; converted back to time.Time on repack
body []byte
}
// splitBaseZip extracts document.xml and returns it alongside every
// other zip entry, ready for repacking.
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
if err != nil {
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
}
var documentXML []byte
parts := make([]baseZipPart, 0, len(zr.File))
for _, f := range zr.File {
body, err := readZipEntry(f)
if err != nil {
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
}
if f.Name == "word/document.xml" {
documentXML = body
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
continue
}
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
}
if documentXML == nil {
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
}
return documentXML, parts, nil
}
// repackBaseZip rebuilds the zip, swapping document.xml for the
// assembled body and leaving every other part untouched.
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
var out bytes.Buffer
zw := zip.NewWriter(&out)
for _, p := range parts {
hdr := &zip.FileHeader{
Name: p.name,
Method: p.method,
}
if p.modTime > 0 {
hdr.Modified = time.Unix(p.modTime, 0)
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
}
body := p.body
if p.name == "word/document.xml" {
body = assembledBody
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
}
return out.Bytes(), nil
}
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// ─────────────────────────────────────────────────────────────────────
// Slice D — hyperlink wiring
// ─────────────────────────────────────────────────────────────────────
// composerLinkAllocator hands out fresh rIds for inline hyperlink
// targets discovered by the MD walker. Each unique URL gets one rId
// (deduped — repeated links to the same URL share one Relationship).
// Allocations land outside the base's rId namespace by prefixing with
// "rIdComposer" so they can't collide with existing relationships.
type composerLinkAllocator struct {
next int
byURL map[string]string
order []string // URLs in allocation order
}
func newComposerLinkAllocator() *composerLinkAllocator {
return &composerLinkAllocator{byURL: map[string]string{}}
}
// Alloc returns the rId for url, allocating one on first sight.
func (a *composerLinkAllocator) Alloc(url string) string {
if rid, ok := a.byURL[url]; ok {
return rid
}
a.next++
rid := fmt.Sprintf("rIdComposer%d", a.next)
a.byURL[url] = rid
a.order = append(a.order, url)
return rid
}
// HasLinks reports whether any links were allocated during this compose.
func (a *composerLinkAllocator) HasLinks() bool {
return len(a.order) > 0
}
// Pairs returns the (rId, URL) pairs in allocation order. The
// document.xml.rels patcher consumes this to emit <Relationship>
// elements.
func (a *composerLinkAllocator) Pairs() [][2]string {
pairs := make([][2]string, 0, len(a.order))
for _, url := range a.order {
pairs = append(pairs, [2]string{a.byURL[url], url})
}
return pairs
}
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
// in `parts` to append the given (rId, URL) pairs as hyperlink
// relationships. If the rels part doesn't exist (some bases omit it
// when the body has no relationships), this function appends a fresh
// part with the minimal Relationships wrapper.
//
// Idempotent on (rId, URL) pairs already present (e.g. when a base
// already references the URL for some other reason).
//
// Returns the (possibly extended) parts slice — callers must overwrite
// their reference because the append in the no-rels-yet case grows the
// backing array.
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
const path = "word/_rels/document.xml.rels"
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
existingIdx := -1
for i := range parts {
if parts[i].name == path {
existingIdx = i
break
}
}
var body string
if existingIdx >= 0 {
body = string(parts[existingIdx].body)
} else {
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
}
var inserts strings.Builder
for _, p := range pairs {
rid := p[0]
url := p[1]
if strings.Contains(body, `Id="`+rid+`"`) {
continue
}
inserts.WriteString(`<Relationship Id="`)
inserts.WriteString(xmlAttrEscape(rid))
inserts.WriteString(`" Type="`)
inserts.WriteString(hyperlinkType)
inserts.WriteString(`" Target="`)
inserts.WriteString(xmlAttrEscape(url))
inserts.WriteString(`" TargetMode="External"/>`)
}
if inserts.Len() == 0 {
return parts, nil
}
closeIdx := strings.LastIndex(body, "</Relationships>")
if closeIdx < 0 {
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
}
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
if existingIdx >= 0 {
parts[existingIdx].body = []byte(patched)
return parts, nil
}
parts = append(parts, baseZipPart{
name: path,
method: zip.Deflate,
modTime: time.Now().Unix(),
body: []byte(patched),
})
return parts, nil
}

28
pkg/docforge/docx/doc.go Normal file
View File

@@ -0,0 +1,28 @@
// Package docx is docforge's .docx (OOXML) adapter — the first
// format adapter in the docforge engine (t-paliad-349 / m/paliad#157).
//
// It owns the in-house OOXML machinery extracted from paliad's submission
// generator in slice 1, with no behaviour change:
//
// - merge.go — the placeholder substitution renderer
// (SubmissionRenderer.Render / RenderHTML). Two-pass {{placeholder}}
// substitution (single-run, then cross-run merge for fragmented
// placeholders), plus the preview-HTML emitter that wraps substituted
// values in clickable <span class="draft-var" data-var="…"> markup.
// - markdown.go — the Markdown→OOXML walker (RenderMarkdownToOOXML*),
// including the b78a984 fix that preserves {{…}} placeholders verbatim
// through inline-span parsing (underscores in keys survive).
// - dotm.go — ConvertDotmToDocx: strips macros from a .dotm/.docm/
// .dotx and rewrites the content-types + rels to a clean .docx,
// passing every other part through bit-for-bit.
//
// Why no third-party docx library: lukasjarosch/go-docx treats sibling
// placeholders in one run ("{{a}} ./. {{b}}") as nested and refuses to
// replace either; patent submissions routinely have several placeholders
// per paragraph, so this in-house renderer is required. See merge.go.
//
// The placeholder grammar — \{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\} — and
// the PlaceholderMap type currently live here with the renderer; a later
// slice hoists the format-neutral grammar up to the docforge root once
// the neutral document model and the VariableResolver interface land.
package docx

View File

@@ -1,4 +1,4 @@
package services
package docx
// Submission .dotm → .docx converter (t-paliad-230, "format-only" scope
// reduction of the original t-paliad-215 submission generator).
@@ -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

@@ -1,4 +1,4 @@
package services
package docx
import (
"archive/zip"
@@ -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,39 @@
package docx
import "mgit.msbls.de/m/paliad/pkg/docforge"
// Exporter is the .docx implementation of docforge.Exporter — it renders a
// neutral Document to OOXML body markup (t-paliad-349 slice 8). The
// stylemap (block kind → Word paragraph style) and the optional hyperlink
// allocator are baked in at construction, so RenderBody matches the
// interface's format-neutral signature.
//
// This is the seam a future PDF/HTML exporter slots into: implement
// docforge.Exporter, no engine change. The submission composer can render
// section content through this exporter instead of calling
// RenderDocumentToOOXML directly once a second format exists.
type Exporter struct {
Stylemap map[string]string
Links HyperlinkAllocator
}
// compile-time conformance.
var _ docforge.Exporter = Exporter{}
// NewExporter builds a .docx exporter with the given stylemap + allocator.
func NewExporter(stylemap map[string]string, links HyperlinkAllocator) Exporter {
return Exporter{Stylemap: stylemap, Links: links}
}
// Format returns the format id.
func (Exporter) Format() string { return "docx" }
// MIMEType returns the .docx container MIME type.
func (Exporter) MIMEType() string {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
// RenderBody renders the Document to OOXML paragraph markup.
func (e Exporter) RenderBody(doc docforge.Document) ([]byte, error) {
return []byte(RenderDocumentToOOXML(doc, e.Stylemap, e.Links)), nil
}

View File

@@ -0,0 +1,34 @@
package docx
import (
"strings"
"testing"
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/markdown"
)
func TestExporter_RenderBodyMatchesWalker(t *testing.T) {
exp := NewExporter(map[string]string{"paragraph": "Body"}, nil)
if exp.Format() != "docx" {
t.Errorf("Format = %q; want docx", exp.Format())
}
if !strings.Contains(exp.MIMEType(), "wordprocessingml.document") {
t.Errorf("MIMEType = %q", exp.MIMEType())
}
md := "Hello **world**\n\n- item"
// The Exporter must produce exactly what the walker entry point does
// for the same input (both go markdown.Import → RenderDocumentToOOXML).
body, err := exp.RenderBody(markdown.Import(md))
if err != nil {
t.Fatalf("RenderBody: %v", err)
}
want := RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": "Body"}, nil)
if string(body) != want {
t.Errorf("RenderBody mismatch:\n got %q\nwant %q", body, want)
}
}
// satisfies the interface (compile-time check mirrored at runtime).
var _ docforge.Exporter = Exporter{}

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

@@ -0,0 +1,188 @@
package docx
// Markdown → OOXML rendering for Composer section content (t-paliad-313
// Slice B/D; restructured in t-paliad-349 slice 8).
//
// Parsing now lives in pkg/docforge/markdown, which produces the neutral
// docforge.Document. This file renders that Document into OOXML paragraph
// elements (<w:p>…</w:p>) ready to splice into a .docx body. There is one
// Markdown parser for docforge; this is the .docx exporter for its model.
//
// Output uses the base's stylemap entry for each block kind on the
// <w:pStyle>, so styling matches the base's typography (HLpat-Body-B0 on
// the HLC base, Normal on the neutral base, etc.). Placeholders ({{key}})
// ride through as literal run text and are substituted by the placeholder
// pass after assembly.
import (
"strconv"
"strings"
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/markdown"
)
// HyperlinkAllocator hands the renderer a `rId` for each external URL it
// encounters in `[label](url)` inline links. The composer's post-pass uses
// these allocations to mutate `word/_rels/document.xml.rels` so the emitted
// `<w:hyperlink r:id="…">` elements resolve. Pass nil to drop links to
// plain text (the label survives, the URL doesn't render). t-paliad-316.
type HyperlinkAllocator func(url string) string
// RenderMarkdownToOOXML renders Markdown into OOXML paragraphs with a
// single paragraph style. Slice B back-compat wrapper.
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
}
// RenderMarkdownToOOXMLWithStyles parses Markdown into a docforge.Document
// and renders it to OOXML. stylemap maps each block kind (paragraph,
// heading_1/2/3, list_bullet, list_numbered, blockquote) to a Word
// paragraph style; missing entries fall back to the "paragraph" style.
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
return RenderDocumentToOOXML(markdown.Import(md), stylemap, links)
}
// RenderDocumentToOOXML renders a neutral Document to OOXML paragraphs —
// the .docx side of the docforge importer→model→exporter pipeline. Any
// Document (Markdown today, a foreign-doc importer later) renders the same
// way.
func RenderDocumentToOOXML(doc docforge.Document, stylemap map[string]string, links HyperlinkAllocator) string {
defaultStyle := stylemap["paragraph"]
// Numbered-list counter resets on every non-numbered block so
// "1. A\n2. B\n\n1. C" renders 1./2./1. — the input determined the
// ordinal, the renderer just emits it.
numbered := 0
var b strings.Builder
for _, blk := range doc.Blocks {
style := stylemap[string(blk.Kind)]
if style == "" {
style = defaultStyle
}
if blk.Kind == docforge.KindListNumbered {
numbered++
} else {
numbered = 0
}
b.WriteString(renderBlock(blk, style, links, numbered))
}
return b.String()
}
// renderBlock emits one <w:p> for a block. List blocks get a visible
// "• " / "N. " prefix run (the base stylemap handles indentation if it
// defines a list style; the prefix at least surfaces the structure).
func renderBlock(blk docforge.Block, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(xmlAttrEscape(paragraphStyle))
b.WriteString(`"/></w:pPr>`)
}
// An empty block is an intentional empty paragraph: one empty run.
if len(blk.Spans) == 0 {
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
return b.String()
}
switch blk.Kind {
case docforge.KindListBullet:
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
case docforge.KindListNumbered:
ordinal := numberedOrdinal
if ordinal <= 0 {
ordinal = 1
}
b.WriteString(`<w:r><w:t xml:space="preserve">`)
b.WriteString(strconv.Itoa(ordinal))
b.WriteString(`. </w:t></w:r>`)
}
for _, span := range blk.Spans {
b.WriteString(renderInlineSpan(span, links))
}
b.WriteString(`</w:p>`)
return b.String()
}
// renderInlineSpan emits one span. A hyperlink span (Link != "") becomes a
// <w:hyperlink r:id="…"> wrapping its children when an allocator yields a
// rId; otherwise the label children render as plain runs (URL dropped).
func renderInlineSpan(span docforge.InlineSpan, links HyperlinkAllocator) string {
if span.Link != "" {
if links != nil {
if rid := links(span.Link); rid != "" {
var hb strings.Builder
hb.WriteString(`<w:hyperlink r:id="`)
hb.WriteString(xmlAttrEscape(rid))
hb.WriteString(`">`)
for _, child := range span.Children {
hb.WriteString(renderRunWithLinkStyle(child))
}
hb.WriteString(`</w:hyperlink>`)
return hb.String()
}
}
// No allocator / no rId — render the label as plain runs.
var fb strings.Builder
for _, child := range span.Children {
fb.WriteString(renderRun(child))
}
return fb.String()
}
return renderRun(span)
}
// renderRunWithLinkStyle emits a hyperlink child run with Word's built-in
// "Hyperlink" character style (colour + underline), plus B/I.
func renderRunWithLinkStyle(span docforge.InlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
if span.Bold {
b.WriteString(`<w:b/>`)
}
if span.Italic {
b.WriteString(`<w:i/>`)
}
b.WriteString(`</w:rPr><w:t xml:space="preserve">`)
b.WriteString(xmlTextEscape(span.Text))
b.WriteString(`</w:t></w:r>`)
return b.String()
}
// renderRun emits one <w:r> for a plain (text/bold/italic) span.
func renderRun(span docforge.InlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r>`)
if span.Bold || span.Italic {
b.WriteString(`<w:rPr>`)
if span.Bold {
b.WriteString(`<w:b/>`)
}
if span.Italic {
b.WriteString(`<w:i/>`)
}
b.WriteString(`</w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlTextEscape(span.Text))
b.WriteString(`</w:t></w:r>`)
return b.String()
}
// xmlTextEscape escapes the XML-significant characters for <w:t> content.
// Quotes/apostrophes are legal in element text — not escaped.
func xmlTextEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// xmlAttrEscape escapes for an attribute value (e.g. <w:pStyle w:val="…"/>).
func xmlAttrEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}

Some files were not shown because too many files have changed in this diff Show More