Compare commits

...

55 Commits

Author SHA1 Message Date
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
05247d7bd7 docs(exports): canonicalize deadline manuscript generator + filter optionals (t-paliad-348)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
m had a one-off /tmp/paliad-deadline-export.py (work/head delegation
#2572) that dumped every published sequencing_rules row. Output
showed 37 entries on upc.inf.cfi including optional rules
(Lodging of translations, Review of CMO, ...) which fights the
engine's IncludeOptional=false default and m's "naked proceeding
with options but not always displayed" mental model.

Move to exports/gen-deadline-list.py as the canonical re-runnable
script and add a SQL-level priority filter that matches the
engine. Default suppresses priority='optional'; --include-optional
opts back in for an exhaustive catalog dump.

- DSN overridable via PALIAD_DEADLINE_EXPORT_DSN env var.
- argparse-driven: --include-optional / -o OUT / --generated-for LABEL.
- Header explains the mode so the PA reader knows what's suppressed.
- Regenerated exports/upc-deadlines-2026-05-28.md: now 178 rules across
  25 proceedings (vs the unfiltered run). upc.inf.cfi section drops
  from ~37 to 28 mandatory + conditional rules - the optional ones
  are gone; trigger_event_id mandatory rules stay in the catalog
  (they're a real PA-knowable surface; runtime anchor state is what
  decides whether they project into a timeline, separate concern).

Run:
    uv run exports/gen-deadline-list.py [--include-optional]

(m/paliad#153)
2026-05-28 11:02:03 +02:00
mAi
a81581878e fix(builder): port engine semantics into Builder triplet calc surface (t-paliad-348)
The Litigation Builder triplet renders /api/tools/fristenrechner output
verbatim and never applied the pre-existing filterByDetailMode pass that
the legacy /tools/verfahrensablauf page uses. With the engine fix
(3c840c0 — pkg/litigationplanner default IncludeOptional=false + trigger
event semantic anchoring) already in main, optional rules are dropped
server-side but rules with an unsatisfied trigger_event_id surface as
IsConditional. Without filterByDetailMode those still rendered as
"abhängig von ..." cards on the triplet, polluting m's "naked
proceeding with options but not always displayed" mental model.

upc.inf.cfi went from 7 mandatory backbone events to 29 visible cards
(22 conditional noise — Lodging of translations, Mängelbeseitigung,
Antrag auf Verweisung, Wiedereinsetzung, ...). Live BEFORE/AFTER
captured in exports/screenshots/.

Fix layers:

- Go handler (internal/handlers/fristenrechner.go): accept
  includeOptional + triggerEventAnchors from request body and
  forward to services.CalcOptions. Default zero values match the
  engine defaults (suppress optionals + no fabricated dates for
  trigger_event_id rules), so the wire is unchanged when callers
  don't set them.

- TS calc surface (frontend/src/client/views/verfahrensablauf-core.ts):
  add the same two fields to CalcParams + forward in the fetch body;
  surface rulesAwaitingAnchor on DeadlineResponse mirroring
  Timeline.RulesAwaitingAnchor.

- Builder triplet (frontend/src/client/builder.ts hydrateTriplet):
  apply filterByDetailMode(detailgrad) before renderColumnsBody, with
  detailgrad sourced from the proceeding row. "selected" (default)
  drops conditional + optional rules; "all_options" passes
  includeOptional=true so the engine returns the optional rules the
  user can opt into.

- Legacy /tools/verfahrensablauf (frontend/src/client/verfahrensablauf.ts):
  pass includeOptional based on detailMode + a small hasOptionalOptIn
  helper so per-rule rule:<uuid>=true deviations still surface their
  optional rule even in "selected" mode (the engine has no rule:<uuid>
  awareness; without the opt-in the user's pick would silently no-op).

Tests:
- frontend/src/client/views/verfahrensablauf-core.test.ts: pin the
  fetch body shape - includeOptional=true and triggerEventAnchors={...}
  round-trip through the request; empty/default values are omitted so
  the wire stays minimal.

bun build + bun test (269 pass) + go vet + go test
./internal/handlers/... ./pkg/litigationplanner/... all clean.

(m/paliad#153)
2026-05-28 11:01:49 +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
mAi
fcdfba209d Merge: t-paliad-346 B3 — event-triggered mode + universal search (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 10:11:05 +02:00
mAi
3e93e94d10 feat(builder): B3 — event-triggered mode + universal search (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.2 + §3.1: the page-header search box drives a typed dropdown
returning grouped event / scenario / project hits, and the "Ereignis"
entry mode is enabled. Picking an event creates a scratch scenario
with one triplet anchored on that event's proceeding type, with the
event card auto-anchored (lime band + "━━━━ DU BIST HIER ━━━━" divider
above the next-coming events).

Backend: new GET /api/builder/search reuses
DeadlineSearchService.SearchEvents for the events corpus (UPC v1),
filters owned scenarios by ILIKE on name, and reuses ProjectService.List
for the Akten group (team-RLS via visibilityPredicate). Each group is
capped independently (default 8 events / 5 scenarios / 5 projects, max
30). Missing services degrade gracefully — empty group, not 503.

Frontend: builder-search.ts owns the dropdown (debounced 180ms,
arrow-key navigation, Enter to pick, abort on next query). builder.ts
gains mode state ("cold" | "event" | "akte"), wires the mode bar +
search input, and runs applyAnchorHighlight after triplet hydration —
the helper finds the .fr-col-item with the picked rule_id, adds the
.builder-anchor-card lime band, and inserts a full-width
.builder-anchor-divider after the anchor's row in the columns grid
via JS row-index math (the grid is row-major with 3 header cells
+ 3-cells-per-row body).

Filter pill reset: setMode() clears the search input and closes the
dropdown when switching entry modes. Forum/proc/party/kind chips are
not yet rendered separately (they live in the search dropdown today);
the reset hook attaches there too when those land in a follow-up.

Verification:
  - bun build (frontend bundles + i18n scan clean)
  - go vet ./... + go test ./... (all packages pass)
  - Playwright: mode switch focuses search, debounced fetch fires,
    typed result groups render with N · M · K pluralization, event
    pick creates scratch scenario + adds proceeding, anchor card
    + DU BIST HIER divider render in the columns grid (screenshots
    confirmed visually)
2026-05-28 10:10:33 +02:00
mAi
28ea103260 Merge: t-paliad-345 — surface proceeding_type id so Builder add-proceeding works (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 09:50:57 +02:00
mAi
1c77cb6e67 fix(builder): surface proceeding_type id so add-proceeding POST works (t-paliad-345)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (pull_request) Has been cancelled
Paliad CI gate / test-go (pull_request) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / deploy (pull_request) Has been cancelled
The Litigation Builder's "+ Verfahren hinzufügen" silently failed in
prod after t-paliad-343 B2 shipped — clicking a Verfahren chip in the
picker did nothing, no visible error.

Root cause: the wire shape FristenrechnerType (the response of
/api/tools/proceeding-types) carried code+name+nameEN+group but not
id. Builder.ts mountAddProceedingPicker's callback POSTed
`{proceeding_type_id: meta.id}` to
/api/builder/scenarios/{id}/proceedings — meta.id was undefined,
JSON.stringify dropped the key, the server returned 400 ("invalid
input: proceeding_type_id is required"), and fetchJSON swallowed the
error to console. The user saw "nothing happens".

Fix:
- Add `ID int json:"id"` to lp.FristenrechnerType.
- SELECT id in FristenrechnerService.ListProceedings + Scan into the
  new field.
- Defensive guard in builder.ts openAddProceedingPicker — refuse to
  POST without a positive integer id and log a clear error, so a
  future wire-shape regression cannot recreate the silent-fail.
- Regression test in pkg/litigationplanner/types_wire_test.go pins the
  contract (id present in JSON, round-trips as integer).

Side-benefit: fristenrechner-wizard.ts:599-628 documented this exact
gap as a known limitation ("S5/follow-up can extend the wire shape to
include id"). That workaround can now be retired in a follow-up.

Refs m/paliad#153 (Litigation Builder)
2026-05-28 09:48:32 +02:00
mAi
1f6e586c63 Merge: t-paliad-344 — fix stale deadlines.rule_id refs + builder null-guards (m/paliad#154)
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 00:48:17 +02:00
mAi
a4b865d6bd fix(builder): initialise scenario sub-arrays + client null-guard (t-paliad-344)
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
GetScenarioDeep returned nil slices for proceedings/events/shares when
a scenario had zero rows, which Go's encoding/json serialises as `null`
rather than `[]`. The builder's renderCanvas then unconditionally calls
`state.active.proceedings.filter(...)` on a null and dies with
`procedures.js:101 TypeError: Cannot read properties of null
(reading 'filter')` — every cold-open scenario crashed the page before
the empty canvas could render.

Backend (root cause): initialise Proceedings / Events / Shares to empty
slices in BuilderScenarioDeep before SelectContext, so the wire shape
is always arrays. Existing rows still load via SelectContext, which
truncates the placeholder and refills from the DB.

Frontend (defence in depth): on loadScenario(), normalise each of the
three arrays to `[]` if the server response is not an array. Catches a
future regression (or an older deployed build) without re-introducing
the same crash class.

bun build clean, go vet + go test ./... green.
2026-05-28 00:47:19 +02:00
mAi
a905911cf4 fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:

  - internal/services/deadline_service.go:268 — DeadlineService.
    ListVisibleForUser. Powers /api/events?type=deadline (dashboard
    deadline rail, /deadlines page, every status bucket). Threw
    `pq: column f.rule_id does not exist` on every request → 500
    for any authenticated user hitting the dashboard.

  - internal/services/projection_service.go:1250 — collectActualsForOverrides.
    Same column on `paliad.deadlines d`. Logged once per projection
    pass (`ERROR service: projection: deadlines: ...`); aliased the
    rename to `rule_id` so the receiving struct tag still scans.

Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).

Root cause: mig 140 commit (1129bab) renamed the JOIN to
`f.sequencing_rule_id` but left the SELECT clause on the older name.
The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so
the wire shape is unchanged — only the column reference flips.

bun build clean, go vet ./... clean, go test ./... green.
2026-05-28 00:47:08 +02:00
mAi
88c03e922f Merge: t-paliad-343 B2 — multi-triplet + spawn + per-event state (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 00:29:50 +02:00
mAi
6bcac2dd20 Merge: t-paliad-343 B1 — Litigation Builder shell + cold-open (m/paliad#153) 2026-05-28 00:29:50 +02:00
mAi
46dc4ec94b feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (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
Builds on B1 (commit 6c1d8cc). After this slice a user can compose a
multi-proceeding scenario kontextfrei: stack proceedings, flip
perspective per-triplet, toggle scenario flags, auto-spawn child
proceedings on flag transitions, and mark individual event cards as
planned / filed / skipped — all auto-saved to paliad.scenario_*.

PRD §7.1 B2 acceptance shipped:
  - Multi-triplet stack: top-level proceedings sorted by ordinal,
    child proceedings nested inline with a left lime border.
  - Per-triplet controls bar: perspective radio (none / claimant /
    defendant), Detailgrad pill (selected / all options), Entfernen
    action. Each control PATCHes the proceeding row and re-renders the
    affected triplet.
  - Per-triplet flag strip: every paliad.scenario_flag_catalog row
    rendered as a checkbox, bound to scenario_proceedings.scenario_flags.
    Active flags also surface as chips in the triplet header for quick
    legibility.
  - Spawn nesting: when `with_ccr` flips ON on upc.inf.cfi the builder
    auto-POSTs an upc.ccr.cfi child proceeding linked via
    parent_scenario_proceeding_id; flip OFF deletes the child (events
    cascade via the schema). The SPAWN_MAP table is data-driven so
    future spawn flags slot in.
  - 3-state event cards (planned / filed / skipped):
    overlayEventStates walks the rendered .fr-col-item nodes (the
    data-rule-id hook added to verfahrensablauf-core in this slice)
    and stamps each card with data-builder-state + per-state action
    buttons (File / Skip / Reset to planned). Filed cards prompt for
    a date; skipped cards prompt for an optional reason. POSTs or
    PATCHes paliad.scenario_events keyed by sequencing_rule_id.
  - Per-card optional horizon chip: stores horizon_optional on the
    scenario_event row, increment / decrement chip on every card.
    The full surface awaits a calc-engine "optionals available"
    counter (PRD §3.4 follow-up); the persistence layer + UX hook are
    in place so the wiring lands without another schema touch.
  - Page-header Stichtag drives default dates for every triplet (the
    triplet's per-stichtag override path is wired but the per-triplet
    Stichtag input is a B3+ affordance).

verfahrensablauf-core.renderColumnsBody now stamps data-rule-id (and
data-submission-code as a future hook) on every .fr-col-item root —
non-breaking enhancement; the legacy /tools/* pages don't read either
attribute. Verified by re-running the existing 57-test suite.

Backend: one new read-only endpoint
GET /api/builder/scenario-flag-catalog passes through
ScenarioFlagsService.ListCatalog so the builder doesn't need a
per-project round-trip to render flag toggles.

bun run build clean (3050 i18n keys), go vet ./... clean, go test ./...
clean, frontend bun test (verfahrensablauf-core suite) 57 / 57 pass.
2026-05-28 00:28:48 +02:00
mAi
6c1d8cc0cf feat(builder): B1 — Litigation Builder shell + cold-open mode (m/paliad#153)
Replaces cronus's U0-U4 catalog at /tools/procedures with a
persistence-backed builder shell on top of B0's API surface
(/api/builder/scenarios/*, t-paliad-340).

PRD §7.1 B1 acceptance shipped:
  - Page header: scenario picker, name action, Akte picker stub,
    Stichtag input, search input, save status indicator.
  - Entry-mode radio (cold-open active; event-triggered + akte
    rendered disabled for B3/B4 layout stability).
  - Empty canvas with "Neues Szenario starten" CTA and a 5-most-recent
    list rendered when the user has saved scenarios.
  - Side panel "Meine Szenarien" with the Aktiv bucket; clicking an
    item loads the scenario into the canvas.
  - Add-proceeding inline picker (Forum chip row → Verfahren chip row
    → Hinzufügen). UPC v1; other forums chipped but disabled.
  - First proceeding triplet renders end-to-end via
    verfahrensablauf-core.calculateDeadlines + renderColumnsBody (the
    existing 3-column proaktiv|court|reaktiv body, read-only in B1).
  - Auto-save with 500ms debounce on name + stichtag patches; save
    status flips idle → saving → saved/error in the page header.

New client modules under frontend/src/client/:
  - builder.ts       — orchestrator (URL state, fetch, auto-save loop,
                       canvas render, scenario-list re-paint).
  - builder-picker.ts — inline Forum/Verfahren popover for the
                       add-proceeding affordance.
  - builder-triplet.ts — single-triplet header + body wrapper.

procedures.tsx rewritten as the shell scaffolding (sidebar, page
header, mode radio, two-column body); procedures.ts now boots the
builder instead of toggling the 4-tab catalog.

Legacy U0-U4 modules (verfahrensablauf.ts, verfahrensablauf-state.ts,
VerfahrensablaufBody.tsx, procedures' tab toggle in client/procedures.ts,
fristenrechner-* mounts) are no longer reachable from /tools/procedures
but kept in the tree for the B6 cleanup sweep per PRD §7.4.

i18n.ts grew 60 keys × 2 langs under builder.*. global.css grew a
self-contained .builder-* block at the file tail.

bun run build, go vet ./..., and go test ./... all green.
2026-05-28 00:20:46 +02:00
mAi
0c857026a2 Merge: pkg/litigationplanner respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
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 00:05:37 +02:00
mAi
3c840c0366 fix(litigationplanner): respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
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
Two paired engine semantics fixes:

1. trigger_event_id is now the authoritative semantic anchor. When a
   rule carries trigger_event_id, the engine no longer falls back to
   the proceeding's trigger date — it resolves the anchor via
   CalcOptions.TriggerEventAnchors keyed by paliad.trigger_events.code.
   Missing anchor renders the rule as IsConditional (empty date) and
   propagates via courtSet so descendants also surface as conditional.
   Fixes the RoP.109.5 bug where the engine fabricated a date 2 weeks
   before the user's SoC instead of waiting for the oral_hearing date.

2. priority='optional' rules are suppressed from the default
   Calculate output. Callers (paliad /tools/procedures,
   youpc.org/deadlines) opt in via CalcOptions.IncludeOptional=true to
   restore the legacy "show optional applications" behaviour. The
   suppression cascades through skippedIDs so child rules drop too.

Wire shape additions:

  - CalcOptions.IncludeOptional bool
  - CalcOptions.TriggerEventAnchors map[string]string
  - Timeline.RulesAwaitingAnchor int (count of suppressed-by-missing-
    anchor rules, for caller telemetry / "N rules need an anchor" UX)

Existing before-court-set-anchor tests opt in to IncludeOptional=true
to preserve their non-optional-related test intent.

Refs: youpcorg/head delegations #2568 + #2570, m/paliad#153 (Litigation
Builder PRD path).
2026-05-28 00:04:30 +02:00
mAi
1b4b2e4758 Merge: submission-md placeholder underscores preserved
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 00:01:30 +02:00
mAi
b78a984a7c fix(submission-md): preserve {{...}} placeholders verbatim through inline scanner
The Markdown inline scanner (parseInlineSpans) treats _ and * as
italic delimiters. A placeholder like {{project.case_number}} fed
through the scanner had its underscores consumed as italic markers,
leaving {{project.casenumber}} in the composed OOXML. The v1
placeholder pass then looked up the wrong key, surfacing
[KEIN WERT: project.casenumber] in the preview. The form ↔ preview
highlighting also stopped working because data-var attributes
mismatched between the input (snake_case) and the rendered span
(stripped).

parseInlineSpans now detects {{ at the cursor and skips ahead to
the matching }}, copying the entire placeholder verbatim into the
current text run. Unmatched {{ falls through to the existing
character handling so legal prose with stray braces still renders.

Tests: regression test for underscored keys (single + multiple +
mixed-with-italics), direct guard on parseInlineSpans, and an
italic-around-placeholder structural test.
2026-05-28 00:01:30 +02:00
mAi
1844df3ae6 Merge: t-paliad-340 B0 — Scenario DB foundation (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-27 23:53:49 +02:00
mAi
0f3c30a647 feat(scenario-builder): B0 schema foundation + minimal API (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
t-paliad-340 — B0 of edison's 7-slice train (PRD §7.1). DB-only: schema
+ RLS land, dev-only test route exercises the surface, no user-facing
change. B1 wires the actual builder UI on top.

Migration 157 (additive on the legacy mig-145 scenarios table — 0 rows
in prod, safe to relax):
- paliad.scenarios gets owner_id / status / origin_project_id /
  promoted_project_id / stichtag / notes. spec drops NOT NULL and the
  scenarios_unique_per_scope constraint drops (the builder allows
  multiple scratch + Unbenanntes Szenario rows per user).
- New tables: scenario_proceedings, scenario_events, scenario_shares.
- paliad.projects.origin_scenario_id for the promote-to-project audit
  trail (the FK lands now; the wizard ships in B5).
- paliad.can_see_scenario(uuid) STABLE SECURITY DEFINER helper covering
  owner / share / global_admin / two legacy paths.
- Replacement RLS on scenarios + RLS on the three new tables; legacy
  service + handlers stay live and unchanged.

PRD §5.1 deviations called out in the migration header:
- proceeding_type_id is integer (live schema), not uuid (PRD draft).
- FK target is paliad.users, matching the rest of paliad's schema.

Go surface:
- ScenarioBuilderService — list/create/get-deep/patch scenarios,
  add/patch/delete proceedings, add/patch/delete events,
  add/delete shares. Writes wrap in transactions with set_config(
  paliad.audit_reason, ..., true) per event_choice_service.go pattern.
- /api/builder/scenarios/* — handlers register under a builder/
  prefix so the legacy /api/scenarios surface still works.
- /dev/scenario-builder — single-page HTML form gated to
  PaliadinOwnerEmail, exercises the B0 surface without Postman.
- Live-DB integration test (TEST_DATABASE_URL gated) covers
  create + list + deep-get + share + visibility negatives + patch.

Audit-first: every DDL block ran clean via BEGIN/ROLLBACK against
the live DB before commit; end-to-end sanity (insert chain + CHECK
constraints + CASCADE-on-delete) verified via the Supabase MCP.

bun build clean. go vet + go test -short ./... green.
2026-05-27 23:50:14 +02:00
mAi
2c2b93bc7c Merge: t-paliad-339 — PRD for Procedures Litigation Builder (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-27 23:04:07 +02:00
102 changed files with 16675 additions and 6251 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,
@@ -246,6 +250,13 @@ func main() {
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
// rendering and per-rule selection state (`rule:<uuid>` keys).
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. 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,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

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

280
exports/gen-deadline-list.py Executable file
View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Generate a markdown deadline-list export for UPC PA training (work/head delegation #2572).
Sorts by proceeding-type display_order then sequence_order. Sections by proceeding.
t-paliad-348 / yoUPC#178 update: matches the engine's `IncludeOptional=false`
default (`pkg/litigationplanner/engine.go`). Optional rules (priority='optional')
are SUPPRESSED by default so the manuscript shows the same "naked proceeding
backbone" the UI now renders. Pass `--include-optional` to opt back in for an
exhaustive catalog dump.
Usage:
uv run exports/gen-deadline-list.py [--include-optional] [-o OUT]
"""
# /// script
# requires-python = ">=3.10"
# dependencies = ["psycopg2-binary"]
# ///
import argparse
import os
import sys
from datetime import date
from pathlib import Path
import psycopg2
import psycopg2.extras
DSN = os.environ.get(
"PALIAD_DEADLINE_EXPORT_DSN",
"postgres://postgres:rpsak3yf4lu1izgefx9p9xweg3qroojw@100.99.98.201:11833/postgres?sslmode=disable",
)
# `priority` filter is wired at the SQL level (not post-filter in Python) so
# the row counter in the markdown header reflects what's actually in the
# manuscript — matching what the lawyer sees on /tools/procedures.
SQL_TEMPLATE = """
SELECT
pt.code AS pt_code,
pt.display_order,
COALESCE(pt.name_en, pt.name) AS pt_label_en,
pt.name AS pt_label_de,
COALESCE(pe.name_en, pe.name) AS event_en,
pe.name AS event_de,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.alt_duration_value,
sr.alt_duration_unit,
sr.combine_op,
sr.rule_code,
COALESCE(te.name, te.name_de) AS trigger_label,
te.code AS trigger_code,
sr.primary_party,
sr.is_court_set,
sr.is_spawn,
sr.priority,
sr.deadline_notes_en,
sr.deadline_notes,
sr.condition_expr,
sr.sequence_order
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
LEFT JOIN paliad.trigger_events te ON te.id = sr.trigger_event_id
WHERE sr.lifecycle_state = 'published'
AND sr.is_active = true
AND pt.id IS NOT NULL
{priority_filter}
ORDER BY pt.display_order NULLS LAST, pt.code, sr.sequence_order NULLS LAST, sr.rule_code, pe.name;
"""
def format_frist(duration_value, duration_unit, timing, alt_value, alt_unit, combine_op):
"""Format the deadline duration cleanly."""
if duration_value is None or duration_unit is None:
return ""
unit_map = {
"days": "d",
"weeks": "w",
"months": "M",
"years": "y",
"calendar_days": "CD",
"working_days": "WD",
}
unit = unit_map.get(duration_unit, duration_unit)
main = f"{duration_value} {unit}"
if alt_value is not None and alt_unit is not None:
alt_unit_short = unit_map.get(alt_unit, alt_unit)
op = combine_op or "or"
main = f"{main} {op} {alt_value} {alt_unit_short}"
if timing == "before":
main = f"{main} before"
elif timing == "after":
main = f"{main} after"
return main
def format_party(primary_party, is_court_set):
if is_court_set:
return "court-set"
if primary_party == "claimant":
return "claimant"
if primary_party == "defendant":
return "defendant"
if primary_party == "both":
return "either"
if primary_party == "court":
return "court"
return primary_party or ""
def detect_r94(notes_en, notes_de):
"""Flag R.9.4 non-extendable from notes text (heuristic — no DB field)."""
blobs = " ".join(filter(None, [notes_en or "", notes_de or ""])).lower()
if "r.9.4" in blobs or "r 9.4" in blobs or "r9.4" in blobs:
return ""
if "non-extendable" in blobs or "nicht verlängerbar" in blobs or "nicht verlaengerbar" in blobs:
return ""
return ""
def conditional_marker(condition_expr):
if condition_expr in (None, "", {}):
return ""
# condition_expr is JSONB → returns dict
if isinstance(condition_expr, dict):
if "flag" in condition_expr:
return f"if `{condition_expr['flag']}`"
if condition_expr.get("op") == "and" and "args" in condition_expr:
flags = [a.get("flag", "?") for a in condition_expr["args"]]
return "if " + " & ".join(f"`{f}`" for f in flags)
if condition_expr.get("op") == "or" and "args" in condition_expr:
flags = [a.get("flag", "?") for a in condition_expr["args"]]
return "if " + " | ".join(f"`{f}`" for f in flags)
return "cond"
def md_escape(s):
if s is None:
return ""
return str(s).replace("|", "\\|").replace("\n", " ")
def render(rows, *, include_optional: bool, generated_for: str) -> str:
by_pt = {}
for r in rows:
key = (r["display_order"] or 9999, r["pt_code"], r["pt_label_de"], r["pt_label_en"])
by_pt.setdefault(key, []).append(r)
out = []
today = date.today().isoformat()
out.append(f"# UPC + DE/EP Deadline Catalog — Stand {today}")
out.append("")
out.append(f"Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).")
out.append(f"Generated for {generated_for}. {len(rows)} rules across {len(by_pt)} proceedings.")
if include_optional:
out.append("")
out.append(
"**Mode:** `--include-optional` — every published rule, including "
"`priority='optional'` rules suppressed by the engine's default "
"(`IncludeOptional=false`). This is the exhaustive catalog dump."
)
else:
out.append("")
out.append(
"**Mode:** default — matches the engine's `IncludeOptional=false` "
"behaviour (pkg/litigationplanner/engine.go). `priority='optional'` "
"rules are suppressed; the manuscript shows only the mandatory "
"backbone the lawyer sees by default on /tools/procedures. "
"Re-run with `--include-optional` for the full catalog. "
"(t-paliad-348 / yoUPC#178)"
)
out.append("")
out.append("**Spalten:**")
out.append("- **Phase/Event** = procedural event (German primary)")
out.append("- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)")
out.append("- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)")
out.append("- **Anchor** = trigger event the deadline runs from")
out.append("- **Seite** = filing party (claimant / defendant / either / court-set)")
out.append("- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)")
out.append("- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)")
out.append("- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)")
out.append("")
out.append("---")
out.append("")
for (order, pt_code, pt_de, pt_en) in sorted(by_pt.keys()):
prules = by_pt[(order, pt_code, pt_de, pt_en)]
out.append(f"## {pt_de} · `{pt_code}`")
out.append("")
if pt_en and pt_en != pt_de:
out.append(f"*{pt_en}*")
out.append("")
if include_optional:
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | Priorität | R.9.4 | Bedingung |")
out.append("|---:|---|---|---|---|---|---|:---:|---|")
else:
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |")
out.append("|---:|---|---|---|---|---|:---:|---|")
for i, r in enumerate(prules, 1):
event = md_escape(r["event_de"] or r["event_en"] or "")
frist = md_escape(
format_frist(
r["duration_value"], r["duration_unit"], r["timing"],
r["alt_duration_value"], r["alt_duration_unit"], r["combine_op"],
)
)
rule = md_escape(r["rule_code"] or "")
anchor = md_escape(r["trigger_label"] or "")
party = format_party(r["primary_party"], r["is_court_set"])
r94 = detect_r94(r["deadline_notes_en"], r["deadline_notes"])
cond = md_escape(conditional_marker(r["condition_expr"]))
spawn_marker = "" if r["is_spawn"] else ""
if include_optional:
priority = md_escape(r["priority"] or "")
out.append(
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {priority} | {r94} | {cond} |"
)
else:
out.append(
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {r94} | {cond} |"
)
out.append("")
out.append("---")
out.append("")
out.append("**Lesehilfe:**")
out.append("- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)")
out.append("- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)")
out.append("- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).")
return "\n".join(out)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--include-optional",
action="store_true",
help="Include priority='optional' rules. Default false matches the engine's IncludeOptional=false default.",
)
parser.add_argument(
"-o",
"--out",
default="exports/upc-deadlines-2026-05-28.md",
help="Output path (relative to repo root).",
)
parser.add_argument(
"--generated-for",
default="PA-Schulung 2026-05-28",
help="Free-text label rendered in the markdown header.",
)
args = parser.parse_args()
priority_filter = "" if args.include_optional else "AND sr.priority != 'optional'"
sql = SQL_TEMPLATE.format(priority_filter=priority_filter)
conn = psycopg2.connect(DSN)
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql)
rows = cur.fetchall()
finally:
conn.close()
md = render(rows, include_optional=args.include_optional, generated_for=args.generated_for)
# Resolve out path relative to the repo root (= the script's grandparent).
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = Path(__file__).resolve().parent.parent / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md)
n_pt = len({(r["display_order"] or 9999, r["pt_code"]) for r in rows})
print(
f"WROTE {out_path} ({len(rows)} rules, {n_pt} proceedings, "
f"include_optional={args.include_optional})"
)
if __name__ == "__main__":
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

View File

@@ -0,0 +1,378 @@
# UPC + DE/EP Deadline Catalog — Stand 2026-05-28
Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).
Generated for PA-Schulung 2026-05-28. 178 rules across 25 proceedings.
**Mode:** default — matches the engine's `IncludeOptional=false` behaviour (pkg/litigationplanner/engine.go). `priority='optional'` rules are suppressed; the manuscript shows only the mandatory backbone the lawyer sees by default on /tools/procedures. Re-run with `--include-optional` for the full catalog. (t-paliad-348 / yoUPC#178)
**Spalten:**
- **Phase/Event** = procedural event (German primary)
- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)
- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)
- **Anchor** = trigger event the deadline runs from
- **Seite** = filing party (claimant / defendant / either / court-set)
- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)
- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)
- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)
---
## Verletzungsverfahren · `upc.inf.cfi`
*Infringement Action*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klageerhebung | 0 M after | RoP.013.1 | | claimant | | |
| 2 | Klageerwiderung | 3 M after | RoP.023 | | defendant | | |
| 3 | Replik | 2 M or 2 M after | RoP.029.b | | claimant | | if `with_ccr` |
| 4 | Duplik | 1 M or 2 M after | RoP.029.c | | defendant | | if `with_ccr` |
| 5 | Erwiderung auf Nichtigkeitswiderklage | 2 M after | RoP.029.a | | claimant | | if `with_ccr` |
| 6 | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 M after | RoP.029.d | | defendant | | if `with_ccr` |
| 7 | Duplik auf Replik zur Erwiderung Nichtigkeitswiderklage | 1 M after | RoP.029.e | | claimant | | if `with_ccr` |
| 8 | Antrag auf Patentänderung | 2 M after | RoP.030.1 | | claimant | | if `with_ccr` & `with_amend` |
| 9 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.032.1 | | defendant | | if `with_ccr` & `with_amend` |
| 10 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_ccr` & `with_amend` |
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_ccr` & `with_amend` |
| 12 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
| 13 | Mitteilung Dolmetscherkosten | 2 w before | RoP.109.4 | Oral hearing | court | | |
| 14 | Übersetzungen einreichen | 2 w after | RoP.109.5 | | either | | |
| 15 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
| 16 | Entscheidung | 0 M after | RoP.118.1 | | court-set | | |
| 17 | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | Reply to the Defence to an Application to amend the patent | defendant | | if `with_ccr` & `with_amend` |
| 18 | Einreichung von Übersetzungen von Schriftstücken | 1 M after | RoP.007.4 | Order of the judge-rapporteur to lodge translations | either | | |
| 19 | Antrag auf Simultanübersetzung | 1 M before | RoP.109.5 | Oral hearing | either | | |
| 20 | Antrag auf Folgemaßnahmen aus einer rechtskräftigen Validitätsentscheidung | 2 M after | RoP.118.4 | Final decision of the central division, Court of Appeal or EPO on the validity of the patent | either | | if `with_ccr` |
| 21 | Antrag auf Überprüfung einer verfahrensleitenden Anordnung | 15 d after | RoP.333 | Case management order (Service) | either | | |
| 22 | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d after | RoP.019 | Preliminary Objection | either | | |
| 23 | Mängelbeseitigung / Zahlung | 14 d after | RoP.016 | Notification by the Registry to correct deficiencies | either | | |
| 24 | Antrag auf Verweisung an die Zentralkammer | 10 d after | RoP.323 | Information by the Court not to approve Application to use the patent's language as language of the proceedings | either | | |
| 25 | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 w before | RoP.109.5 | Oral hearing | either | | |
| 26 | Klärung von Übersetzungsfragen | 2 w after | RoP.109 | Summons to Oral Hearing | court | | |
| 27 | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d after | RoP.262.2 | Opponent Submission | either | | |
| 28 | Wiedereinsetzungsantrag (UPC R.320) | 2 M after | RoP.320 | Removal of obstacle (UPC R.320) | either | | |
## Verletzungsverfahren (LG) · `de.inf.lg`
*Infringement (Regional Court)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klageerhebung | 0 M after | § 253 ZPO | | claimant | | |
| 2 | Anzeige der Verteidigungsbereitschaft | 2 w after | § 276 ZPO | | defendant | | |
| 3 | Klageerwiderung | 6 w after | § 276 ZPO | | court-set | | |
| 4 | Replik | 4 w after | § 282 ZPO | | court-set | | |
| 5 | Duplik | 4 w after | § 282 ZPO | | court-set | | |
| 6 | Haupttermin | 0 M after | § 279 ZPO | | court-set | | |
| 7 | Urteil | 0 M after | § 300 ZPO | | court-set | | |
| 8 | Berufungsfrist | 1 M after | § 517 ZPO | | either | | |
| 9 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
| 10 | Wiedereinsetzungsantrag (§ 233 ZPO) | 2 w after | § 233 ZPO | Removal of obstacle (ZPO §233) | — | | |
| 11 | Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 2 w after | § 339 ZPO | Service of default judgment | — | | |
| 12 | Schriftsatznachreichung (§ 296a ZPO) | 3 w after | § 296a ZPO | End of oral hearing | — | | |
## Nichtigkeitsverfahren · `upc.rev.cfi`
*Revocation Action*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Nichtigkeitsklage | 0 M after | RoP.044 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.049.1 | | defendant | | |
| 3 | Antrag auf Patentänderung | 0 M after | RoP.049.2.a | | defendant | | if `with_amend` |
| 4 | Verletzungswiderklage | 0 M after | RoP.049.2.b | | defendant | | if `with_cci` |
| 5 | Replik | 2 M after | RoP.051 | | claimant | | |
| 6 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.043.3 | | claimant | | if `with_amend` |
| 7 | Erwiderung auf Verletzungswiderklage | 2 M after | RoP.056.1 | | claimant | | if `with_cci` |
| 8 | Duplik | 1 M after | RoP.052 | | defendant | | |
| 9 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_amend` |
| 10 | Replik auf Erwiderung zur Verletzungswiderklage | 1 M after | RoP.056.3 | | defendant | | if `with_cci` |
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_amend` |
| 12 | Duplik auf Replik zur Erwiderung Verletzungswiderklage | 1 M after | RoP.056.4 | | claimant | | if `with_cci` |
| 13 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
| 14 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
| 15 | Entscheidung | 0 M after | RoP.118.3 | | court-set | | |
| 16 | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 M after | RoP.052 | Reply to the Defence to revocation | — | | |
| 17 | Verletzungswiderklage | 2 M after | RoP.053 | Statement for Revocation | — | | |
| 18 | Antrag auf Patentänderung | 2 M after | RoP.050 | Statement for Revocation | — | | |
## Nichtigkeitsverfahren (BPatG) · `de.null.bpatg`
*Nullity (Federal Patent Court)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Nichtigkeitsklage | 0 M after | § 81 PatG | | claimant | | |
| 2 | Klageerwiderung | 2 M after | § 82 Abs. 3 PatG | | defendant | | |
| 3 | Replik | 2 M after | § 83 PatG | | claimant | | |
| 4 | Hinweisbeschluss | 0 M after | § 83 PatG | | court-set | | |
| 5 | Stellungnahme zum Hinweisbeschluss | 0 M after | § 83 PatG | | either | | |
| 6 | Duplik | 1 M after | § 83 PatG | | defendant | | |
| 7 | Mündliche Verhandlung | 0 M after | § 80 PatG | | court-set | | |
| 8 | Urteil | 0 M after | § 84 PatG | | court-set | | |
| 9 | Berufungsfrist | 1 M after | § 110 PatG | | either | | |
| 10 | Berufungsbegründung | 3 M after | § 111 PatG | | either | | |
| 11 | Wiedereinsetzungsantrag (§ 123 PatG) | 2 M after | § 123 PatG | Removal of obstacle (PatG §123) | — | | |
## Einspruchsverfahren · `epa.opp.opd`
*Opposition Proceedings*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Veröffentlichung der Erteilung | 0 M after | Art. 97 EPÜ | | either | | |
| 2 | Einspruchsfrist | 9 M after | Art. 99 EPÜ | | either | | |
| 3 | Erwiderung des Patentinhabers | 4 M after | R. 79(1) EPÜ | | court-set | | |
| 4 | Entscheidung | 0 M after | Art. 102 EPÜ | | court-set | | |
| 5 | Beschwerdefrist | 2 M after | Art. 108 EPÜ | | either | | |
| 6 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
| 7 | Stellungnahme weiterer Beteiligter | 0 M after | R. 79 EPÜ | | either | | |
| 8 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
| 9 | Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 M after | Art. 122 EPÜ | Removal of obstacle (EPC Art.122) | — | | |
## Beschwerdeverfahren · `epa.opp.boa`
*Appeal Proceedings*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung der Beschwerdeentscheidung | 0 M after | R. 124 EPÜ | | either | | |
| 2 | Beschwerdeeinlegung | 2 M after | Art. 108 EPÜ | | either | | |
| 3 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
| 4 | Beschwerdeerwiderung | 4 M after | RPBA Art. 12 | | either | | |
| 5 | Mündliche Verhandlung | 0 M after | Art. 116 EPÜ | | court-set | | |
| 6 | Entscheidung | 0 M after | Art. 111 EPÜ | | court-set | | |
| 7 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
| 8 | Antrag auf Überprüfung | 2 M after | Art. 112a EPÜ | | either | | |
## Einspruchsverfahren DPMA · `dpma.opp.dpma`
*Opposition DPMA*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Veröffentlichung der Erteilung | 0 M after | § 58 PatG | | either | | |
| 2 | Einspruchsfrist | 9 M after | § 59 PatG | | either | | |
| 3 | Erwiderung des Patentinhabers | 4 M after | § 59(2) PatG | | court-set | | |
| 4 | DPMA-Entscheidung | 0 M after | § 61 PatG | | court-set | | |
| 5 | Wiedereinsetzungsantrag (DPMA) | 2 M after | § 123 PatG | Removal of obstacle (DPMA, PatG §123) | — | | |
## Berufungsverfahren · `upc.apl.merits`
*Appeal*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Berufungseinlegung | 2 M after | RoP.224.1.a | | either | | |
| 2 | Berufungsbegründung | 4 M after | RoP.224.2.a | | either | | |
| 3 | Berufungserwiderung | 3 M after | RoP.235.1 | | either | | |
| 4 | Mündliche Verhandlung | 0 M after | RoP.240 | | court-set | | |
| 5 | Entscheidung | 0 M after | RoP.235.4 | | court-set | | |
| 6 | Anschlussberufung | 3 M after | RoP.237 | | either | | |
| 7 | Erwiderung Anschlussberufung | 2 M after | RoP.238.1 | | either | | |
| 8 | Berufungsschrift gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 2 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
| 9 | Berufungsbegründung gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 4 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
| 10 | Anfechtung einer Entscheidung über die Verwerfung der Berufung als unzulässig | 1 M after | RoP.245 | Decision to reject an appeal as inadmissible | — | | |
| 11 | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 M after | RoP.247.2 | Final decision (Service) / Discovery of the fundamental defect (whichever is later) | — | | |
| 12 | Antrag auf Wiederaufnahme (Straftat) | 2 M after | RoP.247.1 | Final decision (Service) / Court decision on criminal offence (whichever is later) | — | | |
## Berufungsverfahren OLG (Verletzung) · `de.inf.olg`
*Appeal OLG (Infringement)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung LG-Urteil | 0 M after | § 540 ZPO | | either | | |
| 2 | Berufungsschrift | 1 M after | § 517 ZPO | | either | | |
| 3 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
| 4 | Berufungserwiderung | 1 M after | § 521 ZPO | | either | | |
| 5 | Anschlussberufung | 0 M after | § 524 ZPO | | either | | |
| 6 | Mündliche Verhandlung | 0 M after | § 540 ZPO | | court-set | | |
| 7 | OLG-Urteil | 0 M after | § 540 ZPO | | court-set | | |
## Revisions-/NZB-Verfahren BGH (Verletzung) · `de.inf.bgh`
*Revision / Non-admission Appeal BGH (Infringement)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung OLG-Urteil | 0 M after | § 555 ZPO | | either | | |
| 2 | Nichtzulassungsbeschwerde | 1 M after | § 544 ZPO | | either | | |
| 3 | Nichtzulassungsbeschwerde-Begründung | 2 M after | § 544 ZPO | | either | | |
| 4 | Revisionsfrist | 1 M after | § 548 ZPO | | either | | |
| 5 | Revisionsbegründung | 2 M after | § 551 ZPO | | either | | |
| 6 | Revisionserwiderung | 1 M after | § 554 ZPO | | either | | |
| 7 | Mündliche Verhandlung BGH | 0 M after | § 555 ZPO | | court-set | | |
| 8 | BGH-Urteil | 0 M after | § 555 ZPO | | court-set | | |
## Berufungsverfahren BGH (Nichtigkeit) · `de.null.bgh`
*Appeal BGH (Nullity)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung BPatG-Urteil | 0 M after | § 110 PatG | | either | | |
| 2 | Berufungsschrift | 1 M after | § 110 PatG | | either | | |
| 3 | Berufungsbegründung | 3 M after | § 520 Abs. 2 ZPO i.V.m. § 117 PatG | | either | | |
| 4 | Berufungserwiderung | 2 M after | § 521 Abs. 2 ZPO i.V.m. § 117 PatG | | court-set | | |
| 5 | Mündliche Verhandlung BGH | 0 M after | § 121 PatG | | court-set | | |
| 6 | BGH-Urteil | 0 M after | § 122 PatG | | court-set | | |
## EP-Erteilungsverfahren · `epa.grant.exa`
*EP Grant Procedure*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Anmeldung | 0 M after | Art. 75 EPÜ | | claimant | | |
| 2 | Recherchenbericht | 6 M after | Art. 92 EPÜ | | court-set | | |
| 3 | Veröffentlichung (A1) | 18 M after | Art. 93 EPÜ | | court-set | | |
| 4 | Prüfungsantrag | 6 M after | R. 70(1) EPÜ | | claimant | | |
| 5 | Mitteilung nach R. 71(3) | 0 M after | R. 71(3) EPÜ | | court-set | | |
| 6 | Zustimmung + Übersetzung | 4 M after | R. 71(3) EPÜ | | claimant | | |
| 7 | Erteilung (B1) | 0 M after | Art. 97 EPÜ | | court-set | | |
## Beschwerdeverfahren BPatG (DPMA) · `dpma.appeal.bpatg`
*Appeal BPatG (against DPMA Decision)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung DPMA-Entscheidung | 0 M after | § 65 PatG | | either | | |
| 2 | Beschwerde | 1 M after | § 73 PatG | | either | | |
| 3 | Beschwerdebegründung | 1 M after | § 75 PatG | | court-set | | |
| 4 | Mündliche Verhandlung BPatG | 0 M after | § 78 PatG | | court-set | | |
| 5 | BPatG-Entscheidung | 0 M after | § 78 PatG | | court-set | | |
## Rechtsbeschwerdeverfahren BGH · `dpma.appeal.bgh`
*Legal Appeal BGH*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung BPatG-Entscheidung | 0 M after | § 100 PatG | | either | | |
| 2 | Rechtsbeschwerde | 1 M after | § 100 PatG | | either | | |
| 3 | Rechtsbeschwerdebegründung | 1 M after | § 102 PatG | | either | | |
| 4 | BGH-Entscheidung | 0 M after | § 100 PatG | | court-set | | |
## Berufungsverfahren Anordnungen · `upc.apl.order`
*Order Appeal (15-day track)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Anordnung / angegriffene Entscheidung | 0 M after | RoP.220 | | court-set | | |
| 2 | Berufung mit Zulassung | 15 d after | RoP.220.2 | | either | | |
| 3 | Antrag auf Ermessensüberprüfung | 15 d after | RoP.220.3 | | either | | |
| 4 | Berufungsbegründung (Orders Track) | 15 d after | RoP.224.2.b | | either | | |
| 5 | Anschlussberufung | 15 d after | RoP.237 | | either | | |
| 6 | Erwiderung Anschlussberufung | 15 d after | RoP.238.2 | | either | | |
| 7 | Berufungsschrift gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
| 8 | Berufungsbegründung gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
## Schadensbemessungsverfahren · `upc.dmgs.cfi`
*Damages Determination*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Schadensbemessung | 0 M after | RoP.125 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.137.2 | | defendant | | |
| 3 | Replik | 1 M after | RoP.139 | | claimant | | |
| 4 | Duplik | 1 M after | RoP.139 | | defendant | | |
## Bucheinsichtsverfahren · `upc.disc.cfi`
*Lay-open Books / Discovery*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Bucheinsicht | 0 M after | RoP.190 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.142.2 | | defendant | | |
| 3 | Replik | 14 d after | RoP.142.3 | | claimant | | |
| 4 | Duplik | 14 d after | RoP.142.3 | | defendant | | |
## Einstweilige Maßnahmen · `upc.pi.cfi`
*Provisional Measures*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag | 0 M after | RoP.206 | | claimant | | |
| 2 | Erwiderung | 0 M after | RoP.211.2 | | court-set | | |
| 3 | Mündliche Verhandlung | 0 M after | RoP.195 | | court-set | | |
| 4 | Mängelbeseitigung Antrag | 14 d after | RoP.207.6.a | | claimant | | |
| 5 | Beschluss | 0 M after | RoP.211 | | court-set | | |
| 6 | Klage in der Hauptsache erheben | 31 d max 20 WD after | RoP.213 | | claimant | | |
## Schutzschrift · `upc.pl.cfi`
*Protective Letter*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Einreichung der Schutzschrift | 0 M after | RoP.207 | | defendant | | |
| 2 | Erneuerung der Schutzschrift | 6 M after | RoP.207.9 | Protective Letter | — | | |
## Berufungsverfahren Kosten · `upc.apl.cost`
*Cost-Decision Appeal*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Kostenfestsetzungsbeschluss | 0 M after | RoP.221.4 | | court-set | | |
| 2 | Antrag auf Berufungszulassung | 15 d after | RoP.221.1 | | either | | |
| 3 | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d after | RoP.220.2 | Decision on fixation of costs (Rule 157) | — | | |
## Negative Feststellungsklage · `upc.dni.cfi`
*Declaration of Non-Infringement*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klage auf negative Feststellung der Nichtverletzung | 0 M after | RoP.063 | | claimant | | |
| 2 | Erwiderung auf die negative Feststellungsklage | 2 M after | RoP.066 | Statement for a declaration of non-infringement | — | | |
| 3 | Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.067 | Defence to the Statement for a declaration of non-infringement | — | | |
| 4 | Duplik zur Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.068 | Reply to the Defence to the Statement for a declaration of non-infringement | — | | |
## Überprüfung von EPA-Entscheidungen · `upc.epo.review`
*Review of EPO decisions*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Überprüfung der EPA-Entscheidung | 0 M after | RoP.088 | | — | | |
| 2 | Antrag auf Aufhebung einer Entscheidung des EPA, mit der ein Antrag auf einheitliche Wirkung zurückgewiesen wurde | 3 w after | RoP.097 | Decision of the EPO not to grant unitary effect | — | | |
## Separate Kostenentscheidung · `upc.costs.cfi`
*Separate Cost Decision*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Kostenfestsetzung | 1 M after | RoP.151 | | claimant | | |
## Beweissicherung / saisie · `upc.bsv.cfi`
*Evidence Preservation*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Beweissicherung | 0 M after | RoP.192 | | court-set | | |
| 2 | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d after | RoP.197.3 | Execution of measures to preserve evidence | — | | |
| 3 | Beginn des Hauptsacheverfahrens | 31 d max 20 WD after | RoP.198 | Date specified in the Court's order to preserve evidence | — | | |
## Widerklage auf Nichtigkeit · `upc.ccr.cfi`
*Counterclaim for Revocation*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Widerklage auf Nichtigkeit | 3 M after | RoP.025 | | defendant | | |
---
**Lesehilfe:**
- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)
- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)
- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).

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.

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,147 @@
// Add-proceeding inline picker for the Litigation Builder.
//
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
// gates the Verfahren chip row, click → callback. Designed for B1's
// single-triplet flow and B2's multi-triplet stacking with no shape
// change between slices.
import { t } from "./i18n";
export interface ProceedingTypeMeta {
id: number;
code: string;
name: string;
nameEN: string;
// group / jurisdiction. The proceeding-types API returns "UPC" /
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
// only renders UPC.
group?: string;
jurisdiction?: string;
}
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
let activePopover: HTMLElement | null = null;
export function mountAddProceedingPicker(
anchor: HTMLElement,
types: ProceedingTypeMeta[],
onPick: OnPick,
): void {
closeActive();
const pop = document.createElement("div");
pop.className = "builder-picker-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("builder.picker.aria"));
const header = document.createElement("div");
header.className = "builder-picker-header";
header.innerHTML = `
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
`;
pop.appendChild(header);
// Forum row — UPC only for v1. Disabled chips render greyed.
const forumRow = document.createElement("div");
forumRow.className = "builder-picker-row";
forumRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
<div class="builder-picker-chips">
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
</div>
`;
pop.appendChild(forumRow);
const procRow = document.createElement("div");
procRow.className = "builder-picker-row";
procRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
`;
pop.appendChild(procRow);
const empty = document.createElement("p");
empty.className = "builder-picker-empty";
empty.hidden = true;
empty.textContent = t("builder.picker.empty");
pop.appendChild(empty);
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
const lang = document.documentElement.lang === "en" ? "en" : "de";
for (const meta of types) {
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-picker-chip builder-picker-chip--proc";
chip.setAttribute("data-code", meta.code);
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
chip.addEventListener("click", () => {
closeActive();
void onPick(meta);
});
procHost.appendChild(chip);
}
if (types.length === 0) empty.hidden = false;
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
closeActive();
});
// Position the popover under the anchor button.
positionUnder(pop, anchor);
document.body.appendChild(pop);
activePopover = pop;
document.addEventListener("click", onOutsideClick, true);
document.addEventListener("keydown", onEscape, true);
}
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
const rect = anchor.getBoundingClientRect();
pop.style.position = "absolute";
const top = rect.bottom + window.scrollY + 6;
// Default left = anchor's left; clamp so popover stays in viewport.
const left = Math.max(8, rect.left + window.scrollX);
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
pop.style.zIndex = "60";
}
function onOutsideClick(ev: Event): void {
if (!activePopover) return;
const target = ev.target as Node;
if (activePopover.contains(target)) return;
closeActive();
}
function onEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") closeActive();
}
function closeActive(): void {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
document.removeEventListener("click", onOutsideClick, true);
document.removeEventListener("keydown", onEscape, true);
}
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,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,412 @@
// Universal search dropdown for the Litigation Builder (m/paliad#153 B3).
//
// PRD §2.2 + §3.1 + §6.3: the page-header search box ("Suche") drives
// a typed dropdown returning grouped event / scenario / project hits.
// Picking an event lands the user on a scratch scenario with one
// triplet anchored on that event's proceeding type. Picking a scenario
// loads it; picking a project (Akte) is deferred to B4 (the dropdown
// row renders but pick falls through to a console hint until B4 wires
// project-backed scenarios).
//
// The controller is owned by builder.ts; this module exports
// `mountBuilderSearch` which wires the input + dropdown lifecycle and
// invokes the supplied callbacks. No module-level state — re-mounting
// is safe.
import { t } from "./i18n";
export interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string | null;
primary_party?: string | null;
anchor_rule_id: string;
follow_up_count: number;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string | null;
};
}
export interface ScenarioSearchHit {
id: string;
name: string;
status: string;
updated_at: string;
}
export interface ProjectSearchHit {
id: string;
type: string;
title: string;
reference?: string | null;
case_number?: string | null;
matter_number?: string | null;
client_number?: string | null;
}
export interface UniversalSearchResponse {
query: string;
events: EventSearchHit[];
scenarios: ScenarioSearchHit[];
projects: ProjectSearchHit[];
counts: { events: number; scenarios: number; projects: number };
}
export interface BuilderSearchCallbacks {
onPickEvent: (hit: EventSearchHit) => void | Promise<void>;
onPickScenario: (hit: ScenarioSearchHit) => void | Promise<void>;
onPickProject?: (hit: ProjectSearchHit) => void | Promise<void>;
}
interface Controller {
input: HTMLInputElement;
dropdown: HTMLElement;
open: boolean;
abort: AbortController | null;
debounceTimer: number | null;
lang: "de" | "en";
}
let active: Controller | null = null;
// mountBuilderSearch wires the universal search behavior onto an
// existing <input>. Idempotent — re-calling tears down the previous
// dropdown and rebinds. Returns a controller exposing focus() so the
// entry-mode toggle in builder.ts can land on the search input.
export function mountBuilderSearch(
input: HTMLInputElement,
cb: BuilderSearchCallbacks,
): { focus: () => void; close: () => void } {
teardown();
const lang: "de" | "en" = document.documentElement.lang === "en" ? "en" : "de";
// Single dropdown container, anchored under the input. Positioned
// absolutely so it floats above the canvas without reflowing layout.
const dropdown = document.createElement("div");
dropdown.className = "builder-search-dropdown";
dropdown.setAttribute("role", "listbox");
dropdown.hidden = true;
document.body.appendChild(dropdown);
active = {
input,
dropdown,
open: false,
abort: null,
debounceTimer: null,
lang,
};
input.addEventListener("input", onInput);
input.addEventListener("focus", onFocus);
input.addEventListener("keydown", onKeydown);
document.addEventListener("click", onOutsideClick, true);
window.addEventListener("resize", reposition);
window.addEventListener("scroll", reposition, true);
// Click handler is wired once on the dropdown root via event
// delegation; per-row data attributes identify the hit type.
dropdown.addEventListener("click", (ev) => {
const row = (ev.target as HTMLElement).closest<HTMLElement>(".builder-search-row");
if (!row) return;
const kind = row.getAttribute("data-hit-kind");
const payload = row.getAttribute("data-hit-payload");
if (!kind || !payload) return;
try {
const hit = JSON.parse(payload);
ev.stopPropagation();
closeDropdown();
if (kind === "event") void cb.onPickEvent(hit);
else if (kind === "scenario") void cb.onPickScenario(hit);
else if (kind === "project" && cb.onPickProject) void cb.onPickProject(hit);
} catch (err) {
console.error("builder-search: bad payload", err);
}
});
return {
focus: () => {
input.focus();
// Open the dropdown on focus even when input is empty — show the
// "start typing" hint per PRD §2.2 (search box auto-focuses).
openDropdown();
renderHint(t("builder.search.hint.start"));
},
close: closeDropdown,
};
}
function teardown(): void {
if (!active) return;
if (active.abort) active.abort.abort();
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
active.dropdown.remove();
active.input.removeEventListener("input", onInput);
active.input.removeEventListener("focus", onFocus);
active.input.removeEventListener("keydown", onKeydown);
document.removeEventListener("click", onOutsideClick, true);
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
active = null;
}
function onInput(): void {
if (!active) return;
const q = active.input.value.trim();
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
if (q.length === 0) {
openDropdown();
renderHint(t("builder.search.hint.start"));
return;
}
if (q.length < 2) {
openDropdown();
renderHint(t("builder.search.hint.short"));
return;
}
active.debounceTimer = window.setTimeout(() => {
void runSearch(q);
}, 180);
}
function onFocus(): void {
if (!active) return;
const q = active.input.value.trim();
if (q.length === 0) {
openDropdown();
renderHint(t("builder.search.hint.start"));
} else if (q.length >= 2) {
void runSearch(q);
}
}
function onKeydown(ev: KeyboardEvent): void {
if (!active) return;
if (ev.key === "Escape") {
closeDropdown();
return;
}
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
const rows = Array.from(active.dropdown.querySelectorAll<HTMLElement>(".builder-search-row"));
if (rows.length === 0) return;
ev.preventDefault();
const current = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
let idx = current ? rows.indexOf(current) : -1;
idx = ev.key === "ArrowDown"
? Math.min(rows.length - 1, idx + 1)
: Math.max(0, idx - 1);
rows.forEach((r) => r.classList.remove("is-focus"));
rows[idx].classList.add("is-focus");
rows[idx].scrollIntoView({ block: "nearest" });
return;
}
if (ev.key === "Enter") {
const focused = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
if (focused) {
ev.preventDefault();
focused.click();
}
}
}
function onOutsideClick(ev: Event): void {
if (!active) return;
const target = ev.target as Node;
if (active.input.contains(target)) return;
if (active.dropdown.contains(target)) return;
closeDropdown();
}
async function runSearch(q: string): Promise<void> {
if (!active) return;
// Cancel any in-flight request so a slow earlier query can't clobber
// a faster newer one.
if (active.abort) active.abort.abort();
const ctl = new AbortController();
active.abort = ctl;
openDropdown();
renderHint(t("builder.search.hint.loading"));
try {
const url = "/api/builder/search?q=" + encodeURIComponent(q);
const resp = await fetch(url, { signal: ctl.signal });
if (!resp.ok) {
renderHint(t("builder.search.hint.error"));
return;
}
const data = (await resp.json()) as UniversalSearchResponse;
if (active.abort !== ctl) return;
renderResults(data);
} catch (err) {
if ((err as { name?: string })?.name === "AbortError") return;
console.error("builder-search error:", err);
renderHint(t("builder.search.hint.error"));
}
}
function renderHint(message: string): void {
if (!active) return;
active.dropdown.innerHTML = `<div class="builder-search-hint">${escHtml(message)}</div>`;
reposition();
}
function renderResults(data: UniversalSearchResponse): void {
if (!active) return;
const lang = active.lang;
const total = data.events.length + data.scenarios.length + data.projects.length;
if (total === 0) {
renderHint(t("builder.search.hint.empty"));
return;
}
// Result-count summary per PRD §2.2: "N Ereignisse · M Szenarios · K Akten"
const counts = `<div class="builder-search-summary">` +
escHtml(tCount("builder.search.summary.events", data.events.length)) +
` · ` +
escHtml(tCount("builder.search.summary.scenarios", data.scenarios.length)) +
` · ` +
escHtml(tCount("builder.search.summary.projects", data.projects.length)) +
`</div>`;
const sections: string[] = [counts];
if (data.events.length > 0) {
sections.push(renderGroup(
t("builder.search.group.events"),
data.events.map((e) => renderEventRow(e, lang)).join(""),
));
}
if (data.scenarios.length > 0) {
sections.push(renderGroup(
t("builder.search.group.scenarios"),
data.scenarios.map((s) => renderScenarioRow(s)).join(""),
));
}
if (data.projects.length > 0) {
sections.push(renderGroup(
t("builder.search.group.projects"),
data.projects.map((p) => renderProjectRow(p, lang)).join(""),
));
}
active.dropdown.innerHTML = sections.join("");
reposition();
}
function renderGroup(label: string, rowsHtml: string): string {
return `<section class="builder-search-group">` +
`<header class="builder-search-group-label">${escHtml(label)}</header>` +
rowsHtml +
`</section>`;
}
function renderEventRow(hit: EventSearchHit, lang: "de" | "en"): string {
const name = lang === "en" ? (hit.name_en || hit.name_de) : (hit.name_de || hit.name_en);
const ptName = lang === "en"
? (hit.proceeding_type.name_en || hit.proceeding_type.name_de)
: (hit.proceeding_type.name_de || hit.proceeding_type.name_en);
const party = hit.primary_party ? `<span class="builder-search-party">${escHtml(hit.primary_party)}</span>` : "";
const kind = hit.event_kind ? `<span class="builder-search-kind">${escHtml(hit.event_kind)}</span>` : "";
// Payload for the click handler — we embed the full hit so builder.ts
// doesn't need a second lookup. JSON-encoded into a data attribute,
// attr-escaped on the way in.
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="event" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-pt-code">${escHtml(hit.proceeding_type.code)}</span>` +
`<span class="builder-search-event-name">${escHtml(name)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">` +
`<span class="builder-search-pt-name">${escHtml(ptName)}</span>` +
kind + party +
`</div>` +
`</div>`;
}
function renderScenarioRow(hit: ScenarioSearchHit): string {
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="scenario" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-scenario-name">${escHtml(hit.name)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">` +
`<span class="builder-search-status">${escHtml(hit.status)}</span>` +
`</div>` +
`</div>`;
}
function renderProjectRow(hit: ProjectSearchHit, _lang: "de" | "en"): string {
const meta: string[] = [];
if (hit.case_number) meta.push(hit.case_number);
if (hit.matter_number) meta.push(hit.matter_number);
if (hit.client_number) meta.push(hit.client_number);
if (hit.reference) meta.push(hit.reference);
const metaText = meta.length > 0 ? meta.join(" · ") : "";
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="project" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-project-type">${escHtml(hit.type)}</span>` +
`<span class="builder-search-project-title">${escHtml(hit.title)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">${escHtml(metaText)}</div>` +
`</div>`;
}
function openDropdown(): void {
if (!active) return;
active.dropdown.hidden = false;
active.open = true;
reposition();
}
function closeDropdown(): void {
if (!active) return;
active.dropdown.hidden = true;
active.open = false;
if (active.abort) {
active.abort.abort();
active.abort = null;
}
}
function reposition(): void {
if (!active || !active.open) return;
const rect = active.input.getBoundingClientRect();
const top = rect.bottom + window.scrollY + 4;
const left = rect.left + window.scrollX;
const width = Math.max(rect.width, 380);
active.dropdown.style.position = "absolute";
active.dropdown.style.top = `${top}px`;
active.dropdown.style.left = `${left}px`;
active.dropdown.style.width = `${width}px`;
active.dropdown.style.zIndex = "60";
}
// tCount applies a simple plural pick: keys ".one" / ".other" carry
// the singular/plural variants; the caller's key is the bare stem.
function tCount(key: string, n: number): string {
const variant = n === 1 ? `${key}.one` : `${key}.other`;
return t(variant).replace("{n}", String(n));
}
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

@@ -0,0 +1,271 @@
// ProceedingTriplet renderer for the Litigation Builder.
//
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
// body.
//
// B2 wires the live controls — perspective radio, scenario-flag strip,
// remove button, collapse — and the per-event-card overlays (3-state
// machine, action buttons, optional-horizon chip). The 3-column body
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
// per-card overlays are layered on top after innerHTML write via the
// data-rule-id hooks added in the same slice.
import { t, tDyn, getLang } from "./i18n";
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
import type { BuilderProceeding, BuilderEvent } from "./builder";
import type { ProceedingTypeMeta } from "./builder-picker";
export interface ScenarioFlagCatalogEntry {
flag_key: string;
label_de: string;
label_en: string;
description?: string;
hidden_unless_set: boolean;
}
export interface TripletViewInput {
proceeding: BuilderProceeding;
meta: ProceedingTypeMeta;
data: DeadlineResponse | null;
side: Side;
// Flag catalog filtered to the keys the active proceeding actually
// references via its rules' condition_expr. B2 passes the global
// catalog and lets the user toggle any — flags that don't gate any
// rule are simply no-ops on this triplet.
flagCatalog: ScenarioFlagCatalogEntry[];
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
// for the per-card state machine. Cards whose rule is absent default
// to "planned".
eventsByRule: Map<string, BuilderEvent>;
// Per-card optional-horizon registry. Each rule with optional
// children carries a `+N Optionen` chip; the chip's count comes from
// here (defaults to scenario_events.horizon_optional, falls back to
// proceeding-level when not stored per-card).
columnsHtml: string;
isChild: boolean;
}
// Triplet header + controls + columns body. Pure-string render; the
// caller (builder.ts) wires click handlers on top.
export function renderTriplet(input: TripletViewInput): string {
const lang = getLang();
const procLabel = lang === "en"
? (input.meta.nameEN || input.meta.name)
: (input.meta.name || input.meta.nameEN);
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
const body = input.data
? input.columnsHtml
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
const controls = renderControls(input);
const flagStrip = renderFlagStrip(input);
return `
<header class="builder-triplet-header">
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
${flagsBadge}
</header>
${controls}
${flagStrip}
<div class="builder-triplet-body">
${body}
</div>
`;
}
function renderControls(input: TripletViewInput): string {
const perspective = input.side ?? "";
const detailgrad = input.proceeding.detailgrad || "selected";
const radio = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-perspective-btn${active}"
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
const detailBtn = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
return `<div class="builder-triplet-controls">
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
<div class="builder-triplet-perspective">
${radio("", "builder.triplet.perspective.none", perspective)}
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
</div>
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
<div class="builder-triplet-detailgrad">
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
</div>
<button type="button" class="builder-triplet-remove" data-action="remove">
${escHtml(t("builder.triplet.remove"))}
</button>
</div>`;
}
function renderFlagStrip(input: TripletViewInput): string {
// B2 ships the full global catalog. Flags that don't gate any of the
// active proceeding's rules are still toggle-able but have no effect
// on the calc result (the engine simply doesn't read them).
const lang = getLang();
const flags = input.proceeding.scenario_flags || {};
if (input.flagCatalog.length === 0) {
return `<div class="builder-triplet-flagstrip">
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
</div>`;
}
const toggles = input.flagCatalog.map((entry) => {
const label = lang === "en" ? entry.label_en : entry.label_de;
const isOn = flags[entry.flag_key] === true;
return `<label class="builder-triplet-flag-toggle">
<input type="checkbox"
data-action="flag"
data-flag-key="${escAttr(entry.flag_key)}"
${isOn ? "checked" : ""} />
<span>${escHtml(label)}</span>
</label>`;
}).join("");
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
}
function jurisdictionFor(meta: ProceedingTypeMeta): string {
if (meta.jurisdiction) return meta.jurisdiction;
if (meta.group) return meta.group;
const dot = meta.code.indexOf(".");
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
return meta.code.toUpperCase();
}
function activeFlagsBadge(flags: Record<string, unknown>): string {
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
if (active.length === 0) return "";
const label = t("builder.triplet.flags.label");
const chips = active.map((f) =>
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
).join("");
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
}
// overlayEventStates walks the rendered .fr-col-item nodes and:
// - sets data-builder-state from eventsByRule lookup;
// - appends a per-card action row (file / skip / reset);
// - shows a +N Optionen chip when the rule has optional children
// (the chip placeholder; B2 ships the per-card horizon control —
// the actual horizon-count→render expansion lands when the calc
// engine surfaces "available optionals" for a parent rule, which
// pasteur's Options.IncludeOptional flag already exposes server-
// side; full wiring is a follow-up). Cards without optional
// children get no chip.
export function overlayEventStates(
root: HTMLElement,
eventsByRule: Map<string, BuilderEvent>,
on: {
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
onHorizon: (ruleId: string, delta: 1 | -1) => void;
},
): void {
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
items.forEach((item) => {
const ruleId = item.getAttribute("data-rule-id");
if (!ruleId) return;
const ev = eventsByRule.get(ruleId.toLowerCase());
const state = ev?.state || "planned";
item.setAttribute("data-builder-state", state);
// Append actions (idempotent: clear any prior overlay first).
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
const actions = document.createElement("div");
actions.className = "builder-event-actions";
actions.innerHTML = actionButtonsHtml(state);
item.appendChild(actions);
actions.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
if (!btn) return;
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
if (!action) return;
ev.stopPropagation();
if (action === "file") {
const today = new Date().toISOString().slice(0, 10);
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
if (v === null) return;
on.onAction(ruleId, "file", { date: v.trim() || today });
} else if (action === "skip") {
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
if (reason === null) return;
on.onAction(ruleId, "skip", { reason: reason.trim() });
} else {
on.onAction(ruleId, "reset");
}
});
// Per-card optional horizon chip. The PRD §3.4 places the chip on
// every card with optional children; until the calc surface exposes
// an "optionals available count" on each parent rule, the chip is
// shown only when the card has a stored non-zero horizon (so the
// user can see and reduce a previously-set horizon). This is the
// graceful B2 baseline; the full surface lands once the engine
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
const horizonCount = ev?.horizon_optional ?? 0;
if (horizonCount > 0) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-toggle");
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, -1);
});
item.appendChild(chip);
} else {
// Inline "+ Optionen" affordance — adds a horizon entry when
// first clicked. Tagged as data-builder-feature so the cleanup
// sweep can rip it out if the calc surface lands a counter.
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-add");
chip.setAttribute("data-builder-feature", "horizon-add");
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, 1);
});
item.appendChild(chip);
}
});
}
function actionButtonsHtml(state: BuilderEvent["state"]): string {
// Re-render the action row per state. Cards in the planned state
// show "File / Skip"; filed/skipped cards show "Reset to planned".
if (state === "planned") {
return `
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
`;
}
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
}
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;");
}

File diff suppressed because it is too large Load Diff

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

@@ -214,6 +214,142 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
"nav.procedures": "Verfahren & Fristen",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
"builder.header.scenario": "Szenario:",
"builder.header.akte": "Akte:",
"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:",
"builder.action.share": "Teilen",
"builder.action.promote": "Als Projekt anlegen",
"builder.mode.cold": "\u00dcbersicht",
"builder.mode.event": "Ereignis",
"builder.mode.akte": "Aus Akte",
"builder.panel.title": "Meine Szenarien",
"builder.panel.new": "+ Neues Szenario",
"builder.panel.empty": "Noch keine Szenarien.",
"builder.bucket.active": "Aktiv",
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
"builder.empty.cta": "Neues Szenario starten",
"builder.empty.recent": "Zuletzt bearbeitet",
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
"builder.picker.close": "Schlie\u00dfen",
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Verfahren:",
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
"builder.triplet.loading": "Berechne Fristen \u2026",
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
"builder.triplet.side.defendant": "Beklagten-Sicht",
"builder.triplet.flags.label": "Optionen:",
"builder.triplet.perspective.label": "Perspektive:",
"builder.triplet.perspective.none": "keine",
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
"builder.triplet.perspective.defendant": "Beklagter",
"builder.triplet.detailgrad.label": "Detailgrad:",
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
"builder.triplet.detailgrad.all_options": "Alle Optionen",
"builder.triplet.remove": "Entfernen",
"builder.triplet.collapse": "Einklappen",
"builder.triplet.expand": "Ausklappen",
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
"builder.event.state.planned": "geplant",
"builder.event.state.filed": "eingereicht",
"builder.event.state.skipped": "ausgelassen",
"builder.event.action.file": "Einreichen",
"builder.event.action.skip": "Auslassen",
"builder.event.action.reset": "Zur\u00fcck zu geplant",
"builder.event.actual_date.prompt": "Datum der Einreichung:",
"builder.event.skip_reason.prompt": "Grund (optional):",
"builder.event.horizon.label": "+{n} Optionen \u25be",
"builder.event.horizon.hide": "Optionen ausblenden",
"builder.save.idle": "\u00a0",
"builder.save.saving": "Speichert \u2026",
"builder.save.saved": "Gespeichert \u2713",
"builder.save.error": "Speichern fehlgeschlagen",
"builder.search.hint.start": "Tippe \u2026 z.\u202fB. \u201eKlageerwiderung\u201c, \u201eHinweis\u201c, \u201eHL-2024\u201c",
"builder.search.hint.short": "Mindestens 2 Zeichen.",
"builder.search.hint.loading": "Suche \u2026",
"builder.search.hint.empty": "Keine Treffer.",
"builder.search.hint.error": "Suche fehlgeschlagen. Erneut versuchen.",
"builder.search.hint.akte_b4": "Akten-Modus folgt in B4.",
"builder.search.group.events": "Ereignisse",
"builder.search.group.scenarios": "Szenarien",
"builder.search.group.projects": "Akten",
"builder.search.summary.events.one": "{n} Ereignis",
"builder.search.summary.events.other": "{n} Ereignisse",
"builder.search.summary.scenarios.one": "{n} Szenario",
"builder.search.summary.scenarios.other": "{n} Szenarien",
"builder.search.summary.projects.one": "{n} Akte",
"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",
@@ -263,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",
@@ -992,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",
@@ -1612,11 +1745,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",
@@ -3418,6 +3568,142 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
"nav.procedures": "Procedures & Deadlines",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
"builder.header.scenario": "Scenario:",
"builder.header.akte": "Matter:",
"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:",
"builder.action.share": "Share",
"builder.action.promote": "Create as project",
"builder.mode.cold": "Overview",
"builder.mode.event": "Event",
"builder.mode.akte": "From matter",
"builder.panel.title": "My scenarios",
"builder.panel.new": "+ New scenario",
"builder.panel.empty": "No scenarios yet.",
"builder.bucket.active": "Active",
"builder.empty.headline": "No scenario open.",
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
"builder.empty.cta": "Start a new scenario",
"builder.empty.recent": "Recent",
"builder.picker.placeholder": "— pick a scenario —",
"builder.picker.title": "Add proceeding",
"builder.picker.close": "Close",
"builder.picker.aria": "Pick a proceeding",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Proceeding:",
"builder.picker.empty": "No proceedings available.",
"builder.picker.future_jurisdiction": "Other forums coming later.",
"builder.canvas.add_proceeding": "+ Add proceeding",
"builder.triplet.loading": "Calculating deadlines …",
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
"builder.triplet.side.claimant": "Claimant view",
"builder.triplet.side.defendant": "Defendant view",
"builder.triplet.flags.label": "Options:",
"builder.triplet.perspective.label": "Perspective:",
"builder.triplet.perspective.none": "none",
"builder.triplet.perspective.claimant": "Claimant",
"builder.triplet.perspective.defendant": "Defendant",
"builder.triplet.detailgrad.label": "Detail:",
"builder.triplet.detailgrad.selected": "Selected",
"builder.triplet.detailgrad.all_options": "All options",
"builder.triplet.remove": "Remove",
"builder.triplet.collapse": "Collapse",
"builder.triplet.expand": "Expand",
"builder.triplet.no_flags": "(no flags for this proceeding type)",
"builder.event.state.planned": "planned",
"builder.event.state.filed": "filed",
"builder.event.state.skipped": "skipped",
"builder.event.action.file": "File",
"builder.event.action.skip": "Skip",
"builder.event.action.reset": "Reset to planned",
"builder.event.actual_date.prompt": "Date of filing:",
"builder.event.skip_reason.prompt": "Reason (optional):",
"builder.event.horizon.label": "+{n} optional ▾",
"builder.event.horizon.hide": "Hide optional",
"builder.save.idle": " ",
"builder.save.saving": "Saving …",
"builder.save.saved": "Saved ✓",
"builder.save.error": "Save failed",
"builder.search.hint.start": "Type … e.g. \"defence\", \"hearing\", \"HL-2024\"",
"builder.search.hint.short": "At least 2 characters.",
"builder.search.hint.loading": "Searching …",
"builder.search.hint.empty": "No matches.",
"builder.search.hint.error": "Search failed. Try again.",
"builder.search.hint.akte_b4": "Matter mode coming in B4.",
"builder.search.group.events": "Events",
"builder.search.group.scenarios": "Scenarios",
"builder.search.group.projects": "Matters",
"builder.search.summary.events.one": "{n} event",
"builder.search.summary.events.other": "{n} events",
"builder.search.summary.scenarios.one": "{n} scenario",
"builder.search.summary.scenarios.other": "{n} scenarios",
"builder.search.summary.projects.one": "{n} matter",
"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",
@@ -3467,10 +3753,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",
@@ -4792,6 +5074,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",
@@ -4802,6 +5088,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

@@ -1,150 +1,15 @@
// /tools/procedures client (m/paliad#151,
// docs/design-unified-procedural-events-tool-2026-05-27.md).
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
//
// Boot logic + tab switching for the unified procedural-events tool.
// Each entry tab mounts its own module; the search box and chip
// filters in the top filter strip are wired in U1+ as each slice adds
// its dimension-aware behaviour.
//
// U0 — Skeleton + tab toggling.
// U1 — Direkt suchen mounts Mode A.
// U2 — Geführt mounts Mode B wizard.
// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle.
//
// Mode A renders its shell into #fristen-overhaul-root (replacing
// children); Mode B renders into #fristen-overhaul-mode-host; the
// result view (post-commit) writes into #fristen-overhaul-root. To
// keep those IDs unique in the DOM, only the active tab's panel ever
// hosts the overhaul scaffold — installOverhaulHost() tears down any
// existing host and installs a fresh one inside the target panel
// before handing off to the per-mode module.
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
// emitted by procedures.tsx; this file boots the i18n + sidebar
// runtime and hands off to builder.ts.
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import { mountModeA } from "./fristenrechner-mode-a";
import { mountResultView } from "./fristenrechner-result";
import { mountWizard } from "./fristenrechner-wizard";
import { initVerfahrensablauf } from "./verfahrensablauf";
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
function readTabFromUrl(): ProceduresTab {
const params = new URLSearchParams(window.location.search);
const raw = params.get("mode");
if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab;
return "proceeding";
}
function writeTabToUrl(tab: ProceduresTab): void {
const url = new URL(window.location.href);
if (tab === "proceeding") {
url.searchParams.delete("mode");
} else {
url.searchParams.set("mode", tab);
}
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
// any existing host first, so the IDs stay unique across the page even
// when the user toggles between Direkt-suchen and Geführt — both Mode
// A and the wizard read these IDs from document.getElementById which
// returns the first match in DOM order, so two parallel hosts would
// cross-wire.
function installOverhaulHost(panelId: string): HTMLElement | null {
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
const panel = document.getElementById(panelId);
if (!panel) return null;
panel.innerHTML = `
<div class="procedures-overhaul-host">
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
<div id="fristen-overhaul-mode-host"></div>
</div>
</div>
`;
return panel;
}
function setActiveTabUI(tab: ProceduresTab): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
const panel = document.getElementById(`procedures-panel-${t}`);
const active = t === tab;
if (btn) {
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
}
if (panel) panel.hidden = !active;
}
}
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
// selectedType + lastResponse + listeners that re-bind on every
// proceeding click). Wire it exactly once per page load; on subsequent
// activations the existing DOM + listeners are reused so picked
// proceeding / dates / flags persist across tab switches.
let verfahrensablaufWired = false;
async function activateTab(tab: ProceduresTab): Promise<void> {
setActiveTabUI(tab);
if (tab === "search") {
installOverhaulHost("procedures-panel-search");
await mountModeA();
return;
}
if (tab === "wizard") {
installOverhaulHost("procedures-panel-wizard");
await mountWizard();
return;
}
if (tab === "proceeding") {
if (!verfahrensablaufWired) {
initVerfahrensablauf();
verfahrensablaufWired = true;
}
}
}
function wireTabs(): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
if (!btn) continue;
btn.addEventListener("click", () => {
void activateTab(t);
writeTabToUrl(t);
});
}
}
// boot dispatches on the URL: a deep link with `?event=` jumps straight
// to the linear result view (the Direkt-suchen tab stays as the visible
// context). Otherwise the requested tab — defaulting to "proceeding" —
// activates per readTabFromUrl().
async function boot(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const eventRef = params.get("event") || "";
if (eventRef) {
setActiveTabUI("search");
installOverhaulHost("procedures-panel-search");
await mountResultView({
eventRef,
triggerDate: params.get("trigger_date") || undefined,
party: params.get("party") || undefined,
courtId: params.get("court_id") || undefined,
});
return;
}
await activateTab(readTabFromUrl());
}
import { mountBuilder } from "./builder";
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
wireTabs();
void boot();
void mountBuilder();
});

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,8 +1,9 @@
import { describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
type DeadlineResponse,
bucketDeadlinesIntoColumns,
calculateDeadlines,
deadlineCardHtml,
formatDurationLabel,
renderColumnsBody,
@@ -773,3 +774,81 @@ describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", (
.toBe("Time limit set by the court");
});
});
// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
// calculateDeadlines must forward `includeOptional` and
// `triggerEventAnchors` straight into the POST body so the Go handler
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
// future refactor drops the fields, the Builder triplet silently
// reverts to "engine emits optional rules" and the unified
// /tools/procedures page loses its naked-proceeding default.
describe("calculateDeadlines — forwards engine options into request body", () => {
type CapturedRequest = { url: string; body: Record<string, unknown> };
let captured: CapturedRequest | null;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
captured = null;
originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
captured = { url: String(input), body };
return new Response(JSON.stringify({
proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("default call omits includeOptional and triggerEventAnchors", async () => {
await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
expect(captured).not.toBeNull();
expect(captured!.body.includeOptional).toBeUndefined();
expect(captured!.body.triggerEventAnchors).toBeUndefined();
});
test("includeOptional=true sends includeOptional: true", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
includeOptional: true,
});
expect(captured!.body.includeOptional).toBe(true);
});
test("includeOptional=false is omitted (matches engine default)", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
includeOptional: false,
});
expect(captured!.body.includeOptional).toBeUndefined();
});
test("triggerEventAnchors forwarded as object", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
triggerEventAnchors: {
"upc.inf.cfi.oral": "2026-09-01",
"upc.inf.cfi.decision": "2026-12-15",
},
});
expect(captured!.body.triggerEventAnchors).toEqual({
"upc.inf.cfi.oral": "2026-09-01",
"upc.inf.cfi.decision": "2026-12-15",
});
});
test("empty triggerEventAnchors is omitted", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
triggerEventAnchors: {},
});
expect(captured!.body.triggerEventAnchors).toBeUndefined();
});
});

View File

@@ -271,6 +271,12 @@ export interface DeadlineResponse {
// when the toggle is OFF — so users know there's something to
// re-surface.
hiddenCount?: number;
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
// engine suppressed because their `trigger_event_id` anchor wasn't
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
// "N rules waiting on an anchor" UI affordances.
rulesAwaitingAnchor?: number;
}
export interface CourtRow {
@@ -311,6 +317,20 @@ export interface CalcParams {
// endentscheidung | kostenentscheidung | anordnung |
// schadensbemessung | bucheinsicht.
appealTarget?: string;
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
// axes to the HTTP boundary:
//
// includeOptional: when true, the engine returns priority='optional'
// rules in the timeline. Default false matches the engine default
// (mandatory backbone only). The /tools/procedures detailgrad
// toggle ("all_options" mode) drives this to true so the dimmed
// optional cards can be rendered for the lawyer to opt into.
// triggerEventAnchors: per-event-code anchor dates the engine
// consults for rules carrying trigger_event_id. Empty/omitted =
// no anchors → such rules render as IsConditional (the engine
// refuses to fabricate a date off the proceeding's trigger date).
includeOptional?: boolean;
triggerEventAnchors?: Record<string, string>;
}
const PARTY_CLASS: Record<string, string> = {
@@ -1042,7 +1062,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
// timeline-item — dotted border + faded styling.
dl.isConditional ? "fr-col-item--conditional" : "",
].filter(Boolean).join(" ");
return `<div class="${itemClasses}">
// data-rule-id on the card root lets the Litigation Builder
// overlay per-card state (planned/filed/skipped) + action
// affordances onto cards rendered through this shared body
// without re-implementing the columns renderer. Empty on
// synthetic rows (appeal trigger marker etc.); the Builder
// skips state lookup when missing.
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
@@ -1110,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
: undefined,
includeHidden: params.includeHidden ? true : undefined,
appealTarget: params.appealTarget || undefined,
includeOptional: params.includeOptional ? true : undefined,
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
? params.triggerEventAnchors
: undefined,
}),
});
if (!resp.ok) {

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

@@ -728,6 +728,138 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "builder.action.promote"
| "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"
| "builder.empty.hint"
| "builder.empty.recent"
| "builder.event.action.file"
| "builder.event.action.reset"
| "builder.event.action.skip"
| "builder.event.actual_date.prompt"
| "builder.event.horizon.hide"
| "builder.event.horizon.label"
| "builder.event.skip_reason.prompt"
| "builder.event.state.filed"
| "builder.event.state.planned"
| "builder.event.state.skipped"
| "builder.header.akte"
| "builder.header.scenario"
| "builder.header.search"
| "builder.header.stichtag"
| "builder.mobile.blocked"
| "builder.mode.akte"
| "builder.mode.cold"
| "builder.mode.event"
| "builder.panel.empty"
| "builder.panel.new"
| "builder.panel.title"
| "builder.picker.aria"
| "builder.picker.axis.forum"
| "builder.picker.axis.proc"
| "builder.picker.close"
| "builder.picker.empty"
| "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"
| "builder.save.saving"
| "builder.search.anchor.divider"
| "builder.search.group.events"
| "builder.search.group.projects"
| "builder.search.group.scenarios"
| "builder.search.hint.akte_b4"
| "builder.search.hint.empty"
| "builder.search.hint.error"
| "builder.search.hint.loading"
| "builder.search.hint.short"
| "builder.search.hint.start"
| "builder.search.placeholder"
| "builder.search.summary.events.one"
| "builder.search.summary.events.other"
| "builder.search.summary.projects.one"
| "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"
| "builder.triplet.detailgrad.label"
| "builder.triplet.detailgrad.selected"
| "builder.triplet.expand"
| "builder.triplet.flags.label"
| "builder.triplet.loading"
| "builder.triplet.no_flags"
| "builder.triplet.perspective.claimant"
| "builder.triplet.perspective.defendant"
| "builder.triplet.perspective.label"
| "builder.triplet.perspective.none"
| "builder.triplet.remove"
| "builder.triplet.side.claimant"
| "builder.triplet.side.defendant"
| "builder.triplet.unknown_proceeding"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"
@@ -2710,6 +2842,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"
@@ -2809,6 +2944,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

@@ -4,23 +4,19 @@ import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody";
// U0 — Skeleton for the unified procedural-events tool
// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md).
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
//
// Folds /tools/fristenrechner (Mode A + Mode B + result) and
// /tools/verfahrensablauf into a single page at /tools/procedures. Each
// later slice fills one of the four entry tabs:
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
// builder shell. Server-rendered chrome is minimal — the page-header
// scenario picker, side panel, and canvas are all hydrated by
// `builder.ts` at boot. The builder loads scenarios from
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
// per-proceeding triplets with the existing verfahrensablauf-core calc.
//
// U1 — Direkt suchen (Mode A search)
// U2 — Geführt (Mode B wizard)
// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter)
// U4 — Hard-cut 301 (drop legacy pages, redirect URLs)
//
// This file ships only the page chrome — sidebar, header, filter strip
// with search box, four entry-mode tabs, and the host containers the
// later slices mount their UI into. No data wiring.
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
export function renderProcedures(): string {
const today = new Date().toISOString().split("T")[0];
@@ -36,151 +32,153 @@ export function renderProcedures(): string {
<title data-i18n="procedures.title">Verfahren &amp; Fristen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-procedures">
<body className="has-sidebar page-procedures page-builder">
<Sidebar currentPath="/tools/procedures" />
<BottomNav currentPath="/tools/procedures" />
<main>
<section className="tool-page">
<section className="tool-page builder-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="procedures.heading">Verfahren &amp; Fristen</h1>
<p className="tool-subtitle" data-i18n="procedures.subtitle">
Verfahrensablauf, Fristenrechner und ger&uuml;hrte Suche in einem Tool.
<p className="tool-subtitle" data-i18n="builder.subtitle">
Litigation Builder &mdash; Szenarien bauen, Verfahren stapeln, Fristen behalten.
</p>
</div>
{/* Shared filter strip — search box + four chip groups
(forum / proceeding / event_kind / party). Lives at the
top of the page so every entry tab and output mode reads
the same active filter set (design §4 + m's Q3
divergence: search composes with chip filters). U0
ships the markup only; chip hydration + search wiring
arrive with U1-U3. */}
<section className="procedures-filter-strip" aria-label="Filter">
<div className="procedures-filter-search">
<svg className="procedures-filter-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="procedures-search-input"
className="procedures-filter-search-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="procedures.filter.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing&hellip;"
/>
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
· Akte picker · Stichtag input. B1 wires the scenario picker
+ name action + Stichtag + save indicator. Akte / share /
promote land at B4 / B5; the affordances render disabled in
B1 so the layout is stable across slices. */}
<section className="builder-pageheader" aria-label="Builder-Steuerung">
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario w&auml;hlen"></select>
</label>
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
<span data-i18n="builder.save.idle">&nbsp;</span>
</span>
<span className="builder-pageheader-spacer"></span>
<button type="button" id="builder-rename-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
data-i18n="builder.action.rename">Benennen</button>
<button type="button" id="builder-share-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
data-i18n="builder.action.share">Teilen</button>
<button type="button" id="builder-promote-btn"
className="builder-action-btn builder-action-btn--primary"
disabled
data-i18n="builder.action.promote">Als Projekt anlegen</button>
</div>
<div className="procedures-filter-chips" id="procedures-filter-chips">
<div className="procedures-filter-chip-row" data-axis="forum">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="proc">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="kind">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="party">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
</div>
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte w&auml;hlen">
<option value="" data-i18n="builder.akte.none">&mdash; ohne &mdash;</option>
</select>
</label>
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
defaultValue={today} aria-label="Stichtag" />
</label>
<label className="builder-pageheader-field builder-pageheader-field--grow">
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
<input type="search" id="builder-search-input" className="builder-search-input"
data-i18n-placeholder="builder.search.placeholder"
placeholder="Ereignis, Szenario, Akte &hellip;"
autocomplete="off" spellcheck="false" />
</label>
</div>
</section>
{/* Entry-mode tab strip — all four tabs visible from boot
(m's Q3 divergence). The active tab is URL-driven
(?mode=proceeding|search|wizard|akte); cold open lands
on "proceeding" per design §11.5.Q3. */}
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
event-triggered + akte ship at B3 / B4 and are disabled
here so the layout stays stable across slices. */}
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
<button type="button"
className="procedures-tab is-active"
className="builder-mode is-active"
role="tab"
aria-selected="true"
data-tab="proceeding"
id="procedures-tab-proceeding">
<span className="procedures-tab-icon" aria-hidden="true">&#128218;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren w&auml;hlen</span>
data-mode="cold"
id="builder-mode-cold">
<span className="builder-mode-label" data-i18n="builder.mode.cold">&Uuml;bersicht</span>
</button>
<button type="button"
className="procedures-tab"
className="builder-mode"
role="tab"
aria-selected="false"
data-tab="search"
id="procedures-tab-search">
<span className="procedures-tab-icon" aria-hidden="true">&#9889;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
data-mode="event"
id="builder-mode-event">
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
</button>
<button type="button"
className="procedures-tab"
className="builder-mode"
role="tab"
aria-selected="false"
data-tab="wizard"
id="procedures-tab-wizard">
<span className="procedures-tab-icon" aria-hidden="true">&#129517;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Gef&uuml;hrt</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="akte"
id="procedures-tab-akte">
<span className="procedures-tab-icon" aria-hidden="true">&#128193;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
data-mode="akte"
id="builder-mode-akte">
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
</button>
</nav>
{/* Per-tab content hosts. Only one is visible at a time —
procedures.ts toggles `hidden` on the inactive ones.
Each later slice fills the corresponding host. */}
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
aria-labelledby="procedures-tab-proceeding">
{/* Verfahrensablauf wizard body — shared TSX component
used by /tools/verfahrensablauf (legacy) and the
unified /tools/procedures page. procedures.ts calls
initVerfahrensablauf() on the first activation of
this tab, which wires the .proceeding-btn clicks,
timeline-container, detail-mode toggle, etc. against
the markup. The legacy page's auto-boot is guarded
against the procedures-only #procedures-panel-proceeding
element so it doesn't fire twice. */}
<VerfahrensablaufBody todayIso={today} />
</section>
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
<div className="builder-body">
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
<header className="builder-sidepanel-header">
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
<button type="button" id="builder-new-scenario-btn"
className="builder-sidepanel-newbtn"
data-i18n="builder.panel.new">+ Neues Szenario</button>
</header>
<div className="builder-sidepanel-bucket" data-bucket="active">
<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>
{/* 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="procedures-panel" id="procedures-panel-search" role="tabpanel"
aria-labelledby="procedures-tab-search" hidden></section>
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
aria-labelledby="procedures-tab-wizard" hidden></section>
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
aria-labelledby="procedures-tab-akte" hidden>
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
Akten-Einstieg folgt in einem sp&auml;teren Slice.
</div>
</section>
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
tree here; U0 leaves it empty + hidden so the
tab placeholders are the only thing visible. */}
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
aria-label="Tree output" hidden></section>
{/* Linear-drawer host. Inline drawer expanding beneath a
tree card (design §8 — desktop) AND the standalone
linear follow-up view that Mode A / Mode B land on
after locking a trigger event (design §3.2). U1
switches it on. */}
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
aria-label="Linear output" hidden></section>
<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. */}
<div className="builder-empty" id="builder-empty">
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
Noch kein Szenario ge&ouml;ffnet.
</p>
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
Starte ein neues Szenario, w&auml;hle aus deiner Liste oder &uuml;bernimm eine Akte (B4).
</p>
<button type="button" id="builder-cta-new" className="builder-cta-new"
data-i18n="builder.empty.cta">
Neues Szenario starten
</button>
</div>
</div>
</section>
</div>
</div>
</section>
</main>

File diff suppressed because it is too large Load Diff

View File

@@ -171,6 +171,33 @@ export function renderSubmissionDraft(): string {
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
</p>
{/* 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. */}
<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>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
{/* t-paliad-277: "Aus Projekt importieren" + last-

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,94 @@
-- 157_scenario_builder_foundation — down
--
-- Rolls back mig 157 in reverse order. Down files are reference material
-- (not auto-applied); operator recovery path is:
--
-- psql ... < 157_scenario_builder_foundation.down.sql
-- DELETE FROM paliad.applied_migrations WHERE version = 157;
--
-- This restores the legacy paliad.scenarios shape from mig 145 — the
-- builder columns and the three sibling tables are dropped wholesale.
-- Any builder data in the dropped tables is lost (the tables CASCADE to
-- their children, and DROP TABLE doesn't keep a backup).
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 157 rollback: tear down Scenario builder foundation (t-paliad-340)',
true
);
-- 8. updated_at triggers
DROP TRIGGER IF EXISTS scenario_events_touch_updated_at_trg ON paliad.scenario_events;
DROP TRIGGER IF EXISTS scenario_proceedings_touch_updated_at_trg ON paliad.scenario_proceedings;
-- 7. RLS — drop new policies + restore legacy four
DROP POLICY IF EXISTS scenario_shares_mutate ON paliad.scenario_shares;
DROP POLICY IF EXISTS scenario_shares_select ON paliad.scenario_shares;
DROP POLICY IF EXISTS scenario_events_mutate ON paliad.scenario_events;
DROP POLICY IF EXISTS scenario_events_select ON paliad.scenario_events;
DROP POLICY IF EXISTS scenario_proceedings_mutate ON paliad.scenario_proceedings;
DROP POLICY IF EXISTS scenario_proceedings_select ON paliad.scenario_proceedings;
DROP POLICY IF EXISTS scenarios_owner_mutate ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_select ON paliad.scenarios;
-- Restore the four mig-145 policies verbatim.
CREATE POLICY scenarios_project_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NULL AND created_by = auth.uid());
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NULL AND created_by = auth.uid())
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
-- 6. helper function
DROP FUNCTION IF EXISTS paliad.can_see_scenario(uuid);
-- 5. paliad.projects.origin_scenario_id
DROP INDEX IF EXISTS paliad.projects_origin_scenario_idx;
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS origin_scenario_id;
-- 4. paliad.scenario_shares
DROP TABLE IF EXISTS paliad.scenario_shares;
-- 3. paliad.scenario_events
DROP TABLE IF EXISTS paliad.scenario_events;
-- 2. paliad.scenario_proceedings
DROP TABLE IF EXISTS paliad.scenario_proceedings;
-- 1. paliad.scenarios — restore mig-145 shape
DROP INDEX IF EXISTS paliad.scenarios_updated_idx;
DROP INDEX IF EXISTS paliad.scenarios_owner_status_idx;
-- Restore the unique constraint mig 145 had.
ALTER TABLE paliad.scenarios
ADD CONSTRAINT scenarios_unique_per_scope
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name);
-- spec was NOT NULL in mig 145. Restore that — but only after backfilling
-- any NULL specs the builder might have created (none in legacy paths;
-- only builder rows have NULL spec, and those are dropped together with
-- the builder schema if a real rollback is needed).
UPDATE paliad.scenarios SET spec = '{}'::jsonb WHERE spec IS NULL;
ALTER TABLE paliad.scenarios ALTER COLUMN spec SET NOT NULL;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS notes;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS stichtag;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS promoted_project_id;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS origin_project_id;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS status;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS owner_id;
COMMIT;

View File

@@ -0,0 +1,500 @@
-- 157_scenario_builder_foundation — t-paliad-340 / m/paliad#153 B0
--
-- Schema foundation for the Litigation Builder (PRD
-- docs/plans/prd-procedures-litigation-planner-2026-05-27.md §5.1 + §5.2).
-- Phase B0 of the 7-slice train described in PRD §7.1. DB-only — no UI
-- depends on these tables yet; B1 wires the builder shell on top.
--
-- What this migration adds:
--
-- 1. Six new columns on paliad.scenarios for the builder shape:
-- owner_id, status, origin_project_id, promoted_project_id,
-- stichtag, notes.
-- Two relaxations on existing columns:
-- - spec NOT NULL → NULL (the builder normalises spec contents
-- into scenario_proceedings / scenario_events; new rows skip
-- spec entirely. Legacy callers from mig 145 still provide it
-- explicitly, so they keep inserting valid rows.)
-- - DROP CONSTRAINT scenarios_unique_per_scope (the builder
-- allows multiple "Unbenanntes Szenario" + multiple scratch
-- scenarios per user — uniqueness on (project_id, created_by,
-- name) blocks that. The legacy service treated the constraint
-- as UX collision avoidance, not correctness.)
--
-- 2. Three new tables for the normalised builder shape:
-- - paliad.scenario_proceedings (one row per proceeding in a
-- scenario; multi-proceeding constellations + spawned children)
-- - paliad.scenario_events (one row per event card on the
-- canvas; planned / filed / skipped state + actual_date + notes
-- + per-card optional horizon)
-- - paliad.scenario_shares (read-only team shares; owner is
-- the sole editor)
--
-- 3. One new column on paliad.projects:
-- - origin_scenario_id — audit trail for promote-to-project
-- (B5; the column lands now so the FK is in place when the
-- wizard arrives).
--
-- 4. New helper function paliad.can_see_scenario(_scenario_id) that
-- mirrors paliad.can_see_project's STABLE SECURITY DEFINER shape.
-- Visibility logic:
-- - global_admin sees everything,
-- - owner_id = auth.uid() (builder-owned scenarios),
-- - scenario_shares.shared_with_user_id = auth.uid()
-- (read-only shared scenarios),
-- - legacy project-scoped scenarios (owner_id IS NULL AND
-- project_id IS NOT NULL) follow can_see_project(project_id),
-- - legacy abstract scenarios (owner_id IS NULL AND project_id
-- IS NULL) follow created_by = auth.uid().
--
-- 5. Replacement RLS policies on paliad.scenarios that fold builder
-- visibility together with the legacy shape. The legacy
-- project_* / abstract_* policies are dropped (they covered only
-- legacy paths) and rewritten as a single pair of policies that
-- treats owner_id, scenario_shares, and the legacy paths uniformly.
--
-- Builder-only RLS for the three new tables: read = scenario
-- visibility; write = scenario owner (or legacy editor) only.
--
-- PRD §5.1 deviations called out for the reader:
--
-- - PRD specs `proceeding_type_id uuid REFERENCES paliad.proceeding_types(id)`.
-- The live column is `integer` (see paliad.proceeding_types.id);
-- scenario_proceedings.proceeding_type_id is integer here to match
-- the real FK target. PRD authors did not check the column type;
-- this migration uses the truth on disk.
--
-- - PRD references `auth.users(id)` for owner_id and share columns;
-- the established paliad convention (see paliad.projects.created_by,
-- paliad.scenarios.created_by) uses `paliad.users(id)`. Same UUIDs
-- either way (paliad.users.id == auth.users.id), but the FK targets
-- paliad.users to stay consistent with project tables.
--
-- Audit-first: all DDL ran clean against a BEGIN/ROLLBACK probe on the
-- live DB before this file was committed. paliad.scenarios has 0 rows
-- (verified pre-mig), so the column additions and constraint relaxations
-- have no data impact.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 157: Scenario builder foundation (t-paliad-340 / m/paliad#153 B0)',
true
);
-- ----------------------------------------------------------------
-- 1. paliad.scenarios — additive columns + constraint relaxations
-- ----------------------------------------------------------------
ALTER TABLE paliad.scenarios
ADD COLUMN owner_id uuid NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
ADD COLUMN status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','promoted')),
ADD COLUMN origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
ADD COLUMN promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
ADD COLUMN stichtag date NULL,
ADD COLUMN notes text NULL;
ALTER TABLE paliad.scenarios ALTER COLUMN spec DROP NOT NULL;
ALTER TABLE paliad.scenarios DROP CONSTRAINT IF EXISTS scenarios_unique_per_scope;
CREATE INDEX scenarios_owner_status_idx
ON paliad.scenarios(owner_id, status)
WHERE owner_id IS NOT NULL;
CREATE INDEX scenarios_updated_idx
ON paliad.scenarios(owner_id, updated_at DESC)
WHERE owner_id IS NOT NULL;
COMMENT ON COLUMN paliad.scenarios.owner_id IS
'Litigation Builder owner (PRD §5.1). NULL = legacy composition-spec '
'scenario from m/paliad#124 Slice D (mig 145). Builder rows MUST have '
'owner_id set; the application enforces it via ScenarioBuilderService.';
COMMENT ON COLUMN paliad.scenarios.status IS
'Lifecycle: active (default; user-editable) / archived (soft-deleted, '
'still visible in side panel) / promoted (converted to project via '
'B5 wizard; read-only). Legacy mig-145 rows default to active.';
COMMENT ON COLUMN paliad.scenarios.origin_project_id IS
'Set when the scenario was exported from an existing project '
'("Im Builder öffnen" — Akte mode, PRD §2.3).';
COMMENT ON COLUMN paliad.scenarios.promoted_project_id IS
'Set after the scenario was promoted to a real project via the 3-step '
'wizard (PRD §5.4). Together with paliad.projects.origin_scenario_id, '
'forms the bidirectional audit link.';
COMMENT ON COLUMN paliad.scenarios.stichtag IS
'Scenario-level default Stichtag; per-proceeding overrides in '
'paliad.scenario_proceedings.stichtag take precedence.';
-- ----------------------------------------------------------------
-- 2. paliad.scenario_proceedings — one proceeding per scenario row
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_proceedings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
proceeding_type_id integer NOT NULL
REFERENCES paliad.proceeding_types(id),
primary_party text NULL
CHECK (primary_party IN ('claimant','defendant')),
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(scenario_flags) = 'object'),
parent_scenario_proceeding_id uuid NULL
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
spawn_anchor_event_id uuid NULL
REFERENCES paliad.sequencing_rules(id),
ordinal int NOT NULL DEFAULT 0,
stichtag date NULL,
detailgrad text NOT NULL DEFAULT 'selected'
CHECK (detailgrad IN ('selected','all_options')),
appeal_target text NULL,
collapsed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenario_proceedings_scenario_idx
ON paliad.scenario_proceedings(scenario_id, ordinal);
CREATE INDEX scenario_proceedings_parent_idx
ON paliad.scenario_proceedings(parent_scenario_proceeding_id)
WHERE parent_scenario_proceeding_id IS NOT NULL;
COMMENT ON TABLE paliad.scenario_proceedings IS
'One proceeding inside a Litigation Builder scenario. Multiple rows '
'per scenario for multi-proceeding constellations. '
'parent_scenario_proceeding_id self-refs for spawned children '
'(e.g. upc.ccr.cfi spawned by with_ccr on upc.inf.cfi). '
'PRD §5.1, m/paliad#153 B0.';
COMMENT ON COLUMN paliad.scenario_proceedings.primary_party IS
'Per-proceeding perspective ("our side"). NULL = no perspective '
'picked yet (both party columns render with natural labels). '
'Per-proceeding so multi-jurisdiction constellations can flip side '
'independently (PRD §3.3).';
COMMENT ON COLUMN paliad.scenario_proceedings.scenario_flags IS
'Per-proceeding flags (e.g. {"with_ccr": true, "with_amend": false}). '
'Mirrors paliad.projects.scenario_flags shape but lives per-proceeding-'
'per-scenario. Validated by the application against '
'paliad.scenario_flag_catalog at write time.';
COMMENT ON COLUMN paliad.scenario_proceedings.spawn_anchor_event_id IS
'Which sequencing_rule of the parent proceeding caused this spawn. '
'NULL for root proceedings. Used by the UI to place the spawned child '
'triplet directly below the parent at the spawn node (PRD §3.6).';
COMMENT ON COLUMN paliad.scenario_proceedings.ordinal IS
'Stack order on canvas (top to bottom). Siblings under the same '
'parent (or top-level) are ordered by ordinal asc, then created_at.';
COMMENT ON COLUMN paliad.scenario_proceedings.detailgrad IS
'Per-proceeding optional-detail toggle: selected (only explicitly '
'chosen optionals + mandatories) or all_options (every optional '
'sequencing_rule surfaces). Matches today''s Verfahrensablauf pattern.';
-- ----------------------------------------------------------------
-- 3. paliad.scenario_events — one event card on the canvas
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_proceeding_id uuid NOT NULL
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
sequencing_rule_id uuid NULL
REFERENCES paliad.sequencing_rules(id),
procedural_event_id uuid NULL
REFERENCES paliad.procedural_events(id),
custom_label text NULL,
state text NOT NULL DEFAULT 'planned'
CHECK (state IN ('planned','filed','skipped')),
actual_date date NULL,
skip_reason text NULL,
notes text NULL,
horizon_optional int NOT NULL DEFAULT 0
CHECK (horizon_optional >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT scenario_events_one_anchor CHECK (
(sequencing_rule_id IS NOT NULL)::int +
(procedural_event_id IS NOT NULL)::int +
(custom_label IS NOT NULL)::int >= 1
)
);
CREATE INDEX scenario_events_proceeding_idx
ON paliad.scenario_events(scenario_proceeding_id);
-- A single proceeding can't carry two cards for the same sequencing rule
-- (each rule maps to one card). Free-form / procedural_event-only cards
-- skip this uniqueness — multiple custom cards per proceeding are OK.
CREATE UNIQUE INDEX scenario_events_rule_uniq_idx
ON paliad.scenario_events(scenario_proceeding_id, sequencing_rule_id)
WHERE sequencing_rule_id IS NOT NULL;
COMMENT ON TABLE paliad.scenario_events IS
'One event card on the Litigation Builder canvas. Captures state '
'(planned/filed/skipped), actual_date, notes, skip_reason, and the '
'per-card optional-horizon setting. At least one of '
'(sequencing_rule_id, procedural_event_id, custom_label) must be '
'set — sequencing-rule-backed cards are the common case; free-form '
'cards exist for events the catalog doesn''t cover yet. '
'PRD §3.4 / §5.1.';
COMMENT ON COLUMN paliad.scenario_events.state IS
'3-state machine: planned (default, future event with computed date) '
'/ filed (past event, actual_date set) / skipped (user chose not to '
'file; optional skip_reason). No "overdue" enum — that''s derived '
'(date < today AND state=planned), not stored. PRD Q10 / §3.4.';
COMMENT ON COLUMN paliad.scenario_events.actual_date IS
'Set when state=filed (real-world filing date) OR when state=planned '
'and the user overrode the computed date (court-set events, manual '
'tweaks). NULL when the computed date is canonical.';
COMMENT ON COLUMN paliad.scenario_events.horizon_optional IS
'Per-card "show N more optional follow-ups" affordance. Default 0 '
'(hidden). PRD Q4 / §3.4.';
-- ----------------------------------------------------------------
-- 4. paliad.scenario_shares — read-only team shares
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
shared_with_user_id uuid NOT NULL
REFERENCES paliad.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES paliad.users(id),
UNIQUE (scenario_id, shared_with_user_id)
);
CREATE INDEX scenario_shares_user_idx
ON paliad.scenario_shares(shared_with_user_id);
COMMENT ON TABLE paliad.scenario_shares IS
'Read-only team shares for Litigation Builder scenarios. Owner '
'(paliad.scenarios.owner_id) is the sole editor; rows here grant '
'view-only access to other paliad users. PRD Q12 / §5.1.';
-- ----------------------------------------------------------------
-- 5. paliad.projects.origin_scenario_id — promote-to-project trail
-- ----------------------------------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN origin_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
CREATE INDEX projects_origin_scenario_idx
ON paliad.projects(origin_scenario_id)
WHERE origin_scenario_id IS NOT NULL;
COMMENT ON COLUMN paliad.projects.origin_scenario_id IS
'FK to the scenario this project was promoted from (B5 wizard). '
'NULL = project was created directly, not via Builder. Together with '
'paliad.scenarios.promoted_project_id, forms the bidirectional audit '
'link. PRD §5.2.';
-- ----------------------------------------------------------------
-- 6. paliad.can_see_scenario — visibility helper
-- ----------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.can_see_scenario(_scenario_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $func$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id AND s.owner_id = auth.uid()
)
OR EXISTS (
SELECT 1 FROM paliad.scenario_shares sh
WHERE sh.scenario_id = _scenario_id
AND sh.shared_with_user_id = auth.uid()
)
-- Legacy project-scoped scenarios (mig 145) — visible via project
-- team membership.
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id
AND s.owner_id IS NULL
AND s.project_id IS NOT NULL
AND paliad.can_see_project(s.project_id)
)
-- Legacy abstract scenarios (mig 145) — owner-only via created_by.
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id
AND s.owner_id IS NULL
AND s.project_id IS NULL
AND s.created_by = auth.uid()
);
$func$;
COMMENT ON FUNCTION paliad.can_see_scenario(uuid) IS
'Returns true if the caller (auth.uid()) can see the given scenario. '
'Mirrors paliad.can_see_project. Covers builder-owned scenarios '
'(owner_id), read-only shares (scenario_shares), and the two legacy '
'paths from mig 145 (project-scoped via can_see_project, abstract '
'via created_by). Used by RLS on all four scenario_* tables.';
-- ----------------------------------------------------------------
-- 7. RLS — replace legacy scenarios policies + new tables
-- ----------------------------------------------------------------
-- Replace mig-145's four policies with a single pair that handles
-- builder + legacy shapes together.
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
CREATE POLICY scenarios_select ON paliad.scenarios
FOR SELECT USING (paliad.can_see_scenario(id));
-- Write rule: builder owner, legacy project team member (if no owner),
-- or legacy abstract creator (if no owner + no project). Shares are
-- read-only — they don't grant mutate.
CREATE POLICY scenarios_owner_mutate ON paliad.scenarios
FOR ALL
USING (
owner_id = auth.uid()
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
)
WITH CHECK (
owner_id = auth.uid()
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
);
-- scenario_proceedings — visibility piggybacks on the parent scenario.
ALTER TABLE paliad.scenario_proceedings ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_proceedings_select ON paliad.scenario_proceedings
FOR SELECT USING (paliad.can_see_scenario(scenario_id));
CREATE POLICY scenario_proceedings_mutate ON paliad.scenario_proceedings
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- scenario_events — visibility piggybacks on the parent scenario via
-- the proceeding row.
ALTER TABLE paliad.scenario_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_events_select ON paliad.scenario_events
FOR SELECT
USING (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
WHERE sp.id = scenario_proceeding_id
AND paliad.can_see_scenario(sp.scenario_id)
));
CREATE POLICY scenario_events_mutate ON paliad.scenario_events
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
JOIN paliad.scenarios s ON s.id = sp.scenario_id
WHERE sp.id = scenario_proceeding_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
JOIN paliad.scenarios s ON s.id = sp.scenario_id
WHERE sp.id = scenario_proceeding_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- scenario_shares — recipient can see their share rows; the scenario
-- owner (or legacy editor) can manage them.
ALTER TABLE paliad.scenario_shares ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_shares_select ON paliad.scenario_shares
FOR SELECT
USING (
shared_with_user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
)
);
CREATE POLICY scenario_shares_mutate ON paliad.scenario_shares
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- ----------------------------------------------------------------
-- 8. updated_at triggers on the new tables (reuse the function mig 145
-- already created for paliad.scenarios).
-- ----------------------------------------------------------------
CREATE TRIGGER scenario_proceedings_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenario_proceedings
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
CREATE TRIGGER scenario_events_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenario_events
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
-- ----------------------------------------------------------------
-- 9. Informational NOTICE.
-- ----------------------------------------------------------------
DO $$
BEGIN
RAISE NOTICE '[mig 157] paliad.scenarios extended with builder columns (0 legacy rows affected)';
RAISE NOTICE '[mig 157] paliad.scenario_proceedings created';
RAISE NOTICE '[mig 157] paliad.scenario_events created';
RAISE NOTICE '[mig 157] paliad.scenario_shares created';
RAISE NOTICE '[mig 157] paliad.projects.origin_scenario_id added';
RAISE NOTICE '[mig 157] paliad.can_see_scenario(uuid) created';
END $$;
COMMIT;

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,199 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-346 / m/paliad#153 B3 — universal search for the Litigation
// Builder. Returns events + scenarios + projects (Akten) keyed by type
// so the search dropdown can render typed result groups.
//
// GET /api/builder/search?q=<term>&limit=<n>
//
// Response shape:
//
// {
// "query": "<echoed q>",
// "events": [ EventSearchHit, ... ], // anchor_rule_id + proceeding_type embedded
// "scenarios": [ { id, name, status, updated_at }, ... ],
// "projects": [ { id, title, type, reference, case_number, matter_number, client_number }, ... ],
// "counts": { "events": N, "scenarios": M, "projects": K }
// }
//
// Each group is independently capped (default 8 events / 5 scenarios /
// 5 projects, max 30 per group). Missing services degrade gracefully —
// an unavailable group is returned as an empty array, not an error,
// so a knowledge-only deploy (DATABASE_URL unset) can still serve a
// best-effort empty response shape rather than a 503 wall.
type builderSearchScenarioHit struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
UpdatedAt string `json:"updated_at"`
}
type builderSearchProjectHit struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Reference *string `json:"reference,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
MatterNumber *string `json:"matter_number,omitempty"`
ClientNumber *string `json:"client_number,omitempty"`
}
type builderSearchResponse struct {
Query string `json:"query"`
Events []services.EventSearchHit `json:"events"`
Scenarios []builderSearchScenarioHit `json:"scenarios"`
Projects []builderSearchProjectHit `json:"projects"`
Counts builderSearchCounts `json:"counts"`
}
type builderSearchCounts struct {
Events int `json:"events"`
Scenarios int `json:"scenarios"`
Projects int `json:"projects"`
}
// handleBuilderSearch — GET /api/builder/search?q=<term>&limit=<n>
//
// Auth required. Returns 200 with empty groups when q is empty (matches
// the fristenrechner search ergonomic — frontend can boot without a
// pre-flight round trip).
func handleBuilderSearch(w http.ResponseWriter, r *http.Request) {
uid, ok := requireUser(w, r)
if !ok {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
perGroupLimit := parseBuilderSearchLimit(r.URL.Query().Get("limit"))
resp := builderSearchResponse{
Query: q,
Events: []services.EventSearchHit{},
Scenarios: []builderSearchScenarioHit{},
Projects: []builderSearchProjectHit{},
}
if q == "" {
// Match fristenrechner search: empty query → empty groups, not 400.
writeJSON(w, http.StatusOK, resp)
return
}
ctx := r.Context()
// Events: reuse the SearchEvents shape so anchor_rule_id +
// proceeding_type travel with each hit. UPC v1 (PRD §0.4) — the
// jurisdiction filter pins the corpus the builder serves today.
if dbSvc != nil && dbSvc.deadlineSearch != nil {
eventsResp, err := dbSvc.deadlineSearch.SearchEvents(ctx, q, services.EventSearchOptions{
Jurisdiction: "UPC",
Limit: perGroupLimit.events,
})
if err == nil && eventsResp != nil {
resp.Events = eventsResp.Events
}
}
// Scenarios: caller's own scenarios filtered by ILIKE on name.
// Borrows ListMyScenarios + filters in-memory; the list endpoint
// already caps at the small per-user fan-out and there's no index
// on (owner_id, name) yet — in-memory filter is cheap at 10s-of-
// rows scale.
if dbSvc != nil && dbSvc.scenarioBuilder != nil {
scenarios, err := dbSvc.scenarioBuilder.ListMyScenarios(ctx, uid, "active")
if err == nil {
needle := strings.ToLower(q)
hits := []builderSearchScenarioHit{}
for _, sc := range scenarios {
if !strings.Contains(strings.ToLower(sc.Name), needle) {
continue
}
hits = append(hits, builderSearchScenarioHit{
ID: sc.ID,
Name: sc.Name,
Status: sc.Status,
UpdatedAt: sc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
if len(hits) >= perGroupLimit.scenarios {
break
}
}
resp.Scenarios = hits
}
}
// Projects (Akten): visible projects filtered by trigram/ILIKE on
// title, reference, client_number, matter_number. ProjectService.List
// already applies team-based RLS via visibilityPredicate.
if dbSvc != nil && dbSvc.projects != nil {
projects, err := dbSvc.projects.List(ctx, uid, services.ProjectFilter{
Search: q,
})
if err == nil {
hits := make([]builderSearchProjectHit, 0, len(projects))
for _, p := range projects {
hits = append(hits, builderSearchProjectHit{
ID: p.ID,
Type: p.Type,
Title: p.Title,
Reference: p.Reference,
CaseNumber: p.CaseNumber,
MatterNumber: p.MatterNumber,
ClientNumber: p.ClientNumber,
})
if len(hits) >= perGroupLimit.projects {
break
}
}
resp.Projects = hits
}
}
resp.Counts = builderSearchCounts{
Events: len(resp.Events),
Scenarios: len(resp.Scenarios),
Projects: len(resp.Projects),
}
writeJSON(w, http.StatusOK, resp)
}
type builderSearchPerGroup struct {
events int
scenarios int
projects int
}
// parseBuilderSearchLimit reads ?limit=<n> as a hint for the events
// group (largest expected hit count). Scenarios + projects use smaller
// caps because their drop-down rows are visually heavier. The shared
// caller-supplied bound is interpreted as the events cap; scenarios
// and projects are derived from it.
func parseBuilderSearchLimit(raw string) builderSearchPerGroup {
def := builderSearchPerGroup{events: 8, scenarios: 5, projects: 5}
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if n > 30 {
n = 30
}
return builderSearchPerGroup{
events: n,
scenarios: max(1, n/2),
projects: max(1, n/2),
}
}

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

@@ -91,6 +91,19 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
// slugs are silently dropped (no filter) so a stale frontend
// chip doesn't 400 the request.
AppealTarget string `json:"appealTarget,omitempty"`
// t-paliad-348 / yoUPC#178 — surface the engine's two new
// CalcOptions axes to the HTTP boundary:
//
// IncludeOptional: when true, priority='optional' rules
// surface on the timeline. Default false matches the
// engine's default (mandatory backbone only).
// TriggerEventAnchors: per-event-code anchor dates the
// engine consults for rules carrying trigger_event_id.
// When a rule's anchor is absent the engine renders the
// rule as IsConditional rather than fabricating a date
// off the proceeding's trigger date.
IncludeOptional bool `json:"includeOptional,omitempty"`
TriggerEventAnchors map[string]string `json:"triggerEventAnchors,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -130,15 +143,17 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
}
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden,
AppealTarget: req.AppealTarget,
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden,
AppealTarget: req.AppealTarget,
IncludeOptional: req.IncludeOptional,
TriggerEventAnchors: req.TriggerEventAnchors,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {

View File

@@ -128,6 +128,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
@@ -142,6 +146,12 @@ type Services struct {
// and per-rule selection state (`rule:<uuid>` keys).
ScenarioFlags *services.ScenarioFlagsService
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder. CRUD over the
// new normalised scenario shape (paliad.scenarios with owner_id +
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
// Nil when DATABASE_URL is unset — /api/builder/scenarios* routes 503.
ScenarioBuilder *services.ScenarioBuilderService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -209,9 +219,11 @@ 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,
scenarioBuilder: svc.ScenarioBuilder,
}
}
@@ -448,6 +460,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.
@@ -514,6 +532,39 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder API over the
// new normalised scenario shape (mig 157). Coexists with the legacy
// /api/scenarios surface during the B0→B6 migration; B6 cleanup
// 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)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingPatch)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings/{pid}/events", handleBuilderEventCreate)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventPatch)
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)
// m/paliad#153 B3 — universal search (events + scenarios + projects).
protected.HandleFunc("GET /api/builder/search", handleBuilderSearch)
// Dev-only test route — gated to PaliadinOwnerEmail (m).
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
@@ -712,6 +763,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))

View File

@@ -77,6 +77,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
@@ -85,6 +88,11 @@ type dbServices struct {
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
scenarioFlags *services.ScenarioFlagsService
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder over the new
// normalised scenario shape (paliad.scenarios with owner_id +
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
scenarioBuilder *services.ScenarioBuilderService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,728 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-340 / m/paliad#153 B0 — REST endpoints over the new normalised
// scenario builder shape (paliad.scenarios with owner_id, +
// paliad.scenario_proceedings / scenario_events / scenario_shares).
//
// Endpoints live under /api/builder/scenarios/* to avoid clashing with
// the legacy /api/scenarios/* endpoints from m/paliad#124 Slice D. The
// B6 cleanup slice retires the legacy surface; until then both shapes
// coexist on the same paliad.scenarios table (the legacy paths require
// project_id IS NOT NULL OR an abstract created_by = caller; the builder
// paths require owner_id = caller).
//
// All handlers gate by requireScenarioBuilderService — 503 when the
// service is nil (DATABASE_URL unset). Auth is checked via requireUser;
// per-row visibility is enforced inside the service.
func requireScenarioBuilderService(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.scenarioBuilder == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Litigation-Builder ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return false
}
return true
}
// scenarioBuilderErrorToStatus maps service errors to HTTP statuses.
func scenarioBuilderErrorToStatus(err error) (int, string) {
switch {
case errors.Is(err, services.ErrScenarioBuilderNotVisible),
errors.Is(err, services.ErrNotVisible):
return http.StatusNotFound, "Szenario nicht gefunden"
case errors.Is(err, services.ErrInvalidInput):
return http.StatusBadRequest, err.Error()
}
return http.StatusInternalServerError, err.Error()
}
func writeBuilderError(w http.ResponseWriter, err error) {
status, msg := scenarioBuilderErrorToStatus(err)
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
// ---------------------------------------------------------------------------
// handleBuilderScenariosList — GET /api/builder/scenarios?status=<active|archived|promoted|all>
func handleBuilderScenariosList(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
status := r.URL.Query().Get("status")
out, err := dbSvc.scenarioBuilder.ListMyScenarios(r.Context(), uid, status)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioCreate — POST /api/builder/scenarios
func handleBuilderScenarioCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateBuilderScenarioInput
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.CreateScenario(r.Context(), uid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderScenarioGet — GET /api/builder/scenarios/{id}
func handleBuilderScenarioGet(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
out, err := dbSvc.scenarioBuilder.GetScenarioDeep(r.Context(), uid, id)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioPatch — PATCH /api/builder/scenarios/{id}
func handleBuilderScenarioPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
var input services.PatchBuilderScenarioInput
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.PatchScenario(r.Context(), uid, id, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Proceedings
// ---------------------------------------------------------------------------
// handleBuilderProceedingCreate — POST /api/builder/scenarios/{id}/proceedings
func handleBuilderProceedingCreate(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.AddProceedingInput
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.AddProceeding(r.Context(), uid, sid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderProceedingPatch — PATCH /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingPatch(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
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.PatchProceedingInput
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.PatchProceeding(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderProceedingDelete — DELETE /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingDelete(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
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteProceeding(r.Context(), uid, sid, pid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
// handleBuilderEventCreate — POST /api/builder/scenarios/{id}/proceedings/{pid}/events
func handleBuilderEventCreate(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
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.AddEventInput
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.AddEvent(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderEventPatch — PATCH /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventPatch(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
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
var input services.PatchEventInput
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.PatchEvent(r.Context(), uid, sid, eid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderEventDelete — DELETE /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventDelete(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
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteEvent(r.Context(), uid, sid, eid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Shares
// ---------------------------------------------------------------------------
// handleBuilderShareCreate — POST /api/builder/scenarios/{id}/shares
// Body: {"shared_with_user_id": "<uuid>"}
func handleBuilderShareCreate(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 body struct {
SharedWithUserID uuid.UUID `json:"shared_with_user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddShare(r.Context(), uid, sid, body.SharedWithUserID)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderShareDelete — DELETE /api/builder/scenarios/{id}/shares/{sid}
func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
scid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
shid, err := uuid.Parse(r.PathValue("sid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Share-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteShare(r.Context(), uid, scid, shid); err != nil {
writeBuilderError(w, err)
return
}
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)
// ---------------------------------------------------------------------------
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
//
// Returns every row of paliad.scenario_flag_catalog so the Litigation
// Builder can render per-triplet flag toggles without a per-project
// round-trip. The catalog itself is global (no jurisdiction or
// proceeding scope baked into the table); which flags actually apply
// to a given proceeding type is decided by the calc engine via
// condition_expr at calculation time. The client renders every catalog
// flag and lets the user toggle them — flags with no effect on the
// active proceeding's rules simply have no condition_expr referencing
// them, so toggling is a no-op.
//
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
// visibility checks aren't needed because the catalog is global.
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.scenarioFlags == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
if _, ok := requireUser(w, r); !ok {
return
}
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Flag-Katalog konnte nicht geladen werden",
})
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Dev-only test route
// ---------------------------------------------------------------------------
// handleBuilderDevTestPage — GET /dev/scenario-builder
//
// Gated to services.PaliadinOwnerEmail (the same single-owner gate the
// /paliadin route uses). Every other authenticated user gets 404. Pure
// HTML — no JS bundle — so the page works even before B1 wires the real
// builder shell. Renders curl-equivalent forms for the B0 surface so the
// schema can be exercised end-to-end without Postman / shell scripts.
//
// This is the "dev-only test route" the head's task spec asked for. It
// disappears in B6 cleanup once the production builder UI ships at
// /tools/procedures.
func handleBuilderDevTestPage(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(builderDevTestHTML))
}
const builderDevTestHTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Scenario Builder — Dev Test (B0)</title>
<style>
body { font-family: ui-monospace, Menlo, monospace; max-width: 880px; margin: 2em auto;
padding: 0 1em; color: #222; background: #fafaf7; }
h1, h2 { font-family: ui-sans-serif, system-ui, sans-serif; }
h1 { border-bottom: 4px solid #c6f41c; padding-bottom: .2em; }
section { background: #fff; border: 1px solid #ddd; border-radius: 4px;
padding: 1em 1.2em; margin: 1em 0; }
label { display: block; margin: .4em 0 .15em; font-size: .85em; color: #555; }
input, textarea, select, button { font: inherit; padding: .35em .5em; box-sizing: border-box; }
input[type="text"], input[type="number"], textarea { width: 100%; }
button { background: #c6f41c; border: 1px solid #9ec61f; cursor: pointer;
padding: .4em 1em; border-radius: 3px; margin: .2em 0; }
button.secondary { background: #eee; border-color: #ccc; }
pre.out { background: #1e1e1e; color: #e6e6e6; padding: .8em 1em; border-radius: 4px;
overflow: auto; max-height: 30em; font-size: .85em; }
.note { color: #777; font-size: .9em; }
.row { display: flex; gap: .5em; }
.row > * { flex: 1; }
</style>
</head>
<body>
<h1>Scenario Builder — Dev Test (B0)</h1>
<p class="note">t-paliad-340 / m/paliad#153 — DB-only slice. Exercises
paliad.scenarios (builder rows), scenario_proceedings, scenario_events,
scenario_shares via /api/builder/scenarios/*. Gated to PaliadinOwnerEmail.</p>
<section>
<h2>1. Liste meine Szenarien</h2>
<label>Status filter</label>
<select id="list-status">
<option value="">(default: alle)</option>
<option value="active">active</option>
<option value="archived">archived</option>
<option value="promoted">promoted</option>
<option value="all">all (explicit)</option>
</select>
<button onclick="listScenarios()">GET /api/builder/scenarios</button>
<pre class="out" id="list-out"></pre>
</section>
<section>
<h2>2. Szenario anlegen</h2>
<label>Name</label>
<input type="text" id="create-name" placeholder="(leer = Unbenanntes Szenario)">
<label>Notes (optional)</label>
<textarea id="create-notes" rows="2"></textarea>
<button onclick="createScenario()">POST /api/builder/scenarios</button>
<pre class="out" id="create-out"></pre>
</section>
<section>
<h2>3. Szenario abrufen (deep)</h2>
<label>Scenario ID</label>
<input type="text" id="get-id">
<button onclick="getScenario()">GET /api/builder/scenarios/{id}</button>
<pre class="out" id="get-out"></pre>
</section>
<section>
<h2>4. Verfahren hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="proc-sid">
<label>proceeding_type_id (integer)</label>
<input type="number" id="proc-pt-id" placeholder="z.B. 7 für upc.inf.cfi">
<label>primary_party</label>
<select id="proc-party">
<option value="">(none)</option>
<option value="claimant">claimant</option>
<option value="defendant">defendant</option>
</select>
<button onclick="addProceeding()">POST .../proceedings</button>
<pre class="out" id="proc-out"></pre>
</section>
<section>
<h2>5. Event-Karte hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="ev-sid">
<label>Proceeding ID</label>
<input type="text" id="ev-pid">
<label>custom_label (oder sequencing_rule_id / procedural_event_id)</label>
<input type="text" id="ev-label" placeholder="freitext-Karte">
<label>state</label>
<select id="ev-state">
<option value="planned">planned</option>
<option value="filed">filed</option>
<option value="skipped">skipped</option>
</select>
<button onclick="addEvent()">POST .../proceedings/{pid}/events</button>
<pre class="out" id="ev-out"></pre>
</section>
<section>
<h2>6. Status patchen (archive / restore)</h2>
<label>Scenario ID</label>
<input type="text" id="patch-sid">
<label>new status</label>
<select id="patch-status">
<option value="active">active</option>
<option value="archived">archived</option>
</select>
<button onclick="patchStatus()">PATCH /api/builder/scenarios/{id}</button>
<pre class="out" id="patch-out"></pre>
</section>
<script>
const j = (id, payload) =>
document.getElementById(id).textContent = JSON.stringify(payload, null, 2);
async function call(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
const text = await r.text();
let parsed = text;
try { parsed = JSON.parse(text); } catch (_) {}
return { status: r.status, body: parsed };
}
async function listScenarios() {
const status = document.getElementById('list-status').value;
const q = status ? '?status=' + encodeURIComponent(status) : '';
j('list-out', await call('GET', '/api/builder/scenarios' + q));
}
async function createScenario() {
const name = document.getElementById('create-name').value;
const notes = document.getElementById('create-notes').value;
const body = {};
if (name) body.name = name;
if (notes) body.notes = notes;
j('create-out', await call('POST', '/api/builder/scenarios', body));
}
async function getScenario() {
const id = document.getElementById('get-id').value.trim();
if (!id) return j('get-out', { error: 'ID erforderlich' });
j('get-out', await call('GET', '/api/builder/scenarios/' + id));
}
async function addProceeding() {
const sid = document.getElementById('proc-sid').value.trim();
const ptID = parseInt(document.getElementById('proc-pt-id').value, 10);
const party = document.getElementById('proc-party').value;
if (!sid || !ptID) return j('proc-out', { error: 'sid + proceeding_type_id erforderlich' });
const body = { proceeding_type_id: ptID };
if (party) body.primary_party = party;
j('proc-out', await call('POST', '/api/builder/scenarios/' + sid + '/proceedings', body));
}
async function addEvent() {
const sid = document.getElementById('ev-sid').value.trim();
const pid = document.getElementById('ev-pid').value.trim();
const label = document.getElementById('ev-label').value.trim();
const state = document.getElementById('ev-state').value;
if (!sid || !pid || !label) return j('ev-out', { error: 'sid + pid + custom_label erforderlich' });
j('ev-out', await call('POST',
'/api/builder/scenarios/' + sid + '/proceedings/' + pid + '/events',
{ custom_label: label, state }));
}
async function patchStatus() {
const sid = document.getElementById('patch-sid').value.trim();
const status = document.getElementById('patch-status').value;
if (!sid) return j('patch-out', { error: 'sid erforderlich' });
j('patch-out', await call('PATCH', '/api/builder/scenarios/' + sid, { status }));
}
</script>
</body>
</html>`

View File

@@ -44,6 +44,7 @@ import (
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// submissionDraftPreviewTimeout caps a single preview round-trip.
@@ -115,10 +116,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 +131,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 +175,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 +208,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 +451,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 +542,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 +598,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
filename := submissionFileName(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 +622,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 +674,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 +941,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 +972,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 +1018,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 +1028,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 +1154,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()
@@ -1155,6 +1264,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,11 +1310,11 @@ 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
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
)
// resolveSubmissionTemplate returns the .docx bytes for the given
@@ -1306,21 +1432,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,144 @@
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 := submissionFileName(tc.rule, tc.project, tc.lang, tc.keyword)
if got != tc.want {
t.Errorf("submissionFileName() = %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"}},
"",
},
{
"key set",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": "Replik"}},
"Replik",
},
{
"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

@@ -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 override store, so
// the keyword stays the auto-derived rule name (t-paliad-354).
filename := submissionFileName(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,66 @@ 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 {
// submissionNoCaseNumberPlaceholder fills the bracketed case-number slot
// when the project has no Aktenzeichen yet. Kept as a named const so the
// wording is one-line changeable (m left the exact text open, t-paliad-354).
const submissionNoCaseNumberPlaceholder = "Az. folgt"
// submissionFileName produces the user-facing download name
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
//
// - Date first (Europe/Berlin) so the files sort chronologically.
// - keyword is the user override when set, else the lang-aware rule
// name, else "submission".
// - The case number is always rendered in parentheses; when the project
// has no Aktenzeichen it falls back to submissionNoCaseNumberPlaceholder.
//
// Each segment is run through SanitiseSubmissionFileName (umlaut-folds for
// legacy SMB shares, strips the Windows-reserved set so a case number like
// "UPC_CFI_123/2026" stays safe) while the assembled "<date> <kw> (<case>)"
// frame keeps its spaces and brackets — the sanitiser preserves both.
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
}
ruleName := strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
ruleName = strings.TrimSpace(rule.NameEN)
kw := strings.TrimSpace(keyword)
if kw == "" {
kw = strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
kw = strings.TrimSpace(rule.NameEN)
}
}
if ruleName == "" {
ruleName = "submission"
if kw == "" {
kw = "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))
if caseNo == "" {
caseNo = submissionNoCaseNumberPlaceholder
}
parts = append(parts, day.Format("2006-01-02"))
return strings.Join(parts, "-") + ".docx"
return fmt.Sprintf("%s %s (%s).docx",
day.Format("2006-01-02"),
services.SanitiseSubmissionFileName(kw),
services.SanitiseSubmissionFileName(caseNo),
)
}
// submissionFilenameKeyword pulls the user's filename keyword override
// from a saved draft's composer_meta jsonb (t-paliad-354). Empty when the
// key is absent or blank — callers then fall back to the auto-derived rule
// name inside submissionFileName. The one-click /generate path has no draft
// row and always passes "".
func submissionFilenameKeyword(d *services.SubmissionDraft) string {
if d == nil || d.ComposerMeta == nil {
return ""
}
if v, ok := d.ComposerMeta["filename_keyword"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
// 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

@@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
query := `
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,

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

@@ -145,7 +145,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
AND pe.event_kind = $%d
)`, opts.EventKind)
}
query := `SELECT code, name, name_en, jurisdiction
query := `SELECT id, code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY sort_order`
@@ -160,7 +160,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
for rows.Next() {
var t lp.FristenrechnerType
var juris sql.NullString
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
return nil, err
}
if juris.Valid {

View File

@@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides(
}
var dRows []drow
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
FROM paliad.deadlines d
WHERE ` + scopeFilter
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,658 @@
package services
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestScenarioBuilderService exercises the t-paliad-340 / m/paliad#153 B0
// surface end-to-end against a live DB: create + list + deep-get + patch
// + add-proceeding + add-event + add/delete-share, plus the visibility
// negative case (a non-owner can't see the scenario unless shared).
//
// Skipped without TEST_DATABASE_URL — matches the pattern in
// project_service_test.go / event_choice_service_test.go.
func TestScenarioBuilderService(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()
other := uuid.New()
cleanup := func() {
// Cascade order: delete from scenarios → CASCADE clears
// proceedings, events, shares. Then the two users.
pool.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE owner_id IN ($1, $2)`, owner, other)
pool.ExecContext(ctx,
`DELETE FROM paliad.users WHERE id IN ($1, $2)`, owner, other)
pool.ExecContext(ctx,
`DELETE FROM auth.users WHERE id IN ($1, $2)`, owner, other)
}
cleanup()
defer cleanup()
for _, seed := range []struct {
id uuid.UUID
email string
name string
}{
{owner, "builder-owner-test@hlc.com", "Builder Owner"},
{other, "builder-other-test@hlc.com", "Builder Other"},
} {
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
seed.id, seed.email); err != nil {
t.Fatalf("seed auth.users %s: %v", seed.email, err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, lang)
VALUES ($1, $2, $3, 'munich', 'de')`,
seed.id, seed.email, seed.name); err != nil {
t.Fatalf("seed paliad.users %s: %v", seed.email, err)
}
}
// Pick a real proceeding_type_id so the FK insert succeeds.
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)
}
svc := NewScenarioBuilderService(pool, nil, nil, nil)
// 1. Create a scenario for the owner. Empty name should default.
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
if err != nil {
t.Fatalf("CreateScenario: %v", err)
}
if sc.Name != "Unbenanntes Szenario" {
t.Errorf("default name = %q, want %q", sc.Name, "Unbenanntes Szenario")
}
if sc.Status != "active" {
t.Errorf("default status = %q, want active", sc.Status)
}
if sc.OwnerID == nil || *sc.OwnerID != owner {
t.Errorf("owner_id = %v, want %v", sc.OwnerID, owner)
}
// 2. List — should return the one row.
list, err := svc.ListMyScenarios(ctx, owner, "active")
if err != nil {
t.Fatalf("ListMyScenarios: %v", err)
}
if len(list) != 1 || list[0].ID != sc.ID {
t.Errorf("ListMyScenarios returned %d rows; want 1 with id %s", len(list), sc.ID)
}
// 3. Other user can NOT see the scenario.
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("GetScenarioDeep by non-owner = %v, want ErrScenarioBuilderNotVisible", err)
}
// 4. Add a proceeding.
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)
}
if pr.ProceedingTypeID != ptID {
t.Errorf("ProceedingTypeID = %d, want %d", pr.ProceedingTypeID, ptID)
}
if pr.PrimaryParty == nil || *pr.PrimaryParty != "defendant" {
t.Errorf("PrimaryParty = %v, want defendant", pr.PrimaryParty)
}
// 5. Add a custom-label event card.
ev, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
CustomLabel: ptrString("Klageerwiderung"),
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent: %v", err)
}
if ev.State != "planned" {
t.Errorf("event state = %q, want planned", ev.State)
}
// 5b. Add-event with NO anchor fields fails.
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("AddEvent without anchor = %v, want ErrInvalidInput", err)
}
// 6. Deep get — should bundle the scenario + 1 proceeding + 1 event + 0 shares.
deep, err := svc.GetScenarioDeep(ctx, owner, sc.ID)
if err != nil {
t.Fatalf("GetScenarioDeep: %v", err)
}
if len(deep.Proceedings) != 1 || deep.Proceedings[0].ID != pr.ID {
t.Errorf("deep proceedings count=%d want 1; ids: %+v", len(deep.Proceedings), deep.Proceedings)
}
if len(deep.Events) != 1 || deep.Events[0].ID != ev.ID {
t.Errorf("deep events count=%d want 1; ids: %+v", len(deep.Events), deep.Events)
}
if len(deep.Shares) != 0 {
t.Errorf("deep shares count=%d want 0", len(deep.Shares))
}
// 7. Share with `other`. Recipient should now see the scenario.
sh, err := svc.AddShare(ctx, owner, sc.ID, other)
if err != nil {
t.Fatalf("AddShare: %v", err)
}
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); err != nil {
t.Errorf("GetScenarioDeep by share recipient: %v", err)
}
// But the recipient can NOT add proceedings.
if _, err := svc.AddProceeding(ctx, other, sc.ID, AddProceedingInput{
ProceedingTypeID: ptID,
}); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("AddProceeding by share recipient = %v, want ErrScenarioBuilderNotVisible", err)
}
// 7b. Self-share should be rejected.
if _, err := svc.AddShare(ctx, owner, sc.ID, owner); !errors.Is(err, ErrInvalidInput) {
t.Errorf("self-share = %v, want ErrInvalidInput", err)
}
// 8. Patch — archive then re-activate.
patched, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("archived"),
})
if err != nil {
t.Fatalf("PatchScenario archive: %v", err)
}
if patched.Status != "archived" {
t.Errorf("after archive, status = %q, want archived", patched.Status)
}
// PATCH to 'promoted' is rejected — that's the wizard's job.
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("promoted"),
}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("PATCH status=promoted = %v, want ErrInvalidInput", err)
}
patched, err = svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("active"),
})
if err != nil {
t.Fatalf("PatchScenario re-activate: %v", err)
}
if patched.Status != "active" {
t.Errorf("after re-activate, status = %q, want active", patched.Status)
}
// 9. Revoke the share. Recipient loses visibility.
if err := svc.DeleteShare(ctx, owner, sc.ID, sh.ID); err != nil {
t.Fatalf("DeleteShare: %v", err)
}
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("after revoke, recipient GetScenarioDeep = %v, want ErrScenarioBuilderNotVisible", err)
}
}
// 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,178 @@
package services
// Auto-naming for freshly-created submission drafts (t-paliad-352 /
// m/paliad#155). A new project-bound draft gets a sortable, legal-
// convention default title instead of the bare "Entwurf N" counter:
//
// <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.1 customization hook: the template is hardcoded here in v1. When m
// promotes naming to a per-user / per-firm / per-base setting (issue
// #155 Q4), the override string lands as an extra parameter on
// AutoSubmissionTitle (or a small template struct) and the segment
// resolvers below stay as the value source. Nothing else needs to move.
import (
"strings"
"time"
"mgit.msbls.de/m/paliad/internal/models"
)
// submissionTitleSep is the separator between identity segments —
// " ./. " is the German legal convention for "gegen" / "versus".
const submissionTitleSep = " ./. "
// AutoSubmissionTitle assembles the auto-generated draft title from the
// resolved identity pieces. Pure and table-testable — every DB hop
// happens in the caller (SubmissionDraftService.autoNameForProject).
//
// clientName is passed separately because the client we act for is the
// root ancestor of the project tree, not a field on the draft's own
// project node; the caller walks the path to resolve it. ourSide and
// the proceeding type both come off the draft's project node, the
// parties hang directly off it.
//
// The date is always present (formatted in Europe/Berlin to match the
// today.* render vars); the three identity segments are appended only
// when non-empty.
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
loc, _ := time.LoadLocation("Europe/Berlin")
if loc != nil {
now = now.In(loc)
}
date := now.Format("2006-01-02")
segments := make([]string, 0, 3)
if c := strings.TrimSpace(clientName); c != "" {
segments = append(segments, c)
}
if f := submissionForumShort(pt); f != "" {
segments = append(segments, f)
}
ourSide := ""
if project != nil {
ourSide = derefString(project.OurSide)
}
if o := submissionOpponentName(parties, ourSide); o != "" {
segments = append(segments, o)
}
if len(segments) == 0 {
return date
}
return date + " " + strings.Join(segments, submissionTitleSep)
}
// submissionForumShort maps a proceeding type to the short forum label
// used in the auto-name. The jurisdiction is the forum for the
// supranational / office tracks (UPC, EPA, DPMA); German court
// proceedings disambiguate by the court that hears them (LG / OLG /
// BGH / BPatG), which is the tail segment of the proceeding code
// (de.inf.lg → LG, de.null.bpatg → BPatG). nil / unknown → "".
func submissionForumShort(pt *models.ProceedingType) string {
if pt == nil {
return ""
}
switch j := strings.ToUpper(strings.TrimSpace(derefString(pt.Jurisdiction))); j {
case "":
return ""
case "DE":
return germanCourtShort(pt.Code)
default:
// UPC / EPA / DPMA and any future jurisdiction are their own
// forum label.
return j
}
}
// germanCourtShort returns the court abbreviation from the tail segment
// of a German proceeding code (the part after the last "."). Known
// courts get their canonical casing; anything else falls back to the
// uppercased tail so a new German proceeding still yields a label.
func germanCourtShort(code string) string {
parts := strings.Split(code, ".")
tail := strings.ToLower(strings.TrimSpace(parts[len(parts)-1]))
switch tail {
case "":
return ""
case "lg":
return "LG"
case "olg":
return "OLG"
case "bgh":
return "BGH"
case "bpatg":
return "BPatG"
default:
return strings.ToUpper(tail)
}
}
// submissionOpponentName picks the name of the primary opposing party
// given the side we act for. We act actively (claimant / applicant /
// appellant) → the opponent is on the defendant bucket; we act
// reactively (defendant / respondent) → the opponent is the claimant.
// An unknown / unset side (third_party, other, NULL) can't fix a
// posture, so no opponent is derived (the segment is omitted). The
// first party of the opposing bucket wins — PartyService.ListForProject
// orders by name, so the pick is deterministic for a given project.
func submissionOpponentName(parties []models.Party, ourSide string) string {
var want string
switch sidePosture(ourSide) {
case "active":
want = "defendant"
case "reactive":
want = "claimant"
default:
return ""
}
for i := range parties {
if partyRoleBucket(parties[i].Role) == want {
if n := strings.TrimSpace(parties[i].Name); n != "" {
return n
}
}
}
return ""
}
// sidePosture folds the our_side sub-role vocabulary (t-paliad-222)
// down to the active / reactive axis. Returns "" for sides that have no
// clear posture (third_party, other) or an unset value.
func sidePosture(ourSide string) string {
switch strings.ToLower(strings.TrimSpace(ourSide)) {
case "claimant", "applicant", "appellant":
return "active"
case "defendant", "respondent":
return "reactive"
default:
return ""
}
}
// partyRoleBucket folds a party's free-text role into the
// claimant / defendant / other buckets. German and English spellings
// both fold in; everything else (Streithelfer, Patentinhaberin, …) is
// "other". Shared with addPartyVars so the two paths can't drift.
func partyRoleBucket(role *string) string {
switch strings.ToLower(strings.TrimSpace(derefString(role))) {
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
return "claimant"
case "defendant", "beklagter", "beklagte":
return "defendant"
default:
return "other"
}
}

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,111 @@
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.
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, _ := got.ComposerMeta["filename_keyword"].(string); v != kw {
t.Fatalf("after set: filename_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, _ := reload.ComposerMeta["filename_keyword"].(string); v != kw {
t.Fatalf("reload: filename_keyword = %q, want %q", v, kw)
}
// Clear the override (empty string) — only filename_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 _, present := cleared.ComposerMeta["filename_keyword"]; present {
t.Fatalf("after clear: filename_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")
}
}

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,94 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
return &d, nil
}
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
// suffix if two callers race; the unique constraint on the table is
// the final guard.
// newDraftName picks the title for a freshly-created draft. Project-
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
// "<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
}
if project != nil {
auto, err := s.autoNameForProject(ctx, time.Now(), project)
if err != nil {
return "", err
}
if strings.TrimSpace(auto) != "" {
return uniqueDraftName(auto, existing), nil
}
}
return nextDraftName(existing, lang), nil
}
// autoNameForProject resolves the three identity segments for a
// project-bound draft and hands them to the pure AutoSubmissionTitle
// assembler. The client is the root ancestor of the project tree (the
// 'client' node), the proceeding type and our_side come off the draft's
// own project node, and the parties hang directly off it.
//
// A failure to resolve the client / proceeding type is not fatal —
// AutoSubmissionTitle just omits the empty segment — so the only errors
// returned here are genuine DB faults.
func (s *SubmissionDraftService) autoNameForProject(ctx context.Context, now time.Time, project *models.Project) (string, error) {
clientName, err := s.clientNameForProject(ctx, project.ID)
if err != nil {
return "", err
}
pt, err := s.vars.loadProceedingType(ctx, project.ProceedingTypeID)
if err != nil {
return "", err
}
var parties []models.Party
if err := s.db.SelectContext(ctx, &parties,
`SELECT id, project_id, name, role, representative, contact_info,
created_at, updated_at
FROM paliad.parties
WHERE project_id = $1
ORDER BY name`, project.ID); err != nil {
return "", fmt.Errorf("auto-name: load parties: %w", err)
}
return AutoSubmissionTitle(now, clientName, project, parties, pt), nil
}
// clientNameForProject returns the title of the 'client' ancestor in
// the project's path (the firm's mandant). Empty string when the tree
// has no client node — the auto-name then omits the client segment.
func (s *SubmissionDraftService) clientNameForProject(ctx context.Context, projectID uuid.UUID) (string, error) {
var title string
err := s.db.GetContext(ctx, &title,
`SELECT p.title
FROM paliad.projects target
JOIN paliad.projects p
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = $1 AND p.type = 'client'
LIMIT 1`, projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("auto-name: resolve client name: %w", err)
}
return title, nil
}
// existingDraftNames returns the names already in use for the
// (project, submission_code, user) slot. A nil projectID scopes to the
// user's project-less drafts for this submission_code — matching the
// DB unique contract (project_id, submission_code, user_id, name) where
// project_id IS NULL is its own equivalence class.
func (s *SubmissionDraftService) existingDraftNames(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID) ([]string, error) {
var names []string
var err error
if projectID == nil {
@@ -446,16 +544,48 @@ func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *u
*projectID, submissionCode, userID)
}
if err != nil {
return "", fmt.Errorf("scan existing draft names: %w", err)
return nil, fmt.Errorf("scan existing draft names: %w", err)
}
return names, nil
}
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
// suffix if two callers race; the unique constraint on the table is
// the final guard. Pure over the supplied name list.
func nextDraftName(existing []string, lang string) string {
prefix := "Entwurf"
if strings.EqualFold(lang, "en") {
prefix = "Draft"
}
highest := 0
for _, n := range names {
for _, n := range existing {
var idx int
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
highest = idx
}
}
return fmt.Sprintf("%s %d", prefix, highest+1), nil
return fmt.Sprintf("%s %d", prefix, highest+1)
}
// uniqueDraftName returns base unchanged when it's free, otherwise
// appends " (N)" with the lowest N≥2 that isn't taken. Mirrors the
// "race → unique constraint is the final guard" contract of
// nextDraftName; pure over the supplied name list.
func uniqueDraftName(base string, existing []string) string {
taken := make(map[string]struct{}, len(existing))
for _, n := range existing {
taken[n] = struct{}{}
}
if _, clash := taken[base]; !clash {
return base
}
for i := 2; ; i++ {
cand := fmt.Sprintf("%s (%d)", base, i)
if _, clash := taken[cand]; !clash {
return cand
}
}
}
// Update patches the draft. Variables is replace-semantics — pass the
@@ -567,6 +697,30 @@ 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 {
// Targeted jsonb merge so other composer_meta keys survive. An
// empty override removes the key entirely, restoring the
// auto-derived rule name as the filename keyword (t-paliad-354).
kw := strings.TrimSpace(*patch.FilenameKeyword)
if kw == "" {
setParts = append(setParts, "composer_meta = composer_meta - 'filename_keyword'")
} else {
setParts = append(setParts,
fmt.Sprintf("composer_meta = composer_meta || jsonb_build_object('filename_keyword', $%d::text)", idx))
args = append(args, kw)
idx++
}
}
if len(setParts) == 0 {
return existing, nil
}
@@ -878,7 +1032,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,486 +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 {
// 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,17 @@ 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},
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
}
@@ -404,11 +412,10 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
var claimants, defendants, others []models.Party
for i := range parties {
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
switch role {
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
switch partyRoleBucket(parties[i].Role) {
case "claimant":
claimants = append(claimants, parties[i])
case "defendant", "beklagter", "beklagte":
case "defendant":
defendants = append(defendants, parties[i])
default:
others = append(others, parties[i])

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,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,200 @@
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 = 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{},
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.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)"),
}
}
// 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,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
}

View File

@@ -1,4 +1,4 @@
package services
package docx
// Unit tests for the Composer's Markdown → OOXML walker (t-paliad-313
// Slice B). Pure function; no DB dependency.
@@ -86,6 +86,50 @@ func TestRenderMarkdownToOOXML_PlaceholdersPassThrough(t *testing.T) {
}
}
func TestRenderMarkdownToOOXML_PlaceholderUnderscoresPreserved(t *testing.T) {
// Regression: a placeholder key containing underscores (project.case_number,
// user.display_name, project.patent_number_upc) used to get its underscores
// consumed by the italic/bold inline scanner — the OOXML stored
// {{project.casenumber}} and the preview surfaced
// [KEIN WERT: project.casenumber] instead of the real value.
cases := []string{
"{{project.case_number}}",
"{{user.display_name}}",
"{{project.patent_number_upc}}",
"prefix {{project.case_number}} suffix",
"two: {{a.b_c}} and {{d.e_f}}",
"mixed: _italic_ then {{project.case_number}} then __bold__",
}
for _, in := range cases {
out := RenderMarkdownToOOXML(in, "Normal")
// Every placeholder substring in the input must appear verbatim
// in the output (XML escaping is irrelevant for {} and _).
for _, ph := range extractPlaceholders(in) {
if !strings.Contains(out, ph) {
t.Errorf("input %q: placeholder %q lost; got %q", in, ph, out)
}
}
}
}
// extractPlaceholders pulls every {{...}} occurrence out of a Markdown
// source. Tiny helper, only used by the regression test above.
func extractPlaceholders(s string) []string {
var out []string
for {
start := strings.Index(s, "{{")
if start < 0 {
return out
}
end := strings.Index(s[start+2:], "}}")
if end < 0 {
return out
}
out = append(out, s[start:start+2+end+2])
s = s[start+2+end+2:]
}
}
func TestRenderMarkdownToOOXML_XMLEscape(t *testing.T) {
out := RenderMarkdownToOOXML("a & b < c > d", "")
if strings.Contains(out, " & ") {
@@ -112,39 +156,6 @@ func TestRenderMarkdownToOOXML_CRLFNormalisation(t *testing.T) {
}
}
func TestParseInlineSpans_Plain(t *testing.T) {
spans := parseInlineSpans("hello world")
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
t.Errorf("expected single plain span; got %+v", spans)
}
}
func TestParseInlineSpans_UnderscoreItalic(t *testing.T) {
spans := parseInlineSpans("_emph_")
var italicHits int
for _, s := range spans {
if s.Italic && s.Text == "emph" {
italicHits++
}
}
if italicHits != 1 {
t.Errorf("expected one italic 'emph' span; got %+v", spans)
}
}
func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
spans := parseInlineSpans("__strong__")
var boldHits int
for _, s := range spans {
if s.Bold && s.Text == "strong" {
boldHits++
}
}
if boldHits != 1 {
t.Errorf("expected one bold 'strong' span; got %+v", spans)
}
}
// ─────────────────────────────────────────────────────────────────────
// Slice D — rich-prose constructs
// ─────────────────────────────────────────────────────────────────────
@@ -265,35 +276,3 @@ func TestRenderMarkdownToOOXML_HyperlinkNilAllocatorFallsBackToPlain(t *testing.
t.Errorf("hyperlink emitted without allocator: %q", out)
}
}
func TestDetectBlockMarker(t *testing.T) {
cases := []struct {
in string
kind string
want string
ok bool
}{
{"# A", "heading_1", "A", true},
{"## B", "heading_2", "B", true},
{"### C", "heading_3", "C", true},
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
{" # too-deep", "", "", false}, // 4 spaces → not a heading
{"- bullet", "list_bullet", "bullet", true},
{"* star", "list_bullet", "star", true},
{"1. one", "list_numbered", "one", true},
{"42. forty-two", "list_numbered", "forty-two", true},
{"1) paren", "list_numbered", "paren", true},
{"1.no-space", "", "", false}, // ordinal needs trailing space
{"> quote", "blockquote", "quote", true},
{"plain", "", "", false},
{"#nospace", "", "", false}, // heading needs space after hash
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
kind, payload, ok := detectBlockMarker(tc.in)
if ok != tc.ok || kind != tc.kind || payload != tc.want {
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
}
})
}
}

View File

@@ -1,4 +1,4 @@
package services
package docx
// Submission template renderer — in-house engine for the submission
// draft editor (t-paliad-238, design doc
@@ -24,7 +24,7 @@ package services
// {{project.case_number}}).
//
// Missing-value behaviour: when a placeholder has no binding in the
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
// docforge.PlaceholderMap, the renderer emits a marker token so the lawyer sees
// the gap in Word rather than failing the request.
import (
@@ -34,18 +34,15 @@ import (
"io"
"regexp"
"strings"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// PlaceholderMap is the variable bag built by SubmissionVarsService.
// Keys are dotted paths without braces (e.g. "project.case_number").
// Values are the substituted text — already locale-aware, pretty-
// printed, and sanitised by the caller.
type PlaceholderMap map[string]string
// MissingPlaceholderFn translates an unbound placeholder key into the
// in-document marker token. The default in DefaultMissingMarker is
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
type MissingPlaceholderFn func(key string) string
// docforge.PlaceholderMap, docforge.MissingPlaceholderFn, and docforge.DefaultMissingMarker — the
// format-neutral variable-bag contract — live in the docforge root
// package (placeholder.go). This adapter consumes them; the {{key}}
// substitution grammar below (placeholderRegex, replacePlaceholders, the
// PUA preview sentinels) is the .docx renderer's own machinery.
// valueWrapperFn wraps a substituted value with a marker the HTML
// preview emitter can recognise — used by RenderHTML to turn each
@@ -74,18 +71,6 @@ func htmlPreviewWrapper(key, value string) string {
return previewVarBegin + key + previewVarMid + value + previewVarEnd
}
// DefaultMissingMarker returns the standard missing-value marker for
// the given UI language.
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
prefix := "KEIN WERT"
if strings.EqualFold(lang, "en") {
prefix = "NO VALUE"
}
return func(key string) string {
return "[" + prefix + ": " + key + "]"
}
}
// placeholderRegex matches a single placeholder. The capture group
// extracts the key name without braces or surrounding whitespace.
//
@@ -95,7 +80,7 @@ func DefaultMissingMarker(lang string) MissingPlaceholderFn {
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
// SubmissionRenderer renders a .docx template into a .docx output by
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
// substituting {{placeholder}} tokens with values from a docforge.PlaceholderMap.
// Stateless; safe for concurrent use.
type SubmissionRenderer struct{}
@@ -112,9 +97,9 @@ func NewSubmissionRenderer() *SubmissionRenderer {
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
// template (macro-bearing) is downgraded to a plain .docx before the
// merge step runs. Idempotent on inputs that are already plain .docx.
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
func (r *SubmissionRenderer) Render(templateBytes []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn) ([]byte, error) {
if missing == nil {
missing = DefaultMissingMarker("de")
missing = docforge.DefaultMissingMarker("de")
}
cleanBytes, err := ConvertDotmToDocx(templateBytes)
if err != nil {
@@ -166,9 +151,9 @@ func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, m
// Returns escaped HTML safe to inject into the page via dangerouslySet
// or innerHTML. The caller is responsible for wrapping in an outer
// container; this method emits only the body fragment.
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) (string, error) {
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn) (string, error) {
if missing == nil {
missing = DefaultMissingMarker("de")
missing = docforge.DefaultMissingMarker("de")
}
cleanBytes, err := ConvertDotmToDocx(templateBytes)
if err != nil {
@@ -241,7 +226,7 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
// paragraph, run the replacement on the merged text, and rewrite
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
// the formatting properties of the first run.
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
func substituteInDocumentXML(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
replaced := substituteInTextNodes(body, vars, missing, wrap)
if !needsCrossRunMerge(replaced) {
return replaced
@@ -256,7 +241,7 @@ var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
// substituteInTextNodes runs the placeholder replacement inside each
// <w:t> text node independently. Format-preserving for single-run
// placeholders.
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
func substituteInTextNodes(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
sub := wTextNodeRegex.FindSubmatch(match)
attrs := string(sub[1])
@@ -297,7 +282,7 @@ var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
// substituteAcrossRuns is pass 2: concatenate every text node in a
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
func substituteAcrossRuns(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
if len(textNodes) == 0 {
@@ -340,7 +325,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
// emit clickable spans around every substituted placeholder, including
// missing ones (clicking a missing marker jumps to the corresponding
// sidebar input).
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) string {
func replacePlaceholders(s string, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) string {
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
sub := placeholderRegex.FindStringSubmatch(match)
if len(sub) < 2 {

View File

@@ -1,4 +1,4 @@
package services
package docx
// Submission merge-engine tests — resurrected from the original
// t-paliad-215 Slice 1 (commit 8ea3509) + Slice 2 (commit 1765d5e).
@@ -12,6 +12,8 @@ import (
"io"
"strings"
"testing"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
@@ -74,7 +76,7 @@ func TestRender_SingleRunPlaceholder(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
@@ -91,7 +93,7 @@ func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{
out, err := r.Render(tmpl, docforge.PlaceholderMap{
"parties.claimant.name": "Acme Inc.",
"parties.claimant.representative": "Kanzlei Müller",
}, nil)
@@ -111,7 +113,7 @@ func TestRender_MissingMarker(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
out, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("de"))
if err != nil {
t.Fatalf("render: %v", err)
}
@@ -119,7 +121,7 @@ func TestRender_MissingMarker(t *testing.T) {
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
t.Errorf("expected KEIN WERT marker, got %q", body)
}
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
outEN, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("en"))
if err != nil {
t.Fatalf("render en: %v", err)
}
@@ -133,7 +135,7 @@ func TestRender_CrossRunPlaceholder(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
out, err := r.Render(tmpl, docforge.PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
@@ -150,7 +152,7 @@ func TestRender_XMLEscaping(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{
out, err := r.Render(tmpl, docforge.PlaceholderMap{
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
}, nil)
if err != nil {
@@ -190,79 +192,6 @@ func TestPlaceholderRegex_Boundaries(t *testing.T) {
}
}
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)
}
})
}
}
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
// bold/italic through to <strong>/<em>. Substituted placeholders are
@@ -276,7 +205,7 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
`</w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
@@ -298,7 +227,7 @@ func TestRenderHTML_EscapesContent(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
"user.display_name": `M&S <Inc> "X"`,
}, nil)
if err != nil {
@@ -317,7 +246,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil)
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
@@ -335,7 +264,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
// value. There is no distinction at the renderer level between a value
// that came from the resolved bag (project / parties / deadline lookups)
// and a value the lawyer typed into the sidebar — both arrive in the
// same PlaceholderMap and both must be wrapped.
// same docforge.PlaceholderMap and both must be wrapped.
func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
@@ -344,7 +273,7 @@ func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
r := NewSubmissionRenderer()
// project.case_number is the typed-by-lawyer override.
// firm.name is the always-resolved value from the firm bag.
html, err := r.RenderHTML(tmpl, PlaceholderMap{
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
"project.case_number": "UPC_CFI_42/2026",
"firm.name": "HLC",
}, nil)
@@ -370,7 +299,7 @@ func TestRender_DocxOutputUnchangedByPreviewWrap(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render docx: %v", err)
}

7
pkg/docforge/errors.go Normal file
View File

@@ -0,0 +1,7 @@
package docforge
import "errors"
// ErrTemplateNotFound is returned by a TemplateStore when a template or
// template version id does not exist. Consumers map it to a 404.
var ErrTemplateNotFound = errors.New("docforge: template not found")

22
pkg/docforge/exporter.go Normal file
View File

@@ -0,0 +1,22 @@
package docforge
// Exporter renders a neutral Document into a target format's body markup.
// docforge owns the interface; each format adapter implements it (the
// .docx adapter in pkg/docforge/docx today; .pdf/.html/.md are future
// siblings — PRD §4 B4: interface now, docx-only impl). Format-specific
// configuration (a stylemap, a hyperlink allocator for .docx) is baked into
// the concrete exporter at construction, so the interface stays
// format-neutral.
//
// "Body markup" is the renderable content fragment, not a complete file —
// for .docx it is the OOXML <w:p> run the composer splices into a carrier.
// Container concerns (MIME type, packaging) are described by Format /
// MIMEType and handled by the assembling layer.
type Exporter interface {
// Format is the short format id, e.g. "docx".
Format() string
// MIMEType is the container MIME type the assembled document carries.
MIMEType() string
// RenderBody renders the document to the format's body markup.
RenderBody(doc Document) ([]byte, error)
}

View File

@@ -0,0 +1,230 @@
// Package markdown imports Markdown source into the neutral
// docforge.Document model (PRD §3.2 / §4 P4 — Markdown is the primary
// input format). It is the single Markdown parser for docforge: the .docx
// renderer consumes the Document this produces, so block-splitting and
// inline tokenisation live here, not in the format adapter.
//
// Grammar (intentionally narrow — unrecognised syntax flows through as a
// plain paragraph, so lawyer prose never errors):
//
// blank line → paragraph break
// # / ## / ### Heading → heading_1 / 2 / 3
// - item / * item → bullet list item
// N. item / N) item → numbered list item
// > quote → blockquote
// **x** / __x__ → bold
// *x* / _x_ → italic
// [label](url) → hyperlink
// {{key}} → preserved verbatim (substituted downstream)
package markdown
import (
"strings"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// Import parses Markdown into a Document. Empty (or all-blank) input yields
// a single empty paragraph so a splice site stays well-formed.
func Import(md string) docforge.Document {
blocks := splitBlocks(md)
if len(blocks) == 0 {
return docforge.Document{Blocks: []docforge.Block{{Kind: docforge.KindParagraph}}}
}
out := make([]docforge.Block, 0, len(blocks))
for _, blk := range blocks {
b := docforge.Block{Kind: docforge.BlockKind(blk.kind)}
// An empty-text block is an intentional empty paragraph: leave
// Spans nil so the exporter emits a single empty run.
if blk.text != "" {
b.Spans = parseInline(blk.text)
}
out = append(out, b)
}
return docforge.Document{Blocks: out}
}
// rawBlock is the intermediate (kind, stripped-text) form before inline
// parsing. kind values match docforge.BlockKind string values.
type rawBlock struct {
kind string
text string
}
// splitBlocks parses the source into a sequence of (kind, text) blocks,
// detecting heading / list / blockquote prefixes line-by-line. A run of
// unmarked lines collapses into one paragraph block (soft line breaks
// inside a paragraph concatenate); each marked line is its own block.
// Blank-run spacing emits extra empty paragraph blocks. CRLF normalised.
func splitBlocks(md string) []rawBlock {
normalised := strings.ReplaceAll(md, "\r\n", "\n")
lines := strings.Split(normalised, "\n")
var blocks []rawBlock
var pendingPara []string
blankRun := 0
flushPara := func() {
if len(pendingPara) > 0 {
blocks = append(blocks, rawBlock{kind: "paragraph", text: strings.Join(pendingPara, "\n")})
pendingPara = nil
}
}
for _, line := range lines {
if strings.TrimSpace(line) == "" {
if len(pendingPara) > 0 {
flushPara()
blankRun = 1
continue
}
blankRun++
continue
}
if kind, payload, ok := detectBlockMarker(line); ok {
flushPara()
for i := 1; i < blankRun; i++ {
blocks = append(blocks, rawBlock{kind: "paragraph", text: ""})
}
blankRun = 0
blocks = append(blocks, rawBlock{kind: kind, text: payload})
continue
}
if len(pendingPara) == 0 {
for i := 1; i < blankRun; i++ {
blocks = append(blocks, rawBlock{kind: "paragraph", text: ""})
}
}
blankRun = 0
pendingPara = append(pendingPara, line)
}
flushPara()
return blocks
}
// detectBlockMarker classifies a single line. Tolerates up to 3 leading
// spaces (CommonMark) before treating the line as a plain paragraph.
func detectBlockMarker(line string) (kind, payload string, ok bool) {
trimmed := strings.TrimLeft(line, " ")
if len(line)-len(trimmed) > 3 {
return "", "", false
}
switch {
case strings.HasPrefix(trimmed, "### "):
return "heading_3", strings.TrimSpace(trimmed[4:]), true
case strings.HasPrefix(trimmed, "## "):
return "heading_2", strings.TrimSpace(trimmed[3:]), true
case strings.HasPrefix(trimmed, "# "):
return "heading_1", strings.TrimSpace(trimmed[2:]), true
case strings.HasPrefix(trimmed, "> "):
return "blockquote", strings.TrimSpace(trimmed[2:]), true
case strings.HasPrefix(trimmed, "- "), strings.HasPrefix(trimmed, "* "):
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
}
if i := indexOfNumberedMarker(trimmed); i > 0 {
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
}
return "", "", false
}
// indexOfNumberedMarker returns the byte index just past an "N. " / "N) "
// marker at the start of s, or -1 when absent.
func indexOfNumberedMarker(s string) int {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 || 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
}
// parseInline splits text around [label](url) hyperlinks and tokenises the
// rest into bold/italic spans. Hyperlinks become a span with Link set and
// the label's spans as Children, preserving link boundaries.
func parseInline(text string) []docforge.InlineSpan {
var out []docforge.InlineSpan
rest := text
for {
idx := strings.Index(rest, "[")
if idx < 0 {
if rest != "" {
out = append(out, parseSpans(rest)...)
}
break
}
closeBracket := strings.Index(rest[idx:], "](")
if closeBracket < 0 {
out = append(out, parseSpans(rest)...)
break
}
closeParen := strings.Index(rest[idx+closeBracket:], ")")
if closeParen < 0 {
out = append(out, parseSpans(rest)...)
break
}
label := rest[idx+1 : idx+closeBracket]
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
if idx > 0 {
out = append(out, parseSpans(rest[:idx])...)
}
out = append(out, docforge.InlineSpan{Link: url, Children: parseSpans(label)})
rest = rest[idx+closeBracket+closeParen+1:]
}
return out
}
// parseSpans tokenises Markdown inline bold/italic into spans, preserving
// {{...}} placeholders verbatim (the b78a984 fix — underscores in a
// placeholder key must not be read as italic delimiters). Empty input
// yields one empty span.
func parseSpans(text string) []docforge.InlineSpan {
var out []docforge.InlineSpan
var cur strings.Builder
bold := false
italic := false
flush := func() {
if cur.Len() == 0 {
return
}
out = append(out, docforge.InlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
cur.Reset()
}
i := 0
n := len(text)
for i < n {
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
if rel := strings.Index(text[i+2:], "}}"); rel >= 0 {
end := i + 2 + rel + 2
cur.WriteString(text[i:end])
i = end
continue
}
}
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, docforge.InlineSpan{Text: ""})
}
return out
}

View File

@@ -0,0 +1,145 @@
package markdown
import (
"strings"
"testing"
)
// Inline-span + block-marker tests, relocated from the docx walker when
// parsing moved here (t-paliad-349 slice 8). parseSpans is the inline
// tokeniser; detectBlockMarker classifies a line.
func TestParseSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
// {{project.case_number}} must emit as a single non-italic span
// containing the full placeholder (the b78a984 fix).
spans := parseSpans("{{project.case_number}}")
if len(spans) != 1 {
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
}
if spans[0].Italic || spans[0].Bold {
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
}
if spans[0].Text != "{{project.case_number}}" {
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
}
}
func TestParseSpans_ItalicAroundPlaceholder(t *testing.T) {
spans := parseSpans("_before_ {{x.y_z}} _after_")
var saw struct {
italicBefore bool
placeholder bool
italicAfter bool
}
for _, s := range spans {
if s.Italic && s.Text == "before" {
saw.italicBefore = true
}
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
saw.placeholder = true
}
if s.Italic && s.Text == "after" {
saw.italicAfter = true
}
}
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
}
}
func TestParseSpans_Plain(t *testing.T) {
spans := parseSpans("hello world")
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
t.Errorf("expected single plain span; got %+v", spans)
}
}
func TestParseSpans_UnderscoreItalic(t *testing.T) {
spans := parseSpans("_emph_")
var italicHits int
for _, s := range spans {
if s.Italic && s.Text == "emph" {
italicHits++
}
}
if italicHits != 1 {
t.Errorf("expected one italic 'emph' span; got %+v", spans)
}
}
func TestParseSpans_UnderscoreBold(t *testing.T) {
spans := parseSpans("__strong__")
var boldHits int
for _, s := range spans {
if s.Bold && s.Text == "strong" {
boldHits++
}
}
if boldHits != 1 {
t.Errorf("expected one bold 'strong' span; got %+v", spans)
}
}
func TestDetectBlockMarker(t *testing.T) {
cases := []struct {
in string
kind string
want string
ok bool
}{
{"# A", "heading_1", "A", true},
{"## B", "heading_2", "B", true},
{"### C", "heading_3", "C", true},
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
{" # too-deep", "", "", false}, // 4 spaces → not a heading
{"- bullet", "list_bullet", "bullet", true},
{"* star", "list_bullet", "star", true},
{"1. one", "list_numbered", "one", true},
{"42. forty-two", "list_numbered", "forty-two", true},
{"1) paren", "list_numbered", "paren", true},
{"1.no-space", "", "", false}, // ordinal needs trailing space
{"> quote", "blockquote", "quote", true},
{"plain", "", "", false},
{"#nospace", "", "", false}, // heading needs space after hash
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
kind, payload, ok := detectBlockMarker(tc.in)
if ok != tc.ok || kind != tc.kind || payload != tc.want {
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
}
})
}
}
// TestImport_Document spot-checks the neutral Document the importer
// produces — block kinds, the link-span shape, and placeholder pass-through.
func TestImport_Document(t *testing.T) {
doc := Import("# Title\n\nBody **bold** and [label](http://x).\n\n- item")
if len(doc.Blocks) != 3 {
t.Fatalf("blocks = %d; want 3 (%+v)", len(doc.Blocks), doc.Blocks)
}
if doc.Blocks[0].Kind != "heading_1" {
t.Errorf("block0 kind = %q; want heading_1", doc.Blocks[0].Kind)
}
if doc.Blocks[2].Kind != "list_bullet" {
t.Errorf("block2 kind = %q; want list_bullet", doc.Blocks[2].Kind)
}
// The body paragraph carries a link span with Link set + children.
var sawLink bool
for _, s := range doc.Blocks[1].Spans {
if s.Link == "http://x" && len(s.Children) > 0 {
sawLink = true
}
}
if !sawLink {
t.Errorf("body block missing link span; got %+v", doc.Blocks[1].Spans)
}
}
func TestImport_EmptyYieldsOneEmptyParagraph(t *testing.T) {
doc := Import("")
if len(doc.Blocks) != 1 || doc.Blocks[0].Kind != "paragraph" || len(doc.Blocks[0].Spans) != 0 {
t.Errorf("empty import = %+v; want one empty paragraph block", doc.Blocks)
}
}

58
pkg/docforge/model.go Normal file
View File

@@ -0,0 +1,58 @@
package docforge
// The neutral document model — the format-independent representation an
// importer produces and an exporter consumes (PRD §3.2). A Markdown
// importer parses source into a Document; the .docx exporter renders a
// Document into OOXML; a future PDF/HTML exporter renders the same
// Document differently. The model carries editable content only —
// placeholders ({{key}}) ride through as literal span text and are
// substituted later by the format exporter's merge pass, exactly as in
// the pre-model pipeline.
//
// Slice 8 (t-paliad-349) lands this model with two real consumers: the
// Markdown importer (pkg/docforge/markdown) and the .docx renderer
// (pkg/docforge/docx), which the shipped submission walker now routes
// through — so there is one parser, not two.
// BlockKind is the logical kind of a block. Its string values are the
// stylemap keys a format exporter looks up (paragraph, heading_1, …), so
// the docx exporter maps Kind → Word paragraph style directly.
type BlockKind string
const (
KindParagraph BlockKind = "paragraph"
KindHeading1 BlockKind = "heading_1"
KindHeading2 BlockKind = "heading_2"
KindHeading3 BlockKind = "heading_3"
KindListBullet BlockKind = "list_bullet"
KindListNumbered BlockKind = "list_numbered"
KindBlockquote BlockKind = "blockquote"
)
// Document is a sequence of blocks — the whole editable content.
type Document struct {
Blocks []Block
}
// Block is one paragraph-level unit. Spans is its inline content; an empty
// Spans slice is an intentional empty paragraph (vertical spacing).
type Block struct {
Kind BlockKind
Spans []InlineSpan
}
// InlineSpan is one run of inline content. A span is either:
// - literal text with optional bold/italic (Link == "", Children nil), or
// - a hyperlink (Link != "") whose label is the Children spans.
//
// Modelling a link as a span with Children (rather than a per-span Link
// flag) preserves link boundaries: two adjacent links to the same URL stay
// two distinct hyperlink spans, so the exporter emits them byte-identically
// to the pre-model walker.
type InlineSpan struct {
Text string
Bold bool
Italic bool
Link string // non-empty → this span is a hyperlink to Link
Children []InlineSpan // hyperlink label content (only when Link != "")
}

View File

@@ -0,0 +1,33 @@
package docforge
import "strings"
// PlaceholderMap is the variable bag a ResolverSet builds and a format
// exporter fills into a template. Keys are dotted paths without braces
// (e.g. "project.case_number"); values are the substituted text — already
// locale-aware, pretty-printed, and sanitised by the resolvers that
// produced them.
//
// It is format-neutral: the .docx exporter substitutes these into OOXML,
// but a future PDF/HTML/Markdown exporter consumes the same bag. The
// {{key}} substitution grammar itself is the exporter's concern and lives
// with the adapter (pkg/docforge/docx), not here.
type PlaceholderMap map[string]string
// MissingPlaceholderFn translates an unbound placeholder key into the
// in-document marker token. DefaultMissingMarker returns the standard
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" form.
type MissingPlaceholderFn func(key string) string
// DefaultMissingMarker returns the standard missing-value marker for the
// given UI language. Unbound placeholders render this marker inline so the
// lawyer sees the gap in the document rather than the render failing.
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
prefix := "KEIN WERT"
if strings.EqualFold(lang, "en") {
prefix = "NO VALUE"
}
return func(key string) string {
return "[" + prefix + ": " + key + "]"
}
}

111
pkg/docforge/store.go Normal file
View File

@@ -0,0 +1,111 @@
package docforge
import "context"
// TemplateMeta is the listable metadata for a stored template — cheap to
// list because it carries no carrier bytes.
type TemplateMeta struct {
ID string
Slug string // optional human handle; may be empty
NameDE string
NameEN string
Kind string // consumer-domain tag, e.g. "submission"
SourceFormat string // "docx"
Firm string // may be empty
IsActive bool
Version int // current version number; 0 when no version exists yet
VersionID string // current version row id; "" when no version exists yet.
// A draft pins VersionID to snapshot this exact version (PRD §4 A3):
// a later template edit creates a new version and re-points current,
// but the pinned draft keeps rendering VersionID.
}
// TemplateSlot is one variable slot placed in a template version's carrier.
type TemplateSlot struct {
// Key is the variable bound here, in the placeholder grammar
// (e.g. "project.case_number").
Key string
// Anchor locates the slot within the carrier. With the sentinel
// strategy this is the token the authoring surface injected into the
// carrier OOXML at the slot position.
Anchor string
// Label is an optional human label for the authoring palette.
Label string
// OrderIndex orders slots for display.
OrderIndex int
}
// Template is a stored template resolved to its current version: metadata
// plus everything needed to author or generate — the carrier bytes, the
// stylemap, and the placed slots. CarrierBytes is format-opaque; the .docx
// adapter wraps (CarrierBytes, Stylemap) into a docx.Carrier at compose
// time, so this root type never imports the adapter.
type Template struct {
TemplateMeta
CarrierBytes []byte
Stylemap map[string]string
Slots []TemplateSlot
}
// TemplateMetaInput is the payload for creating a new template (the
// catalog row). ID and Version are assigned by the store.
type TemplateMetaInput struct {
Slug string // optional
NameDE string
NameEN string
Kind string // defaults to "submission" when empty
SourceFormat string // defaults to "docx" when empty
Firm string // optional
CreatedBy string // auth user id (uuid) for the audit column
}
// TemplateVersionInput is the payload for creating a template version: the
// carrier .docx, its stylemap, and the slots placed in it.
type TemplateVersionInput struct {
CarrierBytes []byte
Stylemap map[string]string
Slots []TemplateSlot
CreatedBy string // auth user id (uuid)
}
// TemplateFilter narrows a List. Zero-value fields mean "any".
type TemplateFilter struct {
Firm string // "" = any firm
Kind string // "" = any kind
ActiveOnly bool // true = is_active templates only
}
// TemplateStore persists and loads document templates. docforge defines
// the contract; the consuming application implements it (paliad against
// Postgres, with the carrier bytes in a bytea column). It is the seam the
// authoring surface writes to and the generator reads from — a second
// docforge consumer implements the same interface against its own storage.
//
// Versioning is snapshot-at-create (PRD §4 A3): Create makes version 1 and
// pins it as current; AddVersion inserts the next version and re-points
// current. Drafts pin a specific version so a later edit never shifts an
// in-flight draft.
type TemplateStore interface {
// List returns catalog metadata for templates matching the filter,
// without carrier bytes.
List(ctx context.Context, f TemplateFilter) ([]TemplateMeta, error)
// Get returns a template resolved to its current version (carrier +
// stylemap + slots). Returns ErrTemplateNotFound when id is unknown.
Get(ctx context.Context, id string) (*Template, error)
// GetVersion returns a template resolved to a specific version id —
// the path a draft uses to render its pinned snapshot. Returns
// ErrTemplateNotFound when the version is unknown.
GetVersion(ctx context.Context, versionID string) (*Template, error)
// Create inserts a new template plus its first version (version 1) and
// pins that version as current. Returns the resolved Template.
Create(ctx context.Context, meta TemplateMetaInput, first TemplateVersionInput) (*Template, error)
// AddVersion inserts the next version for an existing template and
// re-points current_version to it. Returns the resolved Template at
// the new version. Returns ErrTemplateNotFound when templateID is
// unknown.
AddVersion(ctx context.Context, templateID string, v TemplateVersionInput) (*Template, error)
}

89
pkg/docforge/vars.go Normal file
View File

@@ -0,0 +1,89 @@
package docforge
// VariableResolver populates one namespace of the placeholder bag.
//
// Each resolver owns a dotted namespace (e.g. "project", "parties") and
// pushes its keys into a shared PlaceholderMap. The push model — rather
// than a pull Resolve(key) — is deliberate: some namespaces emit a
// data-dependent set of keys (a multi-party suit produces
// parties.claimant.0.name, .1.name, … one per party), which a fixed
// key-by-key pull interface can't enumerate cleanly. Populate lets each
// resolver decide its own (possibly dynamic) key set in one pass.
//
// The consuming application implements concrete resolvers against its own
// data sources (paliad resolves project/party/deadline state from its
// Postgres database); docforge owns only the interface and the
// composition machinery (ResolverSet). This is the seam a second consumer
// (e.g. upc-commentary) plugs its own resolvers into without touching the
// engine.
type VariableResolver interface {
// Namespace returns the dotted prefix this resolver owns, e.g.
// "project". Informational — used for diagnostics and as the default
// group for this resolver's catalogue entries.
Namespace() string
// Populate writes this resolver's keys into bag. Resolvers own
// disjoint namespaces, so population order across resolvers does not
// affect the final bag.
Populate(bag PlaceholderMap)
// Keys returns the user-facing catalogue entries for this resolver —
// the variables an authoring palette can offer and a sidebar form can
// render, each with its bilingual label. This is the curated, static
// surface (e.g. the flat parties.claimant.name form), not the full
// possibly-dynamic key set Populate emits (e.g. the indexed
// parties.claimant.0.name). Go owns these labels so the frontend form
// and the authoring palette read one source of truth instead of a
// duplicated TS table.
Keys() []VariableKey
}
// VariableKey is one catalogue entry: the placeholder key plus its
// bilingual label and a group (the owning namespace by default). The
// frontend maps groups onto its own lawyer-facing presentation sections.
type VariableKey struct {
Key string `json:"key"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Group string `json:"group"`
}
// ResolverSet composes an ordered list of VariableResolvers into a single
// PlaceholderMap. It is the replacement for hard-coded "call addFooVars,
// then addBarVars, …" sequencing: a consumer registers the resolvers that
// apply to a given render and calls BuildBag.
type ResolverSet struct {
resolvers []VariableResolver
}
// NewResolverSet builds a set from the given resolvers, in order.
func NewResolverSet(resolvers ...VariableResolver) *ResolverSet {
return &ResolverSet{resolvers: resolvers}
}
// Add appends a resolver to the set.
func (s *ResolverSet) Add(r VariableResolver) { s.resolvers = append(s.resolvers, r) }
// BuildBag runs every resolver's Populate into a fresh PlaceholderMap and
// returns it. Because resolvers own disjoint namespaces, the result is
// independent of resolver order.
func (s *ResolverSet) BuildBag() PlaceholderMap {
bag := PlaceholderMap{}
for _, r := range s.resolvers {
r.Populate(bag)
}
return bag
}
// Catalogue concatenates every resolver's Keys() in resolver order — the
// full set of user-facing variables for a palette or form, with bilingual
// labels. It does not require any per-call entity state, so a consumer can
// build a metadata-only ResolverSet (resolvers constructed with nil
// entities) purely to serve the catalogue.
func (s *ResolverSet) Catalogue() []VariableKey {
var out []VariableKey
for _, r := range s.resolvers {
out = append(out, r.Keys()...)
}
return out
}

60
pkg/docforge/vars_test.go Normal file
View File

@@ -0,0 +1,60 @@
package docforge
import "testing"
// fakeResolver is a test double: it owns a namespace, populates a fixed
// set of key/value pairs, and advertises a fixed catalogue.
type fakeResolver struct {
ns string
values map[string]string
catalog []VariableKey
}
func (f fakeResolver) Namespace() string { return f.ns }
func (f fakeResolver) Keys() []VariableKey { return f.catalog }
func (f fakeResolver) Populate(bag PlaceholderMap) {
for k, v := range f.values {
bag[k] = v
}
}
func TestResolverSet_BuildBagMergesDisjointNamespaces(t *testing.T) {
set := NewResolverSet(
fakeResolver{ns: "a", values: map[string]string{"a.x": "1", "a.y": "2"}},
fakeResolver{ns: "b", values: map[string]string{"b.z": "3"}},
)
bag := set.BuildBag()
if len(bag) != 3 {
t.Fatalf("bag size = %d; want 3", len(bag))
}
for k, want := range map[string]string{"a.x": "1", "a.y": "2", "b.z": "3"} {
if bag[k] != want {
t.Errorf("bag[%q] = %q; want %q", k, bag[k], want)
}
}
}
func TestResolverSet_AddAndCatalogueOrder(t *testing.T) {
set := NewResolverSet(
fakeResolver{ns: "a", catalog: []VariableKey{{Key: "a.x", Group: "a"}}},
)
set.Add(fakeResolver{ns: "b", catalog: []VariableKey{
{Key: "b.y", Group: "b"},
{Key: "b.z", Group: "b"},
}})
cat := set.Catalogue()
gotOrder := make([]string, len(cat))
for i, e := range cat {
gotOrder[i] = e.Key
}
want := []string{"a.x", "b.y", "b.z"} // resolver order, then Keys() order
if len(gotOrder) != len(want) {
t.Fatalf("catalogue len = %d; want %d", len(gotOrder), len(want))
}
for i := range want {
if gotOrder[i] != want[i] {
t.Errorf("catalogue[%d] = %q; want %q", i, gotOrder[i], want[i])
}
}
}

View File

@@ -205,7 +205,11 @@ func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T)
cat := &stubCatalog{pt: pt, rules: rules}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
// IncludeOptional=true because translation_request carries
// priority='optional'; the test exercises the before-child-of-
// court-set-parent flow, which is orthogonal to the optional-rule
// suppression added in t-paliad-342.
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
@@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
cat := &stubCatalog{pt: pt, rules: rules}
// User pins the oral hearing to 2026-10-15.
// User pins the oral hearing to 2026-10-15. IncludeOptional=true
// because translation_request is priority='optional' (t-paliad-342).
opts := CalcOptions{
IncludeOptional: true,
AnchorOverrides: map[string]string{
oralCode: "2026-10-15",
},

View File

@@ -80,6 +80,21 @@ func Calculate(
overrideDates[code] = od
}
// Trigger-event anchors keyed by paliad.trigger_events.code
// (t-paliad-342). Parsed up-front so malformed dates error before
// the rule walk. When a rule has trigger_event_id set, the engine
// looks up triggerAnchorByCode[trigger_event.code] for the
// semantic anchor instead of falling back to the proceeding's
// trigger date.
triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors))
for code, dateStr := range opts.TriggerEventAnchors {
td, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err)
}
triggerAnchorByCode[code] = td
}
// Look up proceeding type metadata.
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
if err != nil {
@@ -213,6 +228,7 @@ func Calculate(
perCardAppellant := opts.PerCardAppellant
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
hiddenCount := 0
rulesAwaitingAnchor := 0
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range walkRules {
@@ -227,6 +243,17 @@ func Calculate(
continue
}
// Optional-rule suppression (t-paliad-342 / youpcorg#2570).
// Rules tagged priority='optional' don't auto-fire in the
// default timeline; the caller opts in via
// CalcOptions.IncludeOptional. Cascade through skippedIDs so
// children chaining off the suppressed rule also drop — they
// can't compute a date against a missing parent.
if r.Priority == "optional" && !opts.IncludeOptional {
skippedIDs[r.ID] = struct{}{}
continue
}
// SkipRules suppression (t-paliad-265).
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
// we re-surface the directly-skipped row (faded via IsHidden)
@@ -327,15 +354,43 @@ func Calculate(
// (m/paliad#126 / t-paliad-294). When a rule has a real
// trigger_event_id, that catalog event is the actual semantic
// anchor — not the parent_id node, which is only the calc-time
// arithmetic anchor. Only the user-facing wire fields shift;
// parentRule (and the parent_id chain feeding parentIsCourtSet
// and the calc-time arithmetic below) stays anchored on the
// rule tree.
// arithmetic anchor. Only the user-facing wire fields shift
// here; the calc-time anchor logic for trigger_event_id rules
// lives just below.
var triggerEventAnchor time.Time
var hasTriggerEventAnchor bool
if r.TriggerEventID != nil {
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
d.ParentRuleCode = te.Code
d.ParentRuleName = te.NameDE
d.ParentRuleNameEN = te.Name
if td, ok := triggerAnchorByCode[te.Code]; ok {
triggerEventAnchor = td
hasTriggerEventAnchor = true
}
}
// Trigger-event semantic-anchor suppression (t-paliad-342 /
// youpcorg#2568). When a rule has an explicit trigger_event_id
// but the caller hasn't supplied a date for that event via
// CalcOptions.TriggerEventAnchors, the engine refuses to
// fabricate a date off the proceeding's trigger date — the
// rule's semantic anchor is the event itself, not the SoC.
// Render IsConditional with empty dates and propagate via
// courtSet so descendants chaining off this rule also surface
// as conditional rather than projecting fictional dates.
if !hasTriggerEventAnchor {
d.IsConditional = true
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
rulesAwaitingAnchor++
if r.SubmissionCode != nil {
skippedIDs[r.ID] = struct{}{}
}
deadlines = append(deadlines, d)
continue
}
}
@@ -379,6 +434,20 @@ func Calculate(
}
}
// Trigger-event anchor wins over the bucket logic below: a
// zero-duration rule with trigger_event_id is "occurs on the
// trigger event's date". Anchor missing was already caught
// above (suppression branch).
if hasTriggerEventAnchor {
d.DueDate = triggerEventAnchor.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = triggerEventAnchor
}
deadlines = append(deadlines, d)
continue
}
if r.ParentID == nil && !r.IsCourtSet {
// Bucket 1: timeline anchor.
d.IsRootEvent = true
@@ -457,11 +526,19 @@ func Calculate(
continue
}
// Anchor: prefer alt-anchor (e.g. priority_date for
// epa.grant.exa publish) when supplied, then parent's computed
// date (or user override), then trigger date.
// Anchor priority:
// 1. trigger_event_id semantic anchor (t-paliad-342) — when
// the rule has trigger_event_id and the caller supplied a
// date in TriggerEventAnchors, that date wins over the
// parent chain AND the priority_date alt-anchor. The
// missing-anchor case was already short-circuited above.
// 2. priority_date alt-anchor (epa.grant.exa publish).
// 3. parent's computed date (or user override).
// 4. proceeding trigger date (default fallback).
baseDate := triggerDate
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
if hasTriggerEventAnchor {
baseDate = triggerEventAnchor
} else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
baseDate = *priorityDate
} else if r.ParentID != nil {
for _, prev := range rules {
@@ -635,12 +712,13 @@ func Calculate(
}
resp := &Timeline{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
RulesAwaitingAnchor: rulesAwaitingAnchor,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding`.

View File

@@ -0,0 +1,379 @@
package litigationplanner
import (
"context"
"testing"
"github.com/google/uuid"
)
// Tests for t-paliad-342 / youpcorg#2568 + #2570.
//
// Two paired engine semantics:
//
// - Optional rules (priority='optional') don't auto-fire in the
// default timeline; the caller opts in via
// CalcOptions.IncludeOptional.
// - Rules with explicit trigger_event_id anchor on the trigger
// event's date (CalcOptions.TriggerEventAnchors keyed by
// trigger_events.code). Missing anchor = render conditional
// instead of fabricating a date off the proceeding's trigger date.
// stubCatalogWithTriggers extends stubCatalog with a trigger-events
// map so the engine can resolve TriggerEventID → code for the
// trigger-anchor branch. stubCatalog from before_court_set_anchor_test.go
// returns an empty map, which suffices for tests that don't exercise
// trigger_event_id; here we need real entries.
type stubCatalogWithTriggers struct {
stubCatalog
triggerEvents map[int64]TriggerEvent
}
func (s *stubCatalogWithTriggers) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]TriggerEvent, error) {
out := make(map[int64]TriggerEvent, len(ids))
for _, id := range ids {
if te, ok := s.triggerEvents[id]; ok {
out[id] = te
}
}
return out, nil
}
// mandatory_socRule builds a minimal SoC root rule + the proceeding
// type wrapper that nearly every test below needs.
func mandatorySocFixture(t *testing.T) (ProceedingType, Rule, uuid.UUID) {
t.Helper()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
Jurisdiction: &jurisdiction,
IsActive: true,
}
socID, _ := uuid.NewRandom()
socCode := "upc.inf.cfi.soc"
procIDPtr := &procID
str := func(s string) *string { return &s }
soc := Rule{
ID: socID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &socCode,
Name: "Klageerhebung",
NameEN: "SoC",
PrimaryParty: str("claimant"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
}
return pt, soc, socID
}
// TestCalculate_TriggerEventIDWithoutAnchor_Suppressed verifies the
// RoP.109.5 bug from youpcorg#2568: a rule with trigger_event_id and
// no parent_id must NOT fall back to the proceeding's trigger date.
// The buggy behaviour rendered the rule with a fabricated date 2 weeks
// before the user's SoC date.
func TestCalculate_TriggerEventIDWithoutAnchor_Suppressed(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
ruleID, _ := uuid.NewRandom()
ruleCode := "upc.inf.cfi.rop_109_5"
rop109_5Trigger := int64(49)
rop109_5 := Rule{
ID: ruleID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &ruleCode,
Name: "Vorbereitung mündliche Verhandlung",
NameEN: "Oral hearing preparation",
PrimaryParty: str("both"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 100,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
TriggerEventID: &rop109_5Trigger,
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
triggerEvents: map[int64]TriggerEvent{
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
rop, ok := byCode[ruleCode]
if !ok {
t.Fatalf("RoP.109.5 missing from timeline; want present with conditional-no-date")
}
if rop.DueDate != "" {
t.Errorf("RoP.109.5: DueDate=%q, want empty (no oral_hearing anchor supplied)", rop.DueDate)
}
if !rop.IsConditional {
t.Errorf("RoP.109.5: IsConditional=%v, want true", rop.IsConditional)
}
if timeline.RulesAwaitingAnchor != 1 {
t.Errorf("RulesAwaitingAnchor=%d, want 1", timeline.RulesAwaitingAnchor)
}
}
// TestCalculate_TriggerEventIDWithAnchor_Renders verifies the
// caller-supplied trigger-event anchor produces correct arithmetic.
// 2 weeks before 2026-10-15 = 2026-10-01.
func TestCalculate_TriggerEventIDWithAnchor_Renders(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
ruleID, _ := uuid.NewRandom()
ruleCode := "upc.inf.cfi.rop_109_5"
rop109_5Trigger := int64(49)
rop109_5 := Rule{
ID: ruleID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &ruleCode,
Name: "Vorbereitung mündliche Verhandlung",
NameEN: "Oral hearing preparation",
PrimaryParty: str("both"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 100,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
TriggerEventID: &rop109_5Trigger,
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
triggerEvents: map[int64]TriggerEvent{
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
},
}
opts := CalcOptions{
TriggerEventAnchors: map[string]string{
"oral_hearing": "2026-10-15",
},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
rop := byCode[ruleCode]
if rop.DueDate != "2026-10-01" {
t.Errorf("RoP.109.5: DueDate=%q, want 2026-10-01 (2 weeks before oral_hearing 2026-10-15)", rop.DueDate)
}
if rop.IsConditional {
t.Errorf("RoP.109.5: IsConditional=%v, want false (anchor supplied)", rop.IsConditional)
}
if timeline.RulesAwaitingAnchor != 0 {
t.Errorf("RulesAwaitingAnchor=%d, want 0", timeline.RulesAwaitingAnchor)
}
}
// TestCalculate_MandatoryRule_RendersByDefault is the control case for
// the optional-suppression fix: mandatory rules render with their
// computed dates by default. Prevents regression where the optional
// filter accidentally drops mandatory rules too.
func TestCalculate_MandatoryRule_RendersByDefault(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
replyID, _ := uuid.NewRandom()
replyCode := "upc.inf.cfi.reply"
reply := Rule{
ID: replyID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &replyCode,
Name: "Klageerwiderung",
NameEN: "Reply to SoC",
PrimaryParty: str("defendant"),
DurationValue: 3,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 10,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, reply}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
got, ok := byCode[replyCode]
if !ok {
t.Fatalf("mandatory reply rule missing from default timeline")
}
if got.DueDate != "2026-08-26" {
t.Errorf("reply: DueDate=%q, want 2026-08-26 (3 months after SoC)", got.DueDate)
}
}
// TestCalculate_OptionalRule_SuppressedByDefault pins the
// youpcorg#2570 fix: priority='optional' rules don't render in the
// default timeline. The caller opts in via IncludeOptional=true.
func TestCalculate_OptionalRule_SuppressedByDefault(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
confID, _ := uuid.NewRandom()
confCode := "upc.inf.cfi.rop_262_2"
conf := Rule{
ID: confID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &confCode,
Name: "Erwiderung Vertraulichkeitsantrag",
NameEN: "Reply to confidentiality motion",
PrimaryParty: str("both"),
DurationValue: 14,
DurationUnit: "days",
Timing: str("after"),
SequenceOrder: 20,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
for _, d := range timeline.Deadlines {
if d.Code == confCode {
t.Errorf("optional R.262(2) rule rendered in default timeline (DueDate=%q); want suppressed", d.DueDate)
}
}
}
// TestCalculate_OptionalRule_RendersWithIncludeOptional verifies the
// opt-in path: when the caller passes IncludeOptional=true, optional
// rules show up in the timeline with their computed dates.
func TestCalculate_OptionalRule_RendersWithIncludeOptional(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
confID, _ := uuid.NewRandom()
confCode := "upc.inf.cfi.rop_262_2"
conf := Rule{
ID: confID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &confCode,
Name: "Erwiderung Vertraulichkeitsantrag",
NameEN: "Reply to confidentiality motion",
PrimaryParty: str("both"),
DurationValue: 14,
DurationUnit: "days",
Timing: str("after"),
SequenceOrder: 20,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
got, ok := byCode[confCode]
if !ok {
t.Fatalf("optional R.262(2) missing from timeline despite IncludeOptional=true")
}
// R.262(2) is the "optional opposing-side" pattern (priority=optional,
// primary_party=both, parent=SoC root) — the engine renders this as
// IsConditional (no concrete date) per the t-paliad-289 logic
// preserved in the walk. The point of this test is that the rule
// is no longer suppressed wholesale by the t-paliad-342 default —
// it surfaces, just with the conditional-render UX.
if !got.IsConditional {
t.Errorf("R.262(2): IsConditional=%v, want true (optional opposing-side, no real trigger recorded)", got.IsConditional)
}
}
// TestCalculate_TriggerEventID_MalformedAnchor_Errors ensures
// malformed dates in TriggerEventAnchors fail fast at the top of the
// engine, before any rule walking — same protocol as AnchorOverrides.
func TestCalculate_TriggerEventID_MalformedAnchor_Errors(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc}},
}
opts := CalcOptions{
TriggerEventAnchors: map[string]string{
"oral_hearing": "15-10-2026", // wrong format
},
}
_, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err == nil {
t.Fatalf("Calculate: want error on malformed TriggerEventAnchors date, got nil")
}
}

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