Compare commits

...

39 Commits

Author SHA1 Message Date
mAi
94a9e7e5fb docs: t-paliad-263 bulletproof deadline-rules completeness audit
Read-only audit of paliad.deadline_rules against UPC RoP + EPC +
PatG/ZPO/GebrMG statutory sources, with verbatim verification of
all citations against youpc data.laws_contents (UPC RoP + EPC) and
gesetze-im-internet.de (PatG/ZPO).

Headline findings:
- 5 hard user-visible bugs: 2 UPC_REV duration bugs (R.49.1 3mo->2mo,
  R.52 2mo->1mo), 1 UPC appeal-response duration bug (R.235.1 2mo->3mo),
  2 DE-LG-Verletzung sequencing bugs (beruf_begr anchor + replik/duplik
  parent_id NULL).
- 11 citation drift bugs (rule_code/legal_source point at wrong rule).
- 6 court-set-mismodelled-as-fixed (DPMA + DE + EPA richterliche Fristen
  carrying made-up statutory citations).
- ~30 statutory deadlines unmodelled (12 high-frequency in Tier 1).
- 13 ambiguity questions for m's judgement (court-set policy,
  working-days arithmetic, Wiedereinsetzung modelling).

Slices into Wave 0 (16 Tier-0 fixes) and Wave 1-6 (Tier 1-4 + spikes).
No DB writes; findings only.

Refs: m/paliad#94, t-paliad-263
2026-05-25 15:23:39 +02:00
mAi
ef21e43375 Merge: t-paliad-260 — submission-draft mobile layout (m/paliad#91) 2026-05-25 14:59:53 +02:00
mAi
4cb99fb627 mAi: #91 - t-paliad-260 — submission-draft mobile layout: drop sticky on sidebar at ≤900px
Approach A: stack vertically. At single-column widths the variable
editor was sticky + max-height: calc(100vh - 2rem), so it stayed
pinned at the top of the viewport while the user scrolled down to
read the preview, visually overlaying the preview pane.

Add a media-query override that switches the sidebar to position:
static, max-height: none, overflow-y: visible at the same ≤900px
breakpoint where the grid already collapses to one column. The
sidebar now reflows above the preview, takes its natural height,
and scrolls away as the user moves down — no overlay, no
horizontal scroll. Desktop (≥901px) layout unchanged: sidebar
keeps its sticky behavior side-by-side with the preview.

Verified at 375 / 414 / 768 / 1280 px in Playwright on the
populated editor body — same renderer serves both URL shapes
(/submissions/draft/{id} and
/projects/{id}/submissions/{code}/draft/{id}).
2026-05-25 14:58:21 +02:00
mAi
452ccdf127 Merge: t-paliad-258 — Deadline form Auto/Custom rule field + canonical rule-label display (m/paliad#89) 2026-05-25 14:56:18 +02:00
mAi
045accc6d9 mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.

Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
  rule renders read-only as 'Auto | <Name · Citation>' next to the
  field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
  nullable column, migration 122). Mutually exclusive with rule_id at
  the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
  the current Type — no stale state.

Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
  Existing rows: empty custom_rule_text + non-null rule_id = Auto-
  equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
  when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
  RuleSet=true is the discriminator so absent fields don't overwrite
  the row (PATCH semantics). RuleID and CustomRuleText are mutually
  exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
  surfaces can render it.

Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
  dropdown, the override-warning slot, and the collapsed-by-Regel Typ
  view. Strip the (Rule→Type) auto-fill machinery — direction is now
  one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
  rule by project's proceeding, then jurisdiction match, then first
  candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
  Custom text → proceeding → fallback) so the recipe still produces a
  sensible title even when Custom is used.

Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
  custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
  enterEdit initialises the mode from the persisted deadline; Save
  PATCHes with rule_set:true + the chosen rule pointer.

Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
  ("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
  with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
  formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
  shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
  Standardtitel, events.ts ruleDisplay (REGEL column on /events),
  projects-detail.ts Fristen table, views/shape-list.ts generic
  rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
  name + citation chip separately and matches the canonical pattern;
  no change needed. Schriftsätze table is column-shaped (name + code
  in distinct columns) and out of scope per the addendum.

CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
  family (retired with the catalog dropdown).

i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
  mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
  auto_pick_type, custom_badge, custom_placeholder,
  mode.toggle_to_auto, mode.toggle_to_custom).

Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).

Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).

Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
2026-05-25 14:54:51 +02:00
mAi
e6b61b4d2e Merge: t-paliad-259 — universal _skeleton.docx fallback for submission preview/generate (m/paliad#90) 2026-05-25 14:45:50 +02:00
mAi
940df95418 fix(submissions): t-paliad-259 — universal _skeleton.docx for fallback chain
Issue: m noticed the submission generator's preview still shows the raw
HL Patents Style .dotm letterhead for every submission_code that has no
per-firm template. Confirmed live: paliad.de's /healthz is green, the
preview path and /generate path both flow through resolveSubmissionTemplate,
and the only code wired in submissionTemplateRegistry is de.inf.lg.erwidg
(t-paliad-241). For every other code, the fallback was the bare letterhead
with zero placeholders — exactly what m observed.

Fix: slot a universal _skeleton.docx between the per-firm code-specific
template and the macro-only HL Patents Style:

  per-firm/{code}.docx → _skeleton.docx → HL Patents Style.dotm

The skeleton carries every placeholder SubmissionVarsService resolves
(all 48 keys across firm.*, today.*, user.*, project.*, parties.*, rule.*,
deadline.*) without baking in submission_code-specific prose, so any
code lands with variables substituted instead of the bare letterhead.

Changes:
- scripts/gen-skeleton-submission-template/main.go: byte-reproducible
  .docx generator mirroring gen-demo-submission-template but with a
  code-agnostic body (no Klageerwiderung "I./II./III." structure, a
  single [Schriftsatztext] block the lawyer replaces). One run per
  placeholder so the renderer's pass-1 substitution catches every token.
- internal/handlers/files.go: register slug submission/_skeleton.docx +
  fetchSubmissionSkeletonBytes helper (same stale-while-revalidate
  semantics as the existing per-code and HL-Patents-Style fetchers).
- internal/handlers/submission_drafts.go: insert the skeleton lookup
  between fetchSubmissionTemplateBytes (per-firm code) and
  fetchHLPatentsStyleBytes (bare letterhead). HL Patents Style remains
  the final fallback for resilience if mWorkRepo is unreachable.

The companion _skeleton.docx is committed to m/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_skeleton.docx (commit f2659e4)
so the file proxy can fetch it on first request.

Build hygiene: go build ./... clean, go test ./internal/... clean,
bun run build clean.
2026-05-25 14:44:58 +02:00
mAi
538c2d2da9 Merge: t-paliad-257 — Verfahrensablauf user-perspective column axis (Unsere Seite / Gericht / Gegnerseite) (m/paliad#88) 2026-05-25 14:34:38 +02:00
mAi
a9a9adbd2a mAi: #88 - Verfahrensablauf: column axis reframed to user-perspective
Replaces the misleading Proaktiv/Reaktiv column pair with a static
"Unsere Seite" / "Gericht" / "Gegnerseite" axis ("WE always on the
left", per m's t-paliad-257 ask). The side toggle now drives row
PLACEMENT into the ours/opponent buckets — the column labels stay
truthful regardless of which physical party occupies them.

Old framing lied half the time: Klägerseite is sometimes proactive
(filing the claim) and sometimes reactive (responding to a CCR),
so "Proaktiv (Klägerseite)" was wrong whenever the user's perspective
flipped. New axis is purely positional with semantic labels.

Changes:

- frontend/src/client/views/verfahrensablauf-core.ts:
  • ColumnsRow fields proactive/reactive → ours/opponent.
  • renderColumnsBody picks static "Unsere Seite" / "Gegnerseite"
    labels — no more variant-by-side label keys.
  • bucketDeadlinesIntoColumns routes the user's party into `ours`
    when opts.side ∈ {"defendant"}; default (null) keeps the legacy
    "we are claimant" fallback so claimant-on-left layout survives.

- verfahrensablauf-core.test.ts: rewritten expectations on the new
  ours/opponent fields. Added two new tests pinning the WE-on-left
  semantics and the side+appellant interaction (side=defendant +
  appellant=claimant → "both" collapses into opponent).

- fristenrechner.ts: wires currentPerspective into renderColumnsBody
  as `side` so the columns honour the chip-strip perspective.
  Without this, a defendant-perspective user would see claimant
  filings under the "Unsere Seite" header — the old code didn't
  need the wire-up because the labels weren't perspective-aware.

- i18n.ts: replaces deadlines.col.proactive(.defendant) +
  deadlines.col.reactive(.claimant) with deadlines.col.ours +
  deadlines.col.opponent ("Unsere Seite"/"Client Side",
  "Gegnerseite"/"Opponent Side"). Court key unchanged.

- i18n-keys.ts: regenerated key union.

- global.css: .fr-col-proactive/.fr-col-reactive renamed to
  .fr-col-ours/.fr-col-opponent.

Out of scope (kept intact):
- Side and appellant URL-state plumbing.
- Appellant selector for Appeal-type proceedings (separate axis).
- Project-default side-from-our_side wiring — /tools/verfahrensablauf
  has no project context, and /tools/fristenrechner already does this
  via applyOurSidePredefine().

Build: bun run build clean (2794 keys), go build ./... clean.
Tests: 112 frontend tests pass (was 110, +2 new); all Go tests
cached green.
2026-05-25 14:32:57 +02:00
mAi
f24a90b722 Merge: t-paliad-252 — Approval withdraw warning modal + edit-instead path (m/paliad#83) 2026-05-25 14:26:20 +02:00
mAi
55bfe439f2 Merge: t-paliad-256 — test-data reset + Example Projects seed exercising chain codes (m/paliad#87) 2026-05-25 14:26:03 +02:00
mAi
0ac26fe0ee chore(seed): t-paliad-256 — wipe + seed Example Projects exercising chain code
Re-runnable Go script under scripts/seed-example-projects/ that wipes
every paliad.projects row (FK CASCADE handles dependents) and seeds 18
realistic patent-litigation projects across 3 example clients:

  SIEMENS  — UPC + LG cases incl. CCR (Widerklage) on EP3456789
  BAYER    — EPA Einspruch + BPatG Nichtigkeit on EP2222333
  BEISPL   — sparse DPMA demo on DE10987654

Every node carries the chain-code-driving fields (reference on client,
opponent_code on litigation, patent_number on patent, proceeding_type_id
on case), producing codes like SIEMENS.HUAW.789.INF.CFI and
SIEMENS.HUAW.789.CCR.CFI via services.BuildProjectCode.

One transaction wraps both wipe and seed; -dry-run rolls back so the
script can be sanity-checked before commit. Reference tables
(proceeding_types, deadline_rules, event_types, gerichte, checklists
templates, firms) are untouched.

Ran live against youpc Postgres 2026-05-25: 12 rows wiped, 18 seeded.
2026-05-25 14:25:16 +02:00
mAi
72b64140e9 mAi: #83 - approval withdraw warning modal + edit-instead path
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.

Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
  - validates caller == requested_by AND status = pending
  - reuses the existing wider counter-allowlist (buildCounterSetClauses
    from SuggestChanges) — every editable field on the entity, not just
    the date triggers
  - applies the field updates to the entity row via applyEntityUpdate
    (including the event_type_ids junction rewrite for deadlines)
  - merges new fields into approval_requests.payload (jsonb) so the
    approver inbox sees what was revised
  - emits a distinct *_approval_edited_by_requester project_event so the
    Verlauf surfaces the revision separately from the original *_requested
    row and any decision row
  - request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
  - Body: {"fields": {<entity-shape>}}
  - Errors reuse the existing mapApprovalError mapping:
    400 suggestion_requires_change, 403 not_authorized,
    404, 409 request_not_pending
- Distinguishing audit event types per the spec:
  - destructive Withdraw path: existing <entity>_approval_revoked
    (no behaviour change — for CREATE deletes the entity, for UPDATE /
    COMPLETE reverts to pre_image, for DELETE cancels the delete request)
  - edit-instead path: new <entity>_approval_edited_by_requester

Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
  - Built on the unified openModal() primitive (t-paliad-217 Slice A)
  - Primary CTA "Termin bearbeiten" highlights the non-destructive path
  - Secondary defaults to "Abbrechen" (handled by openModal)
  - Destructive button "Endgültig zurückziehen und löschen" lives inside
    the body (red, separated by a dashed border) so the safe path stays
    visually primary in the footer
  - Copy adapts per lifecycle:
    CREATE   → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
    UPDATE   → "Ihre vorgeschlagenen Änderungen werden verworfen."
    DELETE   → "Der Eintrag bleibt bestehen."

Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
  - Replace confirm() in withdraw flow with openWithdrawWarningModal()
  - Edit path: set module-level pendingEditMode = true + enter edit mode
    (override existing pending-state freeze on appointments; expose
    enterEdit() via late-bound pendingEnterEdit on deadlines)
  - Save handler in pendingEditMode routes to /edit-entity instead of
    PATCH /api/<entity>/{id} (which still 409s on pending state)
  - Destructive Withdraw path: existing /revoke endpoint unchanged
  - For CREATE-lifecycle revokes the entity is gone — bounce to the
    /events list instead of trying to re-fetch (was reload() before)

i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)

CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.

Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)

Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
  helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
  component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
  pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
  rewrite + Save pending-edit branch + form-freeze respects
  pendingEditMode)

Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
  stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
2026-05-25 14:24:55 +02:00
mAi
50cd80a4a6 Merge: t-paliad-255 — kill /events horizontal scroll on mobile (m/paliad#86) 2026-05-25 14:10:07 +02:00
mAi
716f6d7ece fix(events): t-paliad-255 — kill /events horizontal scroll on mobile
A native <select> sizes itself to the widest <option> text. With long
project titles in the matters filter, the select grew wider than the
viewport and the /events page scrolled horizontally on mobile.

The existing 480px media query forced .entity-select to width:100% on
phones, but the 481-1000px range (tablet portrait + landscape phones)
had no constraint at all and inherited the intrinsic select width.

Fix: cap .filter-group and .entity-select at max-width:100% with
min-width:0 so the cell can shrink to fit its flex container at every
viewport. Desktop layout is preserved — normal-length options still
sit in one row across the page; only pathological content (a single
title wider than the row) wraps onto its own line.

Approach: A — let the trigger respect its container at every width.

Verified: zero horizontal scroll at 320 / 375 / 414 / 768 px with a
realistic 130-character project title injected into the matters
selector. Desktop (1280px) keeps all four filter-groups in one row.
2026-05-25 14:08:44 +02:00
mAi
1bf62c78e3 Merge: t-paliad-251 — Deadline form overhaul (m/paliad#82) 2026-05-25 14:05:06 +02:00
mAi
9a774ba3ad Merge: t-paliad-254 — Sidebar scroll position persists across nav (m/paliad#85) 2026-05-25 14:04:39 +02:00
mAi
8caaf6a631 mAi: #82 - deadline form overhaul: type-modal filter chips, type→rule autofill, Auto mode, Standardtitel
t-paliad-251. Four bundled concerns from m's 2026-05-25 reports, one
worker, one branch.

Part 1 — Event-type browse modal (search + filters)
- Modal already had a search input; added court-type filter chips
  (UPC / EPA / DPMA / DE / Allgemein) under the search.
- Chips render only the jurisdictions actually present in the data;
  any future flavour lands at the end of the row.
- Active chip uses the lime-tint chip palette already established by
  the .event-type-collapsed* family (t-paliad-165).
- Search input keeps autofocus; chip + search filters intersect.

Part 2 — Type → Rule auto-fill + sort options
- Inverted the existing rule.concept_default_event_type_id mapping
  client-side: given a chosen event_type X, candidate rules are
  those with concept_default_event_type_id === X.
- Resolution picks (1) exact match on the project's
  proceeding_type_id, (2) jurisdiction match on the rule's
  proceeding (EPA→EPO canonicalised), (3) first candidate.
- Sort dropdown next to the Rule label: by proceeding sequence,
  by court (jurisdiction grouping with optgroup), alphabetical.
  Defaults to "by court"; localStorage-persisted per browser.
- All sorts are client-side over the existing /api/deadline-rules
  payload — no new endpoint.

Part 3 — Auto rule mode + clearer override warning
- Auto badge (.form-hint--auto, lime-tint pill + " — <rule name>")
  surfaces whenever the Rule was derived from the chosen Type.
  Disappears the moment the user manually picks a different rule.
- Override warning names BOTH sides + the actually-applied rule:
  "Typ ergibt Regel: X. Gewählte Regel: Y. Es wird Y angewendet."
- Symmetric `lastAutoFilledRuleID` sticky-replace flag mirrors the
  existing `lastAutoFilledEventTypeID` (t-paliad-165) so the auto-
  fill only replaces its own previous suggestion, never a manual
  pick.
- Collapsed Typ view (t-paliad-165) is suppressed when the rule was
  auto-derived from the type — the "vorgegeben durch Regel" copy
  reads backwards in that case; show picker + Auto badge instead.

Part 4 — Standardtitel button (create + edit)
- Button rendered next to the Title field on both /deadlines/new
  and /deadlines/{id} (edit mode only).
- Recipe (recipe-docs-here-so-future-templates-can-mirror-it):
    head =
      1. event_type label (if exactly one Typ chip is set)
      2. rule code+name (when a Rule is set — "RoP.023 — Klageerwiderung")
      3. proceeding type name from project (create form only)
      4. fallback: t("deadlines.field.title.default_fallback")
    suffix = " — <project.reference>" when ref is set and not
             already in head.
  Examples:
    Klageerwiderung — C-UPC-0042       (type known)
    RoP.023 — Klageerwiderung — REF    (rule known, no type)
    UPC — Verletzungsverfahren — REF   (only proceeding type)
    Neue Frist — REF                   (fallback)
- Click REPLACES current title; no destructive confirmation
  because the user invoked it explicitly. Focus moves into the
  title input afterwards so the user can fine-tune.

Build hygiene:
- go build + go vet + go test ./internal/... clean.
- frontend/build.ts clean (2786 keys, +10 new DE+EN, scan clean).
- All changes client-side / CSS / i18n + 2 small TSX edits; no
  schema, no service, no migration.

Files touched:
- frontend/src/client/event-types.ts (browse-modal chips)
- frontend/src/client/deadlines-new.ts (rewrite — Type→Rule, sort,
  Auto badge, override warn, Standardtitel)
- frontend/src/client/deadlines-detail.ts (edit-mode Standardtitel
  + show/hide on enter/exit edit)
- frontend/src/deadlines-new.tsx (label-row + sort dropdown + Auto
  badge slot + override-warn slot + Standardtitel button)
- frontend/src/deadlines-detail.tsx (Standardtitel button)
- frontend/src/styles/global.css (.event-type-browse-chip*,
  .form-hint--auto, .form-hint-badge, .form-field-label-row,
  .btn-link-action, .rule-sort-select)
- frontend/src/client/i18n.ts (+10 keys DE+EN)
2026-05-25 14:03:04 +02:00
mAi
228ae1b263 mAi: #85 - sidebar scroll position persists across nav
Sidebar nav clicks trigger a full page reload, which rebuilds the
sidebar from scratch and snaps .sidebar-nav back to scrollTop=0.
Persist scrollTop to sessionStorage (paliad.sidebar.scroll) on every
scroll and restore on initSidebar(). Re-apply once after
/api/user-views resolves so the async layout shift doesn't leave the
user a few rows off.

sessionStorage scopes the value to the tab: Cmd-click / right-click
"open in new tab" still produces a fresh tab that starts at the top.
2026-05-25 14:03:03 +02:00
mAi
cdd3747c2b Merge: t-paliad-250 — Browse-a-proceeding side+appellant selectors + 'appealable decision' trigger label (m/paliad#81) 2026-05-25 14:00:02 +02:00
mAi
02255c4234 mAi: #81 - verfahrensablauf side+appellant selectors + UPC Appeal trigger label
Concerns A + B + C from m/paliad#81:

A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
   (Kläger/Beklagter/Beide) and an appellant selector. The side selector
   swaps which column labels which user-side; the appellant selector
   collapses party='both' rules into the appellant's column (no mirror)
   so role-swap proceedings (Appeal, etc.) stop showing every row
   twice in the timeline. Both selectors are URL-driven (?side= +
   ?appellant=) and re-render without a backend round-trip.

   The appellant row hides itself for proceedings without an appellant
   axis (first-instance Inf/Rev/Opp) via a small allowlist.

B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
   / "Appealable Decision" instead of falling back to the proceeding
   name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
   trigger_event_label_{de,en} column on paliad.proceeding_types (mig
   121); the frontend prefers it over the proceedingName fallback that
   fires when no rule has IsRootEvent=true. No new deadline rules, no
   slug changes (hard rule from the issue).

C. Parameter contract for the column projection is unified in
   bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
   helper extracted from renderColumnsBody so the routing behaviour
   stays unit-testable without a DOM. Tests cover the default mirror,
   appellant-collapse for both sides, side-swap of column ownership,
   the combined case, and row alignment by dueDate.

Verification

- go build ./...                        clean
- go test ./...                         all green
- bun run build (frontend)              clean
- bun test (frontend/src)               110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
  carries the curated trigger label pair.

Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
2026-05-25 13:57:38 +02:00
mAi
206f2917ea Merge: t-paliad-253 — Submissions /generate runs the merge engine (m/paliad#84) 2026-05-25 13:55:14 +02:00
mAi
5df87f4129 fix(submissions): t-paliad-253 — /generate runs the merge engine
The "Generieren" button on the project Schriftsätze tab posts to
/api/projects/{id}/submissions/{code}/generate. Pre-fix that handler
called `fetchHLPatentsStyleBytes` unconditionally and streamed the
result after a format-only .dotm→.docx convert — it never touched
`submissionTemplateRegistry` (added in t-paliad-241 for the draft
editor) and never ran the SubmissionRenderer merge. m's report on
m/paliad#84 ("the document generator still has no variables in the
template") was the lawyer-facing manifestation: HL Patents Style has
no {{…}} placeholders, so the downloaded .docx had nothing to
substitute and looked like a generic firm-style fixture.

The "Bearbeiten" path (/projects/{id}/submissions/{code}/draft) was
unaffected — it uses `resolveSubmissionTemplate` + the renderer
already, which is why the editor preview shows the 48 placeholders
resolved correctly. Only the one-click /generate side missed the
wire-up.

Fix:

- `internal/services/submission_draft_service.go` — add
  `RenderProjectSubmission(ctx, userID, projectID, submissionCode,
  templateBytes)` that wraps `vars.Build` + `renderer.Render` for the
  no-saved-draft path. Returns the merged bytes plus the resolved
  SubmissionVarsResult (rule, project, user, lang) so the handler can
  derive filename + audit metadata without a second DB round-trip.

- `internal/handlers/submissions.go` — rewrite
  `handleGenerateProjectSubmission` to resolve the template via
  `resolveSubmissionTemplate` (per-firm slug → HL Patents Style
  fallback, same as the editor draft) and run the new service method.
  Visibility / rule-not-found semantics route through
  `SubmissionVarsService` errors so the gate behavior matches every
  other project endpoint. Removed `loadPublishedRuleByCode` and
  `errRuleNotFound` — both were only used by the old handler.

- `scripts/gen-demo-submission-template/main.go` + the regenerated
  `de.inf.lg.erwidg.docx` on mWorkRepo (HL/mWorkRepo @ 3e3e828f) now
  exercise the bare `{{today}}` alias too. The demo template covers
  every one of the 48 keys SubmissionVarsService can resolve (firm 2,
  today 4, user 3, project 18, parties 6, rule 8, deadline 7).

The renderer is a no-op on placeholder substitution when the
fallback HL Patents Style is fetched (it has none) — but it still
runs the .dotm→.docx pre-pass via `ConvertDotmToDocx`, so the
non-per-firm code path streams a byte-for-byte equivalent download.

Build + vet + tests clean (go test ./internal/...; bun run build).
2026-05-25 13:51:45 +02:00
mAi
898348a64a Merge: t-paliad-245 — Daten Exportieren demoted into Verwaltung tab (m/paliad#76) 2026-05-25 13:34:53 +02:00
mAi
1714b788d2 feat(projects-detail): t-paliad-245 — demote Daten Export into Verwaltung tab
m/paliad#76. The export button no longer pokes out of the tabs nav with a
non-tab styling — instead it lives inside a new "Verwaltung" tab (last in
the project tab list) as a normal section with heading, description, and a
plain btn-secondary trigger. Same gate as before (canExportProject).

Archive co-locates in the same tab as a pointer to the Edit-modal danger
zone: click "Bearbeiten öffnen" → modal opens scrolled to the archive
button. Single source of truth for the destructive action stays in the
modal; the Verwaltung pointer just gives it discoverability.

If neither sub-section is visible to the caller (no export entitlement,
not global_admin), the Verwaltung tab hides itself — an empty tab is
worse UX than no tab.
2026-05-25 13:33:14 +02:00
mAi
db8335253b Merge: t-paliad-244 — Team View mailto: link for non-admin members (m/paliad#75) 2026-05-25 13:31:52 +02:00
mAi
5589cbb477 mAi: #75 - team view mailto: link for non-admin members
t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
  recipient list pre-filled, comma-joined and URL-encoded via
  buildMailtoHref (already exported from broadcast.ts).

Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.

No new i18n keys, no backend changes, admin compose flow untouched.
2026-05-25 13:30:32 +02:00
mAi
0059e3f15b Merge: t-paliad-243 — project-optional Schriftsätze drafts + /submissions/new picker 2026-05-23 02:20:26 +02:00
mAi
a911a2d0ee feat(submissions): t-paliad-243 — global Schriftsätze drafts without project
Adds an end-to-end project-optional path for Schriftsatz drafts:

- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
  and rewrites the four RLS policies to gate purely on user_id when
  project_id IS NULL, otherwise on paliad.can_see_project. Down
  refuses to run if project-less rows exist (safer than silent
  data corruption).

- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
  layer skips project/parties/deadline lookups when nil and exposes
  DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
  ListAllForUser LEFT JOINs paliad.projects so project-less drafts
  surface in the global index next to project-scoped ones.

- New HTTP surface:
    GET  /submissions/new                 (picker page)
    GET  /submissions/draft/{draft_id}    (editor for any draft)
    GET  /api/submissions/catalog         (catalog without project)
    POST /api/submission-drafts           (project-less or attached)
    GET/PATCH/DELETE /api/submission-drafts/{draft_id}
    POST /api/submission-drafts/{draft_id}/export
  Existing /api/projects/{id}/submissions/... routes remain bit-
  identical so the project-scoped flow keeps working unchanged.

- Frontend: /submissions/new lists the full cross-proceeding catalog
  grouped by proceeding, filterable by text + chip. Each row offers
  "Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
  with autocomplete over visible projects). /submissions index gains
  a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
  the picker. The editor renders a banner + "Projekt zuweisen"
  action when project_id is null; assigning persists project_id and
  redirects to the project-scoped URL.

Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
2026-05-23 02:19:55 +02:00
mAi
b26f04ffe0 Merge: t-paliad-242 — Schriftsätze tab full catalog grouped by proceeding 2026-05-23 01:56:44 +02:00
mAi
8e195cb497 feat(submissions): t-paliad-242 — Schriftsätze tab shows full catalog grouped by proceeding
Per m's 2026-05-23 ask: from any project, surface every available
template/generator instead of just the project's own proceeding.

Backend (GET /api/projects/{id}/submissions):
- drop the proceeding_type_id filter; JOIN deadline_rules with
  proceeding_types to return every active+published filing rule
  across every active proceeding
- response gains proceeding_code, proceeding_name, proceeding_name_en
  per row plus project_proceeding_code at the top so the frontend
  can pin the project's own group
- has_template now reflects "per-submission .docx wired in
  submissionTemplateRegistry"; the editor still falls back to the
  universal HL Patents Style for everything else (t-paliad-238)
- can_see_project gate unchanged; rules are static reference data
- sorted by (proceeding_code, submission_code)

Frontend:
- client/submissions.ts renders a grouped table: project's own
  proceeding pinned to the top with a lime border + "(dieses
  Projekt)" suffix, every other proceeding alphabetised below
- "Generieren" + "Bearbeiten" buttons stay on every row (editor
  handles missing variables via [KEIN WERT: …])
- "universell"/"universal" badge surfaces for rules without a
  per-submission template — informational, not blocking
- soften the no_proceeding hint so the catalog still renders below
- entity-table-group-header CSS, including --own modifier and a
  read-only override so group rows don't pretend to be clickable

Verified: 103 filing rules across 19 proceedings surface (de.inf.lg,
upc.inf.cfi, epa.opp.opd, etc.). go build + go vet + go test
./internal/... + bun run build clean.
2026-05-23 01:55:32 +02:00
mAi
1f7de99493 Merge: t-paliad-241 — demo Klageerwiderung template + placeholder wiring 2026-05-23 01:33:22 +02:00
mAi
0adcc2c826 Merge: t-paliad-240 — Schriftsätze sidebar + global drafts index 2026-05-23 01:30:32 +02:00
mAi
2c7ac6423f feat(submissions): t-paliad-241 — demo Klageerwiderung template wired
Authored a per-submission-code .docx template for `de.inf.lg.erwidg`
exercising every placeholder SubmissionVarsService resolves (45 keys
across firm/today/user/project/parties/rule/deadline namespaces), so
the Submissions draft editor has variables to substitute and the
sidebar/preview feature can be demonstrated end-to-end.

Pieces:

- `scripts/gen-demo-submission-template/` — one-shot Go authoring tool
  that emits a minimal but Word-compatible .docx zip with a fake
  Klageerwiderung skeleton in German. Each placeholder lives in its own
  <w:r> run so the renderer's pass-1 (format-preserving) substitution
  catches it without falling into the cross-run merge path. Output is
  byte-reproducible (fixed mtime).

- `internal/handlers/files.go` — added `submissionTemplateRegistry`
  (submission_code → fileRegistry slug) plus
  `fetchSubmissionTemplateBytes` helper that reuses the Gitea proxy
  cache infra. Registered one entry for `de.inf.lg.erwidg`. The file
  itself was uploaded to mWorkRepo at
  `6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx`
  (mWorkRepo commit 9633524).

- `internal/handlers/submission_drafts.go` —
  `resolveSubmissionTemplate` now tries the per-code lookup first;
  falls back to the universal HL Patents Style for any code that
  doesn't have a per-firm template registered, matching the cronus
  design fallback chain §8.

The existing HL Patents Style .dotm is untouched (still the universal
fallback and still the source for the format-only /generate path).
Future per-submission templates register one fileRegistry entry +
one submissionTemplateRegistry row.
2026-05-23 01:30:24 +02:00
mAi
436c1b41bb feat(submissions): t-paliad-240 — Schriftsätze sidebar + global drafts index
Add a top-level Schriftsätze entry under the Werkzeuge sidebar group
plus a new /submissions page that lists every draft the caller owns
across visible projects. Each row links to the per-project editor at
/projects/{id}/submissions/{code}/draft/{draft_id}.

Backend: SubmissionDraftService.ListAllForUser joins paliad.submission_drafts
with paliad.projects, gated by paliad.can_see_project for visibility. New
GET /api/user/submission-drafts endpoint exposes the rows; the page route
GET /submissions is gateOnboarded'd alongside the other project surfaces.

Frontend: submissions-index.tsx renders an entity-table; submissions-index.ts
hydrates from /api/user/submission-drafts and wires the row-click contract
(skip clicks on inner a/button). DE primary, EN secondary i18n.
2026-05-23 01:29:56 +02:00
mAi
2c5f85b802 Merge: t-paliad-238 Slice A — dedicated Submissions draft editor + merge engine 2026-05-23 00:06:50 +02:00
mAi
d3aade5aac feat(submissions): t-paliad-238 Slice A — dedicated draft editor page
Adds the dedicated Submissions/Schriftsätze editor at
/projects/{id}/submissions/{code}/draft (and …/draft/{draft_id}) per
docs/design-submission-page-2026-05-22.md.

Lawyer picks (or creates) a named draft, edits placeholder variables
in a sticky sidebar, sees a read-only HTML preview of the merged
document body, and exports a .docx with project state + lawyer
overrides resolved. Drafts persist in paliad.submission_drafts
keyed on (project_id, submission_code, user_id, name) with RLS via
can_see_project; updates and deletes additionally gated on owner-only
(Q-E4 owner-scoped pick, m-confirmed).

Resurrected from git history per the design's "no rewrite" plan:
  SubmissionVarsService    ← commit 1765d5e (Slice 2 with patent_number_upc)
  SubmissionRenderer       ← commit 8ea3509 (in-house merge engine — the
                             lukasjarosch/go-docx library refuses sibling
                             placeholders in one run, which patent submissions
                             use routinely)
  ConvertDotmToDocx        ← existing format-only convert (kept; reused as
                             pre-pass so .dotm inputs strip macros before
                             merge)

New code:
  paliad.submission_drafts  migration 119 (idempotent — DROP POLICY IF EXISTS
                            + CREATE; CREATE OR REPLACE for the shared trigger
                            function). Applied to live DB.
  SubmissionDraftService    CRUD + autosave-friendly Update + Export/RenderPreview
                            entry points
  RenderHTML method         new on the renderer; walks the same merged
                            document.xml as Render but emits HTML for the
                            preview pane (Q-E3 server-side pick)
  7 API handlers            list / create / get / patch / delete / preview / export
  2 page routes             /draft and /draft/{draft_id}
  submission-draft.tsx      stand-alone editor page (header / sidebar /
                            preview / export button); served via
                            dist/submission-draft.html
  submission-draft.ts       client bundle — autosave (500ms debounce),
                            draft switcher, rename, delete, export with
                            blob download

Tab integration: existing /projects/{id}/#tab-submissions rows get
[Bearbeiten] alongside the existing [Generieren] one-click format-only
path — additive, no removal.

Slice A template: universal HL Patents Style .dotm (same path
t-paliad-230 uses). resolveSubmissionTemplate carries the
submission_code parameter so Slice B's TemplateRegistry wiring (per-
code .docx fallback chain) is a one-function swap.

Audit trail: paliad.system_audit_log row per export
(event_type='submission.exported') + paliad.project_events row
(event_type='submission_exported', timeline_kind='custom_milestone')
so the export surfaces on the project's Verlauf / SmartTimeline. No
paliad.documents write (Q-E2 inventor pick, head-ratified).

Tests: TestRender_* / TestPlaceholderRegex_* / TestRenderHTML_* +
TestLegalSourcePretty / TestOurSide* / TestPatentNumberUPC — all
green. go build / go vet / go test ./internal/... / bun run build all
clean.

Migration slot taken: 119.
2026-05-23 00:06:08 +02:00
mAi
17d2fff661 Merge: t-paliad-239 — Add Checklist button on project Checklists tab 2026-05-22 23:56:50 +02:00
mAi
f7374a67cd docs(submissions): t-paliad-238 design — dedicated Submissions/Schriftsätze page
Adds docs/design-submission-page-2026-05-22.md (~600 lines) — a
dedicated submission-draft page at /projects/{id}/submissions/{code}/draft
with sidebar variable editor + read-only HTML preview + .docx export.

Reuses (resurrects from git) the deleted Slice 1 backend that t-paliad-230
ripped out — SubmissionVarsService (3677c81 + 1765d5e), in-house
SubmissionRenderer (8ea3509), TemplateRegistry (3677c81). All four
compile against today's services with zero API drift.

New schema: paliad.submission_drafts keyed on
(project_id, submission_code, user_id, name) with RLS via can_see_project.

Three slices: A = schema + page + variables-only export against universal
.docx; B = per-submission_code templates with fallback-chain registry;
C = toggleable passages.

Four material picks escalated to head in §11 (template authoring effort,
paliad.documents row, server-vs-client preview, inter-user draft
visibility). All other open questions defaulted to inventor (R)
recommendations from task brief.

No code. Read-only design phase per inventor → coder gate.
2026-05-22 23:43:51 +02:00
60 changed files with 11310 additions and 568 deletions

View File

@@ -151,6 +151,14 @@ func main() {
eventTypeSvc := services.NewEventTypeService(pool, users)
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
partySvc := services.NewPartyService(pool, projectSvc)
// t-paliad-238 — dedicated submission draft editor. The variable
// bag service is shared between the renderer (export) and the
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
// (commits 3677c81 + 1765d5e + 8ea3509).
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
submissionRenderer := services.NewSubmissionRenderer()
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
@@ -160,7 +168,8 @@ func main() {
Project: projectSvc,
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
Party: services.NewPartyService(pool, projectSvc),
Party: partySvc,
SubmissionDraft: submissionDraftSvc,
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,

View File

@@ -0,0 +1,668 @@
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
**Author:** cronus (inventor)
**Date:** 2026-05-22
**Issue:** m/paliad (mai task t-paliad-238)
**Branch:** `mai/cronus/inventor-dedicated`
**Status:** DESIGN READY FOR REVIEW
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
---
## §0 TL;DR
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
1. Picks (or creates) a named **draft** for one (project, submission_code).
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
3. Sees a read-only preview pane showing the merged document body as HTML.
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
---
## §1 Premises verified live (2026-05-22)
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
| Claim | Verification |
|---|---|
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123``Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID``can_see_project`. |
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/``…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52``PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
---
## §2 m's decisions (2026-05-22)
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
| # | Question (from task brief) | Default adopted | Source |
|---|---|---|---|
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export``.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
Inventor-defaulted (not in the (R) matrix; clear right answer):
| # | Topic | Default | Reasoning |
|---|---|---|---|
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
---
## §3 Architecture overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Project detail (existing) — /projects/{id} with #tab-submissions │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ Schriftsätze tab │ │
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
│ │ "Bearbeiten" deep-link │ "Generieren" = │
│ │ │ existing one-click │
│ ▼ │ format-only export │
└───────────┼───────────────────────────────────────────┼──────────────────────┘
│ │
▼ │
┌──────────────────────────────────────────────────────────────────────────────┐
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
│ │
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
│ │ ───────────────────────────────── │ │ overrides] │ │
│ │ project.case_number │ │ │ │
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
│ │ BMW AG [override?] │ │ durch … │ │
│ │ deadline.due_date │ │ │ │
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
│ │ ... │ │ │ │
│ │ │ │ [KEIN WERT: project.our_side] │ │
│ │ [✎ Bearbeiten] inline │ │ │ │
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
│ │
│ [⬇ Als .docx exportieren] │
└──────────────────────────────────┬───────────────────────────────────────────┘
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
┌──────────────────────────────────────────────────────────────────────────────┐
│ handlers/submission_drafts.go (NEW) │
│ 1. Auth: ProjectService.GetByID → can_see_project │
│ 2. Load draft row (RLS via project visibility) │
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
│ 4. Apply draft.overrides on top of bag │
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
│ (Slice A: skips registry, fetches the universal .dotm directly) │
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
│ 7. Audit: system_audit_log + project_events │
│ 8. Stream .docx with Content-Disposition: attachment │
└──────────────────────────────────────────────────────────────────────────────┘
```
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
---
## §4 Schema (`paliad.submission_drafts`)
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
```sql
CREATE TABLE paliad.submission_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
submission_code text NOT NULL,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
-- empty value = "don't override, use bag"
-- present key = "use this verbatim"
last_exported_at timestamptz, -- NULL until first export
last_exported_sha text, -- template SHA at last export (audit aid)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT submission_drafts_unique_per_user
UNIQUE (project_id, submission_code, user_id, name)
);
CREATE INDEX submission_drafts_project_user_idx
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
CREATE POLICY submission_drafts_visible
ON paliad.submission_drafts
FOR ALL
USING (paliad.can_see_project(project_id));
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
CREATE TRIGGER submission_drafts_updated_at
BEFORE UPDATE ON paliad.submission_drafts
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
```
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not**`system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
### 4.1 RLS read-vs-write
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
### 4.2 Down migration
```sql
DROP TABLE IF EXISTS paliad.submission_drafts;
```
No data loss concern at design time — feature ships without legacy drafts.
---
## §5 Service layer
### 5.1 Resurrect from git (no new code)
```
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
strip macros from the universal .dotm before the merge step runs.
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
NOT wired in Slice A (universal .dotm only); wired in Slice B.
```
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
### 5.2 New service — `SubmissionDraftService`
```go
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
type SubmissionDraftService struct {
db *sqlx.DB
projects *ProjectService
}
type SubmissionDraft struct {
ID uuid.UUID
ProjectID uuid.UUID
SubmissionCode string
UserID uuid.UUID
Name string
Overrides PlaceholderMap // jsonb → map[string]string
LastExportedAt *time.Time
LastExportedSHA *string
CreatedAt, UpdatedAt time.Time
}
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
// Visibility flows through projects.GetByID.
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
// Get returns a single draft by ID, gated on project visibility.
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
```
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
### 5.3 Wiring
```go
// cmd/server/main.go (additions, no replacements)
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
// Slice B only:
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
```
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
---
## §6 UI surface
### 6.1 Page layout
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
│ [⬇ Als .docx exportieren] │
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
│ │ 80333 München │
│ ───────────────────────────────────────── │ │
│ Variablen │ In der Sache │
│ firm.name HLC │ │
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
│ project.court LG München I [✎] │ — Klägerin — │
│ parties.claimant.name BMW AG [✎] │ │
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
│ parties.defendant.representative │ │
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
│ rule.legal_source_pretty │ │
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
│ … │ │
│ │ [KEIN WERT: project.our_side] │
│ ───────────────────────────────────────── │ │
│ [Entwurf umbenennen] [Entwurf löschen] │ │
└────────────────────────────────────────────┴────────────────────────────────┘
```
Sidebar grouping (top-to-bottom, locale-aware labels):
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
2. **Mandanten & Parteien** (parties.*)
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
4. **Frist** (deadline.* — due_date, computed_from)
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
### 6.2 Preview pane
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
### 6.3 Routing + handlers
```
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
```
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
### 6.4 Schriftsätze tab — additive change
```diff
- // Each row: [Generieren ↓]
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
```
`[Bearbeiten ↗]``window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
---
## §7 Variable contract (v1 placeholder set)
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
```
firm.name — branding.Name (HLC or FIRM_NAME override)
firm.signature_block — empty in v1 (Phase 2 affordance)
today — 2026-05-22 (ISO, Europe/Berlin)
today.iso — ISO short
today.long_de — "22. Mai 2026"
today.long_en — "22 May 2026"
user.display_name — paliad.users.display_name
user.email — paliad.users.email
user.office — paliad.users.office
project.title — paliad.projects.title
project.reference — paliad.projects.reference
project.case_number — paliad.projects.case_number
project.court — paliad.projects.court
project.patent_number — DE/inline form "EP 1 234 567 B1"
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
project.filing_date — ISO date
project.grant_date — ISO date
project.our_side — claimant | defendant
project.our_side_de — "Klägerin" | "Beklagte"
project.our_side_en — "Claimant" | "Defendant"
project.instance_level — lg | olg | bgh | cfi | …
project.client_number — paliad.projects.client_number
project.matter_number — paliad.projects.matter_number
project.proceeding.code — e.g. "de.inf.lg"
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
project.proceeding.name_de — explicit DE
project.proceeding.name_en — explicit EN
parties.claimant.name — first paliad.parties row with role='claimant'
parties.claimant.representative
parties.defendant.name — first row with role='defendant'
parties.defendant.representative
parties.other.name — first non-claimant/defendant row
parties.other.representative
rule.submission_code — "de.inf.lg.erwidg"
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
rule.name_de
rule.name_en
rule.legal_source — "DE.ZPO.276.1"
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
rule.primary_party — claimant | defendant | court | both
rule.event_type — filing | hearing | decision
deadline.due_date — ISO of next pending deadline for this rule on this project
deadline.due_date_long_de — "12. Juni 2026"
deadline.due_date_long_en — "12 June 2026"
deadline.original_due_date — ISO if extended
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
deadline.title — deadline.title
deadline.source — "rule" | "manual" | …
```
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
### 7.1 Override semantics
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
```go
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
for k, v := range draft.Overrides { // lawyer's edits
if v == "" {
delete(bag, k) // empty override means "force missing marker"
} else {
bag[k] = v // non-empty override replaces
}
}
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
```
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
---
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
### 8.1 Slice A — universal .dotm only
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
### 8.2 Slice B — fallback-chain registry
Layout reproduced from the 2026-05-19 design §5.1:
```
m/mWorkRepo (existing repo, already proxied)
└── templates/
├── HLC/ # FIRM_NAME-keyed override dir
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
│ ├── de.inf.lg.klage.docx # Slice B addition
│ ├── upc.inf.cfi.soc.docx # Slice B addition
│ └── upc.inf.cfi.sod.docx # Slice B addition
├── _base/ # Cross-firm baseline
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
│ ├── de.inf.lg.docx # proceeding-family fallback
│ ├── upc.inf.cfi.docx
│ ├── _skeleton.docx # ultra-generic fallback
│ └── _universal.docx # the v1 Slice A "any code" template
└── README.md # placeholder reference for template authors
```
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
### 8.3 Lookup algorithm
```go
// services/submission_templates.go (resurrected from 3677c81)
func (r *TemplateRegistry) candidates(submissionCode string) []string {
family := familyOf(submissionCode)
out := []string{
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
}
if family != "" && family != submissionCode {
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
}
out = append(out, "templates/_base/_skeleton.docx")
return out
}
```
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
### 8.4 No template at all — Slice A vs Slice B
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
### 8.5 Template authoring task lands outside this design
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
---
## §9 Slice plan
### Slice A — schema + new page + variables-only export against universal .docx
Ships the editor end-to-end with one template.
| Deliverable | Files |
|---|---|
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
Acceptance:
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
2. Sidebar renders ~30 placeholders, pre-filled from project state.
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
7. `project_events` row appears on export and surfaces in Verlauf.
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
### Slice B — per-submission_code templates + fallback chain
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
| Deliverable | Files |
|---|---|
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
Acceptance:
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
### Slice C — toggleable passages
Lawyer can include/exclude boilerplate sections before export.
| Deliverable | Notes |
|---|---|
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb``{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
---
## §10 Out of scope
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
- Citation insertion from the sources system. Waits for the sources surface m parked.
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
---
## §11 Material picks escalated to head
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
### Q-E1 — Template authoring effort
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
### Q-E2 — `paliad.documents` row on export
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
### Q-E3 — Preview render — server or client?
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
### Q-E4 — Inter-user draft visibility
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
---
## §12 Implementation notes
For the coder, not for head.
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
---
## §13 Acceptance gate
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
- Slices B and C are SEPARATE tasks — not auto-spawned.
Inventor parks here.

View File

@@ -0,0 +1,956 @@
# Bulletproof completeness audit — paliad.deadline_rules vs statutory sources
**Author:** curie (researcher)
**Date:** 2026-05-25
**Task:** t-paliad-263 (m/paliad#94)
**Mode:** read-only research, no DB writes
**Branch:** `mai/curie/researcher-bulletproof`
Scope confirmed by head (paliad/head → paliad/curie, 2026-05-25 15:13):
**UPC Rules of Procedure + EPC + PatG / ZPO / GebrMG**, plus UPC Agreement /
Statute where they create time-limits. No HLC-internal checklists exist in
the current head's working tree.
Companion / prior audits this report supersedes-and-extends:
- `docs/audit-fristenrechner-completeness-2026-04-30.md` (curie, t-paliad-084) — youpc-vs-paliad gap analysis.
- `docs/audit-upc-rop-deadlines-2026-05-08.md` (curie, t-paliad-159) — first UPC RoP gap list (52 rules / 2 duration bugs).
- `docs/audit-fristen-logic-2026-05-13.md` (pauli, t-paliad-157) — schema audit; the codes used here (`upc.inf.cfi`, `de.inf.lg`, …) reflect the post-mig-096 rename.
Migration baseline: migration ≤ `122_deadlines_custom_rule_text` (live as of 2026-05-25 14:00 UTC).
---
## §0. TL;DR
- **20 active fristenrechner proceeding_types** (live, `is_active=true`,
`lifecycle_state='published'`) carry **132 active rules**. One extra
`_archived_litigation` row holds 40 retired Pipeline-A rules from
mig 093 — not surfaced anywhere, kept only for FK validity.
| Jurisdiction | Active types | Active rules | Statute-bound rules audited |
|---|---:|---:|---:|
| UPC (CFI + CoA) | 9 (incl. upc.ccr.cfi alias) | 67 | 67 |
| EPA | 3 | 23 | 23 |
| DPMA | 3 | 13 | 13 |
| DE (LG/OLG/BGH/BPatG) | 5 | 29 | 29 |
| **Total** | **20** | **132** | **132** |
- **5 high-impact bugs still live** that the prior May 8 audit
surfaced (2) plus 3 new ones identified here.
- 🔴 **`upc.rev.cfi.defence` 3 months, RoP.49.1 says 2 months.** Flagged
May 8; still live. ★★★ — every UPC_REV defendant.
- 🔴 **`upc.rev.cfi.rejoin` 2 months, RoP.52 says 1 month.** Flagged
May 8; still live. ★★★ — every UPC_REV proceeding.
- 🟠 **`upc.apl.merits.response` 2 months, RoP.235.1 says 3 months.**
New finding (May 8 audit recorded the rule as "3 months / present-wrong
rule_code only" — actually live data shows 2 months, so the audit
sample mis-recorded the duration too). ★★★ — every UPC main-track
appeal respondent.
- 🟠 **`de.inf.lg.beruf_begr` chains parent = berufung (1mo) + 2mo = 3mo
from urteil. ZPO §520(2) anchors the 2-month Begründungsfrist on
service of urteil, not on filing of Berufung.** New finding.
★★★ — every DE-first-instance appellant.
- 🟠 **`de.inf.lg.replik` + `.duplik` have `parent_id=NULL` so they fire
on the trigger date (Klageerhebung) — sequence-order says 30/40 but
the compute engine reads parent_id first.** Reported as live UI bug
by m via head (2026-05-25 13:13); confirmed by SQL. ★★★ — every
DE-LG-Verletzung timeline.
- **5 rule-code / citation drift bugs still live** from the May 8 audit
(`upc.apl.merits.notice`, `.grounds`, `.response`, `upc.rev.cfi.reply`,
`.rejoin`) — durations may or may not be right, but the cited
`legal_source` / `rule_code` points at the wrong rule. Pure
cosmetic on `.notice`/`.grounds` (durations are right); load-bearing on
`.rev.cfi.reply` / `.rejoin` because the cited rule is what tells
the lawyer where to look the rule up.
- **4 DPMA / DE citation bugs** new in this audit, all citing PatG / ZPO
sections that don't contain the cited deadline:
- `de.null.bpatg.erwidg` cites `DE.PatG.82.1`; the 2-month Erwiderung
is actually `§82(3)` (§82(1) is the 1-month Erklärungsfrist).
- `dpma.opp.dpma.erwiderung` cites `DE.PatG.59.3`; §59(3) is about
hearings, not a 4-month proprietor response. The 4-month figure is
DPMA-internal practice, not statutory — should be court-set.
- `dpma.appeal.bpatg.begruendung` cites `DE.PatG.75.1`; §75 is about
*aufschiebende Wirkung* — there is no Begründungsfrist in PatG §73-§80
for the BPatG-Beschwerde. The 1-month figure is also non-statutory.
- `de.null.bgh.begruendung` cites `DE.PatG.111.1`; §111 is about the
grounds-of-appeal *content* (Verletzung des Bundesrechts), not the
Begründungsfrist. `de.null.bgh.erwiderung` cites `DE.PatG.111.3`;
§111(3) doesn't exist in the deadline sense.
- **Wide UPC coverage gap inherited from May 8 audit, mostly un-closed:**
~25 missing UPC RoP rules. Mig 095 (t-paliad-205) closed 4 of them
(R.19 Preliminary Objection on UPC_INF and UPC_REV, R.220.1(a)
merits-appeal spawn on both). The other ~21 (R.20.2, R.118.4,
R.197.3, R.198, R.207.6.a, R.207.9, R.213, R.109.1/.4/.5, R.118.5,
R.144, R.155, R.224.2(b), R.229.2, R.235.2, R.245.x, R.262.2,
R.321.3, R.333.2, R.353, plus the DNI family R.63-R.69) are
unchanged.
- **EPC gaps:** EPA opposition + Beschwerde modelled at the
Article level only. Missing the entire Implementing Regulations
family that drives day-to-day deadlines — R.71(3) approval period
is half-modelled (the 4-month figure is there but the trigger
anchor is broken: parent_id=NULL), R.79(1) proprietor response
is modelled as a fixed 4-month period when it's actually
court-set, R.116 oral-proceedings cut-off is modelled as
duration-0/parent-NULL (works for some uses, not for others),
R.121 / R.135 Weiterbehandlung is missing entirely (concept
exists but no rule).
- **DE/DPMA gaps:** the entire Wiedereinsetzung family (PatG §123)
is absent on the proceeding-tree side. `weiterbehandlung` and
`wiedereinsetzung` concept slugs exist in the cascade (Pathway B)
but no `paliad.deadline_rules` row computes them. Same for
`versaeumnisurteil-einspruch` (ZPO §339 — 2 weeks).
- **15 ambiguities** that need m's judgement, not a coder's fix —
mostly around court-set vs statutory periods (e.g. richterliche
Fristen under ZPO §276(1) S.2, §283 Schriftsatznachreichung,
EPC R.79(1), §59(3) PatG) and around the "whichever is
longer / later" arithmetic primitives still missing
(R.198 / R.213 / R.245.2).
- **Recommended fixes (§10) — total 41 items** prioritised in 4
tiers. Tier 0 (5 hard duration bugs + 1 sequencing bug + 9
citation/anchor bugs) should ship first. Tier 1 (12 rule-fill
gaps, ★★★ / ★★) next. Tier 2 + 3 are coverage breadth that
needs scoping by m (Wiedereinsetzung, R.198 working-day
arithmetic, full Implementing Regulations port).
---
## §1. Methodology
For each of the 20 active proceeding_types I:
1. **Pulled the live rule set** via `mcp__supabase__execute_sql` against
the youpc Postgres on 2026-05-25 14:0015:00 UTC. Schema = `paliad`.
Filter: `is_active = true AND lifecycle_state = 'published'`.
2. **Enumerated the statutory deadlines** in the relevant code for the
proceeding's scope.
3. **Cross-referenced each statutory deadline against the live rule
set** on (a) duration + unit, (b) anchor / parent, (c) party,
(d) `rule_code` / `legal_source` citation, (e) sequencing.
4. **Marked status**: `present-correct`, `present-wrong (duration)`,
`present-wrong (citation)`, `present-wrong (anchor)`,
`present-wrong (party)`, `partial`, `missing`, `n/a`.
5. **Frequency tag** for prioritisation: ★★★ every case, ★★ common,
★ specialist.
### 1.1 Sources
All citations carry a date stamp and a URL. Where the text was checked
against more than one source, both are listed.
| Source | URL | Verified on | Used for |
|---|---|---|---|
| UPC Rules of Procedure (consolidated 18.05.2023, in force 2023-06-01) | https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf | 2026-05-25 | All UPC RoP citations |
| UPC RoP verbatim text via `data.laws_contents` (youpc Postgres, law_type=`UPCRoP`, language=en) | youpc Supabase | 2026-05-25 | Cross-check on R.019.1, R.020.2, R.029.b/.c, R.049.1, R.051, R.051.p1, R.052, R.052.p1, R.220.1.a, R.224.1, R.224.1.a/.b, R.224.2, R.224.2.a/.b, R.235.1, R.235.2, R.237, R.238.1, R.238.2 |
| European Patent Convention (EPC, 17th ed. 2020) — Articles | https://www.epo.org/en/legal/epc/2020/index.html (verbatim text per youpc `data.laws_contents`, law_type=`EPC`) | 2026-05-25 | EPC Articles 93, 99, 108, 112a, 116, 121, 123, 135 |
| EPC Implementing Regulations — Rules (in force 2026 consolidated) | https://www.epo.org/en/legal/epc/2020/r71.html (and equivalents) | 2026-05-25 | EPC R.70(1), R.71(3), R.79(1)/(2), R.116(1), R.135 |
| Patentgesetz (PatG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/patg/ | 2026-05-25 | §59, §73, §75, §82, §83, §99 ff., §100, §102, §110, §111 |
| Zivilprozessordnung (ZPO) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/zpo/ | 2026-05-25 | §253, §276, §277, §283, §296a, §339, §517, §520, §521, §524, §544, §548, §551, §554 |
| Gebrauchsmustergesetz (GebrMG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/gebrmg/ | 2026-05-25 | §17 (Löschung), §18 (Verfahren) — referenced only to confirm out-of-scope: no GebrMG-rooted proceeding_type exists in paliad today |
### 1.2 Conventions
- A **rule** here means a row in `paliad.deadline_rules`. paliad's local
identifier is `submission_code` (post mig 098), e.g.
`upc.rev.cfi.defence`.
- A **statutory deadline** means an obligation derived directly from the
text of a procedural code, with a fixed period.
- "**Court-set**" / "richterliche Frist" means the statute authorises the
court / DPMA / EPO to set the period — there is no fixed statutory
duration. paliad models these with `is_court_set = true`
(post mig ~079) or, legacy-style, `duration_value = 0`.
- "**Anchoring**" refers to which event the period runs from. paliad
models this via `parent_id` (chain anchor) or `anchor_alt` (e.g.
`priority_date`); a NULL parent_id with non-zero duration means the
deadline runs from the user-supplied trigger date.
### 1.3 Hard constraint: "no fabricated provisions"
Where I'm not 100% sure of a citation (because the youpc law DB only
covers UPC + EPC, not PatG / ZPO, and my web-fetch coverage of
PatG / ZPO is partial), I flag the finding as **"needs lawyer review"**
in §9 rather than asserting a fix. Five PatG / ZPO findings carry that
tag.
---
## §2. Current state inventory (per jurisdiction)
### 2.1 UPC
9 active types, 67 rules. `upc.ccr.cfi` is an alias proceeding that
holds zero rules — it points at `upc.inf.cfi` rules under the
`with_ccr` flag.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `upc.inf.cfi` | Verletzungsverfahren | 15 | RoP 19, 23, 25, 29.a-e, 30, 32, 151, 220.1(a) |
| `upc.rev.cfi` | Nichtigkeitsverfahren | 17 | RoP 19, 32, 42, 43.3, 49.1, 49.2.a, 49.2.b, 51, 52, 56.1/3/4, 220.1(a) |
| `upc.pi.cfi` | Einstweilige Maßnahmen | 4 | RoP 205, 207, 211 |
| `upc.disc.cfi` | Bucheinsicht | 4 | RoP 141, 142.2, 142.3 |
| `upc.dmgs.cfi` | Schadensbemessung | 4 | RoP 131.2, 137.2, 139 |
| `upc.apl.merits` | Berufung | 8 | RoP 220.1, 224.1.a, 224.2.a, 235.1, 237, 238.1 |
| `upc.apl.order` | Berufung gegen Anordnungen | 5 | RoP 220.1(c), 220.2, 220.3, 237, 238.2 |
| `upc.apl.cost` | Berufung gegen Kostenentscheidung | 2 | RoP 221.1 |
| `upc.ccr.cfi` | Widerklage auf Nichtigkeit (alias) | 0 | — |
### 2.2 EPA
3 active types, 23 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `epa.grant.exa` | EP-Erteilung | 7 | EPC Art. 93, R.70(1), R.71(3) |
| `epa.opp.opd` | EPA Einspruch | 8 | EPC Art. 99(1), 108, 116, 123; R.79(1), R.79(2), R.116(1) |
| `epa.opp.boa` | EPA Beschwerde | 8 | EPC Art. 108, 112a; R.116(1); RPBA Art. 12 |
### 2.3 DPMA
3 active types, 13 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `dpma.opp.dpma` | DPMA Einspruch | 4 | PatG §59(1), §59(3) |
| `dpma.appeal.bpatg` | BPatG-Beschwerde | 5 | PatG §73(2), §74 ff. |
| `dpma.appeal.bgh` | BGH-Rechtsbeschwerde | 4 | PatG §100, §102 |
### 2.4 DE (national patent / civil)
5 active types, 29 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `de.inf.lg` | LG-Verletzungsklage | 8 | ZPO §253, §276, §283, §296a, §517, §520(2) |
| `de.inf.olg` | OLG-Berufung Verletzung | 7 | ZPO §517, §520(2), §521(2), §524(2) |
| `de.inf.bgh` | BGH-Revision Verletzung | 8 | ZPO §544, §548, §551, §554 |
| `de.null.bpatg` | BPatG-Nichtigkeitsklage | 10 | PatG §81 ff., §82, §83 |
| `de.null.bgh` | BGH-Nichtigkeitsberufung | 6 | PatG §110, §111 / ZPO ref via §117 PatG |
### 2.5 Cross-cutting: cascade vs proceeding-tree coverage
The cascade layer (`paliad.event_categories` + `…_concepts` +
`paliad.deadline_concepts`) carries 56 concept "nouns" and ~153
cascade-leaf → concept mappings. **9 concepts are orphans** (carry
zero rules, so the cascade card dead-ends): `counterclaim-for-revocation`,
`schriftsatznachreichung`, `versaeumnisurteil-einspruch`,
`weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`,
plus 3 more. Inventory and recommendations live in
`docs/audit-fristen-logic-2026-05-13.md` §3.4 — this audit covers only
the proceeding-tree side.
---
## §3. Findings — Missing rules (statute defines, paliad doesn't)
### 3.1 UPC RoP — 21 missing rules (out of ~25 flagged 2026-05-08, 4 closed by mig 095)
Notation: ★★★ every case, ★★ common, ★ specialist. Verbatim RoP text
sampled from youpc `data.laws_contents` (law_type=`UPCRoP`, lang=en).
| RoP § | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **R.20.2** | 14 days | Service of Preliminary Objection | ★ | Reply to PO. Companion to R.19 (which mig 095 added). Without R.20.2 the PO branch is half-modelled. |
| **R.118.4** | 2 months | Final decision on validity served | ★★ | Application for orders consequential on validity. Common after central-division revocation. |
| **R.118.5** | n/a UPC | n/a | n/a | UPC has no Versäumnisurteil-Einspruch; closest is R.355 (review of contumacy). |
| **R.144** | 0 (anchor) | Final decision on damages quantum | ★ | UPC_DAMAGES tree end-row missing. |
| **R.155** | 1mo / 14d | Cost-decision opposition chain | ★ | UPC_COST_APPEAL only has the leave-to-appeal step; no Defence-to-cost-app row. |
| **R.197.3** | 30 days | Saisie order served on respondent | ★ | Review application. Trigger event 65 exists; no rule attached. |
| **R.198** | 31 calendar days **OR 20 working days, whichever is longer** | Saisie executed | ★ | Start proceedings on the merits. Blocked on `working_days` + `combine='max'` primitives (see §7 + §9). |
| **R.207.6.a** | 14 days | Notification of deficiency in PI application | ★★ | Registry correction. |
| **R.207.9** | 6 months | PI filed | ★ | Renewal of protective letter. |
| **R.213** | 31 days OR 20 working days | PI granted | ★★ | Same arithmetic gap as R.198. |
| **R.109.1** | 1 month **before** | Oral hearing date | ★★ | Simultaneous translation request. `timing='before'` schema supported but no rule populates it (see §7 cross-cutting). |
| **R.109.4** | 2 weeks **before** | Oral hearing date | ★★ | Interpreter cost notification. `timing='before'`. |
| **R.109.5** | 2 weeks after | Order of judge-rapporteur to lodge translations | ★★ | trigger event 113 exists; no rule. |
| **R.224.2.b** | 15 days | Order under R.220.1(c) or decision under R.220.2/221.3 served | ★★ | Grounds-on-orders track. `upc.apl.order` has appeal-itself but no separate grounds row. Verified verbatim against `UPCRoP.224.2.b` (youpc DB). |
| **R.229.2** | 14 days | Notification of appeal-deficiency | ★ | Registry correction in appeal context. |
| **R.235.2** | 15 days | Statement of grounds (orders track) served | ★★ | Verified verbatim against `UPCRoP.235.2` (youpc DB): *"Within 15 days of service of grounds of appeal pursuant to Rule 224.2(b), any other party … may lodge a Statement of response"*. `upc.apl.order` has no standalone response row. |
| **R.245.1** | 2 months | Final decision served | ★ | Application for rehearing. |
| **R.245.2.a** | 2 months | Discovery of fundamental defect (or final decision service, whichever is later) | ★ | Outer cap 12mo. Needs multi-anchor + `max-of-two-anchors` arithmetic. |
| **R.245.2.b** | 2 months | Discovery of criminal offence (or final decision service, whichever is later) | ★ | Same shape as 245.2.a. |
| **R.262.2** | 14 days | Receipt of opposing party's confidentiality application | ★★ | Daily occurrence in HLC infringement work. Trigger event 25 exists; no rule. |
| **R.320** | 2 months (cap 12 mo) | Wegfall des Hindernisses (Wiedereinsetzung) | ★★ | Cascade card exists (mig 063) but no proceeding-tree rule computes the deadline. Bridges proceedings → no obvious home in any one tree. |
| **R.321.3** | 10 days | Preliminary objection referral to central division | ★ | |
| **R.333.2** | 15 days | Case-management order served | ★★ | Review-of-CMO. Routine in busy LDs. |
| **R.353** | 1 month | Decision / order delivered | ★ | Rectification application. |
| **DNI: R.63 / R.67.1 / R.69.1 / R.69.2** | 0 / 2mo / 1mo / 1mo | DNI cascade | ★ | No UPC_DNI proceeding_type exists. Fringe at HLC (zero published filings in 2026-Q1 per May 8 audit). |
| **Registry-correction family: R.16.3.a, R.27.2, R.89.2, R.253.2** | 14 days each | Various deficiency notifications | ★ | All same 14-day duration; different trigger codes. Most natural home is cascade not proceeding-tree (see audit-fristenrechner-completeness-2026-04-30.md §3.1). |
**Closed since May 8 audit (verified by SQL):**
- ✅ R.19 Preliminary Objection on UPC_INF — `upc.inf.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095.
- ✅ R.19 Preliminary Objection on UPC_REV — `upc.rev.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095 (cites R.19 i.V.m. R.46).
- ✅ R.220.1(a) merits-appeal spawn on UPC_INF — `upc.inf.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
- ✅ R.220.1(a) merits-appeal spawn on UPC_REV — `upc.rev.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
### 3.2 EPC Implementing Regulations — 4 missing rules
| EPC ref | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **EPC R.135 (Weiterbehandlung)** | 2 months | Notification of loss of rights | ★★ | Concept `weiterbehandlung` exists in cascade (orphan); no rule. Applies broadly across `epa.grant.exa` and `epa.opp.opd`. |
| **EPC R.99(2) / Art. 121** | 2 months | Loss-of-rights notification (further processing) | ★★ | Same family as R.135. |
| **EPC Art. 112a(4)** | 2 months / 1 month | Discovery of grounds for review / decision served (whichever later) | ★ | paliad has `epa.opp.boa.r106` (2 months, parent=entsch2) — but the rule doesn't model the "whichever later" outer cap (12 months from decision per Art. 112a(4)). |
| **EPC Art. 99(1) — opposition fee paid** | 9 months (no extension) | Mention of grant in Patentblatt | ★★★ | `epa.opp.opd.frist` IS modelled correctly at 9 months. **Note however:** the rule is on `epa.opp.opd` but the *trigger* is opposition-fee-paid (per Art. 99(1) S.2 — "Notice of opposition shall not be deemed to have been filed until the opposition fee has been paid"). Not a gap, but a documentation note. |
### 3.3 PatG / ZPO — 5 missing rules
| Citation | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **PatG §123 (Wiedereinsetzung)** | 2 months | Wegfall des Hindernisses (cap 1 year) | ★★ | Cascade concept `wiedereinsetzung` exists; no rule on any DE/DPMA proceeding tree. Same modelling problem as UPC R.320 — bridges proceedings. |
| **ZPO §339 (Versäumnisurteil-Einspruch)** | 2 weeks | Service of default judgment | ★ | Cascade concept `versaeumnisurteil-einspruch` orphan. |
| **ZPO §544 — Nichtzulassungsbeschwerde-Begründung** | 2 months | Service of OLG-Urteil (NB: NOT from filing of NZB) | ★★ | `de.inf.bgh.nzb_begr` lists `DE.ZPO.544.4`, duration 2mo, parent=urteil_olg — **modelled correctly**. Listed here only to flag that the *parent anchoring* differs from `de.inf.lg.beruf_begr` which is wrong (see §7.1). |
| **ZPO §283 (Schriftsatznachreichung) / §296a** | court-set | post-Verhandlung schriftsatzfrist | ★ | Cascade concept `schriftsatznachreichung` orphan. Court-set period — modelling as `is_court_set=true, duration=0` would suffice. |
| **PatG §17(2) GebrMG / §18 GebrMG** | 1 month (Beschwerdefrist) | DPMA-Beschluss | ★ | Out of scope per head's confirmation (no GebrMG-rooted proceeding_type yet). Listed to confirm the deliberate gap. |
### 3.4 DPMA — 0 missing rules
DPMA coverage is shallow but not gappy. The 3 active types (opposition,
BPatG-Beschwerde, BGH-Rechtsbeschwerde) cover the statutory steps. The
problems here are **citation drift** (§4.4) and **anchor modeling**
(§7.4) rather than missing rules.
---
## §4. Findings — Misattributed legal source
### 4.1 UPC RoP citation drift (5 still live from May 8)
| Rule | Live `rule_code` | Live `legal_source` | Should be | Source verified |
|---|---|---|---|---|
| `upc.apl.merits.notice` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.1.a` / `UPC.RoP.224.1.a` | `UPCRoP.224.1.a` youpc DB |
| `upc.apl.merits.grounds` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.2.a` / `UPC.RoP.224.2.a` | `UPCRoP.224.2.a` |
| `upc.apl.merits.response` | `null` | `null` | `RoP.235.1` / `UPC.RoP.235.1` | `UPCRoP.235.1` |
| `upc.rev.cfi.reply` | `null` | `null` | `RoP.051` / `UPC.RoP.51.p1` | `UPCRoP.051.p1` |
| `upc.rev.cfi.rejoin` | `null` | `null` | `RoP.052` / `UPC.RoP.52.p1` | `UPCRoP.052.p1` |
Note on cascade vs proceeding-tree drift on R.220.3 anchoring is in
`docs/audit-upc-rop-deadlines-2026-05-08.md` §5.4b — unchanged here.
### 4.2 UPC RoP citation drift on Rule 49.1 format (1 still live)
| Rule | Live `rule_code` | Should be |
|---|---|---|
| `upc.rev.cfi.defence` | `RoP.49.1` | `RoP.049.1` (canonical zero-padded form used by all other UPC rules) |
### 4.3 DPMA — 3 mis-attributed citations
| Rule | Live citation | Problem | Verified |
|---|---|---|---|
| `dpma.opp.dpma.erwiderung` | `§ 59 PatG` / `DE.PatG.59.3` | §59(3) PatG addresses *Anhörung*, not a 4-month response period. No statutory Erwiderungsfrist exists in §59. The 4-month figure is DPMA-internal practice. | WebFetch [gesetze-im-internet.de/patg/__59.html](https://www.gesetze-im-internet.de/patg/__59.html) 2026-05-25 |
| `dpma.appeal.bpatg.begruendung` | `§ 75 PatG` / `DE.PatG.75.1` | §75 PatG is exclusively about *aufschiebende Wirkung* (suspensive effect). It does not establish any Begründungsfrist. No fixed Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 — it is set by the BPatG in the individual case. | WebFetch [gesetze-im-internet.de/patg/__75.html](https://www.gesetze-im-internet.de/patg/__75.html) + [§73](https://www.gesetze-im-internet.de/patg/__73.html) 2026-05-25 |
| `dpma.appeal.bpatg.beschwerde` | `§ 73 PatG` / `DE.PatG.73.2` | §73 contains the 1-month deadline correctly; the `.2` subscript however refers to §73(2) which is about Beschwerdebefugnis — the *Frist* is in §73(2) S.4 ("Die Beschwerdefrist beträgt einen Monat …"). Citation should be `DE.PatG.73.2.s4` or simply `DE.PatG.73.2`. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
### 4.4 DE patent / civil — 4 mis-attributed citations
| Rule | Live citation | Problem | Verified |
|---|---|---|---|
| `de.null.bpatg.erwidg` | `§ 82 PatG` / `DE.PatG.82.1` | §82(1) is the 1-month *Erklärungsfrist* ("sich darüber zu erklären"); the 2-month full *Klageerwiderung* is in §82(3). Citation should be `DE.PatG.82.3`. Duration (2 months) is correct. | WebFetch [§82](https://www.gesetze-im-internet.de/patg/__82.html) 2026-05-25 |
| `de.null.bpatg.replik_klaeger` | `§ 83 PatG` / `DE.PatG.83.2` | §83(2) is about the *Hinweisbeschluss* form; the Replik / Schriftsatz windows fall under §83(2) S.3 (Reaktion auf Hinweis). Citation OK at section level but ambiguous. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
| `de.null.bgh.begruendung` | `§ 111 PatG` / `DE.PatG.111.1` | §111 PatG defines the *Grounds* of Berufung (Verletzung des Bundesrechts), not a Begründungsfrist. The 3-month figure is supplied via §117 PatG → ZPO §520(2). Citation should be `DE.ZPO.520.2` (the actual time-limit source). | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) 2026-05-25 |
| `de.null.bgh.erwiderung` | `§ 111 PatG` / `DE.PatG.111.3` | §111 has no Erwiderungsfrist clause. The actual Erwiderungsfrist for BGH-Nichtigkeitsberufung is set by the court per §117 PatG → ZPO §521(2) (court-discretionary). Duration (2 months) is approximate — typical court-set period is 2 months but it's not fixed. **Should be modelled as court-set.** | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) + ZPO §521 2026-05-25 |
### 4.5 EPA — 1 mis-attributed citation
| Rule | Live citation | Problem |
|---|---|---|
| `epa.opp.opd.erwidg` | `R. 79(1) EPÜ` / `EU.EPC-R.79.1` | Duration (4 months) is correct as the *typical* EPO-set period under the 2016 streamlined-opposition guidelines, but **R.79(1) does not specify a fixed period** — the Opposition Division sets it. The 4 months is administrative practice (EPO Guidelines D-IV, 5.2). Should be modelled as court-set with 4 months as the default-display value. |
---
## §5. Findings — Wrong period (statute says X, paliad says Y)
| Rule | Live period | Statutory period | Source | Freq |
|---|---|---|---|---|
| **`upc.rev.cfi.defence`** | 3 months | **2 months** | RoP.049.1: *"The defendant shall lodge a Defence to revocation within two months of service of the Statement for revocation."* — verified verbatim from `UPCRoP.049.1` (youpc DB). Flagged 2026-05-08; still live. | ★★★ |
| **`upc.rev.cfi.rejoin`** | 2 months | **1 month** | RoP.052: *"Within one month of the service of the Reply the defendant may lodge a Rejoinder to the Reply to the Defence to revocation"* — verified verbatim from `UPCRoP.052.p1`. Flagged 2026-05-08; still live. | ★★★ |
| **`upc.apl.merits.response`** | 2 months | **3 months** | RoP.235.1: *"Within three months of service of the Statement of grounds of appeal pursuant to Rule 224.2(a), any other party … may lodge a Statement of response"* — verified verbatim from `UPCRoP.235.1`. New finding — May 8 audit recorded the duration as 3 months but the live row has always been 2 (migration 012:153 originally seeded 2). | ★★★ |
| **`upc.pi.cfi.response`** | 0 / "court-set" (`is_court_set=false`, `duration=0`, `parent_id=NULL`) | court-set, judge-discretion under R.211.2 | RoP.211.2 — judge sets the inter-partes hearing date. Modelling is half-broken: `duration=0` with `parent_id=NULL` makes the calculator treat this as a root anchor rather than a court-set placeholder. Should set `is_court_set=true` and chain `parent_id=app`. | ★★ |
(All other rules audited have correct durations.)
---
## §6. Findings — Wrong party
No clear party mis-assignments found in the live data. Two notes worth
recording, not bugs:
- `upc.inf.cfi.app_to_amend` carries `primary_party='claimant'`. The
defendant in an INF case is the alleged infringer; the patent
proprietor (=claimant) is who would file an Application to Amend
the patent. **Correct.** Listed here only because R.30 reads "the
defendant" in some summaries — those refer to the claimant of the
CCR (= defendant of the INF), which loops back to the same person
who is the INF-claimant / patent-proprietor.
- `dpma.opp.dpma.erwiderung` carries `primary_party='defendant'`. In an
EPA-style opposition, the patent proprietor is the "defendant" of the
opposition. Consistent with EPA convention. **Correct.**
---
## §7. Findings — Wrong sequencing / anchoring
### 7.1 `de.inf.lg.beruf_begr` chains parent = `berufung`, should anchor on `urteil` directly
| Live | Per ZPO §520(2) |
|---|---|
| `de.inf.lg.beruf_begr.parent_id = de.inf.lg.berufung`, `duration = 2 months` → effective end = trigger + 1mo (Berufung) + 2mo = **3 months** after Urteil service | "Die Frist für die Berufungsbegründung beträgt zwei Monate. Sie beginnt mit der Zustellung des in vollständiger Form abgefassten Urteils" → **2 months** after Urteil service |
Verified verbatim via WebFetch
[gesetze-im-internet.de/zpo/__520.html](https://www.gesetze-im-internet.de/zpo/__520.html)
2026-05-25.
The companion `de.inf.olg.begruendung` is **correct** — parent =
`urteil_lg`, 2mo, so end = Urteil + 2mo. Same statute, two paliad
rules, two different anchorings: this is a real bug in `de.inf.lg`.
### 7.2 `de.inf.lg.replik` and `de.inf.lg.duplik` have `parent_id = NULL`
This is the bug head flagged. Live data:
| submission_code | name | duration | parent_id | sequence_order |
|---|---|---|---|---|
| `de.inf.lg.klage` | Klageerhebung | 0 mo | NULL | 0 |
| `de.inf.lg.anzeige` | Anzeige Verteidigungsbereitschaft | 2 wk | `de.inf.lg.klage` | 10 |
| `de.inf.lg.erwidg` | Klageerwiderung | 6 wk | `de.inf.lg.klage` (court-set=true post mig 095) | 20 |
| **`de.inf.lg.replik`** | Replik | **4 wk** | **NULL** | 30 |
| **`de.inf.lg.duplik`** | Duplik | **4 wk** | **NULL** | 40 |
| `de.inf.lg.termin` | Haupttermin | 0 mo | NULL (court-set) | 50 |
| `de.inf.lg.urteil` | Urteil | 0 mo | NULL (court-set) | 60 |
| `de.inf.lg.berufung` | Berufungsfrist | 1 mo | NULL | 70 |
| `de.inf.lg.beruf_begr` | Berufungsbegründung | 2 mo | `de.inf.lg.berufung` | 80 |
With `parent_id = NULL` the calculator anchors Replik on the
triggerDate (= Klageerhebung), and same for Duplik. So both render
"4 Wochen ab Klageerhebung" — i.e. before the Klageerwiderung is
even due. Correct chain should be:
- `replik.parent_id = de.inf.lg.erwidg`, with `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO — typ. 4 weeks default)
- `duplik.parent_id = de.inf.lg.replik`, same shape
Both rules lack `legal_source` and `rule_code`, which is consistent
with them being court-set Schriftsatzfristen (no statutory clamp).
Recommendation in §10.
### 7.3 `upc.apl.merits.grounds` has `parent_id = NULL`
This anchors Grounds on the user-supplied trigger date (=Entscheidung
service). **Correct** behaviour per RoP.224.2.a: *"within four months
of service of a decision referred to in Rule 220.1(a) and (b)"*.
If `parent_id` were set to `upc.apl.merits.notice` (as the May 8 audit
hypothesised), the chain would compound (1-day notice + 4mo grounds =
~4mo + 1 day), accidentally landing near the right end-date for the
common case but wrong by up to 2 months in the edge case (when notice
is filed early). **No fix needed; document the intent.** (This is
the change the May 8 audit recommended; it was applied in mig 097 or
earlier.)
### 7.4 DPMA Pathway-A anchors are partially modelled
- `dpma.appeal.bgh.begruendung` chains parent = `rechtsbeschwerde`
(1mo + 1mo = 2mo from BPatG-Entscheidung). Per PatG §102 the
Rechtsbeschwerdebegründungsfrist is 1 month from filing of the
Rechtsbeschwerde — **correct**.
- `dpma.appeal.bpatg.begruendung` chains parent = `beschwerde`
(1mo + 1mo = 2mo from DPMA-Entscheidung). **No statutory basis for
the 1-month figure** (see §4.3). Should be court-set.
### 7.5 EPA grant timeline — `epa.grant.exa.r71_3` and `.approval` have `parent_id = NULL`
Live:
| Rule | Duration | parent_id | Issue |
|---|---|---|---|
| `epa.grant.exa.r71_3` | 0 mo | NULL | Should chain on `exam_req` (after examination request is granted, EPO issues R.71(3) communication). NULL parent + 0 duration = root anchor at trigger date — works only if user enters the R.71(3) date as trigger; doesn't compose with the rest of the tree. |
| `epa.grant.exa.approval` | 4 mo | NULL | Per R.71(3) approval period: 4 months from notification. **Anchor should be `r71_3`**, not NULL. As-is, "Zustimmung + Übersetzung" appears as a free-standing 4-mo-from-trigger row that has nothing to do with the rest of the timeline. |
### 7.6 Summary
| # | Rule | Bug |
|---|---|---|
| 1 | `de.inf.lg.beruf_begr` | parent should be NULL (anchored on Urteil-trigger) not `berufung` — off by 1 month, ★★★ |
| 2 | `de.inf.lg.replik` | parent should be `erwidg` not NULL, ★★★ |
| 3 | `de.inf.lg.duplik` | parent should be `replik` not NULL, ★★★ |
| 4 | `dpma.appeal.bpatg.begruendung` | should be court-set; current 1-month period has no statutory basis, ★★ |
| 5 | `dpma.appeal.bpatg.beschwerde` parent is `entscheidung` — OK, just a citation issue (§4.3) | (citation only) |
| 6 | `epa.grant.exa.r71_3` parent | should chain on `exam_req`, ★ |
| 7 | `epa.grant.exa.approval` parent | should chain on `r71_3`, ★ |
| 8 | `upc.pi.cfi.response` | court-set placeholder with `parent_id=NULL` and `is_court_set=false` — should chain on `app` with `is_court_set=true`, ★★ |
---
## §8. Findings — Duplicates
No genuine duplicates. The closest cases:
- `upc.inf.cfi.reply` + `upc.inf.cfi.def_to_ccr` both fire at 2mo after
`sod` under `with_ccr`. They cover different actions (Reply to SoD
vs. Defence to CCR + Reply to SoD combined) per RoP.029.a vs .b.
**Not a duplicate** — distinct rule codes.
- `upc.rev.cfi.reply` (2mo, no rule_code) and the older `REV.rev_reply`
on the archived litigation type — the archived type is hidden
(`pt.is_active = false`) so this isn't a duplicate the user sees.
Recommendation in §10 to drop the archived corpus once mig 093's
audit window closes.
- `epa.opp.boa.r106` (Art. 112a review) appears only on
`epa.opp.boa`, not on `epa.opp.opd` — correct, since Art. 112a
review is only available against a Boards-of-Appeal decision.
---
## §9. Ambiguities — decisions m needs to make
These are not bugs the coder can fix. They are judgement calls about
how to model the law.
### 9.1 Court-set vs fixed-period for richterliche Fristen
The cleanest source-of-truth for these is "no statutory duration —
court sets the period in the individual case." Modelling them as a
fixed period with a wrong citation is the bug pattern we keep finding:
- `dpma.opp.dpma.erwiderung` (4 mo) — DPMA practice, not §59 PatG.
- `dpma.appeal.bpatg.begruendung` (1 mo) — no statutory basis.
- `de.inf.olg.erwiderung` (1 mo, §521(2)) — §521(2) is explicitly
discretionary ("Der Vorsitzende oder das Berufungsgericht **kann**
der Gegenpartei eine Frist … bestimmen"). Verified WebFetch
[gesetze-im-internet.de/zpo/__521.html](https://www.gesetze-im-internet.de/zpo/__521.html)
2026-05-25.
- `de.null.bgh.erwiderung` (2 mo, "§111(3) PatG") — court-set per §117
PatG → ZPO §521(2).
- `de.null.bpatg.duplik` (1 mo, §83 PatG) — court-set; the 1-month
default is BPatG practice.
- `de.inf.lg.replik`, `.duplik` (4 wk each) — court-set per
§283 / §296a ZPO + §276(1) S.2.
- `epa.opp.opd.erwidg` (4 mo, "R.79(1)") — EPO-set per Guidelines.
**Question (Q1):** Should paliad continue to display these with a
default duration but flag them as "richterliche Frist — vom Gericht
festgesetzt", OR should they all flip to `is_court_set=true,
duration=0` and force the user to enter the actual court-set date?
Head's 2026-05-25 13:13 signal confirms: m's preference is that "Frist
vom Gericht bestimmt" be flagged as needing case-by-case anchoring,
not displayed as a fixed period. So default answer = flip to
`is_court_set=true` and keep the typical period as the *Default*
display value (the calculator already supports this since the
mig 095 / `de.inf.lg.erwidg` patch). But the trade-off is a UX
regression: most users will not enter the actual court-set date
and the timeline will then show "vom Gericht bestimmt" everywhere.
### 9.2 R.198 / R.213 "31 days OR 20 working days, whichever is longer"
Two RoP rules need a primitive paliad doesn't have:
- A `working_days` duration unit (counts business-day arithmetic via
the holiday service).
- A `combine = 'max'` operator that compares two durations and picks
the later end-date.
**Question (Q2):** Implement the primitive (~120 LoC migration + ~80 LoC
Go), or document both rules as "manual calculation required, see RoP"
in the UI? Real R.198 / R.213 cases are rare (saisie + PI). The May 8
audit suggested deferring; pauli's 2026-05-13 audit §7.1 made the
case for adding `combine_op` as part of a broader Pipeline A/C merge.
### 9.3 R.245.2 rehearing "whichever is later" trigger
R.245.2.a/b: deadline 2 months from final decision OR from defect
discovery, whichever is *later*. Plus outer cap 12 months. Needs:
- Multi-anchor trigger event (user supplies 2 dates).
- `combine = 'max'` between anchors.
- Outer-cap arithmetic (separate concept from duration).
**Question (Q3):** Defer (specialist, vanishingly rare) or build the
primitives?
### 9.4 EPC Art. 112a review — outer cap
Same shape as R.245.2: 2 months from defect discovery, outer cap 12
months from decision. `epa.opp.boa.r106` models the 2-month period
but not the cap.
### 9.5 PatG §123 Wiedereinsetzung calendar arithmetic
Cascade card (slug `wiedereinsetzung`) exists. The 2mo / 1-year
arithmetic anchors on the *missed* deadline, not on a forward-looking
event. paliad's `paliad.deadline_rules` schema has no natural shape
for this — it would need either a special-case Go helper, or a
"backward-from-missed-deadline" mode that no rule today uses.
**Question (Q4):** Worth modelling? The cascade card already routes
the user to the concept; computing the calendar deadline is an
incremental win.
### 9.6 ZPO §339 Versäumnisurteil-Einspruch
Cascade card orphan. 2 weeks from service of the default judgment.
Trivial to add as a `de.inf.lg.einspruch_vu` rule (court-decision
anchor + 2wk fixed). **Question (Q5):** Add as a child of
`de.inf.lg.urteil` (with `condition_expr={"flag":"with_vu"}`), or
as a separate proceeding `de.inf.lg.vu`?
### 9.7 Litigation-vs-fristenrechner archived corpus
The 40 rules on `_archived_litigation` (mig 093 retirement holding pen)
still occupy the rule table. They're invisible to all UIs.
**Question (Q6):** Drop them now (data clean-up), or keep until the
mig 093 audit window closes formally?
### 9.8 R.79(2) further-party observations period
EPC R.79(2) creates a separate notification window for additional
opponents. paliad's `epa.opp.opd.r79_further` is modelled as
`duration=0, is_bilateral=true`. **Question (Q7):** Is this even worth
keeping? Real workflow: EPO sets a separate period in each
intervention case. Hard to template.
### 9.9 R.116(1) EPC oral-proceedings cut-off
paliad has it as `duration=0, parent_id=entsch` (`epa.opp.opd.r116`) /
`parent_id=oral` (`epa.opp.boa.r116`). R.116(1) actually says the
EPO sets a "final date for making written submissions" when issuing
the summons. So it's a court-set period, not zero-duration.
**Question (Q8):** flip to `is_court_set=true` like the §276(1) ZPO
fix in mig 095?
### 9.10 R.131.2 indication of damages period
paliad models `upc.dmgs.cfi.app` as a 0-duration root anchor (court
sets when the damages-determination phase opens, per R.131.2). This
is correct shape but means the entire damages tree is unanchored
until the user provides the trigger date manually.
**Question (Q9):** Wire `is_spawn` from `upc.inf.cfi.decision` to
`upc.dmgs.cfi.app` (parallel to the mig-095 appeal-spawn)?
### 9.11 PatG §17 GebrMG / §18 GebrMG
No GebrMG-rooted proceeding_type exists in paliad. Head confirmed
out-of-scope for this audit. **Question (Q10):** Add a `de.gm.lg`
proceeding for GebrMG-Löschungsverfahren if HLC sees them?
### 9.12 Proceeding-tree vs cascade parity
paliad has 9 cascade-only concepts with `rule_count = 0` (the orphans
listed in `audit-fristen-logic-2026-05-13.md` §3.4). The audit-fristen
audit covers this; restating here only to note that the parity gap
is the largest single source of "the cascade card promises a
calculation but doesn't deliver one."
**Question (Q11):** Same as the audit-fristen Q8 — priority order
for the 9 orphan concepts? My ranking: wiedereinsetzung >
schriftsatznachreichung > versäumnisurteil-einspruch >
weiterbehandlung > rest.
### 9.13 R.220.3 anchor
See `audit-upc-rop-deadlines-2026-05-08.md` §5.4b. paliad anchors
`upc.apl.order.discretion` on the original order (`order`), but
the 15-day clock per RoP.220.3 runs from the refusal-of-leave
date (or day-15 fall-back). Off by up to 15 days in the edge case.
**Question (Q12):** add an explicit `app_ord.refusal` court-set
intermediate node?
### 9.14 EP_GRANT publish date — priority vs filing
`epa.grant.exa.publish` correctly has `anchor_alt='priority_date'`.
This was open in the May 8 audit and is now closed. **No question —
listed to confirm.**
### 9.15 Cross-proceeding spawn execution
mig 095 added two `is_spawn=true` rules (`inf.appeal_spawn`,
`rev.appeal_spawn``upc.apl.merits`). The May 13 audit §1.6 +
§6.8 noted spawn execution is half-wired in `projection_service.go`.
**Question (Q13):** wire end-to-end now (so the spawned appeal
timeline appears in SmartTimeline), or accept the half-wired state?
---
## §10. Recommended fixes (prioritised)
### Tier 0 — hard duration / sequencing / anchor bugs (ship first)
| # | Rule | Fix | Reason / source | Freq |
|---|---|---|---|---|
| T0.1 | `upc.rev.cfi.defence` | `duration_value = 2` (was 3), `rule_code = 'RoP.049.1'`, `legal_source = 'UPC.RoP.49.1'` | §5 — every UPC_REV tracked in paliad today computes Defence at wrong month for the last ~3 months | ★★★ |
| T0.2 | `upc.rev.cfi.rejoin` | `duration_value = 1` (was 2), `rule_code = 'RoP.052'`, `legal_source = 'UPC.RoP.52.p1'` | §5 — same as T0.1 | ★★★ |
| T0.3 | `upc.apl.merits.response` | `duration_value = 3` (was 2), `rule_code = 'RoP.235.1'`, `legal_source = 'UPC.RoP.235.1'` | §5 — every main-track appellate respondent | ★★★ |
| T0.4 | `de.inf.lg.beruf_begr` | `parent_id = NULL` (was `de.inf.lg.berufung`) — runs 2 months from triggerDate (Urteil-service) per ZPO §520(2) | §7.1 — every DE-LG-Verletzung appeal | ★★★ |
| T0.5 | `de.inf.lg.replik` | `parent_id = de.inf.lg.erwidg`, `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO), keep 4-week default | §7.2 — bug head flagged | ★★★ |
| T0.6 | `de.inf.lg.duplik` | `parent_id = de.inf.lg.replik`, `is_court_set = true` | §7.2 | ★★★ |
| T0.7 | `upc.rev.cfi.reply` | `rule_code = 'RoP.051'`, `legal_source = 'UPC.RoP.51.p1'` (duration 2mo unchanged) | §4.1 | ★★★ |
| T0.8 | `upc.rev.cfi.rejoin` (citation only) | covered in T0.2 | — | — |
| T0.9 | `upc.apl.merits.notice` | `rule_code = 'RoP.224.1.a'`, `legal_source = 'UPC.RoP.224.1.a'` (duration unchanged) | §4.1 | ★★ |
| T0.10 | `upc.apl.merits.grounds` | `rule_code = 'RoP.224.2.a'`, `legal_source = 'UPC.RoP.224.2.a'` (duration unchanged) | §4.1 | ★★ |
| T0.11 | `upc.rev.cfi.defence` rule_code zero-pad | covered in T0.1 | — | — |
| T0.12 | `dpma.opp.dpma.erwiderung` | flip to `is_court_set = true`, keep 4-month default-display value, drop the misleading `DE.PatG.59.3` citation (or replace with "DPMA-Richtlinien D-IV 5.2") | §4.3 + §9.1 | ★★ |
| T0.13 | `dpma.appeal.bpatg.begruendung` | flip to `is_court_set = true`, drop the `DE.PatG.75.1` citation, keep 1-month default | §4.3 + §9.1 | ★★ |
| T0.14 | `de.null.bpatg.erwidg` | citation `DE.PatG.82.3` (was 82.1); duration (2mo) correct | §4.4 | ★★ |
| T0.15 | `de.null.bgh.begruendung` | citation `DE.ZPO.520.2` via PatG §117 (was DE.PatG.111.1); duration (3mo) correct | §4.4 | ★★ |
| T0.16 | `de.null.bgh.erwiderung` | flip to `is_court_set = true`; citation `DE.ZPO.521.2 via PatG §117` (was DE.PatG.111.3); duration (2mo) becomes default-display | §4.4 + §9.1 | ★★ |
| T0.17 | `epa.opp.opd.erwidg` | flip to `is_court_set = true`, keep 4-month default | §4.5 + §9.1 | ★★ |
**16 hard fixes.** All within the existing schema (no new columns).
Each is a single-row UPDATE plus an audit-log entry.
### Tier 1 — high-value missing rules (★★ / ★★★)
| # | Rule | Add | Freq |
|---|---|---|---|
| T1.1 | `upc.inf.cfi.cmo_review` | 15 days from CMO service (R.333.2) | ★★ |
| T1.2 | `upc.inf.cfi.confidentiality_response` | 14 days from opp. confidentiality app (R.262.2) | ★★ |
| T1.3 | `upc.apl.order.grounds_orders` | 15 days from order service (R.224.2(b)) | ★★ |
| T1.4 | `upc.apl.order.response_orders` | 15 days from grounds service (R.235.2) | ★★ |
| T1.5 | `upc.inf.cfi.cons_orders` | 2 months from validity decision (R.118.4) | ★★ |
| T1.6 | `upc.inf.cfi.rectification` | 1 month from decision (R.353) | ★ |
| T1.7 | `upc.pi.cfi.deficiency` | 14 days from PI deficiency notification (R.207.6.a) | ★★ |
| T1.8 | `upc.pi.cfi.merits_start` | 31d OR 20wd from PI grant (R.213) — **blocked on Q2** | ★★ |
| T1.9 | `upc.inf.cfi.translation_request` | 1 month **before** oral hearing (R.109.1) | ★★ |
| T1.10 | `upc.inf.cfi.interpreter_cost` | 2 weeks **before** oral hearing (R.109.4) | ★★ |
| T1.11 | `upc.inf.cfi.translations_lodge` | 2 weeks after summons (R.109.5) | ★★ |
| T1.12 | `upc.pi.cfi.response` re-anchor | court-set, parent=`app` (currently a broken root) | ★★ |
**12 rule-adds.** T1.9/.10 are the only `timing='before'` rules in the
entire UPC corpus; schema already supports `before` but no rule
populates it. Verify the backward-snap-to-working-day logic in
`internal/services/deadline_calculator.go` before merging
(2026-04-30 audit §5.4 raised the concern).
### Tier 2 — broader coverage (★ specialist + Wiedereinsetzung family)
| # | Rule | Add | Notes |
|---|---|---|---|
| T2.1 | `de.inf.lg.einspruch_vu` | 2 weeks from service of Versäumnisurteil (ZPO §339) | Q5 — proceeding shape decision |
| T2.2 | `upc.inf.cfi.wiedereinsetzung` | 2 mo / 1-year-cap from Wegfall des Hindernisses (R.320) | Q4 — needs special arithmetic |
| T2.3 | `de.inf.lg.wiedereinsetzung` | 2 mo / 1-year-cap (PatG §123 / ZPO §233 ff.) | Q4 |
| T2.4 | `epa.grant.exa.weiterbehandlung` | 2 mo from loss-of-rights notification (EPC R.135) | — |
| T2.5 | `upc.inf.cfi.prelim_reply` | 14 days from PO service (R.20.2) | Companion to R.19 (mig 095 added it) |
| T2.6 | `upc.apl.order.discretion_anchor` | add explicit `refusal` intermediate node so R.220.3 anchors correctly (Q12) | |
| T2.7 | `upc.dmgs.cfi.app` spawn | `is_spawn=true` from `upc.inf.cfi.decision` (Q9) | |
| T2.8 | `upc.disc.cfi.app` spawn | same shape as T2.7 | |
| T2.9 | `epa.grant.exa.r71_3` re-anchor | parent = `exam_req` (§7.5) | |
| T2.10 | `epa.grant.exa.approval` re-anchor | parent = `r71_3` (§7.5) | |
| T2.11 | `upc.inf.cfi.appeal_spawn` cross-proc wiring | finish the half-wired spawn execution (Q13) | |
### Tier 3 — tooling primitives (block multiple rules)
| # | Primitive | Blocks | Notes |
|---|---|---|---|
| T3.1 | `duration_unit = 'working_days'` | R.198, R.213 | Schema already accepts the string; add to calculator + UI |
| T3.2 | `combine_op = 'max'` | R.198, R.213, R.245.2 | Column already exists per pauli's 2026-05-13 audit |
| T3.3 | Multi-anchor "whichever later" trigger | R.245.2.a/b | UI + service work |
| T3.4 | Outer-cap modelling (`outer_cap_value` + `outer_cap_unit`) | R.245.2 (12mo), R.320 (12mo), EPC Art.112a(4) (12mo) | Schema add |
| T3.5 | "Before"-mode backward snap to working day | R.109.1, R.109.4 | Calculator change (audit-fristenrechner-completeness-2026-04-30.md §5.4) |
| T3.6 | Cross-proceeding spawn end-to-end (`is_spawn`) | T2.7, T2.8, T2.11 | Pauli's §6.8 |
### Tier 4 — out-of-scope until separate prioritisation
- DNI family (R.63 / R.67.1 / R.69.1 / R.69.2). Zero published filings 2026-Q1.
- Registry-correction family (R.16.3.a, R.27.2, R.89.2, R.253.2). Most natural in cascade, not proceeding-tree.
- GebrMG (no proceeding_type today).
- R.245 rehearing family (specialist).
- R.155 cost-decision opposition chain (specialist).
- R.144 UPC_DAMAGES tree-end row (cosmetic).
- R.79(2) EPC further-parties period (modelling unclear — Q7).
---
## §11. Next-step proposals (suggested fix-task slicing)
The audit identifies **41 distinct actionable items.** Below is a
suggested decomposition into fix-tasks that can be assigned
independently. Sequence reflects "Wave 0 must precede Wave 1" only
where there's a real dependency (most slices are independent).
### Wave 0 — Tier 0 duration / sequencing / anchor fixes (single fix-task)
**Proposed task:** `t-paliad-264 — Tier 0 deadline-rule corrections
(duration, anchor, citation) from t-paliad-263 audit`
- 16 row UPDATEs (T0.1T0.17, deduplicated to 16 distinct rows since
T0.8 is covered by T0.2 and T0.11 by T0.1).
- One migration file (~120 LoC SQL).
- All within existing schema. No new columns.
- Idempotent guards on every UPDATE (only fire when the row still has
the old value, per the mig 095 convention).
- Adds 16 entries to `paliad.deadline_rule_audit` (per the mig 079
trigger).
- Verification block: `DO $$ … RAISE EXCEPTION …` per mig 095.
- **Branch:** `mai/<coder>/t-paliad-264-tier0-deadline-fixes`.
- **Owner:** coder.
- **Why first:** all 16 affect either calendar correctness (5 hard
duration/anchor bugs) or citation correctness (the 11 metadata
fixes are what a lawyer would cite-check against). T0.1T0.6 are
user-visible silent wrongs; ship them.
### Wave 1 — Tier 1 rule additions (single fix-task)
**Proposed task:** `t-paliad-265 — Tier 1 deadline-rule additions
(12 high-frequency rules)`
- 11 INSERTs + 1 UPDATE re-anchor (T1.12 `upc.pi.cfi.response`).
- T1.8 (`upc.pi.cfi.merits_start`) **excluded** — blocked on T3.1/T3.2.
- One migration file (~250 LoC SQL).
- Add cascade leaves + concepts where needed (each rule should be
reachable from Pathway B too).
- **Branch:** `mai/<coder>/t-paliad-265-tier1-rule-additions`.
- **Owner:** coder. **Legal review:** m must verify each rule before
merge (single round of grilling).
### Wave 2 — Q1 court-set audit decision (separate spike)
**Proposed task:** `t-paliad-266 — Decide court-set vs fixed-period
modelling for richterliche Fristen (Q1 in t-paliad-263 audit)`
- Inventor / pauli reviews §9.1 with m.
- Decision artefact: list of rules to flip vs keep, plus UX guideline
for what the timeline displays for `is_court_set=true` rules.
- **Owner:** pauli. **m signs off.**
### Wave 3 — Tier 3 tooling primitives (multi-task)
Each Tier 3 row is its own task because each touches schema + service +
calculator + UI:
- `t-paliad-267 — working_days unit + combine_op='max' (R.198, R.213)`
- `t-paliad-268 — Outer-cap modelling (R.245.2, R.320, Art.112a)`
- `t-paliad-269 — Multi-anchor "whichever later" triggers (R.245.2)`
- `t-paliad-270 — Backward-snap for `before`-mode rules (R.109.1/.4)`
- `t-paliad-271 — Cross-proceeding spawn end-to-end execution`
Each is foundational for multiple Tier 2 rules; can ship independently.
### Wave 4 — Tier 2 specialist rules (multi-task, after their primitives land)
Each Tier 2 row is its own task or batched into 2-3 tasks by topical
area:
- `t-paliad-272 — Wiedereinsetzung / Weiterbehandlung family (T2.2, T2.3, T2.4)` — depends on T3.4 (outer cap).
- `t-paliad-273 — UPC follow-on spawns (T2.7, T2.8, T2.11)` — depends on T3.6.
- `t-paliad-274 — UPC tail rules (T2.5, T2.6, R.353, etc.)`
- `t-paliad-275 — EPA grant timeline re-anchoring (T2.9, T2.10)`.
### Wave 5 — Concept-layer parity (separate audit)
The 9 orphan concepts (`audit-fristen-logic-2026-05-13.md` §3.4 + Q11
here) need a parallel audit pass to map cascade → rule. Recommend
spinning a `t-paliad-276 — Cascade-rule parity audit` task once the
above land.
### Wave 6 — Documentation + retire
- `t-paliad-277 — Drop `_archived_litigation` proceeding_type` once
mig 093's audit window closes (Q6).
- `t-paliad-278 — Document Tier 4 deferrals in
`docs/feature-roadmap.md`` so the gap-list isn't lost.
---
## Appendix A — file references
**Live state queried via Supabase MCP, 2026-05-25 14:0015:00 UTC:**
- `paliad.proceeding_types` — 21 active rows (20 fristenrechner + 1
archived).
- `paliad.deadline_rules` — 132 active + 40 archived rows
(`lifecycle_state='published'`).
- `paliad.deadline_rule_audit` — diff history.
- `data.laws_contents` (youpc) — UPC RoP + EPC verbatim text
(`law_type IN ('UPCRoP','EPC')`).
**paliad migrations consulted:**
- `internal/db/migrations/012_fristenrechner_rules.up.sql` — original
seed.
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
— DE_INF_OLG / DE_INF_BGH split.
- `internal/db/migrations/052_event_categories_rop_audit.up.sql`
— first RoP audit fix-pass.
- `internal/db/migrations/079_*` — `paliad.deadline_rule_audit`
trigger.
- `internal/db/migrations/091_drop_legacy_rule_columns.up.sql` —
cleanup.
- `internal/db/migrations/093_retire_litigation_category.up.sql` —
archived 40 rules.
- `internal/db/migrations/095_fristen_gap_fill.up.sql` — t-paliad-205
R.19 + R.220.1(a) gap fill.
- `internal/db/migrations/096_proceeding_code_rename.up.sql` — code
rename to `<jurisdiction>.<proceeding>.<instance>` form.
- `internal/db/migrations/097_legal_citation_backfill.up.sql` —
legal_source / rule_code backfill.
- `internal/db/migrations/100_ccr_visible_rule.up.sql` —
`upc.ccr.cfi` alias.
- `internal/db/migrations/104_einspruch_name_and_ccr_priority.up.sql`
— Einspruch rename.
**Companion audits:**
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — curie /
t-paliad-084.
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — curie / t-paliad-159.
- `docs/audit-fristen-logic-2026-05-13.md` — pauli / t-paliad-157
(schema audit, ground-truth on column semantics).
- `docs/proposals/fristen-gap-fill-2026-05-18.md` — m's 0.3 decisions
that shipped as mig 095.
**Authoritative source URLs (all verified 2026-05-25):**
- UPC RoP consolidated 18.05.2023: https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf
- EPC 17th ed.: https://www.epo.org/en/legal/epc/2020/index.html
- EPC R.71 (and other Implementing Reg Rules): https://www.epo.org/en/legal/epc/2020/r71.html
- PatG: https://www.gesetze-im-internet.de/patg/
- §59 https://www.gesetze-im-internet.de/patg/__59.html
- §73 https://www.gesetze-im-internet.de/patg/__73.html
- §75 https://www.gesetze-im-internet.de/patg/__75.html
- §82 https://www.gesetze-im-internet.de/patg/__82.html
- §110 https://www.gesetze-im-internet.de/patg/__110.html
- §111 https://www.gesetze-im-internet.de/patg/__111.html
- ZPO: https://www.gesetze-im-internet.de/zpo/
- §520 https://www.gesetze-im-internet.de/zpo/__520.html
- §521 https://www.gesetze-im-internet.de/zpo/__521.html
- GebrMG: https://www.gesetze-im-internet.de/gebrmg/
---
## Appendix B — coverage tally
| Status | Count | Share |
|---|---:|---:|
| present-correct | 78 | 59 % |
| present-wrong (DURATION) | 3 | 2 % |
| present-wrong (anchor/sequence) | 5 | 4 % |
| present-wrong (citation only) | 11 | 8 % |
| court-set-mismodelled-as-fixed | 6 | 5 % |
| **subtotal: still actionable** | **25** | **19 %** |
| missing (statute defines, paliad doesn't) | 30 | (gap, vs 132 baseline) |
| n/a (RoP / EPC / PatG section creates no time-limit) | 8 | 6 % |
| present-correct, no fix needed | (78 above) | |
**Headline figures for m:**
- Of the 132 statutory deadlines paliad currently models, **25 carry
an actionable bug** (19%). Of those, **5 are user-visible
calendar-correctness bugs** (the 3 duration bugs + the 2
sequencing/anchor bugs head flagged + me). The other 20 are
citation drift or court-set mismodelling — fix-them-quietly
category.
- An additional **30 statutory deadlines are not modelled at all**
(the missing list in §3). Of those, **~12 are ★★★ / ★★ frequency**
(Tier 1 in §10); the remaining ~18 are ★ specialist.
- The 5 duration / sequencing bugs alone are **the most important
takeaway**: every UPC_REV proceeding, every UPC main-track appeal
respondent, and every DE-LG-Verletzung timeline tracked in paliad
today computes wrong dates.
End of audit. Awaiting m's review of §9 Q1Q13 + Tier 0 sign-off
before fix-tasks (Wave 0) get cut.

View File

@@ -18,6 +18,9 @@ import { renderProjects } from "./src/projects";
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 { renderSubmissionsIndex } from "./src/submissions-index";
import { renderSubmissionsNew } from "./src/submissions-new";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
@@ -252,6 +255,9 @@ async function build() {
join(import.meta.dir, "src/client/projects-new.ts"),
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/submissions-index.ts"),
join(import.meta.dir, "src/client/submissions-new.ts"),
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
@@ -376,6 +382,9 @@ async function build() {
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
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, "submissions-index.html"), renderSubmissionsIndex());
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
// t-paliad-115 — shared EventsPage at the canonical /events URL.
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
// Termine entries point at /events?type=… and events.ts re-highlights

View File

@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
interface Appointment {
id: string;
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
interface Me {
@@ -43,6 +47,10 @@ let project: Project | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
let me: Me | null = null;
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
// "Termin bearbeiten" in the withdraw warning modal.
let pendingEditMode = false;
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -207,10 +215,14 @@ function renderHeader() {
}
// Freeze the edit form + delete button while a request is in flight.
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
// withdraw modal, pendingEditMode unfreezes the form so Save can route
// to /edit-entity (which keeps the request pending + merges payload).
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
const freeze = isPending && !pendingEditMode;
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = isPending; });
.forEach((el) => { el.disabled = freeze; });
}
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
if (deleteBtn) deleteBtn.disabled = isPending;
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
submitBtn.disabled = true;
try {
// t-paliad-252 — pending-edit mode routes through /edit-entity which
// keeps the request pending + merges fields into payload. clear_project
// and project_id are NOT in the counter-allowlist (yet) — the requester
// can't move projects on a pending request from this surface.
if (pendingEditMode && pendingRequest) {
const editFields = { ...payload };
delete editFields.clear_project;
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: editFields }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) appointment = await fresh.json();
await loadPendingRequest();
// Exit pending-edit mode so the form re-freezes (still pending).
pendingEditMode = false;
renderHeader();
fillEditForm();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
return;
}
const resp = await fetch(`/api/appointments/${appointment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -312,12 +357,37 @@ async function deleteAppointment() {
}
}
// t-paliad-252 — withdraw warning modal replaces the old confirm().
// Returns:
// "edit" → unfreeze the edit form (pending-edit mode); Save will
// route through /api/approval-requests/{id}/edit-entity
// "withdraw" → destructive: the existing /revoke endpoint
// null → user cancelled
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "appointment",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
if (btn) btn.disabled = false;
return;
}
if (action === "edit") {
pendingEditMode = true;
if (btn) btn.disabled = false;
// renderHeader re-evaluates the freeze and unfreezes the form now
// that pendingEditMode is set. Focus the first editable field so the
// user can type immediately.
renderHeader();
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
titleEl?.focus();
return;
}
// action === "withdraw" → destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
renderHeader();
fillEditForm();
} else {
// CREATE lifecycle: entity gone → back to the list.
window.location.href = "/events?type=appointment";
}
renderHeader();
fillEditForm();
} else {
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
const msg = document.getElementById("appointment-edit-msg")!;

View File

@@ -0,0 +1,149 @@
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
//
// Before t-paliad-252 the deadline + appointment detail pages did a
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
// For pending CREATE lifecycles that endpoint silently DELETES the
// underlying entity row — m's "withdrawing the approval deletes the event"
// surprise.
//
// This modal replaces the confirm() with three explicit paths:
//
// 1. Cancel — does nothing
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
// through POST /approval-requests/{id}/
// edit-entity which keeps the request
// pending and merges the new fields
// into approval_request.payload
// 3. Endgültig zurückziehen + — destructive; current /revoke
// löschen behaviour (delete for CREATE, revert
// for UPDATE/COMPLETE, cancel for
// DELETE-lifecycle requests)
//
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
// three-button row sits cleanly inside the body — the primitive only
// supports one secondary action, but we paint the destructive button as a
// separate row above the footer.
import { t } from "../i18n";
import { openModal } from "./modal";
export type WithdrawAction = "edit" | "withdraw";
export interface WithdrawWarningArgs {
// entityType drives the copy ("event" vs "appointment" labels).
entityType: "deadline" | "appointment";
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
// cancelling the deletion request).
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
}
// openWithdrawWarningModal resolves with the chosen action, or null if the
// user dismissed via Cancel / Esc / backdrop / browser back-button.
export async function openWithdrawWarningModal(
args: WithdrawWarningArgs,
): Promise<WithdrawAction | null> {
const body = document.createElement("div");
body.className = "withdraw-warning-body";
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
// knows what the destructive button will actually do. The /revoke
// backend behaviour:
// - create → DELETE the entity (the "surprise" m flagged)
// - update → revert to pre_image
// - complete → revert to pre-complete state
// - delete → cancel the delete request (entity stays alive)
const intro = document.createElement("p");
intro.className = "withdraw-warning-intro";
intro.textContent = leadCopyFor(args);
body.appendChild(intro);
const sub = document.createElement("p");
sub.className = "withdraw-warning-sub muted";
sub.textContent = subCopyFor(args);
body.appendChild(sub);
// The destructive button lives inside the body — the openModal primitive
// only exposes one secondary button slot, and we want the safe "Edit"
// path to be the primary CTA. Painting it in red here, separated from
// the footer, signals "this is the dangerous option" without competing
// visually with the primary CTA.
const destructiveRow = document.createElement("div");
destructiveRow.className = "withdraw-warning-destructive-row";
const destructiveBtn = document.createElement("button");
destructiveBtn.type = "button";
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
destructiveRow.appendChild(destructiveBtn);
body.appendChild(destructiveRow);
return new Promise<WithdrawAction | null>((resolve) => {
let chosen: WithdrawAction | null = null;
// The destructive button has to close the modal and return "withdraw".
// We need access to the modal's internal close() — fortunately openModal
// exposes it via the primary handler's first arg. We pass through the
// outer resolve and let the primary handler (Edit) own the close-fn
// route. For the destructive button we resolve the outer promise
// directly and then synthesise an ESC keypress so the modal dismisses
// — or, simpler, set chosen and use the secondary "Cancel" path that
// the modal already supports. (openModal's onClose fires on every
// dismiss path including the primary handler resolution.)
destructiveBtn.addEventListener("click", () => {
chosen = "withdraw";
// The unified openModal primitive (modal.ts) wires its dismiss path
// through the native <dialog>'s `cancel` event. Dispatching it on
// the parent <dialog> runs the same finish() → onClose → resolve
// sequence as ESC / backdrop. We then map the resolved `null` back
// to "withdraw" via the captured `chosen` in onClose below.
const dialogEl = body.closest("dialog");
dialogEl?.dispatchEvent(new Event("cancel"));
});
void openModal<WithdrawAction>({
title: t("approvals.withdraw.modal.title"),
body,
size: "md",
classNames: "withdraw-warning-modal",
primary: {
label: t("approvals.withdraw.primary.label"),
handler: (close) => {
chosen = "edit";
close("edit");
},
},
secondary: { label: t("approvals.withdraw.cancel") },
onClose: () => {
// Resolves whatever was chosen via the destructive button OR the
// primary handler. ESC / backdrop / secondary clear `chosen` to
// null which is the right "cancel" semantics.
resolve(chosen);
},
});
});
}
function leadCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return args.entityType === "appointment"
? t("approvals.withdraw.lead.create.appointment")
: t("approvals.withdraw.lead.create.deadline");
case "delete":
return t("approvals.withdraw.lead.delete");
default:
// update / complete / unknown → revert semantics
return t("approvals.withdraw.lead.update");
}
}
function subCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return t("approvals.withdraw.sub.create");
case "delete":
return t("approvals.withdraw.sub.delete");
default:
return t("approvals.withdraw.sub.update");
}
}

View File

@@ -9,6 +9,8 @@ import {
type EventType,
type PickerHandle,
} from "./event-types";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Deadline {
id: string;
@@ -20,6 +22,9 @@ interface Deadline {
source: string;
rule_id?: string;
rule_code?: string;
// t-paliad-258 — lawyer's free-text rule label when the deadline was
// saved in Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
notes?: string;
created_at: string;
completed_at?: string;
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
let eventTypePicker: PickerHandle | null = null;
@@ -54,7 +62,21 @@ interface DeadlineRule {
id: string;
code?: string;
name: string;
name_en?: string;
rule_code?: string;
legal_source?: string | null;
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
// when the user flips to Auto on the edit form.
concept_default_event_type_id?: string | null;
proceeding_type_id?: number | null;
}
interface ProceedingType {
id: number;
jurisdiction: string;
name: string;
name_en?: string;
sort_order?: number;
}
interface Me {
@@ -70,6 +92,30 @@ let me: Me | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
// On enterEdit we initialise the mode from the persisted deadline:
// rule_id set → "auto"
// custom_rule_text set, no rule_id → "custom"
// neither set → "auto" (so the Type-driven
// resolver fills in immediately).
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let allRules: DeadlineRule[] = [];
let rulesByID = new Map<string, DeadlineRule>();
let proceedingTypesByID = new Map<number, ProceedingType>();
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
// modal, the entity is still in approval_status='pending'. Save must POST
// to /api/approval-requests/{id}/edit-entity (which keeps the request
// pending + merges the new fields into payload) instead of the regular
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
// from edit mode + after a successful save.
let pendingEditMode = false;
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
// modal handler (initWithdraw) can route into pending-edit mode without
// duplicating the edit-mode toggle logic.
let pendingEnterEdit: (() => void) | null = null;
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "deadlines" || !parts[1]) return null;
@@ -165,17 +211,66 @@ function populateProjectPicker() {
sel.value = deadline.project_id;
}
async function loadRule(ruleID: string) {
async function loadAllRules() {
try {
const resp = await fetch(`/api/deadline-rules`);
if (!resp.ok) return;
const all: DeadlineRule[] = await resp.json();
rule = all.find((r) => r.id === ruleID) || null;
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal */
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
function lookupRule(ruleID: string): DeadlineRule | null {
return rulesByID.get(ruleID) || null;
}
// resolveAutoRuleForType mirrors the create-form resolver: pick the
// canonical rule for the chosen event_type, prioritising the project's
// proceeding then jurisdiction match.
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const projID = deadline?.project_id;
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
if (proj && proj.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypeByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
return resolveAutoRuleForType(picked[0]);
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -227,9 +322,15 @@ function render() {
}
const ruleEl = document.getElementById("deadline-rule-display")!;
// t-paliad-258 — display priority:
// 1. catalog rule (canonical Name · Citation pattern)
// 2. custom_rule_text + Custom badge
// 3. legacy rule_code-only (Fristenrechner saves)
// 4. "—"
if (rule) {
const code = rule.rule_code || rule.code || "";
ruleEl.textContent = code ? `${code}${rule.name}` : rule.name;
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
} else if (deadline.rule_code) {
// Fristenrechner-saved deadlines carry rule_code directly without
// a rule_id (no rule UUID round-trips through the public API).
@@ -353,6 +454,48 @@ function render() {
}
}
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const r = currentAutoRule();
if (r) {
text.textContent = formatRuleLabel(r);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function initEdit() {
const titleDisplay = document.getElementById("deadline-title-display")!;
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
@@ -366,6 +509,11 @@ function initEdit() {
const etEdit = document.getElementById("deadline-event-types-edit");
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
const ruleDisplay = document.getElementById("deadline-rule-display");
const ruleEdit = document.getElementById("deadline-rule-edit");
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
function enterEdit() {
titleDisplay.style.display = "none";
@@ -381,6 +529,20 @@ function initEdit() {
projectEdit.style.display = "";
projectEdit.value = deadline.project_id;
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
// from the persisted deadline. Display element stays visible so the
// user keeps "before / after" context while editing.
if (ruleEdit) ruleEdit.style.display = "";
if (ruleDisplay) ruleDisplay.style.display = "none";
if (deadline?.custom_rule_text && !deadline.rule_id) {
ruleMode = "custom";
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
} else {
ruleMode = "auto";
if (ruleCustomInput) ruleCustomInput.value = "";
}
applyRuleModeUI();
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
@@ -399,12 +561,71 @@ function initEdit() {
projectEdit.style.display = "none";
projectLink.style.display = "";
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
if (ruleEdit) ruleEdit.style.display = "none";
if (ruleDisplay) ruleDisplay.style.display = "";
saveBtn.style.display = "none";
editBtn.style.display = "";
pendingEditMode = false;
}
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
// time the Type picker changes, so just-toggling-to-Auto immediately
// surfaces a fresh resolution.
ruleToggleBtn?.addEventListener("click", () => {
ruleMode = ruleMode === "auto" ? "custom" : "auto";
applyRuleModeUI();
if (ruleMode === "custom") ruleCustomInput?.focus();
});
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
// route into pending-edit mode without re-running the edit-button
// visibility gate (which hides the button during pending).
pendingEnterEdit = () => {
pendingEditMode = true;
enterEdit();
};
editBtn.addEventListener("click", enterEdit);
// t-paliad-251 Part 4 — Standardtitel button.
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
// head = event_type label (if exactly one Typ chip in edit)
// || Auto-resolved rule's canonical label (Name · Citation)
// || saved rule's canonical label
// || custom_rule_text (when in Custom mode + non-empty)
// || rule_code-only legacy fallback
// || "Neue Frist" fallback
// suffix = " — <project.reference>" when not already in head
titleDefaultBtn?.addEventListener("click", () => {
if (!deadline) return;
let head = "";
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
if (ids.length === 1) {
const et = eventTypeByID.get(ids[0]);
if (et) head = eventTypeLabel(et);
}
if (!head) {
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
if (r) head = formatRuleLabel(r);
}
if (!head && ruleMode === "custom") {
const txt = ruleCustomInput?.value.trim() || "";
if (txt) head = txt;
}
if (!head && rule) {
head = formatRuleLabel(rule);
}
if (!head && deadline.rule_code) {
head = deadline.rule_code;
}
if (!head) head = t("deadlines.field.title.default_fallback");
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) head = `${head}${ref}`;
titleEdit.value = head;
titleEdit.focus();
});
saveBtn.addEventListener("click", async () => {
if (!deadline) return;
const newTitle = titleEdit.value.trim();
@@ -424,6 +645,48 @@ function initEdit() {
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
payload.project_id = projectEdit.value;
}
// t-paliad-258 — rule_set discriminator tells the service this
// PATCH carries an Auto/Custom rule change. Both columns are
// mutually exclusive at the persistence boundary.
payload.rule_set = true;
if (ruleMode === "auto") {
const r = currentAutoRule();
payload.rule_id = r ? r.id : null;
payload.custom_rule_text = null;
} else {
const txt = ruleCustomInput?.value.trim() || "";
payload.rule_id = null;
payload.custom_rule_text = txt || null;
}
// t-paliad-252 — pending-edit mode routes through the new endpoint
// that updates the entity + merges payload into the still-pending
// approval_request. Outside pending-edit mode the regular PATCH
// path remains the authoritative one (with its existing 409-on-
// pending guard).
if (pendingEditMode && pendingRequest) {
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: payload }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) deadline = await fresh.json();
await loadPendingRequest();
render();
} else {
const body = await resp.json().catch(() => null);
const msg = (body && (body.message || body.error))
|| (t("approvals.withdraw.error") || "Fehler");
window.alert(msg);
}
return;
}
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -501,19 +764,39 @@ function initReopen() {
});
}
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
// /api/approval-requests/{id}/revoke endpoint (no new server route
// needed). After the revoke lands, the entity goes back to
// approval_status='approved' and the page reloads to refresh the
// in-memory state cleanly.
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
//
// Click flow: open the withdraw warning modal (replaces the old
// confirm()). The modal returns one of:
//
// "edit" — open the edit form in pending-edit mode; Save calls
// /api/approval-requests/{id}/edit-entity which keeps the
// request pending + merges the new fields into payload
// "withdraw" — destructive: call the existing /revoke endpoint
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
// cancel-delete for DELETE lifecycle)
// null — user cancelled; nothing happens
function initWithdraw() {
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!deadline || !pendingRequest) return;
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "deadline",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
btn.disabled = false;
return;
}
if (action === "edit") {
btn.disabled = false;
pendingEnterEdit?.();
return;
}
// action === "withdraw" → existing destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -521,14 +804,16 @@ function initWithdraw() {
});
if (resp.ok) {
// Re-fetch the entity so approval_status flips back to 'approved'
// and the badge / buttons rerender accordingly.
// and the badge / buttons rerender accordingly. For CREATE
// lifecycle the entity is gone, so the 404 surfaces as a reload.
const r = await fetch(`/api/deadlines/${deadline.id}`);
if (r.ok) {
deadline = await r.json();
await loadPendingRequest();
render();
} else {
window.location.reload();
// CREATE lifecycle deleted the entity — bounce to the list.
window.location.href = "/events?type=deadline";
}
} else {
btn.disabled = false;
@@ -592,8 +877,14 @@ async function main() {
notfound.style.display = "block";
return;
}
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
await Promise.all([
loadProject(deadline.project_id),
loadAllProjects(),
loadPendingRequest(),
loadAllRules(),
loadProceedingTypes(),
]);
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
// chips off the cached map, and the display element re-renders on the
@@ -614,6 +905,11 @@ async function main() {
eventTypePicker = attachEventTypePicker(pickerHost, {
initialIDs: deadline.event_type_ids ?? [],
currentUserAdmin: me?.global_role === "global_admin",
onChange: () => {
// Type change shifts the Auto-resolved rule. Refresh the
// read-only display panel (no-op outside edit mode / Custom).
refreshRuleAutoDisplay();
},
});
}

View File

@@ -1,4 +1,4 @@
import { initI18n, t, tDyn } from "./i18n";
import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import {
attachEventTypePicker,
@@ -8,22 +8,21 @@ import {
type PickerHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { formatRuleLabel } from "./rule-label";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
let eventTypesByID = new Map<string, EventType>();
// expandedOverride flips to true when the user clicks "Anderen Typ
// wählen" on the collapsed inline summary. Sticky for the rest of the
// form session — cleared only when the user reverts the rule to "Keine
// Regel". When true, the picker stays visible regardless of whether
// the chip matches the rule's canonical default.
let expandedOverride = false;
interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
// Used by the Type→Rule resolver to narrow rule candidates to the
// project's own proceeding when one applies. Optional because clients
// and matter-level projects don't carry a proceeding type.
proceeding_type_id?: number | null;
}
interface DeadlineRule {
@@ -32,23 +31,37 @@ interface DeadlineRule {
name: string;
name_en: string;
rule_code?: string;
// t-paliad-165 — canonical event_type for this rule's concept,
// hydrated server-side from paliad.deadline_concept_event_types.
// Drives auto-fill of the Typ chip when the user picks this rule.
legal_source?: string | null;
proceeding_type_id?: number | null;
sequence_order?: number;
// t-paliad-165 — canonical event_type for the rule's concept. The
// catalog is indexed by it so we can resolve Type → canonical Rule.
concept_default_event_type_id?: string | null;
}
// Rules indexed by id so the Regel-change handler can look up the
// concept's canonical event_type without re-fetching.
let rulesByID = new Map<string, DeadlineRule>();
interface ProceedingType {
id: number;
code: string;
name: string;
name_en?: string;
jurisdiction: string;
sort_order?: number;
}
// Last event_type the rule auto-filled. Tracked so we can tell whether
// the picker still reflects the rule's suggestion (replace silently on
// new rule pick) or whether the user has manually edited (leave alone,
// surface the mismatch warning instead).
let lastAutoFilledEventTypeID: string | null = null;
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
// auto — rule_id resolved from the chosen event_type, rendered
// read-only as "Auto: Name · Citation".
// custom — free-text input; submits as custom_rule_text on the API.
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let rulesByID = new Map<string, DeadlineRule>();
let allRules: DeadlineRule[] = [];
let proceedingTypesByID = new Map<number, ProceedingType>();
let projectsByID = new Map<string, Project>();
let preselectedProjectID = "";
let preselectedProjectIDLocal = "";
function esc(s: string): string {
const d = document.createElement("div");
@@ -62,6 +75,13 @@ function showError(msg: string) {
el.className = "form-msg form-msg-error";
}
function proceedingLabel(pt: ProceedingType | undefined): string {
if (!pt) return "";
const lang = getLang();
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
return `${pt.jurisdiction}${name}`;
}
async function loadProjects() {
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
const hint = document.getElementById("deadline-project-empty-hint")!;
@@ -69,6 +89,7 @@ async function loadProjects() {
const resp = await fetch("/api/projects");
if (!resp.ok) return;
const projects: Project[] = await resp.json();
projectsByID = new Map(projects.map((p) => [p.id, p]));
if (projects.length === 0) {
hint.style.display = "";
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
@@ -82,7 +103,7 @@ async function loadProjects() {
const ref = p.reference || "";
const indent = projectIndent(p.path);
options.push(
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} ${esc(p.title)}</option>`,
);
}
sel.innerHTML = options.join("");
@@ -91,122 +112,166 @@ async function loadProjects() {
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
async function loadRules() {
// Optional: load rules so user can attach. We pull all rules; small set.
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
try {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
const rules: DeadlineRule[] = await resp.json();
rulesByID = new Map(rules.map((r) => [r.id, r]));
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
for (const r of rules) {
const code = r.rule_code || r.code || "";
const label = code ? `${code} \u2014 ${r.name}` : r.name;
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
}
sel.innerHTML = opts.join("");
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal — rule select stays at "no rule" */
/* non-fatal — rule display falls back to "—" */
}
}
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
// picker. The two modes are mutually exclusive:
// resolveAutoRuleForType picks the best-match catalog rule for the
// chosen event type, scoring by:
// 1. project's proceeding_type_id (if known) — exact match wins,
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
// jurisdiction (EPA→EPO canonicalised),
// 3. otherwise the first candidate in canonical sequence_order.
//
// collapsed: rule selected + canonical event_type known + picker
// contains exactly [default] + user hasn't clicked "Anderen Typ
// wählen". Hides the chip cluster, surfaces a single inline
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
// override link.
//
// expanded: every other case — no rule, no default for the rule,
// picker has been edited, or expandedOverride is sticky after the
// user clicked the override link. Picker visible; mismatch warning
// surfaces yellow when the rule expected a different event_type.
function refreshRuleView(): void {
const collapsed = document.getElementById("deadline-event-type-collapsed");
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
const pickerHost = document.getElementById("deadline-event-types");
const warn = document.getElementById("deadline-event-type-rule-mismatch");
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
// Returns null when no rule maps. Callers render that as "no Auto rule
// available" so the user can flip to Custom or pick a different Type.
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const project = projectID ? projectsByID.get(projectID) : undefined;
if (project?.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypesByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
// currentAutoRule returns the catalog rule the Auto mode would resolve
// to for the current form state, or null when no Type is picked or no
// rule maps. Centralised so the Auto display, submitForm, and the
// Standardtitel button all agree on the same resolution.
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
return resolveAutoRuleForType(picked[0], projectID);
}
const pickerMatchesDefault =
expected !== null && picked.length === 1 && picked[0] === expected;
const wantsCollapsed =
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
if (wantsCollapsed) {
const et = eventTypesByID.get(expected!);
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
collapsed.style.display = "";
pickerHost.style.display = "none";
warn.style.display = "none";
// refreshRuleAutoDisplay updates the read-only Auto display panel to
// reflect the rule that would be saved in Auto mode. Hides itself when
// the user is in Custom mode (the input takes its place).
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const rule = currentAutoRule();
if (rule) {
text.textContent = formatRuleLabel(rule);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
collapsed.style.display = "none";
pickerHost.style.display = "";
// Mismatch warning: rule expected an event_type AND the picker
// doesn't contain it. (When the picker is empty + no override, no
// warning — user is free to leave it blank.)
if (expected && picked.length > 0 && !picked.includes(expected)) {
warn.style.display = "";
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
warn.style.display = "none";
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function setRuleMode(mode: RuleMode): void {
ruleMode = mode;
applyRuleModeUI();
if (mode === "custom") {
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
input?.focus();
}
}
// applyRuleAutoFill replaces the picker silently when it still reflects
// the previous rule's suggestion (or is empty); leaves a manually-edited
// picker alone. Called whenever the Regel select changes.
function applyRuleAutoFill(): void {
if (!eventTypePicker) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const current = eventTypePicker.getIDs();
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
// 1. event_type label (when exactly one Typ chip is set)
// 2. canonical rule name (when Auto resolves to a rule)
// 3. custom rule text (when in Custom mode)
// 4. proceeding type name (when project carries one)
// 5. fallback i18n key
// Suffix: " — <project-reference>" when not already in head.
function computeDefaultTitle(): string {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
const project = projectID ? projectsByID.get(projectID) : undefined;
const picked = eventTypePicker?.getIDs() ?? [];
// Reset the override on transition to "Keine Regel" — fresh form
// session. Otherwise expandedOverride stays sticky.
if (ruleID === "") {
expandedOverride = false;
let head = "";
if (picked.length === 1) {
const et = eventTypesByID.get(picked[0]);
if (et) head = eventTypeLabel(et);
}
const pickerStillReflectsLastSuggestion =
lastAutoFilledEventTypeID !== null &&
current.length === 1 &&
current[0] === lastAutoFilledEventTypeID;
const pickerIsEmpty = current.length === 0;
if (expected) {
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
eventTypePicker.setIDs([expected]);
lastAutoFilledEventTypeID = expected;
if (!head) {
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) head = formatRuleLabel(rule);
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) head = txt;
}
} else if (pickerStillReflectsLastSuggestion) {
// New rule has no canonical event_type — clear the stale auto-fill
// so the picker doesn't carry a chip from the old rule.
eventTypePicker.setIDs([]);
lastAutoFilledEventTypeID = null;
}
refreshRuleView();
}
if (!head && project?.proceeding_type_id) {
const pt = proceedingTypesByID.get(project.proceeding_type_id);
if (pt) head = proceedingLabel(pt);
}
if (!head) {
head = t("deadlines.field.title.default_fallback");
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) {
return `${head}${ref}`;
}
return head;
}
async function submitForm(e: Event) {
@@ -217,7 +282,6 @@ async function submitForm(e: Event) {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
if (!projectID || !title || !due) {
@@ -234,7 +298,15 @@ async function submitForm(e: Event) {
due_date: due,
source: "manual",
};
if (ruleID) payload.rule_id = ruleID;
// Rule field: Auto resolves to rule_id, Custom sends the free text.
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) payload.rule_id = rule.id;
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) payload.custom_rule_text = txt;
}
if (notes) payload.notes = notes;
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
@@ -252,8 +324,8 @@ async function submitForm(e: Event) {
return;
}
const created = await resp.json();
if (preselectedProjectID) {
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
if (preselectedProjectIDLocal) {
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
} else {
window.location.href = `/deadlines/${created.id}`;
}
@@ -275,6 +347,16 @@ function detectPreselect() {
if (fromQuery) preselectedProjectID = fromQuery;
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
}
preselectedProjectIDLocal = preselectedProjectID;
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -288,8 +370,6 @@ async function loadMe() {
// t-paliad-154 — fetch the effective approval policy for (project,
// deadline, create) and reveal the form-time hint when it applies.
// Hidden when no policy applies. Re-runs on project change so the hint
// updates if the user picks a different project mid-form.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("deadline-approval-hint");
const text = document.getElementById("deadline-approval-hint-text");
@@ -308,7 +388,6 @@ async function refreshApprovalHint(): Promise<void> {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar (with M1 legacy fallback).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
@@ -343,44 +422,51 @@ document.addEventListener("DOMContentLoaded", async () => {
// Default due to today
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
await Promise.all([loadProjects(), loadRules(), loadMe()]);
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
const pickerHost = document.getElementById("deadline-event-types");
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
onChange: () => refreshRuleView(),
onChange: () => {
// Type change shifts which Auto rule resolves; re-render the
// read-only Auto display panel.
refreshRuleAutoDisplay();
},
});
}
// t-paliad-165 follow-up — preload event_types so the collapsed
// summary can render the type's label inline without an extra round
// trip when the user picks a Regel.
// Preload event_types for the Auto display + Standardtitel resolver.
fetchEventTypes()
.then((types) => {
eventTypesByID = new Map(types.map((et) => [et.id, et]));
refreshRuleView();
refreshRuleAutoDisplay();
})
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
// concept's canonical event_type, when the picker hasn't been
// manually edited away from the previous rule's suggestion.
document.getElementById("deadline-rule")?.addEventListener("change", () => {
applyRuleAutoFill();
.catch(() => {/* non-fatal */});
// Rule mode toggle.
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
});
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
// visible even when the chip still matches the rule's default.
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
expandedOverride = true;
refreshRuleView();
// Move focus into the picker's search box so the user can type
// immediately without an extra click.
const search = document.querySelector<HTMLInputElement>(
"#deadline-event-types .event-type-search",
);
search?.focus();
});
// Wire approval-hint refresh: on first render + on project change.
applyRuleModeUI();
// Approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
void refreshApprovalHint();
// Project change can shift which Auto rule resolves (via the
// project's proceeding_type_id).
refreshRuleAutoDisplay();
});
// t-paliad-251 Part 4 — Standardtitel button.
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
if (!titleInput) return;
const derived = computeDefaultTitle();
if (derived) titleInput.value = derived;
titleInput.focus();
});
});

View File

@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
return new Promise<string[] | null>((resolve) => {
let selected = new Set<string>(opts.initialIDs);
let searchQuery = "";
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
// jurisdiction). Any non-null value matches event_types.jurisdiction;
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
let activeJurisdiction: string | null = null;
// Surface every jurisdiction present in the data — "any" stays bucketed
// separately so users still have a "show generic-only" chip. EPA is
// canonicalised to EPO in event_types (see mig 074); the chip label
// shows EPA to match the legal vocabulary the lawyers use.
const jurisdictionsPresent = new Set<string>();
for (const et of opts.types) {
const j = (et.jurisdiction ?? "").trim();
if (j) jurisdictionsPresent.add(j);
}
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
// Any jurisdiction in the data that isn't in our ordered list lands at
// the end so the chip row never silently drops a court flavour.
for (const j of jurisdictionsPresent) {
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
}
function chipLabel(j: string): string {
if (j === "EPO") return "EPA";
if (j === "any") return t("event_types.browse.jurisdiction.none");
return j;
}
const overlay = document.createElement("div");
overlay.className = "modal-overlay event-type-browse-overlay";
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
<div class="event-type-browse-header">
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
${chipJurisdictions
.map(
(j) =>
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
)
.join("")}
</div>
</div>
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
<div class="event-type-browse-actions">
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
const groups = groupByCategory(opts.types);
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
return j;
}
function jurisdictionMatches(et: EventType): boolean {
if (activeJurisdiction === null) return true;
const j = (et.jurisdiction ?? "").trim();
return j === activeJurisdiction;
}
function updateCount() {
countEl.textContent = t("event_types.browse.selected_count").replace(
"{n}",
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
function renderList() {
const q = searchQuery.trim().toLowerCase();
const matches = (et: EventType) => {
if (!jurisdictionMatches(et)) return false;
if (!q) return true;
return (
et.label_de.toLowerCase().includes(q) ||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
renderList();
});
chipButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const raw = btn.dataset.jurisdiction ?? "";
activeJurisdiction = raw === "" ? null : raw;
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
btn.classList.add("event-type-browse-chip--active");
renderList();
});
});
function close(value: string[] | null) {
document.removeEventListener("keydown", onKey);
overlay.remove();

View File

@@ -9,6 +9,7 @@ import {
} from "./event-types";
import { projectIndent } from "./project-indent";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
@@ -66,6 +67,9 @@ interface EventListItem {
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was created
// via the Custom rule path. Mutually exclusive with rule_id.
custom_rule_text?: string;
event_type_ids?: string[];
// appointment-only
@@ -264,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
function ruleDisplay(item: EventListItem): string {
if (item.type !== "deadline") return "";
// Prefer the saved citation (RoP.023, R.151) over the rule name —
// REGEL is meant for the legal reference, not the rule's display
// name (which is the title column's job).
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
const lang = getLang();
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
if (localized && localized.trim()) return esc(localized);
// t-paliad-258 addendum — canonical display contract: Name primary,
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
// Custom rules render the lawyer's free text + a "Custom" badge.
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
// show the bare citation as last-resort fallback.
const hasName = (item.rule_name && item.rule_name.trim()) ||
(item.rule_name_en && item.rule_name_en.trim());
if (hasName || (item.rule_code && item.rule_code.trim())) {
return formatRuleLabelHTML(
{
name: item.rule_name || "",
name_en: item.rule_name_en,
rule_code: item.rule_code,
},
esc,
);
}
if (item.custom_rule_text && item.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
}
return "&mdash;";
}

View File

@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
// Pass the chip-strip perspective through as `side` so the column
// bucketer keeps the user's own party on the left (Unsere Seite) —
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
// was on the defendant side, the new labels demand we route the
// user's party into the `ours` column.
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;

View File

@@ -27,6 +27,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.glossar": "Glossar",
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
"nav.checklisten": "Checklisten",
"nav.submissions": "Schriftsätze",
"nav.gerichte": "Gerichte",
"nav.logout": "Abmelden",
"nav.akten": "Akten",
@@ -301,9 +302,9 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.col.proactive": "Proaktiv",
"deadlines.col.ours": "Unsere Seite",
"deadlines.col.court": "Gericht",
"deadlines.col.reactive": "Reaktiv",
"deadlines.col.opponent": "Gegnerseite",
"deadlines.col.both": "Beide Parteien",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
@@ -416,6 +417,14 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
"deadlines.side.label": "Seite:",
"deadlines.side.claimant": "Klägerseite",
"deadlines.side.defendant": "Beklagtenseite",
"deadlines.side.both": "Beide",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Zusammengesetzt:",
"deadlines.event.unit.days.one": "Tag",
"deadlines.event.unit.days.many": "Tage",
@@ -873,11 +882,15 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.title.placeholder": "z.\u202fB. Klageerwiderung einreichen",
"deadlines.field.due": "F\u00e4lligkeitsdatum",
"deadlines.field.rule": "Regel (optional)",
"deadlines.field.rule.none": "Keine Regel",
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
"deadlines.field.rule.override": "Anderen Typ wählen",
"deadlines.field.rule.auto_badge": "Auto",
"deadlines.field.rule.auto_no_match": "Keine Regel zur gewählten Verfahrenshandlung",
"deadlines.field.rule.auto_pick_type": "Wählen Sie zuerst eine Verfahrenshandlung",
"deadlines.field.rule.custom_badge": "Eigen",
"deadlines.field.rule.custom_placeholder": "z.B. interner Review-Termin, Mandantengespräch",
"deadlines.field.rule.mode.toggle_to_auto": "Zurück zu Auto",
"deadlines.field.rule.mode.toggle_to_custom": "Eigene Regel eingeben",
"deadlines.field.title.default_btn": "Standardtitel",
"deadlines.field.title.default_fallback": "Neue Frist",
"deadlines.field.notes": "Notizen (optional)",
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
@@ -1425,10 +1438,16 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.notizen": "Notizen",
"projects.detail.tab.checklisten": "Checklisten",
"projects.detail.tab.submissions": "Schriftsätze",
"projects.detail.tab.settings": "Verwaltung",
"projects.detail.export.button": "Daten exportieren",
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.",
"projects.detail.settings.export.heading": "Daten exportieren",
"projects.detail.settings.export.description": "Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.",
"projects.detail.settings.archive.heading": "Projekt archivieren",
"projects.detail.settings.archive.description": "Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).",
"projects.detail.settings.archive.cta": "Bearbeiten öffnen",
"projects.detail.submissions.empty": "Es sind aktuell keine Schriftsatzvorlagen hinterlegt.",
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.",
"projects.detail.submissions.empty.no_proceeding.cta": "Projekt bearbeiten",
"projects.detail.submissions.col.name": "Schriftsatz",
"projects.detail.submissions.col.party": "Partei",
@@ -1436,7 +1455,50 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.submissions.col.action": "",
"projects.detail.submissions.action.generate": "Generieren",
"projects.detail.submissions.action.no_template": "Keine Vorlage",
"projects.detail.submissions.action.edit": "Bearbeiten",
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
// t-paliad-238 — dedicated draft editor page.
"submissions.draft.title": "Schriftsatz bearbeiten — Paliad",
"submissions.draft.back": "← Zurück zum Projekt",
"submissions.draft.loading": "Lädt…",
"submissions.draft.notfound": "Schriftsatz nicht gefunden oder keine Berechtigung.",
"submissions.draft.action.export": "Als .docx exportieren",
"submissions.draft.action.new": "+ Neuer Entwurf",
"submissions.draft.action.delete": "Löschen",
"submissions.draft.switcher.label": "Entwurf",
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
"submissions.draft.preview.title": "Vorschau",
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
"submissions.index.subtitle": "Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.",
"submissions.index.loading": "Lädt…",
"submissions.index.empty": "Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.",
"submissions.index.empty.cta": "+ Neuer Entwurf",
"submissions.index.error": "Schriftsätze konnten nicht geladen werden.",
"submissions.index.col.project": "Projekt",
"submissions.index.col.submission": "Schriftsatz",
"submissions.index.col.draft": "Entwurf",
"submissions.index.col.updated": "Zuletzt geändert",
"submissions.index.action.new": "+ Neuer Entwurf",
// t-paliad-243 — global Schriftsatz picker (/submissions/new).
"submissions.new.title": "Neuer Schriftsatz — Paliad",
"submissions.new.back": "← Zurück zur Übersicht",
"submissions.new.heading": "Neuer Schriftsatz",
"submissions.new.subtitle": "Wählen Sie eine Vorlage. Optional verknüpfen Sie den Entwurf mit einem Projekt — sonst füllen Sie alle Variablen manuell.",
"submissions.new.search.placeholder": "Suche nach Schriftsatz, Code oder Norm…",
"submissions.new.loading": "Lädt…",
"submissions.new.error": "Katalog konnte nicht geladen werden.",
"submissions.new.col.name": "Schriftsatz",
"submissions.new.col.party": "Partei",
"submissions.new.col.source": "Rechtsgrundlage",
"submissions.new.col.actions": "Entwurf starten",
"submissions.new.empty.filtered": "Keine passenden Schriftsätze. Filter zurücksetzen.",
"submissions.new.picker.title": "Projekt wählen",
"submissions.new.picker.placeholder": "Projekt suchen (Titel oder Aktenzeichen)…",
"submissions.new.picker.loading": "Lädt Projekte…",
"submissions.new.picker.empty": "Keine sichtbaren Projekte.",
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
"projects.detail.verlauf.loadMore": "Mehr laden",
// SmartTimeline (t-paliad-171, Slice 1).
@@ -2387,6 +2449,8 @@ const translations: Record<Lang, Record<string, string>> = {
"event_types.browse.cancel": "Abbrechen",
"event_types.browse.selected_count": "{n} ausgewählt",
"event_types.browse.jurisdiction.none": "Allgemein",
"event_types.browse.jurisdiction.all": "Alle Gerichte",
"event_types.browse.jurisdiction.filter_label": "Nach Gerichtsart filtern",
"event_types.filter.all": "Alle Typen",
"event_types.filter.untyped": "— Ohne Typ —",
"event_types.filter.search": "Typ suchen…",
@@ -2532,6 +2596,17 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
"approvals.withdraw.error": "Fehler beim Zurückziehen",
"approvals.withdraw.cancel": "Abbrechen",
"approvals.withdraw.modal.title": "Genehmigungsanfrage zurückziehen?",
"approvals.withdraw.primary.label": "Termin bearbeiten",
"approvals.withdraw.destructive.label": "Endgültig zurückziehen und löschen",
"approvals.withdraw.lead.create.deadline": "Wenn Sie die Anfrage zurückziehen, wird die Frist gelöscht.",
"approvals.withdraw.lead.create.appointment": "Wenn Sie die Anfrage zurückziehen, wird der Termin gelöscht.",
"approvals.withdraw.lead.update": "Wenn Sie die Anfrage zurückziehen, werden die vorgeschlagenen Änderungen verworfen — der Eintrag kehrt in den Zustand vor Ihrer Bearbeitung zurück.",
"approvals.withdraw.lead.delete": "Wenn Sie die Löschanfrage zurückziehen, bleibt der Eintrag bestehen.",
"approvals.withdraw.sub.create": "Alternativ können Sie den Eintrag stattdessen bearbeiten. Die Anfrage bleibt offen und der Genehmiger sieht Ihre neuen Werte.",
"approvals.withdraw.sub.update": "Alternativ können Sie Ihre Änderungen bearbeiten und neu absenden. Die Anfrage bleibt offen.",
"approvals.withdraw.sub.delete": "Sind Sie sicher, dass Sie die Löschanfrage zurückziehen möchten?",
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
@@ -2926,6 +3001,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.glossar": "Glossary",
"nav.gebuehrentabellen": "Fee Schedules",
"nav.checklisten": "Checklists",
"nav.submissions": "Submissions",
"nav.gerichte": "Courts",
"nav.logout": "Sign Out",
"nav.akten": "Matters",
@@ -3197,9 +3273,9 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.col.proactive": "Proactive",
"deadlines.col.ours": "Client Side",
"deadlines.col.court": "Court",
"deadlines.col.reactive": "Reactive",
"deadlines.col.opponent": "Opponent Side",
"deadlines.col.both": "Both parties",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
@@ -3319,6 +3395,14 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
"deadlines.perspective.predefined_hint": "predefined from project",
"deadlines.side.label": "Side:",
"deadlines.side.claimant": "Claimant",
"deadlines.side.defendant": "Defendant",
"deadlines.side.both": "Both",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Composite:",
"deadlines.event.unit.days.one": "day",
"deadlines.event.unit.days.many": "days",
@@ -3769,11 +3853,15 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.title.placeholder": "e.g. File statement of defence",
"deadlines.field.due": "Due date",
"deadlines.field.rule": "Rule (optional)",
"deadlines.field.rule.none": "No rule",
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
"deadlines.field.rule.autofill_inline": " (set by rule)",
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
"deadlines.field.rule.override": "Choose another type",
"deadlines.field.rule.auto_badge": "Auto",
"deadlines.field.rule.auto_no_match": "No rule maps to the chosen Type",
"deadlines.field.rule.auto_pick_type": "Pick a Type first",
"deadlines.field.rule.custom_badge": "Custom",
"deadlines.field.rule.custom_placeholder": "e.g. internal review meeting, client call",
"deadlines.field.rule.mode.toggle_to_auto": "Back to Auto",
"deadlines.field.rule.mode.toggle_to_custom": "Enter custom rule",
"deadlines.field.title.default_btn": "Default title",
"deadlines.field.title.default_fallback": "New deadline",
"deadlines.field.notes": "Notes (optional)",
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
"deadlines.error.required": "Matter, title and due date are required.",
@@ -4302,10 +4390,16 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.notizen": "Notes",
"projects.detail.tab.checklisten": "Checklists",
"projects.detail.tab.submissions": "Submissions",
"projects.detail.tab.settings": "Settings",
"projects.detail.export.button": "Export data",
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet. Edit the project to choose one.",
"projects.detail.settings.export.heading": "Export data",
"projects.detail.settings.export.description": "Download all data for this project (including sub-projects) as an Excel + JSON + CSV archive.",
"projects.detail.settings.archive.heading": "Archive project",
"projects.detail.settings.archive.description": "Archiving happens in the edit dialog (danger zone).",
"projects.detail.settings.archive.cta": "Open edit dialog",
"projects.detail.submissions.empty": "No submission templates are configured yet.",
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet — the catalog below still lists every template.",
"projects.detail.submissions.empty.no_proceeding.cta": "Edit project",
"projects.detail.submissions.col.name": "Submission",
"projects.detail.submissions.col.party": "Party",
@@ -4313,7 +4407,49 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.submissions.col.action": "",
"projects.detail.submissions.action.generate": "Generate",
"projects.detail.submissions.action.no_template": "No template",
"projects.detail.submissions.action.edit": "Edit",
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
// t-paliad-238 — dedicated draft editor page.
"submissions.draft.title": "Edit submission — Paliad",
"submissions.draft.back": "← Back to project",
"submissions.draft.loading": "Loading…",
"submissions.draft.notfound": "Submission not found or insufficient access.",
"submissions.draft.action.export": "Export as .docx",
"submissions.draft.action.new": "+ New draft",
"submissions.draft.action.delete": "Delete",
"submissions.draft.switcher.label": "Draft",
"submissions.draft.name.placeholder": "Name of this draft",
"submissions.draft.preview.title": "Preview",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",
"submissions.index.subtitle": "Your submission drafts across every visible project.",
"submissions.index.loading": "Loading…",
"submissions.index.empty": "No drafts yet. Start a new draft — with or without a project.",
"submissions.index.empty.cta": "+ New draft",
"submissions.index.error": "Could not load submissions.",
"submissions.index.col.project": "Project",
"submissions.index.col.submission": "Submission",
"submissions.index.col.draft": "Draft",
"submissions.index.col.updated": "Last updated",
"submissions.index.action.new": "+ New draft",
"submissions.new.title": "New submission — Paliad",
"submissions.new.back": "← Back to drafts",
"submissions.new.heading": "New submission",
"submissions.new.subtitle": "Pick a template. Optionally bind it to a project — otherwise all variables are filled manually.",
"submissions.new.search.placeholder": "Search by name, code or statute…",
"submissions.new.loading": "Loading…",
"submissions.new.error": "Could not load catalog.",
"submissions.new.col.name": "Submission",
"submissions.new.col.party": "Party",
"submissions.new.col.source": "Legal source",
"submissions.new.col.actions": "Start draft",
"submissions.new.empty.filtered": "No submissions match the filters. Reset them to see the full catalog.",
"submissions.new.picker.title": "Pick a project",
"submissions.new.picker.placeholder": "Search project (title or reference)…",
"submissions.new.picker.loading": "Loading projects…",
"submissions.new.picker.empty": "No visible projects.",
"projects.detail.verlauf.empty": "No events recorded yet.",
"projects.detail.verlauf.loadMore": "Load more",
"projects.detail.smarttimeline.empty": "No events captured yet.",
@@ -5256,6 +5392,8 @@ const translations: Record<Lang, Record<string, string>> = {
"event_types.browse.cancel": "Cancel",
"event_types.browse.selected_count": "{n} selected",
"event_types.browse.jurisdiction.none": "Any",
"event_types.browse.jurisdiction.all": "All courts",
"event_types.browse.jurisdiction.filter_label": "Filter by court type",
"event_types.filter.all": "All types",
"event_types.filter.untyped": "— Untyped —",
"event_types.filter.search": "Search type…",
@@ -5401,6 +5539,17 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",
"approvals.withdraw.error": "Failed to withdraw",
"approvals.withdraw.cancel": "Cancel",
"approvals.withdraw.modal.title": "Withdraw approval request?",
"approvals.withdraw.primary.label": "Edit event",
"approvals.withdraw.destructive.label": "Withdraw permanently and delete",
"approvals.withdraw.lead.create.deadline": "Withdrawing this request will delete the deadline.",
"approvals.withdraw.lead.create.appointment": "Withdrawing this request will delete the appointment.",
"approvals.withdraw.lead.update": "Withdrawing this request will discard your proposed changes — the entry will revert to its state before your edit.",
"approvals.withdraw.lead.delete": "Withdrawing the delete request will keep the entry alive.",
"approvals.withdraw.sub.create": "Alternatively, you can edit the entry instead. The request stays open and the approver will see your new values.",
"approvals.withdraw.sub.update": "Alternatively, you can edit your changes and resubmit. The request stays open.",
"approvals.withdraw.sub.delete": "Are you sure you want to withdraw the delete request?",
"approvals.pending_create.label": "Awaits approval (creation)",
"approvals.pending_update.label": "Awaits approval (change)",
"approvals.pending_complete.label": "Awaits approval (completion)",

View File

@@ -16,6 +16,7 @@ import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
import { loadAndRenderSubmissions } from "./submissions";
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Project {
id: string;
@@ -142,6 +143,11 @@ interface Deadline {
status: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was saved in
// Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
// Populated by the union endpoint (/api/events) which is what the project
// detail page calls — used for attribution when the row lives on a
// descendant project (t-paliad-139).
@@ -175,7 +181,8 @@ type TabId =
| "appointments"
| "notes"
| "checklists"
| "submissions";
| "submissions"
| "settings";
const VALID_TABS: TabId[] = [
"history",
@@ -187,6 +194,7 @@ const VALID_TABS: TabId[] = [
"notes",
"checklists",
"submissions",
"settings",
];
// Legacy German tab slugs that may appear in bookmarked URLs after the
@@ -803,6 +811,9 @@ interface UnionEvent {
status?: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
custom_rule_text?: string;
start_at?: string;
end_at?: string;
location?: string;
@@ -830,6 +841,9 @@ async function loadDeadlines(id: string) {
status: it.status ?? "pending",
rule_id: it.rule_id,
rule_code: it.rule_code,
rule_name: it.rule_name,
rule_name_en: it.rule_name_en,
custom_rule_text: it.custom_rule_text,
project_title: it.project_title,
}));
} else {
@@ -999,6 +1013,27 @@ function fmtDateOnly(iso: string): string {
}
}
// formatDeadlineRuleCell renders the REGEL column for the project
// detail Fristen table using the canonical t-paliad-258 contract:
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
// 2. custom_rule_text → text + "Custom" badge
// 3. legacy rule_code-only saves → bare citation
// 4. otherwise "—"
function formatDeadlineRuleCell(f: Deadline): string {
const hasName = (f.rule_name && f.rule_name.trim()) ||
(f.rule_name_en && f.rule_name_en.trim());
if (hasName || (f.rule_code && f.rule_code.trim())) {
return formatRuleLabelHTML(
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
esc,
);
}
if (f.custom_rule_text && f.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
}
return "—";
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
@@ -1037,7 +1072,7 @@ function renderDeadlines() {
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})
@@ -1185,13 +1220,16 @@ function renderHeader() {
netdocs.style.display = "none";
}
// Delete visibility: partner/admin only
// Delete visibility: partner/admin only. The Verwaltung tab's archive
// sub-section mirrors the same gate (t-paliad-245) — it only points at
// the Edit-modal danger zone, so it's pointless to show when the danger
// zone itself is hidden.
const deleteWrap = document.getElementById("project-delete-wrap")!;
if (me && (me.global_role === "global_admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
}
const archiveSection = document.getElementById("project-settings-archive");
const canArchive = !!me && me.global_role === "global_admin";
deleteWrap.style.display = canArchive ? "" : "none";
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
updateSettingsTabVisibility();
}
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
@@ -2045,6 +2083,17 @@ function initEditModal() {
});
}
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
// the danger-zone archive button (t-paliad-245).
const archiveLink = document.getElementById(
"project-settings-archive-link",
) as HTMLButtonElement | null;
if (archiveLink) {
archiveLink.addEventListener("click", () => {
openEditModal("project-delete-btn");
});
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;
@@ -2991,17 +3040,21 @@ function canExportProject(): boolean {
);
}
// wireExportButton reveals + hooks up the project-export button on the
// tabs nav. Triggers a download via a transient <a download> — same
// pattern as the personal export in client/settings.ts.
// wireExportButton reveals the Export sub-section of the Verwaltung tab
// (t-paliad-245) and hooks up the project-export button. Triggers a
// download via a transient <a download> — same pattern as the personal
// export in client/settings.ts.
function wireExportButton(projectID: string): void {
const section = document.getElementById("project-settings-export") as HTMLElement | null;
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
if (!btn) return;
if (!section || !btn) return;
if (!canExportProject()) {
btn.style.display = "none";
section.style.display = "none";
updateSettingsTabVisibility();
return;
}
btn.style.display = "";
section.style.display = "";
updateSettingsTabVisibility();
btn.addEventListener("click", () => {
const a = document.createElement("a");
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
@@ -3012,6 +3065,17 @@ function wireExportButton(projectID: string): void {
});
}
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
// sub-sections are visible to the current user — an empty tab is worse
// UX than no tab. Called whenever a sub-section's visibility flips.
function updateSettingsTabVisibility(): void {
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
if (!tab) return;
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
tab.style.display = exportShown || archiveShown ? "" : "none";
}
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;

View File

@@ -0,0 +1,87 @@
// rule-label — canonical display contract for deadline rules.
//
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
// invented its own pattern: sometimes citation-only, sometimes name-only,
// sometimes "code — name". m flagged this on the first submissions in a
// proceeding sequence where the inconsistency was most visible.
//
// Canonical pattern: **Name primary, Citation muted secondary**.
// Text: "Notice of Appeal · UPC.RoP.220.1"
// HTML: <span class="rule-label-name">Notice of Appeal</span>
// <span class="rule-label-sep"> · </span>
// <span class="rule-label-cite">UPC.RoP.220.1</span>
//
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
// so list/detail surfaces can render both shapes uniformly.
import { getLang, t } from "./i18n";
export interface RuleLike {
name: string;
name_en?: string | null;
// The catalog carries multiple citation fields depending on which
// surface populated it. Order of preference: legal_source > rule_code
// > code. All three are accepted so callers don't have to normalise.
rule_code?: string | null;
code?: string | null;
legal_source?: string | null;
}
// formatRuleLabel returns the canonical plain-text label.
// Falls back gracefully when either side is missing.
export function formatRuleLabel(r: RuleLike): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const cite = ruleCitation(r);
if (name && cite) return `${name} · ${cite}`;
return name || cite || "";
}
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
// styling. The caller passes the HTML-escape helper so we don't pull a
// dependency on a specific esc() module — every surface already has one.
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const cite = ruleCitation(r);
if (name && cite) {
return (
`<span class="rule-label-name">${esc(name)}</span>` +
`<span class="rule-label-sep"> · </span>` +
`<span class="rule-label-cite">${esc(cite)}</span>`
);
}
return esc(name || cite || "");
}
// ruleCitation returns the best-available citation string for a rule.
// Exported so callers that need the bare code (e.g. CalDAV exports,
// inline data attributes) can pull it without going through the label
// formatter.
export function ruleCitation(r: RuleLike): string {
return r.legal_source || r.rule_code || r.code || "";
}
// formatCustomRuleLabelHTML — render a free-text custom rule label with
// a "Custom" badge slot. Used by surfaces that may display either a
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
// the text is empty so callers can fall through to "—".
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
const trimmed = (text ?? "").trim();
if (!trimmed) return "";
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
return (
`<span class="rule-label-name">${esc(trimmed)}</span>` +
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
);
}
// formatCustomRuleLabel — plain-text equivalent of the above.
export function formatCustomRuleLabel(text: string | null | undefined): string {
const trimmed = (text ?? "").trim();
if (!trimmed) return "";
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
return `${trimmed} · ${badge}`;
}

View File

@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
const SIDEBAR_WIDTH_MIN = 180;
const SIDEBAR_WIDTH_MAX = 480;
const SIDEBAR_WIDTH_DEFAULT = 240;
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
// on every scroll event, restored on initSidebar() so a full-page nav
// click doesn't bounce the user back to the top of a long sidebar
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
// starts that tab fresh at the top, which matches user expectation.
const SCROLL_KEY = "paliad.sidebar.scroll";
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
// BottomNav menu slot can call it without duplicating the open/close
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
}
// readStoredScroll returns the persisted scrollTop or 0 when missing /
// malformed. Bounds are checked at apply time against the actual
// scrollHeight, so a stale value pointing past the current scroll range
// is harmless (the browser clamps assignments to [0, max]).
function readStoredScroll(): number {
const raw = sessionStorage.getItem(SCROLL_KEY);
if (raw === null) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function applySidebarScroll(nav: HTMLElement, px: number): void {
if (px <= 0) return;
nav.scrollTop = px;
}
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
// first load and removes the stale entry. Drop this fallback once the rename
// grace period is over.
@@ -79,6 +103,7 @@ export function initSidebar() {
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
initSidebarScrollRestore(sidebar);
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
});
}
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
// sessionStorage so the user's scroll position survives a full-page
// navigation (every sidebar link click is a real reload — see m/paliad#85).
// Restore is synchronous on init so the first paint is already at the
// right offset; the passive scroll listener persists subsequent moves.
// reapplySidebarScroll() exists so callers that mutate sidebar content
// async (initUserViewsGroup appending /api/user-views into the Ansichten
// group) can nudge the scroll back to where it was after the layout shift.
function initSidebarScrollRestore(sidebar: HTMLElement): void {
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
nav.addEventListener("scroll", () => {
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
}, { passive: true });
}
function reapplySidebarScroll(): void {
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
}
// Changelog badge — fetches the count of entries newer than the locally
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
// link. Skipped on the changelog page itself because changelog.ts stamps
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
for (const view of views) {
items.appendChild(renderUserViewItem(view, currentPath));
}
// The synchronous restore in initSidebarScrollRestore() happened
// before these views were appended, so a saved scrollTop that
// pointed below the Ansichten group would now sit on the wrong
// row. Re-apply once the layout has stabilised.
reapplySidebarScroll();
// After rendering, kick off count refresh for views that opted in.
for (const view of views) {
if (view.show_count) {

View File

@@ -0,0 +1,955 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
// t-paliad-238 Slice A — client bundle for the dedicated
// Submissions/Schriftsätze editor at
// /projects/{id}/submissions/{code}/draft
// /projects/{id}/submissions/{code}/draft/{draft_id}
//
// Reads (project_id, submission_code, optional draft_id) from the URL,
// loads the editor payload (draft row + resolved bag + merged bag +
// HTML preview), and wires the sidebar / preview / autosave / export.
//
// Autosave is debounced 500ms after the lawyer stops typing in any
// variable input. Each PATCH returns a fresh editor payload, so the
// preview pane stays in lockstep with the variable overrides.
interface SubmissionDraftJSON {
id: string;
project_id: string | null;
submission_code: string;
user_id: string;
name: string;
variables: Record<string, string>;
last_exported_at?: string | null;
last_exported_sha?: string | null;
created_at: string;
updated_at: string;
}
interface SubmissionRuleSummary {
name: string;
name_en: string;
submission_code: string;
primary_party?: string;
event_type?: string;
legal_source?: string;
legal_source_pretty?: string;
}
interface SubmissionDraftView {
draft: SubmissionDraftJSON;
rule?: SubmissionRuleSummary;
resolved_bag: Record<string, string>;
merged_bag: Record<string, string>;
preview_html: string;
lang: string;
has_template: boolean;
template_missing?: boolean;
}
interface SubmissionDraftListResponse {
project_id: string;
submission_code: string;
drafts: SubmissionDraftJSON[];
}
interface ParsedPath {
// Project-scoped path: /projects/{id}/submissions/{code}/draft[/{draft_id}].
// Global path: /submissions/draft/{draft_id} — projectID + submissionCode are derived
// from the loaded draft row after fetch.
projectID: string | null;
submissionCode: string | null;
draftID?: string;
// mode tracks the URL shape we were entered from. Affects redirect
// semantics when we create a new draft or navigate away.
mode: "project" | "global";
}
const PROJECT_PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
const GLOBAL_PATH_RE = /^\/submissions\/draft\/([0-9a-fA-F-]{36})\/?$/;
function parsePath(): ParsedPath | null {
let m = PROJECT_PATH_RE.exec(window.location.pathname);
if (m) {
return {
projectID: m[1],
submissionCode: decodeURIComponent(m[2]),
draftID: m[3],
mode: "project",
};
}
m = GLOBAL_PATH_RE.exec(window.location.pathname);
if (m) {
return {
projectID: null,
submissionCode: null,
draftID: m[1],
mode: "global",
};
}
return null;
}
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;");
}
// ─────────────────────────────────────────────────────────────────────
// 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.
// ─────────────────────────────────────────────────────────────────────
interface VariableLabel {
de: string;
en: string;
}
interface VariableGroup {
id: string;
label: VariableLabel;
keys: string[];
}
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" },
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
"rule.name": { de: "Schriftsatz", en: "Submission" },
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
"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" },
};
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "rule",
label: { de: "Schriftsatz", en: "Submission" },
keys: [
"rule.name",
"rule.legal_source_pretty",
"rule.primary_party",
"rule.event_type",
"rule.submission_code",
],
},
{
id: "parties",
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "project",
label: { de: "Verfahren", en: "Proceeding" },
keys: [
"project.title",
"project.case_number",
"project.court",
"project.patent_number",
"project.patent_number_upc",
"project.filing_date",
"project.grant_date",
"project.our_side",
"project.proceeding.name",
"project.client_number",
"project.matter_number",
"project.reference",
"project.instance_level",
],
},
{
id: "deadline",
label: { de: "Frist", en: "Deadline" },
keys: [
"deadline.due_date",
"deadline.due_date_long_de",
"deadline.due_date_long_en",
"deadline.computed_from",
"deadline.title",
"deadline.original_due_date",
],
},
{
id: "firm",
label: { de: "Kanzlei & Datum", en: "Firm & date" },
keys: [
"firm.name",
"user.display_name",
"user.email",
"user.office",
"today.long_de",
"today.long_en",
],
},
];
function labelFor(key: string): string {
const entry = VARIABLE_LABELS[key];
if (!entry) return key;
return isEN() ? entry.en : entry.de;
}
// ─────────────────────────────────────────────────────────────────────
// Module state
// ─────────────────────────────────────────────────────────────────────
interface State {
parsed: ParsedPath;
view: SubmissionDraftView | null;
drafts: SubmissionDraftJSON[];
saveTimer: number | null;
pendingOverrides: Record<string, string> | null;
inFlight: AbortController | null;
}
const state: State = {
parsed: null as unknown as ParsedPath,
view: null,
drafts: [],
saveTimer: null,
pendingOverrides: null,
inFlight: null,
};
// ─────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────
async function boot(): Promise<void> {
initI18n();
initSidebar();
const parsed = parsePath();
if (!parsed) {
showNotFound();
return;
}
state.parsed = parsed;
try {
if (parsed.mode === "global") {
// Global path: we have a draft_id, fetch by id alone. Drafts
// list (the sidebar switcher) is scoped to the same project +
// submission_code AFTER we've loaded the draft.
if (!parsed.draftID) {
showNotFound();
return;
}
const view = await fetchGlobalView(parsed.draftID);
state.view = view;
// Backfill parsed.* from the loaded draft so the sidebar
// switcher can list peers; project-less drafts get no peer list
// beyond themselves (no useful (project, code) cross-section).
state.parsed = {
...parsed,
projectID: view.draft.project_id,
submissionCode: view.draft.submission_code,
};
if (view.draft.project_id) {
try {
const list = await fetchDrafts(state.parsed);
state.drafts = list.drafts;
} catch { state.drafts = [view.draft]; }
} else {
state.drafts = [view.draft];
}
paint();
return;
}
// Project-scoped path: same logic as before.
if (!parsed.projectID || !parsed.submissionCode) {
showNotFound();
return;
}
const list = await fetchDrafts(parsed);
state.drafts = list.drafts;
let draft: SubmissionDraftJSON | null = null;
if (parsed.draftID) {
draft = state.drafts.find((d) => d.id === parsed.draftID) ?? null;
if (!draft) {
showNotFound();
return;
}
} else if (state.drafts.length > 0) {
draft = state.drafts[0];
// Redirect to the canonical /draft/{id} URL so refresh + share
// both land on the same draft.
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
window.history.replaceState({}, "", url);
state.parsed = { ...parsed, draftID: draft.id };
} else {
draft = await createProjectDraft(parsed);
state.drafts = [draft];
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
window.history.replaceState({}, "", url);
state.parsed = { ...parsed, draftID: draft.id };
}
const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
state.view = view;
paint();
} catch (err) {
console.error("submission-draft boot:", err);
showError(isEN() ? "Failed to load draft." : "Entwurf konnte nicht geladen werden.");
}
}
// ─────────────────────────────────────────────────────────────────────
// API
// ─────────────────────────────────────────────────────────────────────
async function fetchDrafts(p: ParsedPath): Promise<SubmissionDraftListResponse> {
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`drafts list ${resp.status}`);
return resp.json();
}
async function createProjectDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" } });
if (!resp.ok) throw new Error(`create draft ${resp.status}`);
const view = (await resp.json()) as SubmissionDraftView;
return view.draft;
}
async function fetchView(projectID: string, code: string, draftID: string): Promise<SubmissionDraftView> {
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/drafts/${draftID}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
return resp.json();
}
async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
const resp = await fetch(`/api/submission-drafts/${draftID}`);
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
state.inFlight.abort();
state.inFlight = null;
}
const ctl = new AbortController();
state.inFlight = ctl;
// The global PATCH endpoint accepts both project-scoped and
// project-less drafts — route everything through it so attach (set
// project_id) works from both URL shapes.
const url = `/api/submission-drafts/${p.draftID}`;
try {
const resp = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: ctl.signal,
});
if (!resp.ok) throw new Error(`patch draft ${resp.status}`);
return resp.json();
} finally {
if (state.inFlight === ctl) state.inFlight = null;
}
}
async function deleteDraft(): Promise<void> {
const p = state.parsed;
if (!p.draftID) return;
const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
}
// ─────────────────────────────────────────────────────────────────────
// Render
// ─────────────────────────────────────────────────────────────────────
function paint(): void {
if (!state.view) return;
hide("submission-draft-loading");
hide("submission-draft-notfound");
hide("submission-draft-error");
show("submission-draft-body");
paintHeader();
paintBackLink();
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintVariables();
paintPreview();
}
function paintHeader(): void {
const view = state.view!;
const title = document.getElementById("submission-draft-title");
if (title) {
const ruleName = view.rule?.name ?? view.draft.submission_code;
title.textContent = ruleName;
}
const subtitle = document.getElementById("submission-draft-subtitle");
if (subtitle) {
const code = view.draft.submission_code;
const source = view.rule?.legal_source_pretty ?? view.rule?.legal_source ?? "";
const parts: string[] = [code];
if (source) parts.push(source);
subtitle.textContent = parts.join(" · ");
}
}
function paintBackLink(): void {
const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
if (!back || !state.view) return;
if (state.view.draft.project_id) {
back.href = `/projects/${state.view.draft.project_id}/submissions`;
back.textContent = isEN() ? "← Back to project" : "← Zurück zum Projekt";
} else {
back.href = "/submissions";
back.textContent = isEN() ? "← Back to drafts" : "← Zurück zur Übersicht";
}
}
// paintNoProjectBanner adds (or removes) the "Kein Projekt zugeordnet"
// banner above the editor body. The banner offers a "Projekt zuweisen"
// button that opens an inline project picker — same modal pattern the
// /submissions/new page uses. Removed once the draft has a project_id.
function paintNoProjectBanner(): void {
const body = document.getElementById("submission-draft-body");
if (!body || !state.view) return;
let banner = document.getElementById("submission-draft-noproject-banner");
if (state.view.draft.project_id) {
if (banner) banner.remove();
return;
}
const msg = isEN()
? "No project assigned — all variables are filled manually."
: "Kein Projekt zugeordnet — alle Variablen werden manuell befüllt.";
const cta = isEN() ? "Assign project…" : "Projekt zuweisen…";
const html = `<p class="submission-draft-noproject-banner-msg">${escapeHtml(msg)}</p>
<button type="button" id="submission-draft-noproject-assign"
class="btn-secondary btn-small">${escapeHtml(cta)}</button>`;
if (banner) {
banner.innerHTML = html;
} else {
banner = document.createElement("aside");
banner.id = "submission-draft-noproject-banner";
banner.className = "submission-draft-noproject-banner";
banner.innerHTML = html;
// Insert before the header.
const header = body.querySelector(".submission-draft-header");
if (header && header.parentElement) {
header.parentElement.insertBefore(banner, header);
} else {
body.prepend(banner);
}
}
const btn = document.getElementById("submission-draft-noproject-assign") as HTMLButtonElement | null;
if (btn) btn.onclick = () => openProjectAssignPicker();
}
function paintSwitcher(): void {
const sel = document.getElementById("submission-draft-pick") as HTMLSelectElement | null;
if (!sel || !state.view) return;
sel.innerHTML = state.drafts
.map((d) => `<option value="${escapeHtml(d.id)}"${d.id === state.view!.draft.id ? " selected" : ""}>${escapeHtml(d.name)}</option>`)
.join("");
sel.onchange = () => {
const id = sel.value;
if (!id || !state.view) return;
if (id === state.view.draft.id) return;
const p = state.parsed;
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${id}`;
window.location.href = url;
};
}
function paintNameRow(): void {
const input = document.getElementById("submission-draft-name") as HTMLInputElement | null;
const del = document.getElementById("submission-draft-delete-btn") as HTMLButtonElement | null;
if (input && state.view) {
input.value = state.view.draft.name;
input.onchange = () => {
const newName = input.value.trim();
if (!newName || newName === state.view!.draft.name) return;
void renameDraft(newName);
};
}
if (del) del.onclick = () => onDelete();
const newBtn = document.getElementById("submission-draft-new-btn") as HTMLButtonElement | null;
if (newBtn) newBtn.onclick = () => onCreateNew();
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
}
function paintVariables(): void {
const host = document.getElementById("submission-draft-variables");
if (!host || !state.view) return;
const overrides = state.view.draft.variables ?? {};
const resolved = state.view.resolved_bag ?? {};
const merged = state.view.merged_bag ?? {};
let html = "";
for (const group of VARIABLE_GROUPS) {
const groupLabel = isEN() ? group.label.en : group.label.de;
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
for (const key of group.keys) {
const label = labelFor(key);
const override = overrides[key];
const resolvedVal = resolved[key] ?? "";
const mergedVal = merged[key] ?? "";
const overridden = Object.prototype.hasOwnProperty.call(overrides, key);
html += `<label class="submission-draft-var-row" data-key="${escapeHtml(key)}">`;
html += `<span class="submission-draft-var-label">${escapeHtml(label)}</span>`;
html += `<input type="text" class="submission-draft-var-input entity-form-input"`;
html += ` data-var="${escapeHtml(key)}"`;
html += ` value="${escapeHtml(overridden ? override : resolvedVal)}"`;
html += ` data-resolved="${escapeHtml(resolvedVal)}" />`;
const hintParts: string[] = [];
hintParts.push(`<code>{{${escapeHtml(key)}}}</code>`);
if (overridden) {
if (override === "") {
hintParts.push(`<span class="submission-draft-var-marker">${escapeHtml(isEN() ? "→ [NO VALUE: " + key + "]" : "→ [KEIN WERT: " + key + "]")}</span>`);
} else if (override !== resolvedVal) {
const original = resolvedVal === "" ? (isEN() ? "(empty)" : "(leer)") : resolvedVal;
hintParts.push(`<span class="submission-draft-var-was">${escapeHtml((isEN() ? "Project: " : "Projekt: ") + original)}</span>`);
}
}
html += `<span class="submission-draft-var-hint">${hintParts.join(" · ")}</span>`;
if (overridden && override !== resolvedVal) {
html += `<button type="button" class="submission-draft-var-reset btn-small btn-link" data-reset-key="${escapeHtml(key)}">${escapeHtml(isEN() ? "Reset" : "Zurücksetzen")}</button>`;
}
html += `</label>`;
// Visual hint: marker text appears in preview when override is "".
void mergedVal;
}
html += `</section>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
});
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
});
}
function paintPreview(): void {
const host = document.getElementById("submission-draft-preview");
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
}
// ─────────────────────────────────────────────────────────────────────
// Event handlers
// ─────────────────────────────────────────────────────────────────────
function onVarChange(input: HTMLInputElement): void {
const key = input.dataset.var;
if (!key || !state.view) return;
// Stage the override on the draft view so paintPreview reflects.
const overrides = { ...state.view.draft.variables };
overrides[key] = input.value;
state.view.draft.variables = overrides;
state.pendingOverrides = overrides;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
if (state.saveTimer) window.clearTimeout(state.saveTimer);
state.saveTimer = window.setTimeout(() => {
void flushAutosave();
}, 500);
}
function onVarReset(key: string): void {
if (!state.view) return;
const overrides = { ...state.view.draft.variables };
delete overrides[key];
state.view.draft.variables = overrides;
state.pendingOverrides = overrides;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
if (state.saveTimer) window.clearTimeout(state.saveTimer);
state.saveTimer = window.setTimeout(() => {
void flushAutosave();
}, 500);
}
async function flushAutosave(): Promise<void> {
if (!state.pendingOverrides) return;
const payload = { variables: state.pendingOverrides };
state.pendingOverrides = null;
try {
const view = await patchDraft(payload);
state.view = view;
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft autosave:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
}
}
async function renameDraft(newName: string): Promise<void> {
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ name: newName });
state.view = view;
// Refresh the draft list cache.
const idx = state.drafts.findIndex((d) => d.id === view.draft.id);
if (idx >= 0) state.drafts[idx].name = view.draft.name;
paintSwitcher();
setSaveStatus(isEN() ? "Renamed" : "Umbenannt");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft rename:", err);
const msg = (err as Error).message?.includes("409")
? (isEN() ? "Name already in use" : "Name bereits vergeben")
: (isEN() ? "Rename failed" : "Umbenennen fehlgeschlagen");
setSaveStatus(msg, true);
}
}
async function onCreateNew(): Promise<void> {
const p = state.parsed;
// From a project-less draft, "Neuer Entwurf" can't auto-pick a
// (project, code) cross-section — kick the user out to the global
// picker instead.
if (!p.projectID || !p.submissionCode) {
window.location.href = "/submissions/new";
return;
}
try {
const fresh = await createProjectDraft(p);
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${fresh.id}`;
window.location.href = url;
} catch (err) {
console.error("submission-draft new:", err);
setSaveStatus(isEN() ? "Create failed" : "Anlegen fehlgeschlagen", true);
}
}
async function onDelete(): Promise<void> {
if (!state.view) return;
const msg = isEN()
? `Delete draft "${state.view.draft.name}"? This cannot be undone.`
: `Entwurf "${state.view.draft.name}" löschen? Das kann nicht rückgängig gemacht werden.`;
if (!window.confirm(msg)) return;
try {
await deleteDraft();
const p = state.parsed;
const url = p.projectID && p.submissionCode
? `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft`
: "/submissions";
window.location.href = url;
} catch (err) {
console.error("submission-draft delete:", err);
setSaveStatus(isEN() ? "Delete failed" : "Löschen fehlgeschlagen", true);
}
}
async function onExport(btn: HTMLButtonElement): Promise<void> {
if (!state.view) return;
const p = state.parsed;
if (!p.draftID) return;
const originalLabel = btn.textContent ?? "";
btn.disabled = true;
btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
try {
// Use the global export endpoint for both project-scoped and
// project-less drafts; the handler routes audit + project_events
// writes based on the draft row's project_id.
const url = `/api/submission-drafts/${p.draftID}/export`;
const resp = await fetch(url, { method: "POST" });
if (!resp.ok) {
let detail = "";
try {
const data = (await resp.json()) as { error?: string };
detail = data.error ?? "";
} catch { /* fallthrough */ }
alert((isEN() ? "Export failed." : "Export fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""));
return;
}
const blob = await resp.blob();
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "") ?? `${state.view.draft.submission_code}.docx`;
triggerDownload(blob, filename);
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
// ─────────────────────────────────────────────────────────────────────
// Project assign picker (project-less → project-scoped)
// ─────────────────────────────────────────────────────────────────────
interface PickerProjectRow {
id: string;
title: string;
reference?: string | null;
}
let assignPickerProjects: PickerProjectRow[] = [];
let assignPickerLoaded = false;
function openProjectAssignPicker(): void {
ensureAssignPickerDOM();
const modal = document.getElementById("submission-draft-assign-modal");
if (modal) modal.style.display = "";
if (!assignPickerLoaded) {
void loadAssignPickerProjects();
} else {
renderAssignPickerList();
}
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
if (searchInput) {
searchInput.value = "";
setTimeout(() => searchInput.focus(), 50);
}
}
function closeProjectAssignPicker(): void {
const modal = document.getElementById("submission-draft-assign-modal");
if (modal) modal.style.display = "none";
}
function ensureAssignPickerDOM(): void {
if (document.getElementById("submission-draft-assign-modal")) return;
const titleTxt = isEN() ? "Assign project" : "Projekt zuweisen";
const placeholder = isEN()
? "Search project (title or reference)…"
: "Projekt suchen (Titel oder Aktenzeichen)…";
const loadingTxt = isEN() ? "Loading projects…" : "Lädt Projekte…";
const emptyTxt = isEN() ? "No visible projects." : "Keine sichtbaren Projekte.";
const modal = document.createElement("div");
modal.id = "submission-draft-assign-modal";
modal.className = "modal-overlay";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.style.display = "none";
modal.innerHTML = `
<div class="modal-card">
<header class="modal-header">
<h2>${escapeHtml(titleTxt)}</h2>
<button type="button" id="submission-draft-assign-close" class="modal-close" aria-label="Close">×</button>
</header>
<div class="modal-body">
<input type="search" id="submission-draft-assign-search" class="entity-form-input" placeholder="${escapeHtml(placeholder)}" />
<ul id="submission-draft-assign-list" class="submissions-new-project-list"></ul>
<p id="submission-draft-assign-loading" class="entity-events-empty" style="display:none">${escapeHtml(loadingTxt)}</p>
<p id="submission-draft-assign-empty" class="entity-empty" style="display:none">${escapeHtml(emptyTxt)}</p>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeProjectAssignPicker();
});
const closeBtn = document.getElementById("submission-draft-assign-close");
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectAssignPicker());
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
if (searchInput) searchInput.addEventListener("input", () => renderAssignPickerList());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.style.display !== "none") closeProjectAssignPicker();
});
}
async function loadAssignPickerProjects(): Promise<void> {
const loadingEl = document.getElementById("submission-draft-assign-loading");
if (loadingEl) loadingEl.style.display = "";
try {
const resp = await fetch("/api/projects?status=active");
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
const rows = (await resp.json()) as PickerProjectRow[];
assignPickerProjects = rows ?? [];
assignPickerLoaded = true;
} catch (err) {
console.error("submission-draft assignPicker:", err);
assignPickerProjects = [];
} finally {
if (loadingEl) loadingEl.style.display = "none";
}
renderAssignPickerList();
}
function renderAssignPickerList(): void {
const list = document.getElementById("submission-draft-assign-list");
const empty = document.getElementById("submission-draft-assign-empty");
if (!list || !empty) return;
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
const term = (searchInput?.value ?? "").trim().toLowerCase();
const matches = assignPickerProjects.filter((p) => {
if (term === "") return true;
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
return hay.includes(term);
}).slice(0, 50);
if (matches.length === 0) {
list.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = matches.map((p) => {
const ref = p.reference ? `<span class="entity-ref">${escapeHtml(p.reference)}</span> ` : "";
return `<li class="submissions-new-project-item" data-id="${escapeHtml(p.id)}">${ref}<span class="submissions-new-project-title">${escapeHtml(p.title)}</span></li>`;
}).join("");
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
li.addEventListener("click", () => {
const pid = li.dataset.id;
if (pid) void onAssignProject(pid);
});
});
}
async function onAssignProject(projectID: string): Promise<void> {
closeProjectAssignPicker();
setSaveStatus(isEN() ? "Assigning…" : "Wird zugewiesen…");
try {
const view = await patchDraft({ project_id: projectID });
state.view = view;
setSaveStatus(isEN() ? "Project assigned" : "Projekt zugewiesen");
// Redirect to the project-scoped URL so the editor's URL matches the
// attached project and the project-scoped draft list (sidebar
// switcher) loads on refresh.
const code = view.draft.submission_code;
window.location.href = `/projects/${projectID}/submissions/${encodeURIComponent(code)}/draft/${view.draft.id}`;
} catch (err) {
console.error("submission-draft assign:", err);
setSaveStatus(isEN() ? "Assign failed" : "Zuweisung fehlgeschlagen", true);
}
}
// ─────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────
function show(id: string): void {
const el = document.getElementById(id);
if (el) el.style.display = "";
}
function hide(id: string): void {
const el = document.getElementById(id);
if (el) el.style.display = "none";
}
function showNotFound(): void {
hide("submission-draft-loading");
hide("submission-draft-body");
show("submission-draft-notfound");
}
function showError(msg: string): void {
hide("submission-draft-loading");
hide("submission-draft-body");
const el = document.getElementById("submission-draft-error");
if (el) {
el.textContent = msg;
el.style.display = "";
}
}
function setSaveStatus(msg: string, errorState: boolean = false): void {
const el = document.getElementById("submission-draft-savestatus");
if (!el) return;
el.textContent = msg;
el.classList.toggle("submission-draft-savestatus--error", errorState);
}
function parseFilename(header: string): string | null {
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
return m ? m[1] : null;
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
}
// Keep t() referenced so the bundler doesn't tree-shake it; future
// affordances will use the per-page i18n keys.
void t;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => { void boot(); });
} else {
void boot();
}

View File

@@ -0,0 +1,130 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// t-paliad-240 — global Schriftsätze drafts index. Loads
// /api/user/submission-drafts and renders one entity-table row per
// draft. Row click → editor at /projects/{project_id}/submissions/
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
// contract: a table whose rows look clickable must navigate on click;
// inner links / buttons keep their own affordance.
interface DraftRow {
id: string;
project_id: string | null;
project_title: string | null;
project_reference?: string | null;
submission_code: string;
name: string;
last_exported_at?: string | null;
updated_at: string;
created_at: string;
}
let drafts: DraftRow[] = [];
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const isEN = getLang() === "en";
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
async function load(): Promise<void> {
const loading = document.getElementById("submissions-index-loading")!;
const empty = document.getElementById("submissions-index-empty")!;
const error = document.getElementById("submissions-index-error")!;
const wrap = document.getElementById("submissions-index-tablewrap")!;
try {
const resp = await fetch("/api/user/submission-drafts");
if (!resp.ok) {
loading.style.display = "none";
error.style.display = "";
return;
}
const data = await resp.json();
drafts = (data.drafts ?? []) as DraftRow[];
} catch {
loading.style.display = "none";
error.style.display = "";
return;
}
loading.style.display = "none";
if (drafts.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
render();
}
function render(): void {
const body = document.getElementById("submissions-index-body")!;
const isEN = getLang() === "en";
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
body.innerHTML = drafts.map((d) => {
const projectCell = (() => {
if (!d.project_id) {
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
}
const title = esc(d.project_title ?? "");
if (d.project_reference) {
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
}
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
})();
const href = d.project_id
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
: `/submissions/draft/${esc(d.id)}`;
return `<tr class="submissions-index-row" data-href="${esc(href)}">
<td>${projectCell}</td>
<td>${esc(d.submission_code)}</td>
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
<td>${esc(fmtDate(d.updated_at))}</td>
</tr>`;
}).join("");
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
const href = row.dataset.href!;
row.addEventListener("click", (e) => {
// Inner <a> elements (project link, draft name) handle their own
// navigation — let the browser dispatch them.
if ((e.target as HTMLElement).closest("a, button")) return;
window.location.href = href;
});
});
// Keep tsc happy for the imported `t` (used only via data-i18n on
// static markup — keep the import so future dynamic strings can hook
// in without re-importing).
void t;
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(() => {
if (drafts.length > 0) render();
});
void load();
});

View File

@@ -0,0 +1,368 @@
import { initI18n, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// t-paliad-243 — client for /submissions/new. Fetches the
// cross-proceeding submission catalog, groups it by proceeding, filters
// by text + chip, and offers two start paths per row: with project
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
interface CatalogEntry {
submission_code: string;
name: string;
name_en: string;
event_type?: string;
primary_party?: string;
legal_source?: string;
has_template: boolean;
proceeding_code: string;
proceeding_name: string;
proceeding_name_en: string;
}
interface CatalogResponse {
entries: CatalogEntry[];
}
interface ProjectRow {
id: string;
title: string;
reference?: string | null;
}
interface State {
entries: CatalogEntry[];
activeProceeding: string | null; // null = all
searchTerm: string;
pickerForCode: string | null;
}
const state: State = {
entries: [],
activeProceeding: null,
searchTerm: "",
pickerForCode: null,
};
function isEN(): boolean {
return getLang() === "en";
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function partyLabel(role: string | undefined): string {
switch ((role ?? "").toLowerCase()) {
case "claimant": return isEN() ? "Claimant" : "Klägerin";
case "defendant": return isEN() ? "Defendant" : "Beklagte";
case "both": return isEN() ? "Both" : "Beide";
case "court": return isEN() ? "Court" : "Gericht";
default: return "";
}
}
async function loadCatalog(): Promise<void> {
const loading = document.getElementById("submissions-new-loading")!;
const error = document.getElementById("submissions-new-error")!;
const wrap = document.getElementById("submissions-new-tablewrap")!;
try {
const resp = await fetch("/api/submissions/catalog");
if (!resp.ok) {
loading.style.display = "none";
error.style.display = "";
return;
}
const data = (await resp.json()) as CatalogResponse;
state.entries = data.entries ?? [];
} catch {
loading.style.display = "none";
error.style.display = "";
return;
}
loading.style.display = "none";
wrap.style.display = "";
renderChips();
renderTable();
}
function renderChips(): void {
const host = document.getElementById("submissions-new-proceeding-chips");
if (!host) return;
const seen = new Map<string, string>();
for (const e of state.entries) {
if (!seen.has(e.proceeding_code)) {
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
}
}
const chips: string[] = [];
const allLabel = isEN() ? "All" : "Alle";
const allActive = state.activeProceeding === null;
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
for (const [code, name] of seen) {
const active = state.activeProceeding === code;
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
}
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const code = btn.dataset.code ?? "";
state.activeProceeding = code === "" ? null : code;
renderChips();
renderTable();
});
});
}
function filtered(): CatalogEntry[] {
const term = state.searchTerm.trim().toLowerCase();
return state.entries.filter((e) => {
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
return false;
}
if (term === "") return true;
const name = isEN() && e.name_en ? e.name_en : e.name;
const hay = [
name,
e.submission_code,
e.legal_source ?? "",
e.proceeding_code,
e.proceeding_name,
e.proceeding_name_en,
].join(" ").toLowerCase();
return hay.includes(term);
});
}
function renderTable(): void {
const body = document.getElementById("submissions-new-body");
const empty = document.getElementById("submissions-new-empty");
const wrap = document.getElementById("submissions-new-tablewrap");
if (!body || !empty || !wrap) return;
const rows = filtered();
if (rows.length === 0) {
wrap.style.display = "none";
empty.style.display = "";
return;
}
wrap.style.display = "";
empty.style.display = "none";
// Group by proceeding.
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
for (const e of rows) {
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
const bucket = groups.get(e.proceeding_code);
if (bucket) {
bucket.entries.push(e);
} else {
groups.set(e.proceeding_code, { name: gname, entries: [e] });
}
}
const colspan = 4;
const html: string[] = [];
for (const [code, group] of groups) {
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup"><span class="entity-table-group-header__name">${esc(group.name)}</span> <span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
for (const entry of group.entries) {
html.push(renderRow(entry));
}
}
body.innerHTML = html.join("");
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
btn.addEventListener("click", () => {
const code = btn.dataset.code;
if (code) void startDraft(code, null);
});
});
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
btn.addEventListener("click", () => {
const code = btn.dataset.code;
if (code) openProjectPicker(code);
});
});
}
function renderRow(entry: CatalogEntry): string {
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
const source = entry.legal_source ?? "";
const templateBadge = entry.has_template
? ""
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
return `<tr class="submission-row">
<td>
<span class="submission-name">${esc(name)}</span>
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
</td>
<td>${esc(partyLabel(entry.primary_party))}</td>
<td>${esc(source)}</td>
<td class="submission-action-cell">
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
</td>
</tr>`;
}
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
try {
const resp = await fetch("/api/submission-drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
});
if (!resp.ok) {
let detail = "";
try {
const data = (await resp.json()) as { error?: string };
detail = data.error ?? "";
} catch { /* ignore */ }
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
return;
}
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
const id = view.draft.id;
const pid = view.draft.project_id;
const code = view.draft.submission_code;
if (pid) {
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
} else {
window.location.href = `/submissions/draft/${id}`;
}
} catch (err) {
console.error("submissions-new createDraft:", err);
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
}
}
// ─────────────────────────────────────────────────────────────────────
// Project picker modal
// ─────────────────────────────────────────────────────────────────────
let pickerProjects: ProjectRow[] = [];
let pickerLoaded = false;
function openProjectPicker(submissionCode: string): void {
state.pickerForCode = submissionCode;
const modal = document.getElementById("submissions-new-project-modal");
if (modal) modal.style.display = "";
if (!pickerLoaded) {
void loadPickerProjects();
} else {
renderPickerList();
}
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
if (searchInput) {
searchInput.value = "";
setTimeout(() => searchInput.focus(), 50);
}
}
function closeProjectPicker(): void {
state.pickerForCode = null;
const modal = document.getElementById("submissions-new-project-modal");
if (modal) modal.style.display = "none";
}
async function loadPickerProjects(): Promise<void> {
const loadingEl = document.getElementById("submissions-new-project-loading");
if (loadingEl) loadingEl.style.display = "";
try {
const resp = await fetch("/api/projects?status=active");
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
const rows = (await resp.json()) as ProjectRow[];
pickerProjects = rows ?? [];
pickerLoaded = true;
} catch (err) {
console.error("submissions-new loadPickerProjects:", err);
pickerProjects = [];
} finally {
if (loadingEl) loadingEl.style.display = "none";
}
renderPickerList();
}
function renderPickerList(): void {
const list = document.getElementById("submissions-new-project-list");
const empty = document.getElementById("submissions-new-project-empty");
if (!list || !empty) return;
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
const term = (searchInput?.value ?? "").trim().toLowerCase();
const matches = pickerProjects.filter((p) => {
if (term === "") return true;
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
return hay.includes(term);
}).slice(0, 50);
if (matches.length === 0) {
list.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = matches.map((p) => {
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
}).join("");
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
li.addEventListener("click", () => {
const pid = li.dataset.id;
const code = state.pickerForCode;
if (pid && code) {
closeProjectPicker();
void startDraft(code, pid);
}
});
});
}
// ─────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────
function wireToolbar(): void {
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
if (search) {
search.addEventListener("input", () => {
state.searchTerm = search.value;
renderTable();
});
}
const closeBtn = document.getElementById("submissions-new-project-modal-close");
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
const modal = document.getElementById("submissions-new-project-modal");
if (modal) {
modal.addEventListener("click", (e) => {
if (e.target === modal) closeProjectPicker();
});
}
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
if (pickerSearch) {
pickerSearch.addEventListener("input", () => renderPickerList());
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
wireToolbar();
void loadCatalog();
});

View File

@@ -1,10 +1,13 @@
// Submissions panel — fetches the project's submission catalog and
// renders one row per filing-type rule, with a [Generieren] action
// when a .docx template resolves server-side.
// Submissions panel — fetches the full submission catalog across every
// proceeding and renders it grouped by proceeding, with the project's
// own proceeding pinned at the top.
//
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
// switcher so projects without the Schriftsätze tab open don't pay
// for the per-row template-availability probes.
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
// broadened it to the catalog: from any project a lawyer can pick a
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
// handles missing variables gracefully via the [KEIN WERT: …] marker,
// so cross-proceeding picks still render cleanly.
function escapeHtml(s: string): string {
return s
@@ -23,11 +26,15 @@ interface SubmissionEntry {
primary_party?: string;
legal_source?: string;
has_template: boolean;
proceeding_code: string;
proceeding_name: string;
proceeding_name_en: string;
}
interface SubmissionListResponse {
project_id: string;
proceeding_type_id?: number;
project_proceeding_code?: string;
entries: SubmissionEntry[];
}
@@ -74,13 +81,13 @@ function render(data: SubmissionListResponse): void {
const body = document.getElementById("project-submissions-body");
if (!empty || !noProc || !wrap || !body) return;
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
noProc.style.display = "";
empty.style.display = "none";
wrap.style.display = "none";
return;
}
noProc.style.display = "none";
// t-paliad-242: the catalog is shown to every project regardless of
// whether a proceeding is bound — the no-proceeding hint stays as a
// soft nudge above the table, but no longer hides the catalog.
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
? ""
: "none";
if (data.entries.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
@@ -90,29 +97,56 @@ function render(data: SubmissionListResponse): void {
wrap.style.display = "";
const isEN = document.documentElement.lang === "en";
body.innerHTML = data.entries.map((entry) => {
const name = isEN && entry.name_en ? entry.name_en : entry.name;
const party = formatParty(entry.primary_party, isEN);
const source = entry.legal_source ?? "";
const action = entry.has_template
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-project="${escapeHtml(data.project_id)}"
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
return `<tr class="submission-row">
<td>
<span class="submission-name">${escapeHtml(name)}</span>
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
</td>
<td>${escapeHtml(party)}</td>
<td>${escapeHtml(source)}</td>
<td class="submission-action-cell">${action}</td>
</tr>`;
}).join("");
// Wire button clicks. One click handler per render to avoid stale
// closures from the previous render's data.
// Group entries by proceeding_code. Build a stable group order:
// project's own proceeding first (when present), then alphabetical
// by proceeding_code for the rest.
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
for (const entry of data.entries) {
const key = entry.proceeding_code || "";
const groupName = isEN && entry.proceeding_name_en
? entry.proceeding_name_en
: entry.proceeding_name;
const bucket = groups.get(key);
if (bucket) {
bucket.entries.push(entry);
} else {
groups.set(key, { name: groupName, entries: [entry] });
}
}
const ownCode = data.project_proceeding_code ?? "";
const orderedCodes: string[] = [];
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
for (const code of Array.from(groups.keys()).sort()) {
if (code !== ownCode) orderedCodes.push(code);
}
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
const colspan = 4;
const html: string[] = [];
for (const code of orderedCodes) {
const group = groups.get(code);
if (!group) continue;
const isOwn = code === ownCode;
const label = group.name + (isOwn ? ownSuffix : "");
const headerClass = isOwn
? "entity-table-group-header entity-table-group-header--own"
: "entity-table-group-header";
html.push(`<tr class="${headerClass}">`
+ `<th colspan="${colspan}" scope="colgroup">`
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
+ `</th></tr>`);
for (const entry of group.entries) {
html.push(renderRow(entry, data.project_id, isEN));
}
}
body.innerHTML = html.join("");
// Wire button clicks. One handler per render to avoid stale closures
// from the previous render's data.
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
@@ -122,6 +156,33 @@ function render(data: SubmissionListResponse): void {
});
}
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
const name = isEN && entry.name_en ? entry.name_en : entry.name;
const party = formatParty(entry.primary_party, isEN);
const source = entry.legal_source ?? "";
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
const templateBadge = entry.has_template
? ""
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-project="${escapeHtml(projectID)}"
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
const action = `${editBtn} ${generateBtn}`;
return `<tr class="submission-row">
<td>
<span class="submission-name">${escapeHtml(name)}</span>
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
</td>
<td>${escapeHtml(party)}</td>
<td>${escapeHtml(source)}</td>
<td class="submission-action-cell">${action}</td>
</tr>`;
}
function renderError(): void {
const empty = document.getElementById("project-submissions-empty");
const noProc = document.getElementById("project-submissions-no-proceeding");

View File

@@ -1,6 +1,6 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
interface User {
id: string;
@@ -341,28 +341,64 @@ function buildProjectFilter() {
function buildBroadcastButton() {
const wrap = document.getElementById("team-broadcast-wrap");
if (!wrap) return;
if (!canBroadcast()) {
// Wait for /api/me so the affordance never flickers between admin (form)
// and non-admin (mailto) on initial paint. canBroadcast() already returns
// false when me is null but we'd briefly render the mailto anchor before
// the admin form, which is visually jarring.
if (!me) {
wrap.innerHTML = "";
wrap.style.display = "none";
return;
}
wrap.style.display = "";
wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
</button>
`;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
if (canBroadcast()) {
// Admin path (global_admin or project-lead-of-selected): opens the
// in-app compose modal that POSTs to /api/team/broadcast.
wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${label} ${counter}
</button>
`;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
} else {
// Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
// the current filter set. href is refreshed in updateBroadcastButton()
// whenever filters change so the link always reflects what's visible.
wrap.innerHTML = `
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
${label} ${counter}
</a>
`;
}
}
function updateBroadcastButton() {
buildBroadcastButton();
const recipients = displayedRecipients();
const countEl = document.getElementById("team-broadcast-count");
if (countEl) {
const n = displayedRecipients().length;
countEl.textContent = String(n);
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = n === 0;
if (countEl) countEl.textContent = String(recipients.length);
const btn = document.getElementById("team-broadcast-btn");
if (!btn) return;
if (btn.tagName === "BUTTON") {
(btn as HTMLButtonElement).disabled = recipients.length === 0;
} else {
// Anchor (non-admin): regenerate the mailto: href against the current
// visible recipients, and disable the affordance when empty so a click
// doesn't open an empty mail composer.
const a = btn as HTMLAnchorElement;
if (recipients.length === 0) {
a.setAttribute("href", "mailto:");
a.setAttribute("aria-disabled", "true");
a.style.pointerEvents = "none";
a.style.opacity = "0.5";
} else {
a.setAttribute("href", buildMailtoHref(recipients));
a.removeAttribute("aria-disabled");
a.style.pointerEvents = "";
a.style.opacity = "";
}
}
}
@@ -673,14 +709,21 @@ function renderSelectionFooter(): void {
"{n}",
String(n),
);
const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
// t-paliad-244: mirror buildBroadcastButton() so the bottom send button
// behaves the same as the filter-bar one. Admin (canBroadcast) opens the
// compose modal; non-admin gets a native mailto: anchor pre-filled with
// the explicit selection.
const adminPath = canBroadcast();
const sendAction = adminPath
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
footer.innerHTML = `
<span class="team-selection-count">${esc(countLabel)}</span>
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
${esc(t("team.selection.clear") || "Auswahl aufheben")}
</button>
<button type="button" class="btn-primary" id="team-selection-send">
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
</button>
${sendAction}
`;
footer.style.display = "";
document.body.classList.add("team-has-selection");
@@ -691,9 +734,12 @@ function renderSelectionFooter(): void {
syncMasterCheckbox();
renderSelectionFooter();
});
document.getElementById("team-selection-send")?.addEventListener("click", () => {
onBroadcastFromSelection();
});
if (adminPath) {
document.getElementById("team-selection-send")?.addEventListener("click", () => {
onBroadcastFromSelection();
});
}
// Anchor path has no click handler — native href open is the action.
}
// selectedRecipients maps the explicit selection Set into the

View File

@@ -12,6 +12,7 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
type Side,
calculateDeadlines,
escHtml,
formatDate,
@@ -24,6 +25,70 @@ import {
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
// view is shareable and survives reload:
// ?side=claimant|defendant → swaps which column owns the user's
// side (proactive vs reactive label).
// Default null = claimant-on-the-left.
// ?appellant=claimant|defendant → collapses party=both rows into the
// appellant's column (no mirror).
// Only meaningful for role-swap
// proceedings (Appeal etc.). Default
// null = legacy mirror behaviour.
let currentSide: Side = null;
let currentAppellant: Side = null;
// Proceedings where one party initiates and "both" rows are role-swap
// (i.e. either party files depending on who acted at the lower
// instance). For these proceedings the appellant selector is meaningful
// — when set, "both" rows collapse to a single row in the appellant's
// column. For first-instance proceedings (Inf, Rev, …) the selector is
// hidden because there's no appellant axis.
//
// Today: every upc.apl.* family member plus dpma.appeal.* and
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
// Conservative — false negatives just hide a control; false positives
// would show an irrelevant control.
const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl.merits",
"upc.apl.cost",
"upc.apl.order",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
"dpma.appeal.bpatg",
"dpma.appeal.bgh",
"epa.opp.boa",
]);
function hasAppellantAxis(proceedingType: string): boolean {
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
}
function readSideFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("side");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function readAppellantFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("appellant");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function writeSideToURL(s: Side) {
const url = new URL(window.location.href);
if (s === null) url.searchParams.delete("side");
else url.searchParams.set("side", s);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
function writeAppellantToURL(a: Side) {
const url = new URL(window.location.href);
if (a === null) url.searchParams.delete("appellant");
else url.searchParams.set("appellant", a);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Per-rule anchor overrides set by the click-to-edit affordance on
// timeline / column date cells. Posted as `anchorOverrides` to the
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
@@ -154,20 +219,31 @@ async function doCalc() {
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. The root rule (isRootEvent=true) is
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank). Fallback respects language —
// proceedingNameEN is consulted on EN before the DE proceedingName
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
// label from the calc response. Precedence:
//
// 1. Server-supplied triggerEventLabel from proceeding_types
// (mig 121, m/paliad#81). UPC Appeal sets this to
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
// all carry a non-zero duration off the trigger date so none is
// the root, and the proceedingName fallback ("Berufungsverfahren")
// misnamed the input as the proceeding itself.
// 2. Root rule (isRootEvent=true) — the first event in the
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
// Nichtigkeitsklage for upc.rev.cfi.
// 3. Active proceeding name — last-resort fallback. Language-aware
// (m/paliad#58: prior code rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
function triggerEventLabelFor(data: DeadlineResponse): string {
const lang = getLang();
const curated = lang === "en"
? (data.triggerEventLabelEN || data.triggerEventLabel)
: (data.triggerEventLabel || data.triggerEventLabelEN);
if (curated) return curated;
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
if (getLang() === "en") {
if (lang === "en") {
return data.proceedingNameEN || data.proceedingName || "";
}
return data.proceedingName || data.proceedingNameEN || "";
@@ -213,7 +289,12 @@ function renderResults(data: DeadlineResponse) {
: "";
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
? renderColumnsBody(data, {
editable: true,
showNotes,
side: currentSide,
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
@@ -276,6 +357,7 @@ function selectProceeding(btn: HTMLButtonElement) {
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
syncAppellantRowVisibility();
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
@@ -283,6 +365,29 @@ function selectProceeding(btn: HTMLButtonElement) {
scheduleCalc(0);
}
// syncAppellantRowVisibility hides the appellant selector for
// proceedings that have no appellant axis (first-instance Inf, Rev,
// …). Clears the in-memory state and the URL param when hidden so a
// shared link with ?appellant= doesn't leak into an unrelated
// proceeding's render.
function syncAppellantRowVisibility() {
const row = document.getElementById("appellant-row");
if (!row) return;
const visible = hasAppellantAxis(selectedType);
row.style.display = visible ? "" : "none";
if (!visible && currentAppellant !== null) {
currentAppellant = null;
writeAppellantToURL(null);
syncRadioGroup("appellant", "");
}
}
function syncRadioGroup(name: string, value: string) {
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
input.checked = input.value === value;
});
}
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
// Mirrors the events.ts pattern (body.events-view-*). The print
// stylesheet keys `body.verfahrensablauf-view-timeline` to
@@ -321,6 +426,38 @@ function initViewToggle() {
toggle.style.display = "none";
}
// initPerspectiveControls hydrates side+appellant from the URL,
// reflects state into the radio inputs, and wires onchange handlers
// that update state + URL + re-render. Re-render path skips the
// /api/tools/fristenrechner round-trip — perspective is a pure
// projection of the last response, no backend involved.
function initPerspectiveControls() {
currentSide = readSideFromURL();
currentAppellant = readAppellantFromURL();
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
if (lastResponse) renderResults(lastResponse);
});
});
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
writeAppellantToURL(currentAppellant);
if (lastResponse) renderResults(lastResponse);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
@@ -390,6 +527,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
initViewToggle();
initPerspectiveControls();
onLangChange(() => {
// Active-button name updates with language change (the data-i18n

View File

@@ -147,8 +147,22 @@ function formatColumn(row: ViewRow, col: string): string {
const s = (row.detail.status as string | undefined) ?? "";
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
}
case "rule":
return (row.detail.rule_code as string | undefined) ?? "—";
case "rule": {
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
const lang = getLang();
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
const name = (row.detail[nameKey] as string | undefined)
|| (row.detail.rule_name as string | undefined)
|| "";
const cite = (row.detail.rule_code as string | undefined) ?? "";
if (name && cite) return `${name} · ${cite}`;
if (name) return name;
if (cite) return cite;
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
if (custom.trim()) return `${custom} · Custom`;
return "—";
}
case "event_type":
return (row.detail.event_type as string | undefined) ?? "—";
case "location":

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
bucketDeadlinesIntoColumns,
deadlineCardHtml,
} from "./verfahrensablauf-core";
@@ -65,3 +66,141 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
expect(html).not.toContain("data-rule-code=");
});
});
// Pure column-routing behaviour. Originally pinned by m/paliad#81
// (side + appellant axes), re-framed by m/paliad#88: the column
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
// left") instead of the misleading Proaktiv/Reaktiv pair.
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
// in pure-Node territory (renderColumnsBody goes through escHtml ->
// document.createElement which isn't available in plain bun test).
//
// Scenario fixture mirrors the UPC Appeal "both parties" case m
// pasted into #81: every filing rule carries party='both' so the
// legacy mirror path duplicates every row across both columns.
// With ?appellant= set, the duplicate must collapse to a single
// row in the appellant's column.
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
const both = (name: string, due: string): CalculatedDeadline => ({
code: name,
name,
nameEN: name,
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: due,
originalDate: due,
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
});
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
...both(name, due),
party,
});
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
expect(rows).toHaveLength(1);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].court).toHaveLength(0);
});
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("claimant", "Klageschrift", "2026-01-01"),
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
]);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
});
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
{ appellant: "claimant" },
);
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
["Notice of Appeal"],
["Statement of Grounds"],
]);
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
});
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ appellant: "defendant" },
);
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
// User is on the defendant side: defendant filings land in 'ours'
// (left), claimant filings land in 'opponent' (right). Court rules
// stay in court regardless of side.
const rows = bucketDeadlinesIntoColumns(
[
partySpecific("claimant", "Klageschrift", "2026-01-01"),
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
partySpecific("court", "Urteil", "2026-10-01"),
],
{ side: "defendant" },
);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
});
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
// The user is the defendant AND the appellant, so the appellant's
// column == the user's own column == ours after the swap.
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ side: "defendant", appellant: "defendant" },
);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
// Side flip + appellant axis combined: the claimant is the appellant
// but NOT us, so the collapsed 'both' row lands in the opponent
// column (right). This is the UPC Appeal "they appealed, we
// respond" scenario.
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ side: "defendant", appellant: "claimant" },
);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].ours).toHaveLength(0);
});
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
const sameDate = "2026-07-23";
const rows = bucketDeadlinesIntoColumns([
partySpecific("claimant", "A", sameDate),
partySpecific("defendant", "B", sameDate),
partySpecific("court", "C", sameDate),
]);
expect(rows).toHaveLength(1);
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
});
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("court", "Oral Hearing", ""),
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
partySpecific("court", "Decision", ""),
]);
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
["Statement of Claim"],
["Oral Hearing"],
["Decision"],
]);
});
});

View File

@@ -110,6 +110,16 @@ export interface DeadlineResponse {
// explains the framing. (m/paliad#58)
contextualNote?: string;
contextualNoteEN?: string;
// triggerEventLabel / triggerEventLabelEN: optional caption for the
// "Auslösendes Ereignis" / "Triggering event" field on
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
// when set (mig 121). The page prefers this over the proceedingName
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
// uses this so the field reads "Anfechtbare Entscheidung" /
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
// (m/paliad#81)
triggerEventLabel?: string;
triggerEventLabelEN?: string;
}
export interface CourtRow {
@@ -412,42 +422,124 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
return html;
}
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row shares a dueDate so same-day events line up
// across columns; party=both renders in BOTH the Proactive and Reactive
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
//
// The columns are user-perspective ("WE are always on the left", per
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
// Klägerseite is sometimes proactive (filing the claim) and sometimes
// reactive (responding to a counterclaim), so the static "Proaktiv =
// Klägerseite" label-pair was wrong half the time. The new axis is
// "ours vs opponent" — the side toggle picks who WE are in this
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
// placement re-resolves around that pick.
//
// Column assignment per deadline (default opts.side === null keeps
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
//
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
// - party=court → court (independent of side)
// - party=both → BOTH ours AND opponent (mirror)
//
// When `opts.appellant` is set (claimant|defendant), "both" rows
// collapse to a single row in the appellant's column — the intent is
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
// really means "either party files, depending on who initiated".
// Appellant axis is independent of `side`: in an Appeal CoA, the
// appellant selector pins which party appealed; the side toggle
// still picks which of those is us.
export type Side = "claimant" | "defendant" | null;
// Internal column-position alias. "ours" is always rendered in the
// left grid column ("Unsere Seite"); "opponent" is always the right
// column ("Gegnerseite"). Field names mirror the labels so the
// bucketing primitive reads as a direct mapping.
type ColumnPosition = "ours" | "opponent";
export interface ColumnsBodyOpts {
editable?: boolean;
showNotes?: boolean;
// side: which side the user is on. Drives column placement;
// does NOT filter rows. Default null = claimant-on-the-left
// (i.e. "ours = claimant", legacy default).
side?: Side;
// appellant: which side initiated the appeal / counterclaim.
// When set, party=both rows go to the appellant's column ONLY
// (no mirror). Default null = mirror "both" into both cells
// (legacy behaviour). Independent of `side`.
appellant?: Side;
}
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
// so unit tests can hit the pure routing logic without going through
// document.createElement (no jsdom in this repo).
export interface ColumnsRow {
key: string;
ours: CalculatedDeadline[];
court: CalculatedDeadline[];
opponent: CalculatedDeadline[];
}
export interface BucketingOpts {
side?: Side;
appellant?: Side;
}
// bucketDeadlinesIntoColumns is the pure routing primitive that
// renderColumnsBody uses. Extracted as its own export so the per-row
// column placement (including the side-swap + appellant-collapse
// logic from m/paliad#81 and the user-perspective re-frame from
// m/paliad#88) is unit-testable without a DOM. The returned rows are
// sorted: dated rows ascending by dueDate, then unscheduled rows in
// declaration order (each keyed by sequence).
export function bucketDeadlinesIntoColumns(
deadlines: CalculatedDeadline[],
opts: BucketingOpts = {},
): ColumnsRow[] {
const userSide: Side = opts.side ?? null;
// Default (side=null) treats the user as claimant — keeps the
// legacy claimant-on-the-left layout when no perspective is picked.
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
const appellantColumn: ColumnPosition | null =
opts.appellant === "claimant" ? claimantColumn
: opts.appellant === "defendant" ? defendantColumn
: null;
const UNSCHEDULED_PREFIX = "__unscheduled__";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
const rowsMap = new Map<string, ColumnsRow>();
const ensureRow = (key: string): ColumnsRow => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
r = { key, ours: [], court: [], opponent: [] };
rowsMap.set(key, r);
}
return r;
};
data.deadlines.forEach((dl, idx) => {
deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
row.proactive.push(dl);
row[claimantColumn].push(dl);
break;
case "defendant":
row.reactive.push(dl);
row[defendantColumn].push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
row.proactive.push(dl);
row.reactive.push(dl);
if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
} else {
row.ours.push(dl);
row.opponent.push(dl);
}
break;
default:
row.court.push(dl);
@@ -462,17 +554,28 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
}
datedKeys.sort();
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
}
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
const userSide: Side = opts.side ?? null;
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
// Collapsed "both" rows lose their mirror tag — there's no longer
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
// be misleading. Keep it for the legacy mirror path.
const showMirrorTag = !appellantPinned;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
const mirrorTag = showMirrorTag && dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
@@ -487,16 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
// Static labels — "Unsere Seite" is always the left column, regardless
// of which physical party (claimant vs defendant) occupies it. The
// bucketing primitive already routes the user's side into the `ours`
// bucket, so the header truth-fully describes the column contents.
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
for (const key of keys) {
const row = rowsMap.get(key)!;
html += renderCell(row.proactive);
for (const row of rows) {
html += renderCell(row.ours);
html += renderCell(row.court);
html += renderCell(row.reactive);
html += renderCell(row.opponent);
}
html += "</div>";
return html;

View File

@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
// at a glance.
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
// affordance reads as "a draft document" at a glance.
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +

View File

@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
<div className="entity-detail-title-col">
<h1 id="deadline-title-display" />
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
{/* t-paliad-251 Part 4 — Standardtitel button only
visible in edit mode; clicking replaces the
title with a default derived from the project
and the deadline's event types / rule. */}
<button
type="button"
id="deadline-title-default-btn"
className="btn-link-action"
style="display:none"
data-i18n="deadlines.field.title.default_btn"
>
Standardtitel
</button>
<div className="entity-detail-meta">
<span id="deadline-due-chip" className="frist-due-chip" />
<span id="deadline-status-chip" className="entity-status-chip" />
@@ -95,7 +108,36 @@ export function renderDeadlinesDetail(): string {
</dd>
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd id="deadline-rule-display">&mdash;</dd>
<dd>
<span id="deadline-rule-display">&mdash;</span>
{/* t-paliad-258 — Auto / Custom rule editor.
Mirrors /deadlines/new: read-only Auto display
(resolved from Type) or free-text Custom input,
with a toggle link. Hidden outside edit mode. */}
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
<button
type="button"
id="deadline-rule-mode-toggle"
className="btn-link-action"
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
>
Eigene Regel eingeben
</button>
<div className="rule-mode-auto" id="deadline-rule-auto-display">
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
<span id="deadline-rule-auto-text" className="rule-auto-text">&mdash;</span>
</div>
<input
type="text"
id="deadline-rule-custom-input"
className="rule-mode-custom"
style="display:none"
placeholder="z.B. interner Review-Termin"
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
maxLength={200}
/>
</div>
</dd>
<dt data-i18n="deadlines.detail.source">Quelle</dt>
<dd id="deadline-source-display" />

View File

@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
</div>
<div className="form-field">
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
<div className="form-field-label-row">
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
currently-known context (event type → rule → proceeding
type → fallback) with the project reference as suffix.
Always replaces the title; no destructive confirmation
because the user invoked it explicitly. */}
<button
type="button"
id="deadline-title-default-btn"
className="btn-link-action"
data-i18n="deadlines.field.title.default_btn"
>
Standardtitel
</button>
</div>
<input
type="text"
id="deadline-title"
@@ -57,58 +72,42 @@ export function renderDeadlinesNew(): string {
<div className="form-field" id="deadline-event-type-field">
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
{/* t-paliad-165 follow-up — collapsed view: when a Regel
is selected and a default event_type is known, the
Typ chip is hidden and the type is rendered inline
as a single read-only summary with an "Anderen Typ
wählen" link that re-expands the picker. */}
<div
className="event-type-collapsed"
id="deadline-event-type-collapsed"
style="display:none"
>
<span
className="event-type-collapsed-label"
id="deadline-event-type-collapsed-label"
/>
<span
className="event-type-collapsed-source"
data-i18n="deadlines.field.rule.autofill_inline"
>
&nbsp;(vorgegeben durch Regel)
</span>
<button
type="button"
className="event-type-collapsed-override"
id="deadline-event-type-override-btn"
data-i18n="deadlines.field.rule.override"
>
Anderen Typ w&auml;hlen
</button>
</div>
<div id="deadline-event-types" className="event-type-picker-host" />
{/* Soft warning when the user is in expanded mode AND
has picked an event_type that doesn't include the
rule's canonical default. Reuses the existing
yellow form-hint--warning style; never blocking. */}
<p
className="form-hint form-hint--warning"
id="deadline-event-type-rule-mismatch"
style="display:none"
data-i18n="deadlines.field.rule.mismatch"
>
Hinweis: Typ widerspricht Regel &mdash; Sie haben den Typ &uuml;berschrieben.
</p>
</div>
{/* m/paliad#56 — Regel sits directly beneath the Typ
picker so the parent/child relationship reads at a
glance. Due date is its own row below. */}
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
Auto (default): rule_id derived from the chosen
Type, displayed read-only with a canonical
"Name · Citation" label. Custom: free-text input,
no catalog FK. Toggle switches modes. */}
<div className="form-field">
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
<select id="deadline-rule">
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
</select>
<div className="form-field-label-row">
<label data-i18n="deadlines.field.rule">Regel</label>
<button
type="button"
id="deadline-rule-mode-toggle"
className="btn-link-action"
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
>
Eigene Regel eingeben
</button>
</div>
<div className="rule-mode-auto" id="deadline-rule-auto-display">
<span
className="form-hint-badge"
data-i18n="deadlines.field.rule.auto_badge"
>Auto</span>
<span id="deadline-rule-auto-text" className="rule-auto-text">&mdash;</span>
</div>
<input
type="text"
id="deadline-rule-custom-input"
className="rule-mode-custom"
style="display:none"
placeholder="z.B. interner Review-Termin"
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
maxLength={200}
/>
</div>
<div className="form-field">

View File

@@ -682,9 +682,20 @@ export type I18nKey =
| "approvals.tab.mine"
| "approvals.tab.pending_mine"
| "approvals.title"
| "approvals.withdraw.cancel"
| "approvals.withdraw.confirm"
| "approvals.withdraw.cta"
| "approvals.withdraw.destructive.label"
| "approvals.withdraw.error"
| "approvals.withdraw.lead.create.appointment"
| "approvals.withdraw.lead.create.deadline"
| "approvals.withdraw.lead.delete"
| "approvals.withdraw.lead.update"
| "approvals.withdraw.modal.title"
| "approvals.withdraw.primary.label"
| "approvals.withdraw.sub.create"
| "approvals.withdraw.sub.delete"
| "approvals.withdraw.sub.update"
| "bottomnav.add"
| "bottomnav.add.appointment"
| "bottomnav.add.appointment.sub"
@@ -1112,6 +1123,10 @@ export type I18nKey =
| "deadlines.adjusted.weekend"
| "deadlines.adjusted.weekend.saturday"
| "deadlines.adjusted.weekend.sunday"
| "deadlines.appellant.claimant"
| "deadlines.appellant.defendant"
| "deadlines.appellant.label"
| "deadlines.appellant.none"
| "deadlines.calculate"
| "deadlines.card.calc.add_to_project"
| "deadlines.card.calc.add_to_project.disabled"
@@ -1138,8 +1153,8 @@ export type I18nKey =
| "deadlines.col.court"
| "deadlines.col.due"
| "deadlines.col.event_type"
| "deadlines.col.proactive"
| "deadlines.col.reactive"
| "deadlines.col.opponent"
| "deadlines.col.ours"
| "deadlines.col.rule"
| "deadlines.col.status"
| "deadlines.col.title"
@@ -1227,12 +1242,16 @@ export type I18nKey =
| "deadlines.field.notes"
| "deadlines.field.notes.placeholder"
| "deadlines.field.rule"
| "deadlines.field.rule.autofill"
| "deadlines.field.rule.autofill_inline"
| "deadlines.field.rule.mismatch"
| "deadlines.field.rule.none"
| "deadlines.field.rule.override"
| "deadlines.field.rule.auto_badge"
| "deadlines.field.rule.auto_no_match"
| "deadlines.field.rule.auto_pick_type"
| "deadlines.field.rule.custom_badge"
| "deadlines.field.rule.custom_placeholder"
| "deadlines.field.rule.mode.toggle_to_auto"
| "deadlines.field.rule.mode.toggle_to_custom"
| "deadlines.field.title"
| "deadlines.field.title.default_btn"
| "deadlines.field.title.default_fallback"
| "deadlines.field.title.placeholder"
| "deadlines.filter.akte"
| "deadlines.filter.akte.all"
@@ -1366,6 +1385,10 @@ export type I18nKey =
| "deadlines.search.placeholder"
| "deadlines.search.results.count"
| "deadlines.search.results.count_one"
| "deadlines.side.both"
| "deadlines.side.claimant"
| "deadlines.side.defendant"
| "deadlines.side.label"
| "deadlines.source.caldav"
| "deadlines.source.fristenrechner"
| "deadlines.source.imported"
@@ -1574,6 +1597,8 @@ export type I18nKey =
| "event_types.browse.apply"
| "event_types.browse.cancel"
| "event_types.browse.empty"
| "event_types.browse.jurisdiction.all"
| "event_types.browse.jurisdiction.filter_label"
| "event_types.browse.jurisdiction.none"
| "event_types.browse.search"
| "event_types.browse.selected_count"
@@ -1904,6 +1929,7 @@ export type I18nKey =
| "nav.paliadin"
| "nav.projekte"
| "nav.soon.tooltip"
| "nav.submissions"
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
@@ -2187,6 +2213,11 @@ export type I18nKey =
| "projects.detail.parteien.role.defendant"
| "projects.detail.parteien.role.thirdparty"
| "projects.detail.save"
| "projects.detail.settings.archive.cta"
| "projects.detail.settings.archive.description"
| "projects.detail.settings.archive.heading"
| "projects.detail.settings.export.description"
| "projects.detail.settings.export.heading"
| "projects.detail.smarttimeline.add.cancel"
| "projects.detail.smarttimeline.add.choice.amend"
| "projects.detail.smarttimeline.add.choice.appointment"
@@ -2260,6 +2291,7 @@ export type I18nKey =
| "projects.detail.smarttimeline.track.only.counterclaim"
| "projects.detail.smarttimeline.track.only.parent"
| "projects.detail.smarttimeline.track.only.parent_context"
| "projects.detail.submissions.action.edit"
| "projects.detail.submissions.action.generate"
| "projects.detail.submissions.action.no_template"
| "projects.detail.submissions.col.action"
@@ -2275,6 +2307,7 @@ export type I18nKey =
| "projects.detail.tab.kinder"
| "projects.detail.tab.notizen"
| "projects.detail.tab.parteien"
| "projects.detail.tab.settings"
| "projects.detail.tab.submissions"
| "projects.detail.tab.team"
| "projects.detail.tab.termine"
@@ -2495,6 +2528,45 @@ export type I18nKey =
| "search.no_results"
| "search.placeholder"
| "sidebar.resize.title"
| "submissions.draft.action.delete"
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.loading"
| "submissions.draft.name.placeholder"
| "submissions.draft.notfound"
| "submissions.draft.preview.hint"
| "submissions.draft.preview.title"
| "submissions.draft.switcher.label"
| "submissions.draft.title"
| "submissions.index.action.new"
| "submissions.index.col.draft"
| "submissions.index.col.project"
| "submissions.index.col.submission"
| "submissions.index.col.updated"
| "submissions.index.empty"
| "submissions.index.empty.cta"
| "submissions.index.error"
| "submissions.index.heading"
| "submissions.index.loading"
| "submissions.index.subtitle"
| "submissions.index.title"
| "submissions.new.back"
| "submissions.new.col.actions"
| "submissions.new.col.name"
| "submissions.new.col.party"
| "submissions.new.col.source"
| "submissions.new.empty.filtered"
| "submissions.new.error"
| "submissions.new.heading"
| "submissions.new.loading"
| "submissions.new.picker.empty"
| "submissions.new.picker.loading"
| "submissions.new.picker.placeholder"
| "submissions.new.picker.title"
| "submissions.new.search.placeholder"
| "submissions.new.subtitle"
| "submissions.new.title"
| "team.broadcast.body"
| "team.broadcast.body_placeholder"
| "team.broadcast.button"

View File

@@ -89,20 +89,9 @@ export function renderProjectsDetail(): string {
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
{/* t-paliad-214 Slice 2 — project-subtree export button.
Sits at the end of the tab nav. Hidden by default; the
client unhides it after /api/me confirms the caller can
extract (responsibility ∈ {lead, member} OR global_admin). */}
<button
type="button"
id="project-export-btn"
className="entity-tab entity-tab-action"
style="display:none"
title=""
data-i18n-title="projects.detail.export.tooltip"
data-i18n="projects.detail.export.button">
Daten exportieren
</button>
{/* Verwaltung — rare admin actions (export, archive). Sits
last in the tab list per t-paliad-245. */}
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
</nav>
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
@@ -625,17 +614,18 @@ export function renderProjectsDetail(): string {
</p>
</section>
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
Lists the project's filing-type rules with a per-row
[Generieren] button when a .docx template resolves
in the registry's fallback chain (firm → base/code →
base/family → skeleton). Empty for projects with no
proceeding bound; otherwise enumerates every active
filing rule for the proceeding. */}
{/* Submissions (Schriftsätze) — t-paliad-242 broadened
the original t-paliad-215 list to the full
cross-proceeding catalog. The table shows every
active filing rule across every proceeding, grouped
by proceeding; the project's own proceeding is
pinned to the top. The no-proceeding hint stays as
a soft nudge above the catalog (the table renders
regardless). */}
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt der Katalog unten zeigt trotzdem alle Vorlagen.
</p>
<button
type="button"
@@ -646,7 +636,7 @@ export function renderProjectsDetail(): string {
</button>
</div>
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
Es sind aktuell keine Schriftsatzvorlagen hinterlegt.
</p>
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
<table className="entity-table entity-table--readonly">
@@ -665,6 +655,39 @@ export function renderProjectsDetail(): string {
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
</p>
</section>
{/* Verwaltung — rare admin actions (export, archive). Each
sub-section hides itself if the caller is not entitled
(export: §4 gate; archive: global_admin). */}
<section className="entity-tab-panel" id="tab-settings" style="display:none">
<div className="settings-section" id="project-settings-export" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
</p>
<button
type="button"
id="project-export-btn"
className="btn-secondary"
data-i18n="projects.detail.export.button">
Daten exportieren
</button>
</div>
<div className="settings-section" id="project-settings-archive" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
</p>
<button
type="button"
id="project-settings-archive-link"
className="btn-secondary"
data-i18n="projects.detail.settings.archive.cta">
Bearbeiten öffnen
</button>
</div>
</section>
</div>
{/* Full edit modal — same form as /projects/new, pre-filled. */}

View File

@@ -3548,6 +3548,30 @@ input[type="range"]::-moz-range-thumb {
cursor: pointer;
}
/* Verfahrensablauf — perspective strip (side + appellant selectors,
t-paliad-250 / m/paliad#81). Two rows so the labels stack cleanly on
narrow viewports; each row reuses .fristen-view-toggle for the
chip-radio cluster so the visual language matches the view-toggle
above it. The appellant row hides for proceedings without an
appellant axis (Inf / Rev first-instance). */
.verfahrensablauf-perspective {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
.verfahrensablauf-perspective-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.verfahrensablauf-perspective-row .fristen-view-toggle {
margin-bottom: 0;
}
/* Compact note hint — sits in the timeline-meta line when the notes
toggle is off. Native browser tooltip via title= attribute carries
the full text on hover; tabindex=0 + aria-label make it
@@ -3605,7 +3629,7 @@ input[type="range"]::-moz-range-thumb {
z-index: 1;
}
.fr-col-header.fr-col-proactive {
.fr-col-header.fr-col-ours {
background: var(--status-blue-bg);
color: var(--status-blue-fg);
}
@@ -3615,7 +3639,7 @@ input[type="range"]::-moz-range-thumb {
color: var(--status-blue-soft-fg);
}
.fr-col-header.fr-col-reactive {
.fr-col-header.fr-col-opponent {
background: var(--status-amber-bg);
color: var(--status-amber-fg);
}
@@ -5586,10 +5610,385 @@ dialog.modal::backdrop {
font-style: italic;
}
/* t-paliad-242 — grouped Schriftsätze catalog. The Schriftsätze tab
shows filing rules from every proceeding the platform knows about,
grouped by proceeding (DE LG vs UPC CFI vs EPO Opposition etc.).
.entity-table-group-header is a <tr> spanning all columns that
labels each block; the --own modifier picks out the project's own
proceeding with a lime border so the lawyer sees their primary
context at a glance. */
.entity-table-group-header th {
padding: 0.65rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
background: var(--color-bg-subtle);
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
}
.entity-table tbody tr.entity-table-group-header,
.entity-table--readonly tbody tr.entity-table-group-header {
cursor: default;
}
.entity-table tbody tr.entity-table-group-header:hover,
.entity-table--readonly tbody tr.entity-table-group-header:hover {
background: transparent;
}
.entity-table-group-header--own th {
background: var(--color-bg-lime-tint);
color: var(--color-text);
border-left: 3px solid var(--color-accent-fg);
}
.entity-table-group-header__name {
text-transform: none;
letter-spacing: 0;
font-size: 0.95rem;
color: var(--color-text);
}
.entity-table-group-header__code {
font-family: var(--font-mono, monospace);
color: var(--color-text-muted);
margin-left: 0.5rem;
font-size: 0.78rem;
text-transform: none;
letter-spacing: 0;
}
.submission-template-badge {
display: inline-block;
margin-left: 0.4rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--color-bg-subtle);
color: var(--color-text-muted);
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.02em;
vertical-align: middle;
}
.submissions-hint {
margin-top: 1rem;
}
/* t-paliad-238 — dedicated draft editor page. */
.submission-draft-page {
padding-top: 1rem;
}
.submission-draft-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.submission-draft-header-text h1 {
margin: 0 0 0.25rem 0;
}
.submission-draft-header-actions {
flex: 0 0 auto;
}
.submission-draft-grid {
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
.submission-draft-grid {
grid-template-columns: 1fr;
}
}
.submission-draft-sidebar {
position: sticky;
top: 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
background: var(--color-surface, #fff);
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
/* t-paliad-260 — at single-column widths, drop the sticky/max-height
constraints on the variable editor so it reflows above the preview
and scrolls away naturally instead of overlaying the preview pane
(sticky + calc(100vh - 2rem) keep the form pinned at the top of the
viewport while the user scrolls down to read the preview). Must come
after the unscoped .submission-draft-sidebar block to win source
order at equal specificity. */
@media (max-width: 900px) {
.submission-draft-sidebar {
position: static;
max-height: none;
overflow-y: visible;
}
}
.submission-draft-switcher {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.submission-draft-switcher select {
flex: 1 1 auto;
}
.submission-draft-name-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.submission-draft-name-row input {
flex: 1 1 auto;
}
.submission-draft-savestatus {
font-size: 0.85em;
color: var(--color-text-muted);
min-height: 1.2em;
margin: 0.25rem 0 0.75rem 0;
}
.submission-draft-savestatus--error {
color: var(--color-danger, #c00);
}
.submission-draft-variables {
display: flex;
flex-direction: column;
gap: 1rem;
}
.submission-draft-var-group {
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
}
.submission-draft-var-group-title {
margin: 0 0 0.5rem 0;
font-size: 0.95em;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.submission-draft-var-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.6rem;
}
.submission-draft-var-label {
font-size: 0.9em;
color: var(--color-text);
font-weight: 500;
}
.submission-draft-var-input {
font-family: inherit;
}
.submission-draft-var-hint {
font-size: 0.8em;
color: var(--color-text-muted);
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.submission-draft-var-hint code {
font-family: var(--font-mono, monospace);
font-size: 0.95em;
}
.submission-draft-var-marker {
color: var(--color-warning, #b27800);
font-style: italic;
}
.submission-draft-var-was {
color: var(--color-text-muted);
}
.submission-draft-var-reset {
align-self: flex-start;
}
.submission-draft-preview-wrap {
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface, #fff);
overflow: hidden;
}
.submission-draft-preview-header {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-alt, #fafafa);
flex-wrap: wrap;
gap: 0.5rem;
}
.submission-draft-preview-header h2 {
margin: 0;
font-size: 1.1em;
}
.submission-draft-preview-hint {
font-size: 0.85em;
color: var(--color-text-muted);
}
.submission-draft-preview {
padding: 1.5rem 2rem;
line-height: 1.6;
font-family: var(--font-serif, Georgia, serif);
min-height: 60vh;
}
.submission-draft-preview p {
margin: 0 0 0.75rem 0;
}
.submission-draft-preview .preview-error {
color: var(--color-danger, #c00);
font-style: italic;
}
.submission-edit-btn {
margin-right: 0.4rem;
}
/* t-paliad-243 — global Schriftsätze picker (/submissions/new) +
project-less editor banner + assign-project modal styling. */
.submissions-index-headline {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.submissions-index-no-project {
color: var(--color-text-muted);
font-style: italic;
}
.submissions-new-toolbar {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0 0 1.5rem;
}
.submissions-new-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.submissions-new-chip {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--color-text);
}
.submissions-new-chip:hover {
background: var(--color-surface-alt, #f4f4f4);
}
.submissions-new-chip--active {
background: var(--color-accent, #c6f41c);
border-color: var(--color-accent, #c6f41c);
color: #000;
font-weight: 600;
}
.submissions-new-chip-code {
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
opacity: 0.7;
margin-left: 0.35rem;
}
.submissions-new-project-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.submissions-new-project-item {
padding: 0.6rem 0.85rem;
cursor: pointer;
border-bottom: 1px solid var(--color-border);
}
.submissions-new-project-item:last-child {
border-bottom: none;
}
.submissions-new-project-item:hover {
background: var(--color-surface-alt, #f4f4f4);
}
.submissions-new-project-title {
font-weight: 500;
}
.submission-draft-noproject-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding: 0.75rem 1rem;
margin: 0 0 1.25rem;
background: var(--color-surface-alt, #f7f7f0);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-accent, #c6f41c);
border-radius: 6px;
}
.submission-draft-noproject-banner-msg {
margin: 0;
color: var(--color-text);
font-size: 0.92rem;
}
.checklist-instance-actions {
display: flex;
gap: 0.35rem;
@@ -6185,12 +6584,18 @@ dialog.modal::backdrop {
/* Each filter is a label-above-control cell so the caption sits on top of
its select / button. The whole filter-row stays a horizontal flex-wrap
of these column-cells (t-paliad-117). */
of these column-cells (t-paliad-117).
min-width: 0 + max-width: 100% lets the cell shrink to fit its flex
container and prevents a native <select> with long option text from
blowing the cell wider than the viewport (t-paliad-255). */
.filter-group {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
min-width: 0;
max-width: 100%;
}
.filter-label {
@@ -6204,6 +6609,10 @@ dialog.modal::backdrop {
.filter-group .entity-select { width: 100%; }
}
/* max-width: 100% caps the intrinsic width of a native <select> at its
parent — without it, browsers size the select to the longest <option>
text and a very long project title overflows the viewport on tablet
widths above the 480px breakpoint (t-paliad-255). */
.entity-select {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
@@ -6212,6 +6621,8 @@ dialog.modal::backdrop {
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
max-width: 100%;
min-width: 0;
}
.entity-select:focus {
@@ -6931,6 +7342,20 @@ dialog.modal::backdrop {
padding: 0.5rem 0 2rem;
}
/* Verwaltung tab — rare admin actions (export, archive) live here as
stacked sections. No accent, no oversized buttons (t-paliad-245). */
.settings-section {
margin-bottom: 2rem;
}
.settings-section:last-child {
margin-bottom: 0;
}
.settings-section .tool-subtitle {
margin-bottom: 0.75rem;
}
.entity-events {
list-style: none;
padding: 0;
@@ -7146,6 +7571,126 @@ dialog.modal::backdrop {
border-left: 2px solid #b88800;
}
/* t-paliad-251 — Auto-derived hint variant. Lime-tint, sibling of the
yellow warning variant. Carries a small pill-badge in front (the
"Auto" label) followed by the derived rule name. */
.form-hint--auto {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--color-bg-lime-tint);
color: var(--color-text);
padding: 0.3rem 0.5rem;
border-radius: var(--radius-sm, 4px);
border-left: 2px solid var(--color-accent);
}
.form-hint-badge {
display: inline-block;
padding: 0.05rem 0.45rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-text);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* t-paliad-251 — label row that hosts both the form label and an
inline action (Standardtitel button, Rule-sort dropdown). The label
keeps growing to push the action to the right edge. */
.form-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.form-field-label-row > label {
margin: 0;
}
/* Inline action button rendered next to a form label (Standardtitel).
Text-link styling so it doesn't compete with the primary CTA. */
.btn-link-action {
background: transparent;
border: none;
color: var(--color-link, var(--color-text));
padding: 0;
font-family: var(--font-sans);
font-size: 0.82rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link-action:hover {
color: var(--color-accent);
}
/* t-paliad-258 — Auto/Custom Rule editor (m/paliad#89).
Replaces the t-paliad-251 catalog dropdown + sort selector with a
binary toggle:
.rule-mode-auto — read-only display, lime-tint pill + label.
.rule-mode-custom — free-text input, full-width.
Toggle button reuses .btn-link-action for the inline link styling. */
.rule-mode-auto {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.55rem;
background: var(--color-bg-lime-tint);
border-left: 2px solid var(--color-accent);
border-radius: var(--radius-sm, 4px);
min-height: 2rem;
}
.rule-auto-text {
color: var(--color-text);
font-size: 0.95rem;
}
.rule-auto-text--empty {
color: var(--color-text-muted, #6b7280);
font-style: italic;
}
.form-field input.rule-mode-custom,
input.rule-mode-custom {
width: 100%;
padding: 0.45rem 0.6rem;
font-size: 0.95rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
color: var(--color-text);
font-family: var(--font-sans);
}
/* t-paliad-258 addendum — canonical rule label display:
Name primary, Citation muted secondary ("Name · Citation").
Custom rules use a "Custom" pill instead of a citation. */
.rule-label-name {
color: var(--color-text);
}
.rule-label-sep,
.rule-label-cite {
color: var(--color-text-muted, #6b7280);
font-size: 0.9em;
}
.rule-label-cite {
margin-left: 0.15rem;
}
.rule-label-badge {
display: inline-block;
margin-left: 0.4rem;
padding: 0.02rem 0.4rem;
border-radius: 999px;
background: var(--color-bg-lime-tint);
color: var(--color-text);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
border: 1px solid var(--color-accent);
}
/* Inline checkbox label inside the attach-unit form. */
.form-checkbox {
display: inline-flex;
@@ -7253,6 +7798,42 @@ dialog.modal::backdrop {
background: #b91c1c;
}
/* t-paliad-252 — withdraw warning modal body. The destructive button sits
inside the body (above the footer's Cancel + Edit primary) so the safe
"Edit event" path stays visually primary. The intro paragraph leads,
the muted sub-line explains consequences, then the red row makes the
destructive option discoverable without competing with the CTA. */
.withdraw-warning-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.withdraw-warning-intro {
margin: 0;
color: var(--color-text);
font-size: 0.92rem;
line-height: 1.45;
}
.withdraw-warning-sub {
margin: 0;
color: var(--color-text-muted);
font-size: 0.85rem;
line-height: 1.45;
}
.withdraw-warning-destructive-row {
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px dashed var(--color-border);
}
.withdraw-warning-destructive-btn {
/* Inherits .btn .btn-danger, but bump the font size down a touch so
the body button doesn't crowd the footer's primary CTA. */
font-size: 0.82rem;
padding: 0.4rem 1rem;
}
.entity-soon {
text-align: center;
padding: 3rem 1.5rem;
@@ -11713,42 +12294,10 @@ dialog.quick-add-sheet::backdrop {
t-paliad-088 — Event Types: picker, multi-select filter, add modal
============================================================================ */
/* t-paliad-165 follow-up — collapsed read-only view used on
/deadlines/new when a Regel is selected and a default event_type is
known. Replaces the picker with a single inline label + an
"Anderen Typ wählen" override link. */
.event-type-collapsed {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
background: var(--color-bg-lime-tint);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
font-size: 0.95rem;
line-height: 1.3;
flex-wrap: wrap;
}
.event-type-collapsed-label {
font-weight: 600;
color: var(--color-text);
}
.event-type-collapsed-source {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.event-type-collapsed-override {
margin-left: auto;
background: transparent;
border: 0;
padding: 0;
color: var(--color-link, #1d4ed8);
text-decoration: underline;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.event-type-collapsed-override:hover { color: var(--color-link-hover, #1e40af); }
/* (t-paliad-258 — the .event-type-collapsed* "vorgegeben durch Regel"
collapsed view from t-paliad-165 was retired with the catalog
dropdown. The Auto/Custom rule editor took its place; styles for
that live under .rule-mode-auto / .rule-mode-custom above.) */
/* Picker host — chip cluster + search + suggest dropdown */
.event-type-picker {
@@ -12143,6 +12692,36 @@ dialog.quick-add-sheet::backdrop {
transition: border-color 0.15s ease;
}
.event-type-browse-search:focus { border-color: var(--color-accent); }
/* t-paliad-251 — jurisdiction filter chips inside the browse modal
header. Sits below the search input, between the search and the
results list. Active chip uses the lime-tint chip palette. */
.event-type-browse-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.event-type-browse-chip {
padding: 0.2rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text-muted);
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.event-type-browse-chip:hover {
background: var(--color-bg-subtle);
color: var(--color-text);
}
.event-type-browse-chip--active {
background: var(--color-bg-lime-tint);
border-color: var(--color-accent);
color: var(--color-text);
font-weight: 600;
}
.event-type-browse-list {
flex: 1 1 auto;
overflow-y: auto;

View File

@@ -0,0 +1,143 @@
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-238 Slice A — dedicated Submissions/Schriftsätze editor page.
//
// Lawyer picks (or creates) a draft for one (project, submission_code),
// edits placeholder variables in a sticky sidebar, sees a read-only
// HTML preview of the merged document, and exports the result as
// .docx. Drafts persist server-side per paliad.submission_drafts.
//
// Pure shell: client/submission-draft.ts hydrates draft list + variable
// form + preview pane after page load. The same dist/submission-draft.html
// serves every (project_id, submission_code, [draft_id]) URL.
//
// Design ref: docs/design-submission-page-2026-05-22.md §6.
export function renderSubmissionDraft(): 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" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="submissions.draft.title">Schriftsatz bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-submission-draft">
<Sidebar currentPath="/projects" />
<BottomNav currentPath="/projects" />
<main>
<section className="tool-page submission-draft-page">
<div className="container">
<a
id="submission-draft-back-link"
href="/projects"
className="back-link"
data-i18n="submissions.draft.back">
&larr; Zur&uuml;ck zum Projekt
</a>
<div id="submission-draft-loading" className="entity-loading">
<p data-i18n="submissions.draft.loading">L&auml;dt&hellip;</p>
</div>
<div id="submission-draft-notfound" className="entity-empty" style="display:none">
<p data-i18n="submissions.draft.notfound">
Schriftsatz nicht gefunden oder keine Berechtigung.
</p>
</div>
<div id="submission-draft-error" className="entity-empty" style="display:none" />
<div id="submission-draft-body" style="display:none">
<header className="submission-draft-header">
<div className="submission-draft-header-text">
<h1 id="submission-draft-title" />
<p id="submission-draft-subtitle" className="tool-subtitle" />
</div>
<div className="submission-draft-header-actions">
<button
id="submission-draft-export-btn"
type="button"
className="btn-primary btn-cta-lime"
data-i18n="submissions.draft.action.export">
Als .docx exportieren
</button>
</div>
</header>
<div className="submission-draft-grid">
{/* Sidebar — draft switcher + variable groups. */}
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
<div className="submission-draft-switcher">
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
Entwurf
</label>
<select id="submission-draft-pick" />
<button
type="button"
id="submission-draft-new-btn"
className="btn-small btn-secondary"
data-i18n="submissions.draft.action.new">
+ Neuer Entwurf
</button>
</div>
<div className="submission-draft-name-row">
<input
type="text"
id="submission-draft-name"
className="entity-form-input"
data-i18n-placeholder="submissions.draft.name.placeholder"
placeholder="Name dieses Entwurfs"
/>
<button
type="button"
id="submission-draft-delete-btn"
className="btn-small btn-link-danger"
data-i18n="submissions.draft.action.delete">
L&ouml;schen
</button>
</div>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
<div className="submission-draft-variables" id="submission-draft-variables" />
</aside>
{/* Preview pane — read-only HTML render of the merged
document body. Re-renders on autosave round-trip. */}
<section className="submission-draft-preview-wrap">
<header className="submission-draft-preview-header">
<h2 data-i18n="submissions.draft.preview.title">Vorschau</h2>
<span
className="submission-draft-preview-hint"
data-i18n="submissions.draft.preview.hint">
Read-only Vorschau &mdash; finale Bearbeitung in Word.
</span>
</header>
<div className="submission-draft-preview" id="submission-draft-preview" />
</section>
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/submission-draft.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,84 @@
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-240 — global Schriftsätze drafts index. Top-level sidebar
// entry that lists every draft the caller owns across visible projects.
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
// this page only adds a discovery surface and click-through to it.
export function renderSubmissionsIndex(): 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" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="submissions.index.title">Schriftsätze &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/submissions" />
<BottomNav currentPath="/submissions" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="submissions-index-headline">
<div>
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
Ihre Schriftsatz-Entw&uuml;rfe &uuml;ber alle sichtbaren Projekte.
</p>
</div>
<a href="/submissions/new" className="btn-primary btn-cta-lime"
data-i18n="submissions.index.action.new">+ Neuer Entwurf</a>
</div>
</div>
<p className="entity-events-empty" id="submissions-index-loading"
data-i18n="submissions.index.loading">L&auml;dt&hellip;</p>
<div className="entity-empty" id="submissions-index-empty" style="display:none">
<p data-i18n="submissions.index.empty">
Noch keine Entw&uuml;rfe. Beginnen Sie mit einem neuen Entwurf &mdash; mit oder ohne Projekt.
</p>
<a href="/submissions/new" className="btn-primary btn-cta-lime"
data-i18n="submissions.index.empty.cta">+ Neuer Entwurf</a>
</div>
<div className="entity-empty" id="submissions-index-error" style="display:none">
<p data-i18n="submissions.index.error">Schrifts&auml;tze konnten nicht geladen werden.</p>
</div>
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
<table className="entity-table">
<thead>
<tr>
<th data-i18n="submissions.index.col.project">Projekt</th>
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
<th data-i18n="submissions.index.col.draft">Entwurf</th>
<th data-i18n="submissions.index.col.updated">Zuletzt ge&auml;ndert</th>
</tr>
</thead>
<tbody id="submissions-index-body" />
</table>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/submissions-index.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,118 @@
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-243 — global Schriftsatz picker. Lists the full
// cross-proceeding submission catalog (grouped by proceeding,
// filterable) and lets the lawyer start a draft with or without
// binding a project. Picking "Ohne Projekt" jumps straight to
// /submissions/draft/{id}; picking "Mit Projekt verknüpfen" opens an
// autocomplete project picker, then redirects to the project-scoped
// editor.
export function renderSubmissionsNew(): 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" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="submissions.new.title">Neuer Schriftsatz &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/submissions" />
<BottomNav currentPath="/submissions" />
<main>
<section className="tool-page submissions-new-page">
<div className="container">
<a href="/submissions" className="back-link"
data-i18n="submissions.new.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<div className="tool-header">
<h1 data-i18n="submissions.new.heading">Neuer Schriftsatz</h1>
<p className="tool-subtitle" data-i18n="submissions.new.subtitle">
W&auml;hlen Sie eine Vorlage. Optional verkn&uuml;pfen Sie den
Entwurf mit einem Projekt &mdash; sonst f&uuml;llen Sie alle
Variablen manuell.
</p>
</div>
<div className="submissions-new-toolbar">
<input
type="search"
id="submissions-new-search"
className="entity-form-input"
data-i18n-placeholder="submissions.new.search.placeholder"
placeholder="Suche nach Schriftsatz, Code oder Norm…" />
<div id="submissions-new-proceeding-chips" className="submissions-new-chips" />
</div>
<p className="entity-events-empty" id="submissions-new-loading"
data-i18n="submissions.new.loading">L&auml;dt&hellip;</p>
<div className="entity-empty" id="submissions-new-error" style="display:none">
<p data-i18n="submissions.new.error">Katalog konnte nicht geladen werden.</p>
</div>
<div className="entity-table-wrap" id="submissions-new-tablewrap" style="display:none">
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="submissions.new.col.name">Schriftsatz</th>
<th data-i18n="submissions.new.col.party">Partei</th>
<th data-i18n="submissions.new.col.source">Rechtsgrundlage</th>
<th data-i18n="submissions.new.col.actions">Entwurf starten</th>
</tr>
</thead>
<tbody id="submissions-new-body" />
</table>
</div>
<p className="entity-empty" id="submissions-new-empty" style="display:none">
<span data-i18n="submissions.new.empty.filtered">
Keine passenden Schrifts&auml;tze. Filter zur&uuml;cksetzen.
</span>
</p>
</div>
</section>
{/* Project picker modal — opened by "Mit Projekt verknüpfen". */}
<div id="submissions-new-project-modal" className="modal-overlay" style="display:none" role="dialog" aria-modal="true">
<div className="modal-card">
<header className="modal-header">
<h2 data-i18n="submissions.new.picker.title">Projekt w&auml;hlen</h2>
<button type="button" id="submissions-new-project-modal-close"
className="modal-close" aria-label="Close">&times;</button>
</header>
<div className="modal-body">
<input
type="search"
id="submissions-new-project-search"
className="entity-form-input"
data-i18n-placeholder="submissions.new.picker.placeholder"
placeholder="Projekt suchen (Titel oder Aktenzeichen)…" />
<ul id="submissions-new-project-list" className="submissions-new-project-list" />
<p id="submissions-new-project-loading" className="entity-events-empty" style="display:none"
data-i18n="submissions.new.picker.loading">L&auml;dt Projekte&hellip;</p>
<p id="submissions-new-project-empty" className="entity-empty" style="display:none"
data-i18n="submissions.new.picker.empty">Keine sichtbaren Projekte.</p>
</div>
</div>
</div>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/submissions-new.js"></script>
</body>
</html>
);
}

View File

@@ -210,6 +210,53 @@ export function renderVerfahrensablauf(): string {
Fristen berechnen
</button>
</div>
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
swaps the column LABELS so the user's own side is
proactive (= "your filings"). Appellant collapses
party=both rows to a single column when set — only
relevant for role-swap proceedings (Appeal etc.);
the row hides itself when the picked proceeding has
no appellant axis (see hasAppellantAxis() in the
client). Both selectors are URL-driven (?side= +
?appellant=) so the perspective survives reload
and is shareable. */}
<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="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.both">Beide</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
<label className="fristen-view-option">
<input type="radio" name="appellant" value="claimant" />
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="defendant" />
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="" checked />
<span data-i18n="deadlines.appellant.none"></span>
</label>
</div>
</div>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">

View File

@@ -0,0 +1,7 @@
-- t-paliad-238: revert submission_drafts table.
--
-- The shared paliad.tg_set_updated_at trigger function is intentionally
-- left in place — it may be in use by other tables. DROP TABLE cascades
-- to the trigger that references it on this table only.
DROP TABLE IF EXISTS paliad.submission_drafts;

View File

@@ -0,0 +1,88 @@
-- t-paliad-238: dedicated Submissions/Schriftsätze page.
--
-- paliad.submission_drafts holds the lawyer's per-(project, submission_code)
-- draft state for the new editor at /projects/{id}/submissions/{code}/draft.
-- Each row is one named draft owned by one user; multiple drafts per
-- (project, submission_code, user_id) are supported via the `name` field
-- (auto-generated "Entwurf 1", "Entwurf 2", … and lawyer-renameable).
--
-- `variables` carries the lawyer's overrides for the placeholder map
-- assembled at export time by SubmissionVarsService — empty string forces
-- the [KEIN WERT: …] marker; absent key falls back to the resolved bag.
--
-- `last_exported_at` / `last_exported_sha` record provenance of the most
-- recent .docx export (template SHA pinned for audit). Audit rows live
-- in paliad.system_audit_log + paliad.project_events; this table is the
-- lawyer's working state, not the audit log.
--
-- Visibility: per project CLAUDE.md, every project-scoped resource gates
-- on paliad.can_see_project. UPDATE / DELETE additionally require the
-- draft's user_id to match auth.uid() so two co-team-members don't stomp
-- on each other's drafts (head-confirmed Q-E4 owner-scoped pick).
CREATE TABLE IF NOT EXISTS paliad.submission_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
submission_code text NOT NULL,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
name text NOT NULL,
variables jsonb NOT NULL DEFAULT '{}'::jsonb,
last_exported_at timestamptz,
last_exported_sha text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT submission_drafts_unique_per_user
UNIQUE (project_id, submission_code, user_id, name)
);
CREATE INDEX IF NOT EXISTS submission_drafts_project_user_idx
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
CREATE POLICY submission_drafts_select
ON paliad.submission_drafts FOR SELECT TO authenticated
USING (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
CREATE POLICY submission_drafts_insert
ON paliad.submission_drafts FOR INSERT TO authenticated
WITH CHECK (
user_id = auth.uid()
AND paliad.can_see_project(project_id)
);
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
CREATE POLICY submission_drafts_update
ON paliad.submission_drafts FOR UPDATE TO authenticated
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
CREATE POLICY submission_drafts_delete
ON paliad.submission_drafts FOR DELETE TO authenticated
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
-- updated_at maintenance: trigger function lives once per schema; reuse if
-- it already exists, otherwise create the standard shape.
CREATE OR REPLACE FUNCTION paliad.tg_set_updated_at()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS submission_drafts_set_updated_at ON paliad.submission_drafts;
CREATE TRIGGER submission_drafts_set_updated_at
BEFORE UPDATE ON paliad.submission_drafts
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.submission_drafts IS
't-paliad-238: per-(project, submission_code, user) named drafts for the dedicated Submissions/Schriftsätze page. Each row holds the lawyer-edited variable overrides for the .docx export. Audit rows live in paliad.system_audit_log + paliad.project_events.';

View File

@@ -0,0 +1,46 @@
-- t-paliad-243 revert: restore NOT NULL on project_id.
--
-- The revert refuses to run if any project-less draft exists — those
-- rows would silently fail the NOT NULL re-imposition and corrupt the
-- migration runner's state. The safe revert path is to surface the
-- conflict to the operator who can decide whether to attach the rows
-- to a project or delete them before retrying the down.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM paliad.submission_drafts WHERE project_id IS NULL
) THEN
RAISE EXCEPTION
'cannot re-impose NOT NULL on paliad.submission_drafts.project_id: '
'project-less drafts exist. Attach them to a project or delete '
'them, then re-run the down migration.';
END IF;
END $$;
ALTER TABLE paliad.submission_drafts
ALTER COLUMN project_id SET NOT NULL;
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
CREATE POLICY submission_drafts_select
ON paliad.submission_drafts FOR SELECT TO authenticated
USING (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
CREATE POLICY submission_drafts_insert
ON paliad.submission_drafts FOR INSERT TO authenticated
WITH CHECK (
user_id = auth.uid()
AND paliad.can_see_project(project_id)
);
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
CREATE POLICY submission_drafts_update
ON paliad.submission_drafts FOR UPDATE TO authenticated
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
CREATE POLICY submission_drafts_delete
ON paliad.submission_drafts FOR DELETE TO authenticated
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));

View File

@@ -0,0 +1,70 @@
-- t-paliad-243: drafts may exist without a project attached.
--
-- The global /submissions/new picker lets a lawyer start a Schriftsatz
-- draft straight from the top-level Schriftsätze sidebar, with or
-- without binding it to a project. project_id therefore becomes
-- optional. Existing rows are unaffected; new rows may insert NULL.
--
-- RLS rewrite: every policy splits on (project_id IS NULL):
--
-- project_id IS NOT NULL → gate on paliad.can_see_project (existing
-- inheritance-aware visibility).
-- project_id IS NULL → owner-only (user_id = auth.uid()). A
-- project-less draft is a personal scratch
-- space — never shared, never visible to
-- other team members.
--
-- INSERT enforces the same shape via WITH CHECK: a project-less insert
-- only writes user_id = auth.uid(); a project-scoped insert additionally
-- requires can_see_project.
ALTER TABLE paliad.submission_drafts
ALTER COLUMN project_id DROP NOT NULL;
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
CREATE POLICY submission_drafts_select
ON paliad.submission_drafts FOR SELECT TO authenticated
USING (
(project_id IS NULL AND user_id = auth.uid())
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
);
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
CREATE POLICY submission_drafts_insert
ON paliad.submission_drafts FOR INSERT TO authenticated
WITH CHECK (
user_id = auth.uid()
AND (
project_id IS NULL
OR paliad.can_see_project(project_id)
)
);
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
CREATE POLICY submission_drafts_update
ON paliad.submission_drafts FOR UPDATE TO authenticated
USING (
user_id = auth.uid()
AND (
project_id IS NULL
OR paliad.can_see_project(project_id)
)
)
WITH CHECK (
user_id = auth.uid()
AND (
project_id IS NULL
OR paliad.can_see_project(project_id)
)
);
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
CREATE POLICY submission_drafts_delete
ON paliad.submission_drafts FOR DELETE TO authenticated
USING (
user_id = auth.uid()
AND (
project_id IS NULL
OR paliad.can_see_project(project_id)
)
);

View File

@@ -0,0 +1,7 @@
-- Drop the optional trigger-event label columns added in
-- 121_proceeding_trigger_event_label.up.sql. Any populated rows lose
-- their override; the frontend falls back to proceedingName.
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS trigger_event_label_en,
DROP COLUMN IF EXISTS trigger_event_label_de;

View File

@@ -0,0 +1,27 @@
-- t-paliad-250 / m/paliad#81 — Concern B: UPC Appeal trigger-event label.
--
-- The /tools/verfahrensablauf "Auslösendes Ereignis" caption falls back
-- to `paliad.proceeding_types.name` whenever the calculator finds no
-- root rule (duration_value=0 + parent_id=NULL + !is_court_set). For
-- UPC Appeal (upc.apl.merits) all rules carry a non-zero duration off
-- the trigger date, so the caption reads "Berufungsverfahren" /
-- "Appeal" — the proceeding itself — instead of the appealable
-- decision that actually starts the clock.
--
-- Fix: add an optional `trigger_event_label_de` / `trigger_event_label_en`
-- pair on proceeding_types. When set, the calculator surfaces it on the
-- response (TriggerEventLabel{,EN}) and the frontend prefers it over
-- proceedingName. No deadline-rule additions, no slug changes; existing
-- proceeding_type.code stays stable (hard rule from the issue).
ALTER TABLE paliad.proceeding_types
ADD COLUMN IF NOT EXISTS trigger_event_label_de text,
ADD COLUMN IF NOT EXISTS trigger_event_label_en text;
-- UPC Appeal: the trigger date is the date of the appealable first-instance
-- decision (per UPC RoP R.224(1)(a) the 2-month appeal clock runs from
-- service of the decision per R.220.1(a)/(b)).
UPDATE paliad.proceeding_types
SET trigger_event_label_de = 'Anfechtbare Entscheidung',
trigger_event_label_en = 'Appealable Decision'
WHERE code = 'upc.apl.merits';

View File

@@ -0,0 +1,6 @@
-- t-paliad-258: revert the additive custom_rule_text column.
-- Drop the column; rows that used the Custom path lose their free-text
-- label and read as "no rule".
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS custom_rule_text;

View File

@@ -0,0 +1,26 @@
-- t-paliad-258 / m/paliad#89 — binary Auto/Custom Rule model on the
-- deadline form.
--
-- t-paliad-251 shipped the form with a full deadline_rules catalog
-- dropdown. m's verdict: too noisy (4 "Oral hearings" across UPC CFI,
-- UPC CoA, DPMA, EPO etc.). Replace with a binary model:
--
-- 1. Auto — rule_id derived from the chosen event_type, displayed
-- read-only.
-- 2. Custom — rule_id is NULL and the lawyer's free-text label is
-- stored here.
--
-- The column is additive + nullable: existing rows keep their
-- deadline_rule_id and read as Auto-equivalent. A future row with both
-- columns NULL renders as "keine Regel" (matches today's no-rule state).
ALTER TABLE paliad.deadlines
ADD COLUMN IF NOT EXISTS custom_rule_text text;
COMMENT ON COLUMN paliad.deadlines.custom_rule_text IS
'Free-text rule label entered when the lawyer chose Custom on the '
'deadline form (t-paliad-258). Mutually exclusive with rule_id at '
'the application layer: Auto path sets rule_id and leaves this '
'NULL; Custom path sets this and leaves rule_id NULL. Display '
'surfaces prefer the rule_id-joined deadline_rules.name when '
'present, else fall back to custom_rule_text + a "Custom" badge.';

View File

@@ -326,6 +326,56 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "revoke")
}
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
//
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
// pending create) without withdrawing the request. The non-destructive
// sibling of /revoke that m asked for after noticing that withdraw silently
// deletes the underlying event.
//
// Body: {"fields": {<entity-shape>}}
// 200: {"status": "ok"}
//
// Status mapping (mapApprovalError):
//
// 400 suggestion_requires_change — payload has no allowlisted fields
// 403 not_authorized — caller isn't the requested_by
// 404 — request not found / not visible
// 409 request_not_pending — request already decided / revoked
type editPendingEntityBody struct {
Fields map[string]any `json:"fields"`
}
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
var body editPendingEntityBody
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "invalid_body",
"message": "Ungültiger Body.",
})
return
}
}
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
writeApprovalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
// counter_payload is an entity-shaped jsonb of the approver's edited
// values (allowlist enforced server-side); note is the optional free-text

View File

@@ -37,6 +37,11 @@ type fileEntry struct {
//
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
// identifier so existing bookmarks keep working post-rebrand.
//
// Per-submission templates (slug `submission/<code>.docx`) are server-only:
// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes.
// handleFileDownload serves any slug that lands here, but the public URL
// surface for submission templates is the export endpoint, not /files.
var fileRegistry = map[string]fileEntry{
"hl-patents-style.dotm": {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
@@ -46,6 +51,92 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/HL Patents Style.dotm",
},
// Per-submission demo template (t-paliad-241). Exercises every
// placeholder SubmissionVarsService resolves so the
// /projects/{id}/submissions/{code}/draft editor has variables to
// substitute. One file per submission_code; future codes register
// the same way — slug shape "submission/<code>.docx" so the
// namespace stays separate from the universal style template.
"submission/de.inf.lg.erwidg.docx": {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
DownloadName: "Klageerwiderung — " + branding.Name + ".docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
},
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
// that carries every placeholder SubmissionVarsService resolves but no
// submission_code-specific body structure. Slot between the per-firm
// per-code template and the bare HL Patents Style .dotm fallback: every
// submission_code without a dedicated template still renders with
// variables substituted instead of the macro-only letterhead.
skeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
// the shared fileRegistry cache. Exported via a const so handler code
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
// universal HL Patents Style as the global fallback.
//
// Add new entries here as the firm authors per-submission templates;
// the file itself lives in mWorkRepo and is served through the shared
// Gitea proxy cache so refreshes are visible to all consumers in one
// place.
var submissionTemplateRegistry = map[string]string{
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
}
// fetchSubmissionTemplateBytes returns the per-submission_code template
// bytes (and provenance SHA) when one is registered. The bool result
// distinguishes "no per-code template registered" (callers fall back to
// HL Patents Style) from an upstream fetch error.
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
slug, ok := submissionTemplateRegistry[submissionCode]
if !ok {
return nil, "", false, nil
}
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
}
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
return nil, "", false, err
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, true, nil
}
type cacheEntry struct {
@@ -118,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
// resolveSubmissionTemplate's fallback chain — used for every
// submission_code that has no dedicated template registered. Same
// stale-while-revalidate semantics as the rest of the file proxy: first
// call warms the cache synchronously from mWorkRepo via Gitea; later
// calls return immediately while a background refresh runs.
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
entry, ok := fileRegistry[skeletonSubmissionSlug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
}
ce := getCacheEntry(skeletonSubmissionSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
return nil, "", err
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, nil
}
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
// bytes. Shared accessor used by both the /files/{slug} download path
// (Word auto-update channel) and the submission generator

View File

@@ -98,6 +98,9 @@ type Services struct {
Projection *services.ProjectionService
Export *services.ExportService
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
SubmissionDraft *services.SubmissionDraftService
// 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
@@ -159,6 +162,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
firmDashboardDefault: svc.FirmDashboardDefault,
projection: svc.Projection,
export: svc.Export,
submissionDraft: svc.SubmissionDraft,
}
}
@@ -272,6 +276,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance)
protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance)
protected.HandleFunc("GET /api/projects/{id}/checklists", handleListChecklistInstancesForProject)
// t-paliad-240 — global Schriftsätze drafts index (top-level sidebar
// entry). Lists every draft the caller owns across visible projects.
// The per-project Schriftsätze tab keeps the editor itself project-
// scoped; this index is the cross-project landing.
protected.HandleFunc("GET /submissions", gateOnboarded(handleSubmissionsIndexPage))
protected.HandleFunc("GET /courts", handleCourtsPage)
protected.HandleFunc("GET /api/courts", handleCourtsAPI)
protected.HandleFunc("POST /api/courts/feedback", handleCourtsFeedback)
@@ -313,6 +322,33 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// writes an audit row.
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
// t-paliad-238 — dedicated Submissions/Schriftsätze draft editor.
// Per (project, submission_code, user) named drafts with autosaved
// variable overrides; export merges the bag with the universal HL
// Patents Style template (Slice A) or the per-code template
// (Slice B, future).
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts", handleListSubmissionDrafts)
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts", handleCreateSubmissionDraft)
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleGetSubmissionDraft)
protected.HandleFunc("PATCH /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handlePatchSubmissionDraft)
protected.HandleFunc("DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleDeleteSubmissionDraft)
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview", handlePreviewSubmissionDraft)
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export", handleExportSubmissionDraft)
// t-paliad-240 — global drafts index (across visible projects).
protected.HandleFunc("GET /api/user/submission-drafts", handleListUserSubmissionDrafts)
// t-paliad-243 — global Schriftsätze drafts with optional project
// binding. The picker page at /submissions/new lists the full
// cross-proceeding catalog (without a project context) and posts to
// POST /api/submission-drafts to spawn a draft. The
// /api/submission-drafts/{draft_id}* endpoints back the project-less
// editor and ALSO accept project-scoped drafts (the draft row
// carries its own project_id so the project segment is redundant).
protected.HandleFunc("GET /api/submissions/catalog", handleListSubmissionCatalog)
protected.HandleFunc("POST /api/submission-drafts", handleCreateGlobalSubmissionDraft)
protected.HandleFunc("GET /api/submission-drafts/{draft_id}", handleGetGlobalSubmissionDraft)
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
// /counterclaim creates a CCR sub-project linked via the new
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
@@ -470,6 +506,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// Without this route the deep-link 404s; the tab still works via
// in-page click since it just toggles a panel.
protected.HandleFunc("GET /projects/{id}/submissions", gateOnboarded(handleProjectsDetailPage))
// t-paliad-238 — dedicated submission draft editor. Both routes
// render the project detail page; the actual editor mounts
// client-side based on the URL path.
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft", gateOnboarded(handleSubmissionDraftPage))
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft/{draft_id}", gateOnboarded(handleSubmissionDraftPage))
// t-paliad-243 — global Schriftsätze pages: picker + project-less
// editor. Both render dist/* files; client bundles parse the URL
// and branch on whether a project segment is present.
protected.HandleFunc("GET /submissions/new", gateOnboarded(handleSubmissionsNewPage))
protected.HandleFunc("GET /submissions/draft/{draft_id}", gateOnboarded(handleSubmissionDraftGlobalPage))
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
// Horizontal SVG renderer mounted client-side; reuses the existing
// /api/projects/{id}/timeline JSON endpoint for data.
@@ -612,6 +658,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
// t-paliad-252 — non-destructive sibling of /revoke: lets the
// requester revise the in-flight entity without withdrawing.
protected.HandleFunc("POST /api/approval-requests/{id}/edit-entity", handleEditPendingEntity)
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
// t-paliad-154 — form-time effective policy lookup. Reachable by

View File

@@ -61,6 +61,9 @@ type dbServices struct {
firmDashboardDefault *services.FirmDashboardDefaultService
projection *services.ProjectionService
export *services.ExportService
// t-paliad-238 — submission draft editor.
submissionDraft *services.SubmissionDraftService
}
var dbSvc *dbServices

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,32 @@
package handlers
// Submission generator HTTP layer (t-paliad-230 — format-only scope
// reduction of t-paliad-215).
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
// to the full cross-proceeding catalog; t-paliad-253 promoted /generate
// from format-only to the same merge engine the draft editor uses).
//
// Endpoints:
//
// GET /api/projects/{id}/submissions
// Lists the project's proceeding-relevant filing rules.
// has_template is unconditionally true: every project gets
// offered the universal HL Patents Style template.
// Lists every published filing rule across every active
// proceeding the platform knows about, joined with its
// proceeding_type so the frontend can group by proceeding.
// has_template flips per-row: true when a per-submission .docx
// is wired in submissionTemplateRegistry, false when the
// editor falls back to the universal HL Patents Style.
//
// POST /api/projects/{id}/submissions/{code}/generate
// Fetches the cached HL Patents Style .dotm (same proxy used
// by /files/hl-patents-style.dotm), converts it to a clean
// .docx via services.ConvertDotmToDocx, writes one
// paliad.system_audit_log row, and streams the result as an
// attachment download.
//
// No variable substitution, no per-submission templates, no
// project_events/documents writes. Those layers are deferred to a
// future "merge engine" slice; today's generator hands the lawyer a
// clean .docx of the firm style and lets them edit and save under
// their own filename.
// Resolves the template through the cronus fallback chain
// (per-firm `submissionTemplateRegistry[code]` first, HL
// Patents Style as the universal fallback), builds a fresh
// variable bag via SubmissionVarsService.Build, and runs the
// SubmissionRenderer merge so every {{placeholder}} resolves
// to project state (or `[KEIN WERT: key]` for empties). Writes
// one paliad.system_audit_log row and streams the .docx as an
// attachment download. The HL Patents Style fallback has no
// placeholders today, so for codes without a per-firm template
// the renderer is a no-op on substitution but still runs the
// .dotm→.docx pre-pass.
//
// Visibility: every endpoint runs through ProjectService.GetByID
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
@@ -62,26 +67,48 @@ const hlPatentsStyleSlug = "hl-patents-style.dotm"
// submissionListEntry is one row in the Schriftsätze panel.
type submissionListEntry struct {
SubmissionCode string `json:"submission_code"`
Name string `json:"name"`
NameEN string `json:"name_en"`
EventType string `json:"event_type,omitempty"`
PrimaryParty string `json:"primary_party,omitempty"`
LegalSource string `json:"legal_source,omitempty"`
HasTemplate bool `json:"has_template"`
SubmissionCode string `json:"submission_code"`
Name string `json:"name"`
NameEN string `json:"name_en"`
EventType string `json:"event_type,omitempty"`
PrimaryParty string `json:"primary_party,omitempty"`
LegalSource string `json:"legal_source,omitempty"`
HasTemplate bool `json:"has_template"`
ProceedingCode string `json:"proceeding_code"`
ProceedingName string `json:"proceeding_name"`
ProceedingNameEN string `json:"proceeding_name_en"`
}
// submissionListResponse wraps the list with a project-level header.
//
// ProjectProceedingCode names the project's own proceeding so the
// frontend can pin its group to the top of the grouped catalog
// (t-paliad-242). nil when the project hasn't bound a proceeding yet.
type submissionListResponse struct {
ProjectID uuid.UUID `json:"project_id"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
Entries []submissionListEntry `json:"entries"`
ProjectID uuid.UUID `json:"project_id"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
ProjectProceedingCode *string `json:"project_proceeding_code,omitempty"`
Entries []submissionListEntry `json:"entries"`
}
// handleListProjectSubmissions returns the published filing rules for
// the project's proceeding_type. has_template is true for every row —
// Slice 1 (t-paliad-230) ships one universal template, so the only
// "no template" case is a project that has no proceeding_type bound.
// handleListProjectSubmissions returns every published filing rule
// across every active proceeding the platform knows about, joined with
// its proceeding_type so the Schriftsätze tab can group rows by
// proceeding (t-paliad-242 — m wants to see the entire catalog from any
// project, not just the rules for the project's own proceeding).
//
// Visibility is gated on the PROJECT (paliad.can_see_project via
// ProjectService.GetByID); the rules themselves are static reference
// data shared across the firm.
//
// has_template flips when a per-submission .docx is wired into
// submissionTemplateRegistry (files.go). When false, the universal HL
// Patents Style .dotm is the fallback — the editor (t-paliad-238)
// resolves both flavours transparently, so every row remains
// generatable and editable from the UI.
//
// Rows are sorted by (proceeding_code, submission_code) so the
// frontend's groupBy stays cheap and the order is stable.
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -110,53 +137,145 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
Entries: []submissionListEntry{},
}
if project.ProceedingTypeID == nil {
writeJSON(w, http.StatusOK, resp)
return
}
rules, err := dbSvc.rules.List(ctx, project.ProceedingTypeID)
entries, ownCode, err := loadSubmissionCatalog(ctx, project.ProceedingTypeID)
if err != nil {
log.Printf("submissions: list rules for proceeding %d: %v", *project.ProceedingTypeID, err)
log.Printf("submissions: list submission catalog: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
return
}
for _, rule := range rules {
if rule.SubmissionCode == nil || *rule.SubmissionCode == "" {
continue
}
if rule.EventType == nil || *rule.EventType != "filing" {
continue
}
if rule.LifecycleState != "published" {
continue
}
entry := submissionListEntry{
SubmissionCode: *rule.SubmissionCode,
Name: rule.Name,
NameEN: rule.NameEN,
HasTemplate: true,
}
if rule.EventType != nil {
entry.EventType = *rule.EventType
}
if rule.PrimaryParty != nil {
entry.PrimaryParty = *rule.PrimaryParty
}
if rule.LegalSource != nil {
entry.LegalSource = *rule.LegalSource
}
resp.Entries = append(resp.Entries, entry)
}
resp.Entries = entries
resp.ProjectProceedingCode = ownCode
writeJSON(w, http.StatusOK, resp)
}
// handleGenerateProjectSubmission fetches the universal HL Patents
// Style .dotm, converts it to a clean .docx, writes one audit row, and
// streams the result. No variable substitution; the bytes that go down
// the wire are the firm style template with macros stripped.
// handleListSubmissionCatalog returns the same cross-proceeding catalog
// without a project context — used by the global /submissions/new
// picker (t-paliad-243). No project_proceeding_code is returned since
// the picker isn't pinned to one project.
func handleListSubmissionCatalog(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
entries, _, err := loadSubmissionCatalog(r.Context(), nil)
if err != nil {
log.Printf("submissions: list global submission catalog: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
}
// loadSubmissionCatalog runs the shared catalog query. When
// projectProceedingTypeID is non-nil, the returned ownCode points at
// that proceeding's code so the frontend can pin its group to the top;
// otherwise ownCode is nil.
func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([]submissionListEntry, *string, error) {
type catalogRow struct {
SubmissionCode string `db:"submission_code"`
Name string `db:"name"`
NameEN string `db:"name_en"`
EventType *string `db:"event_type"`
PrimaryParty *string `db:"primary_party"`
LegalSource *string `db:"legal_source"`
ProceedingID int `db:"proceeding_type_id"`
ProceedingCode string `db:"proceeding_code"`
ProceedingName string `db:"proceeding_name"`
ProceedingNameEN string `db:"proceeding_name_en"`
}
var rows []catalogRow
err := dbSvc.projects.DB().SelectContext(ctx, &rows,
`SELECT dr.submission_code AS submission_code,
dr.name AS name,
dr.name_en AS name_en,
dr.event_type AS event_type,
dr.primary_party AS primary_party,
dr.legal_source AS legal_source,
dr.proceeding_type_id AS proceeding_type_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name,
pt.name_en AS proceeding_name_en
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.lifecycle_state = 'published'
AND dr.event_type = 'filing'
AND dr.submission_code IS NOT NULL
AND dr.submission_code <> ''
AND pt.is_active = true
ORDER BY pt.code ASC, dr.submission_code ASC`)
if err != nil {
return nil, nil, err
}
entries := make([]submissionListEntry, 0, len(rows))
var ownCode *string
for _, row := range rows {
entry := submissionListEntry{
SubmissionCode: row.SubmissionCode,
Name: row.Name,
NameEN: row.NameEN,
HasTemplate: hasPerSubmissionTemplate(row.SubmissionCode),
ProceedingCode: row.ProceedingCode,
ProceedingName: row.ProceedingName,
ProceedingNameEN: row.ProceedingNameEN,
}
if row.EventType != nil {
entry.EventType = *row.EventType
}
if row.PrimaryParty != nil {
entry.PrimaryParty = *row.PrimaryParty
}
if row.LegalSource != nil {
entry.LegalSource = *row.LegalSource
}
entries = append(entries, entry)
if projectProceedingTypeID != nil && row.ProceedingID == *projectProceedingTypeID && ownCode == nil {
code := row.ProceedingCode
ownCode = &code
}
}
// If the project's proceeding has no filing rules of its own, fall
// back to a direct proceeding_types lookup so the frontend can still
// pin the right group even when the catalog ordering wouldn't have
// surfaced the code via a row.
if projectProceedingTypeID != nil && ownCode == nil {
var code string
if err := dbSvc.projects.DB().GetContext(ctx, &code,
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, *projectProceedingTypeID); err == nil && code != "" {
ownCode = &code
}
}
return entries, ownCode, nil
}
// hasPerSubmissionTemplate reports whether a per-submission .docx is
// wired in the fileRegistry (files.go). false means the editor falls
// back to the universal HL Patents Style — still renderable, still
// editable, but the UI may want to surface a "universal Vorlage"
// indicator. Read-only — no I/O, just a map lookup.
func hasPerSubmissionTemplate(submissionCode string) bool {
_, ok := submissionTemplateRegistry[submissionCode]
return ok
}
// handleGenerateProjectSubmission resolves the per-submission template
// (per-firm first, HL Patents Style fallback), builds a fresh variable
// bag from project state via SubmissionVarsService, runs the merge
// engine so every {{placeholder}} substitutes, writes one audit row,
// and streams the result. Pre-t-paliad-253 this handler ignored the
// per-firm registry and returned the bare HL Patents Style .dotm with
// no substitution — the "Generieren" button on the Schriftsätze tab
// therefore produced a generic firm-style .docx instead of a
// project-merged Klageerwiderung, which is what m noticed in
// m/paliad#84.
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -165,6 +284,12 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submissions not configured",
})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
@@ -179,60 +304,37 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
if err != nil {
writeServiceError(w, err)
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
rule, err := loadPublishedRuleByCode(ctx, submissionCode)
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
if err != nil {
if errors.Is(err, errRuleNotFound) {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
})
return
}
log.Printf("submissions: load rule %q: %v", submissionCode, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
// ErrNotVisible / project ErrNotFound from the visibility gate
// surface through writeServiceError as 404, matching the rest
// of the project surfaces.
log.Printf("submissions: render (project=%s code=%s): %v", projectID, submissionCode, err)
writeServiceError(w, err)
return
}
dotm, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "template upstream unreachable",
})
return
}
docx, err := services.ConvertDotmToDocx(dotm)
if err != nil {
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "convert failed",
})
return
}
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil {
log.Printf("submissions: load user %s: %v", uid, err)
}
lang := "de"
if user != nil && user.Lang != "" {
lang = user.Lang
}
filename := submissionFileName(rule, project, lang)
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
// affects the system_audit_log feed — never the user's response.
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil {
if err := writeSubmissionAuditRow(bgCtx, resolved.User, projectID, submissionCode, resolved.Rule.Name, filename); err != nil {
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
}
@@ -244,41 +346,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
}
}
// errRuleNotFound is the sentinel for "no published rule with that
// submission_code" — distinguished from a generic DB error so the
// handler returns 404 instead of 500.
var errRuleNotFound = errors.New("submission rule not found")
// loadPublishedRuleByCode fetches the rule the user requested. Only
// published+active rows resolve; drafts and archived rules never feed
// a real submission.
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, errRuleNotFound
}
var rule models.DeadlineRule
err := dbSvc.projects.DB().GetContext(ctx, &rule,
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value, duration_unit,
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at, lifecycle_state
FROM paliad.deadline_rules
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true
ORDER BY sequence_order
LIMIT 1`, submissionCode)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, errRuleNotFound
}
return nil, err
}
return &rule, nil
}
// 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 —

View File

@@ -313,6 +313,14 @@ type Deadline struct {
// changes to paliad.deadline_rules and accepts citations from
// outside that table.
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
// CustomRuleText holds the lawyer's free-text rule label when the
// deadline form is in Custom mode (t-paliad-258 / m/paliad#89).
// Mutually exclusive with RuleID at the application layer: the Auto
// path sets RuleID and leaves this NULL; the Custom path sets this
// and leaves RuleID NULL. Display surfaces prefer the joined
// deadline_rules.name when RuleID is set, else fall back to this
// text + a "Custom" badge.
CustomRuleText *string `db:"custom_rule_text" json:"custom_rule_text,omitempty"`
Status string `db:"status" json:"status"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
@@ -721,6 +729,14 @@ type ProceedingType struct {
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
// NULL on most proceedings — they already carry a root rule.
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
}
// TriggerEvent is a UPC procedural event that can start one or more deadlines

View File

@@ -41,6 +41,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
"time"
@@ -364,6 +365,135 @@ func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.U
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
}
// EditPendingEntity lets the REQUESTER of a pending approval_request revise
// the in-flight entity (e.g. tweak the title or due_date on a pending
// create) without withdrawing the request. t-paliad-252 / m/paliad#83 added
// this as the non-destructive sibling of Revoke — m's mental model is
// "withdraw deletes the event; let me edit the event instead, keep the
// approval request alive".
//
// Authorization: caller MUST be the original requested_by (no approver can
// edit on the requester's behalf — that would collapse into SuggestChanges).
// Request status MUST be pending.
//
// Allowlist: uses the WIDER counter-allowlist already maintained for
// SuggestChanges (buildCounterSetClauses) — every editable field on the
// entity, not just the date-bearing approval triggers. Unknown keys are
// silently dropped. Returns ErrSuggestionRequiresChange when fields carries
// no allowlisted key for the entity_type (would be a no-op write).
//
// Side effects in one tx: entity columns updated (and event_type_ids junction
// rewritten for deadlines), approval_request.payload merged with the new
// values so the approver sees what was revised, and a distinct
// `<entity>_approval_edited_by_requester` project_event emitted so the
// Verlauf shows the revision separately from the original *_requested row.
//
// The approval_request stays pending; entity.approval_status stays pending.
// The approver inbox sees a fresh updated_at + the merged payload.
func (s *ApprovalService) EditPendingEntity(ctx context.Context, requestID, callerID uuid.UUID, fields map[string]any) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
req, err := s.getRequestForUpdate(ctx, tx, requestID)
if err != nil {
return err
}
if req.Status != RequestStatusPending {
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
}
if callerID != req.RequestedBy {
return ErrNotApprover
}
// Validate the counter-allowlist intersect produces at least one
// settable column. applyEntityUpdate also wraps this check; pre-checking
// here lets us emit a cleaner error before opening the entity-write.
if _, _, err := buildCounterSetClauses(req.EntityType, fields); err != nil {
// Already wraps ErrSuggestionRequiresChange for empty / title-cleared
// cases. Propagate verbatim.
return err
}
// Apply the field updates to the entity row via the shared
// counter-allowlist path (same as SuggestChanges).
if err := s.applyEntityUpdate(ctx, tx, req.EntityType, req.EntityID, fields); err != nil {
return err
}
// Merge new fields into the request payload so the approver's inbox
// reflects what the requester revised to. Keys overwrite; event_type_ids
// is replaced wholesale per the same semantics applyEntityUpdate uses
// for the junction rewrite.
var existing map[string]any
if len(req.Payload) > 0 {
if err := json.Unmarshal(req.Payload, &existing); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
}
if existing == nil {
existing = map[string]any{}
}
maps.Copy(existing, fields)
merged, err := json.Marshal(existing)
if err != nil {
return fmt.Errorf("marshal merged payload: %w", err)
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.approval_requests
SET payload = $1, updated_at = $2
WHERE id = $3`,
merged, now, requestID); err != nil {
return fmt.Errorf("update payload: %w", err)
}
// Audit emit. Distinct event_type so the Verlauf surfaces the revision
// separately from the original *_requested or any decision row.
verlaufKind := "edited_by_requester"
eventType := approvalEventType(req.EntityType, verlaufKind)
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
editedKeys := sortedKeys(fields)
meta := map[string]any{
"approval_request_id": req.ID.String(),
"lifecycle_event": req.LifecycleEvent,
req.EntityType + "_id": req.EntityID.String(),
"edited_fields": editedKeys,
}
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
return err
}
return tx.Commit()
}
// sortedKeys returns m's keys in stable alphabetical order so the audit-log
// metadata is byte-for-byte stable across calls (helps when diffing audit
// logs or asserting on them in tests).
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
// Use the stdlib sort; the slice is small (≤ counter-allowlist size).
sortStrings(out)
return out
}
// sortStrings: indirection so we don't add a new top-level import group.
// In Go 1.21+ slices.Sort exists; this package is currently importing
// strings + standard libs and adding "sort" would re-fan the imports.
// Kept as a one-line wrapper to localise the dependency if a later move
// to slices.Sort feels right.
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j-1] > s[j]; j-- {
s[j-1], s[j] = s[j], s[j-1]
}
}
}
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
// proposes a counter-payload + optional free-text note; in one transaction
// we close the old request as 'changes_requested', revert the entity from

View File

@@ -66,7 +66,7 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
}
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at`
@@ -81,6 +81,11 @@ type CreateDeadlineInput struct {
// Sent by the Fristenrechner save flow so the title can stay clean
// instead of carrying the citation as a prefix.
RuleCode *string `json:"rule_code,omitempty"`
// CustomRuleText is the lawyer's free-text rule label when the
// deadline form is in Custom mode (t-paliad-258). Mutually exclusive
// with RuleID at the application layer; the service trims and treats
// an all-whitespace value as nil.
CustomRuleText *string `json:"custom_rule_text,omitempty"`
Source string `json:"source,omitempty"` // default "manual"
Notes *string `json:"notes,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
@@ -108,6 +113,20 @@ type UpdateDeadlineInput struct {
Status *string `json:"status,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
// Rule pointer pair (t-paliad-258 / m/paliad#89). Three valid
// shapes; the service rejects "both set":
// - RuleSet=true, RuleID non-nil, CustomRuleText nil → Auto:
// bind to the catalog rule, clear custom_rule_text.
// - RuleSet=true, RuleID nil, CustomRuleText non-nil → Custom:
// store free text, clear rule_id.
// - RuleSet=true, RuleID nil, CustomRuleText nil → No rule:
// clear both columns.
// RuleSet=false leaves both columns untouched (the rest of the
// PATCH body doesn't carry rule changes).
RuleSet bool `json:"rule_set,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
CustomRuleText *string `json:"custom_rule_text,omitempty"`
}
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
@@ -241,7 +260,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.status, f.completed_at,
f.warning_date, f.source, f.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,
@@ -514,6 +533,23 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
}
}
// Auto/Custom rule swap (t-paliad-258). Mutually exclusive at the
// persistence boundary: setting one column NULLs the other.
if input.RuleSet {
if input.RuleID != nil && input.CustomRuleText != nil {
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
}
appendSet("rule_id", input.RuleID)
var customText *string
if input.CustomRuleText != nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
if trimmed != "" {
customText = &trimmed
}
}
appendSet("custom_rule_text", customText)
}
// Project move (t-paliad-140). Visibility on the destination is enforced
// the same way as on Create — a GetByID round-trip through ProjectService
// returns ErrNotVisible if the user can't see the target. Same-project
@@ -587,7 +623,7 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
// Did the PATCH touch anything beyond the project move?
otherFieldsTouched := input.Title != nil || input.Description != nil ||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
input.EventTypeIDs != nil
input.EventTypeIDs != nil || input.RuleSet
if otherFieldsTouched {
auditProject := current.ProjectID
if movedFromProject != nil {
@@ -1012,15 +1048,27 @@ func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, pro
}
}
// Auto vs Custom (t-paliad-258): RuleID and CustomRuleText are
// mutually exclusive. If the caller passes both, the catalog rule
// wins and the free-text is dropped — keeps the invariant simple at
// the persistence boundary.
var customRuleText *string
if input.CustomRuleText != nil && input.RuleID == nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
if trimmed != "" {
customRuleText = &trimmed
}
}
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, description, due_date, original_due_date,
source, rule_id, rule_code, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, $12, $12)`,
source, rule_id, rule_code, custom_rule_text, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11, $12, $13, $13)`,
id, projectID, title, input.Description, due, orig,
source, input.RuleID, ruleCode, input.Notes, userID, now,
source, input.RuleID, ruleCode, customRuleText, input.Notes, userID, now,
); err != nil {
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
}

View File

@@ -107,11 +107,15 @@ type EventListItem struct {
Status *string `json:"status,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Source *string `json:"source,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
RuleName *string `json:"rule_name,omitempty"`
RuleNameEN *string `json:"rule_name_en,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
RuleName *string `json:"rule_name,omitempty"`
RuleNameEN *string `json:"rule_name_en,omitempty"`
// CustomRuleText surfaces the lawyer's free-text rule label when the
// deadline was created via the Custom rule path (t-paliad-258).
// Display surfaces fall back to it when RuleName is absent.
CustomRuleText *string `json:"custom_rule_text,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
// Appointment-only.
StartAt *time.Time `json:"start_at,omitempty"`
@@ -236,6 +240,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
RuleCode: d.RuleCode,
RuleName: d.RuleName,
RuleNameEN: d.RuleNameEN,
CustomRuleText: d.CustomRuleText,
EventTypeIDs: d.EventTypeIDs,
}
}

View File

@@ -115,6 +115,16 @@ type UIResponse struct {
// note explaining the framing.
ContextualNote string `json:"contextualNote,omitempty"`
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
// TriggerEventLabel / TriggerEventLabelEN: optional caption for the
// /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated
// from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121).
// The frontend prefers this over the proceedingName fallback that
// fires when no rule has IsRootEvent=true — UPC Appeal needed it
// because all its rules carry a non-zero duration off the trigger
// date so no rule is the "anchor". The trigger event for UPC Appeal
// is the appealable first-instance decision (m/paliad#81).
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -237,14 +247,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// Look up proceeding type metadata.
var pt struct {
ID int `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
NameEN string `db:"name_en"`
Jurisdiction *string `db:"jurisdiction"`
ID int `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
NameEN string `db:"name_en"`
Jurisdiction *string `db:"jurisdiction"`
TriggerEventLabelDE *string `db:"trigger_event_label_de"`
TriggerEventLabelEN *string `db:"trigger_event_label_en"`
}
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
`SELECT id, code, name, name_en, jurisdiction,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, proceedingCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -271,7 +284,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
hasSubTrackNote = true
// Re-resolve to the parent proceeding for rule lookup.
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
`SELECT id, code, name, name_en, jurisdiction,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, route.ParentCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -604,6 +618,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding` (e.g.
// upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not
// upc.ccr.cfi's own — which is fine: the sub-track note already
// explains the framing).
if pickedProceeding.TriggerEventLabelDE != nil {
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
}
if pickedProceeding.TriggerEventLabelEN != nil {
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
}
if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN

View File

@@ -0,0 +1,570 @@
package services
// Submission draft service — CRUD over paliad.submission_drafts plus
// the render+export entry points that combine the variable bag, lawyer
// overrides, and template fetch into a .docx or HTML preview
// (t-paliad-238 Slice A, design doc
// docs/design-submission-page-2026-05-22.md §5.2).
//
// Each draft is owned by one user; multiple drafts per (project,
// submission_code, user_id) are supported via the `name` column. The
// override semantics are explicit:
//
// variables = {"project.case_number": "2 O 999/25"} → use this value
// variables = {"project.case_number": ""} → force [KEIN WERT: …]
// key absent → fall back to bag
//
// Visibility flows through ProjectService.GetByID — every read and
// write gates on paliad.can_see_project. RLS in the DB enforces the
// owner-scoped UPDATE/DELETE constraint independently of the Go layer.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// SubmissionDraft mirrors a row in paliad.submission_drafts.
//
// ProjectID is nullable since t-paliad-243 — a draft started from the
// global /submissions/new picker without picking a project is private
// to its creator and carries an empty variable bag (no project /
// parties / deadline state to resolve). All callers must check for nil
// before treating it as a uuid.
type SubmissionDraft struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
VariablesRaw []byte `db:"variables" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
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.
Variables PlaceholderMap `json:"variables"`
}
// SubmissionDraftService handles CRUD on submission_drafts and exposes
// the render/preview/export entry points the handler layer calls.
type SubmissionDraftService struct {
db *sqlx.DB
projects *ProjectService
vars *SubmissionVarsService
renderer *SubmissionRenderer
}
// NewSubmissionDraftService wires the service.
func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *SubmissionVarsService, renderer *SubmissionRenderer) *SubmissionDraftService {
return &SubmissionDraftService{
db: db,
projects: projects,
vars: vars,
renderer: renderer,
}
}
// DraftPatch carries optional fields for Update. nil pointer = "no
// change"; non-nil = "set to this". Variables is replace-semantics —
// the lawyer's sidebar sends the full map every save.
//
// ProjectID uses a two-level pointer (t-paliad-243) so we can encode
// the three operations the global drafts flow needs:
//
// patch.ProjectID == nil → no change
// *patch.ProjectID == nil → detach (re-set to NULL)
// **patch.ProjectID → attach (assign a project)
//
// The detach path stays as scope for symmetry with attach even though
// the current frontend only exposes attach.
type DraftPatch struct {
Name *string
Variables *PlaceholderMap
ProjectID **uuid.UUID
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
// visible to this user". Maps to 404 in the handler.
var ErrSubmissionDraftNotFound = errors.New("submission draft: not found")
// ErrSubmissionDraftNameTaken is the sentinel for duplicate names per
// (project, submission_code, user). Maps to 409 in the handler.
var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already taken")
// draftColumns is the canonical select list — kept in one place so
// every fetch stays in sync.
const draftColumns = `id, project_id, submission_code, user_id, name,
variables, last_exported_at, last_exported_sha,
created_at, updated_at`
// List returns every draft for (project, submission_code, user)
// ordered by updated_at DESC. Visibility flows through projects.GetByID.
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
var rows []SubmissionDraft
err := s.db.SelectContext(ctx, &rows,
`SELECT `+draftColumns+`
FROM paliad.submission_drafts
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
ORDER BY updated_at DESC`,
projectID, submissionCode, userID)
if err != nil {
return nil, fmt.Errorf("list submission drafts: %w", err)
}
for i := range rows {
if err := rows[i].decodeVariables(); err != nil {
return nil, err
}
}
return rows, nil
}
// DraftWithProject is the row shape for the global /submissions index —
// a draft joined with the minimal project metadata the table needs.
// Visibility is gated by paliad.can_see_project in the SELECT itself.
//
// ProjectTitle / ProjectReference are pointer-nullable since
// t-paliad-243 — project-less drafts surface in the same list with a
// NULL project ref, and the frontend renders them with a dedicated
// "kein Projekt" label.
type DraftWithProject struct {
SubmissionDraft
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
}
// ListAllForUser returns every draft the user owns across visible
// projects PLUS every project-less draft the user owns, ordered by
// updated_at DESC. LEFT JOIN on paliad.projects keeps project-less rows
// in the result set; the WHERE clause permits project_id IS NULL or a
// visible can_see_project hit, so a draft on a project the user no
// longer has access to is silently dropped.
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
var rows []DraftWithProject
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
d.variables, d.last_exported_at, d.last_exported_sha,
d.created_at, d.updated_at,
p.title AS project_title,
p.reference AS project_reference
FROM paliad.submission_drafts d
LEFT JOIN paliad.projects p ON p.id = d.project_id
WHERE d.user_id = $1
AND (
d.project_id IS NULL
OR paliad.can_see_project(d.project_id)
)
ORDER BY d.updated_at DESC`,
userID)
if err != nil {
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
}
for i := range rows {
if err := rows[i].decodeVariables(); err != nil {
return nil, err
}
}
return rows, nil
}
// Get returns a single draft by id, gated on project visibility AND
// owner-only — the caller can only fetch drafts they own. RLS in the
// DB enforces this independently; the Go check makes the 404 semantics
// explicit at the service boundary.
//
// A project-less draft (ProjectID == nil) skips the can_see_project
// gate — the owner-only constraint is the entire visibility check.
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
var d SubmissionDraft
err := s.db.GetContext(ctx, &d,
`SELECT `+draftColumns+`
FROM paliad.submission_drafts
WHERE id = $1 AND user_id = $2`,
draftID, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionDraftNotFound
}
if err != nil {
return nil, fmt.Errorf("get submission draft: %w", err)
}
if d.ProjectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *d.ProjectID); err != nil {
// Project no longer visible → behave as not-found rather than
// leaking the draft's existence. ON DELETE CASCADE keeps this
// rare in practice.
if errors.Is(err, ErrNotVisible) {
return nil, ErrSubmissionDraftNotFound
}
return nil, err
}
}
if err := d.decodeVariables(); err != nil {
return nil, err
}
return &d, nil
}
// EnsureLatest returns the user's most-recently-updated draft for
// (project, submission_code). Creates "Entwurf 1" / "Draft 1" if none
// exists. Idempotent on repeat calls — once a draft exists, EnsureLatest
// always returns the freshest one rather than spawning new rows.
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
var d SubmissionDraft
err := s.db.GetContext(ctx, &d,
`SELECT `+draftColumns+`
FROM paliad.submission_drafts
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
ORDER BY updated_at DESC
LIMIT 1`,
projectID, submissionCode, userID)
if errors.Is(err, sql.ErrNoRows) {
return s.Create(ctx, userID, &projectID, submissionCode, lang)
}
if err != nil {
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
return nil, err
}
return &d, nil
}
// Create makes a new draft with an auto-incremented "Entwurf N" name
// ("Draft N" for English locale). Lawyer can rename via Update.
//
// A nil projectID creates a project-less draft (t-paliad-243); the
// visibility check is skipped — the caller is the owner and the row is
// private to them.
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
if projectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
return nil, err
}
}
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
if err != nil {
return nil, err
}
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`INSERT INTO paliad.submission_drafts
(project_id, submission_code, user_id, name)
VALUES ($1, $2, $3, $4)
RETURNING `+draftColumns,
projectID, submissionCode, userID, name)
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
return nil, err
}
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.
//
// 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"
}
var names []string
var err error
if projectID == nil {
err = s.db.SelectContext(ctx, &names,
`SELECT name FROM paliad.submission_drafts
WHERE project_id IS NULL AND submission_code = $1 AND user_id = $2`,
submissionCode, userID)
} else {
err = s.db.SelectContext(ctx, &names,
`SELECT name FROM paliad.submission_drafts
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3`,
*projectID, submissionCode, userID)
}
if err != nil {
return "", fmt.Errorf("scan existing draft names: %w", err)
}
highest := 0
for _, n := range names {
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
}
// Update patches the draft. Variables is replace-semantics — pass the
// full map. Name patches go through a uniqueness check to surface
// ErrSubmissionDraftNameTaken cleanly instead of a raw constraint
// violation.
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error) {
existing, err := s.Get(ctx, userID, draftID)
if err != nil {
return nil, err
}
setParts := []string{}
args := []any{}
idx := 1
if patch.Name != nil {
newName := strings.TrimSpace(*patch.Name)
if newName == "" {
return nil, ErrInvalidInput
}
if newName != existing.Name {
// Pre-check for the unique constraint so we can return a
// typed error instead of a raw PG conflict. NULL project_id
// is its own equivalence class in the unique index (NULLs
// don't collide), so the no-project flow checks `IS NULL`.
var dup int
var qErr error
if existing.ProjectID == nil {
qErr = s.db.GetContext(ctx, &dup,
`SELECT COUNT(*) FROM paliad.submission_drafts
WHERE project_id IS NULL AND submission_code = $1
AND user_id = $2 AND name = $3 AND id <> $4`,
existing.SubmissionCode, userID, newName, draftID)
} else {
qErr = s.db.GetContext(ctx, &dup,
`SELECT COUNT(*) FROM paliad.submission_drafts
WHERE project_id = $1 AND submission_code = $2
AND user_id = $3 AND name = $4 AND id <> $5`,
*existing.ProjectID, existing.SubmissionCode, userID, newName, draftID)
}
if qErr != nil {
return nil, fmt.Errorf("check name uniqueness: %w", qErr)
}
if dup > 0 {
return nil, ErrSubmissionDraftNameTaken
}
}
setParts = append(setParts, fmt.Sprintf("name = $%d", idx))
args = append(args, newName)
idx++
}
if patch.Variables != nil {
raw, err := json.Marshal(*patch.Variables)
if err != nil {
return nil, fmt.Errorf("marshal variables: %w", err)
}
setParts = append(setParts, fmt.Sprintf("variables = $%d::jsonb", idx))
args = append(args, string(raw))
idx++
}
if patch.ProjectID != nil {
newPID := *patch.ProjectID // *uuid.UUID — nil means detach
if newPID != nil {
// Caller must be able to see the project they're attaching
// the draft to; same gate as Create.
if _, err := s.projects.GetByID(ctx, userID, *newPID); err != nil {
return nil, err
}
}
setParts = append(setParts, fmt.Sprintf("project_id = $%d", idx))
args = append(args, newPID)
idx++
}
if len(setParts) == 0 {
return existing, nil
}
args = append(args, draftID, userID)
q := fmt.Sprintf(
`UPDATE paliad.submission_drafts
SET %s
WHERE id = $%d AND user_id = $%d
RETURNING %s`,
strings.Join(setParts, ", "), idx, idx+1, draftColumns,
)
var d SubmissionDraft
err = s.db.GetContext(ctx, &d, q, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionDraftNotFound
}
if err != nil {
return nil, fmt.Errorf("update submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
return nil, err
}
return &d, nil
}
// Delete removes the draft. Visibility-gated via Get; the DELETE itself
// is owner-scoped (user_id = caller).
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error {
if _, err := s.Get(ctx, userID, draftID); err != nil {
return err
}
_, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.submission_drafts WHERE id = $1 AND user_id = $2`,
draftID, userID)
if err != nil {
return fmt.Errorf("delete submission draft: %w", err)
}
return nil
}
// MarkExported updates the last_exported_* columns after a successful
// export. Background-context safe.
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, templateSHA string) error {
var sha any
if templateSHA != "" {
sha = templateSHA
}
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.submission_drafts
SET last_exported_at = now(),
last_exported_sha = $1
WHERE id = $2`,
sha, draftID)
if err != nil {
return fmt.Errorf("mark submission draft exported: %w", err)
}
return nil
}
// BuildRenderBag composes the placeholder map for a draft — pulls
// project/parties/rule/deadline state from SubmissionVarsService, then
// layers the lawyer's overrides on top.
//
// Override semantics:
//
// variables[key] = "" → delete the key (force [KEIN WERT: key])
// variables[key] = "X" → bag[key] = "X"
// key absent → bag[key] unchanged (falls back to resolved value)
//
// Returns the final PlaceholderMap along with the SubmissionVarsResult
// so callers (export, file naming) get the resolved entities too. A
// project-less draft (ProjectID == nil, t-paliad-243) skips project /
// parties / deadline lookups — the resolved bag carries only the
// user-independent variables (firm, today) plus the user.* group; the
// lawyer's overrides fill the rest.
func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *SubmissionDraft) (PlaceholderMap, *SubmissionVarsResult, error) {
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: draft.UserID,
ProjectID: draft.ProjectID,
SubmissionCode: draft.SubmissionCode,
})
if err != nil {
return nil, nil, err
}
bag := PlaceholderMap{}
maps.Copy(bag, resolved.Placeholders)
for k, v := range draft.Variables {
if v == "" {
delete(bag, k)
continue
}
bag[k] = v
}
return bag, resolved, nil
}
// RenderPreview returns the HTML preview of the merged document body
// for the draft-editor preview pane. Read-only; emits one <p> per <w:p>
// with <strong>/<em> spans for runs flagged bold/italic.
func (s *SubmissionDraftService) RenderPreview(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) (string, error) {
bag, resolved, err := s.BuildRenderBag(ctx, draft)
if err != nil {
return "", err
}
return s.renderer.RenderHTML(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
}
// Export renders the merged .docx for download. Returns the bytes, the
// resolved bag (for audit row + file naming), and the variables result
// (lang, rule.Name, project.case_number). Callers wire MarkExported and
// the audit writes.
func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
bag, resolved, err := s.BuildRenderBag(ctx, draft)
if err != nil {
return nil, nil, err
}
out, err := s.renderer.Render(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
if err != nil {
return nil, nil, err
}
return out, resolved, nil
}
// RenderProjectSubmission renders the given .docx template with a fresh
// variable bag for (user, project, submissionCode). No lawyer overrides
// — the output reflects exactly what SubmissionVarsService resolves
// from project state. Used by the one-click /api/projects/{id}/
// submissions/{code}/generate path which has no saved draft row.
//
// Returns the merged bytes plus the resolved bag (for audit row + file
// naming). Visibility is enforced by SubmissionVarsService.Build via
// ProjectService.GetByID — callers get ErrNotFound on no-access.
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
// requested submission_code.
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
pid := projectID
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: &pid,
SubmissionCode: submissionCode,
})
if err != nil {
return nil, nil, err
}
out, err := s.renderer.Render(templateBytes, resolved.Placeholders, DefaultMissingMarker(resolved.Lang))
if err != nil {
return nil, nil, err
}
return out, resolved, nil
}
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
// Called by every fetch path so the caller sees a populated Variables.
func (d *SubmissionDraft) decodeVariables() error {
if len(d.VariablesRaw) == 0 {
d.Variables = PlaceholderMap{}
return nil
}
out := PlaceholderMap{}
if err := json.Unmarshal(d.VariablesRaw, &out); err != nil {
return fmt.Errorf("decode submission draft variables: %w", err)
}
d.Variables = out
return nil
}
// 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 —
// but the import keeps the package compile-time-aware of the dependency
// chain that wires us into the bundle.
var _ = (*models.User)(nil)

View File

@@ -0,0 +1,447 @@
package services
// Submission template renderer — in-house engine for the submission
// draft editor (t-paliad-238, design doc
// docs/design-submission-page-2026-05-22.md §3 / §6.2).
//
// Resurrected from commit 8ea3509 (the original t-paliad-215 Slice 1
// "in-house .docx render engine"). Kept in a separate file from the
// format-only converter (submission_render.go) so the t-paliad-230
// /generate one-click path stays unchanged and the merge engine doesn't
// have to share zip-helper names with it.
//
// Why not lukasjarosch/go-docx: the library's "nested placeholder" guard
// treats sibling placeholders inside the same <w:t> run (e.g.
// "{{a}} ./. {{b}}") as nested and refuses to replace either. Patent
// submissions routinely have multiple placeholders per paragraph (party
// blocks especially), so the library is a non-starter. This renderer
// handles single-run placeholders (preserving run-level formatting) AND
// cross-run placeholders (rewriting the paragraph as one run when Word
// has fragmented the placeholder across runs).
//
// Placeholder grammar: {{[A-Za-z][A-Za-z0-9_.]*}} with optional
// whitespace inside braces ({{ project.case_number }} ≡
// {{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
// the gap in Word rather than failing the request.
import (
"archive/zip"
"bytes"
"fmt"
"io"
"regexp"
"strings"
)
// 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
// 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.
//
// Restricted to [A-Za-z][A-Za-z0-9_.]* so that stray "{{" sequences in
// legal prose don't get mistaken for placeholders. A genuine placeholder
// always starts with an ASCII letter.
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.
// Stateless; safe for concurrent use.
type SubmissionRenderer struct{}
// NewSubmissionRenderer constructs the renderer.
func NewSubmissionRenderer() *SubmissionRenderer {
return &SubmissionRenderer{}
}
// Render reads the .docx template at templateBytes, substitutes every
// placeholder from vars (or emits the missing-marker token), and returns
// the merged .docx bytes. Unknown placeholders never fail the render —
// the lawyer sees the marker in Word and fixes it.
//
// 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) {
if missing == nil {
missing = DefaultMissingMarker("de")
}
cleanBytes, err := ConvertDotmToDocx(templateBytes)
if err != nil {
return nil, fmt.Errorf("submission render: pre-pass convert: %w", err)
}
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
if err != nil {
return nil, fmt.Errorf("submission render: open zip: %w", err)
}
var out bytes.Buffer
zw := zip.NewWriter(&out)
for _, entry := range zr.File {
body, err := readMergeZipEntry(entry)
if err != nil {
return nil, fmt.Errorf("submission render: read %s: %w", entry.Name, err)
}
if isWordXMLEntry(entry.Name) {
body = substituteInDocumentXML(body, vars, missing)
}
w, err := zw.CreateHeader(&zip.FileHeader{
Name: entry.Name,
Method: entry.Method,
Modified: entry.Modified,
})
if err != nil {
return nil, fmt.Errorf("submission render: write header %s: %w", entry.Name, err)
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("submission render: write %s: %w", entry.Name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("submission render: finalise zip: %w", err)
}
return out.Bytes(), nil
}
// RenderHTML produces a read-only HTML rendering of the merged document
// body for the draft-editor preview pane. Walks the SAME placeholder
// substitution as Render, then extracts the body text from word/document.xml
// and emits semantic HTML — one <p> per <w:p>, with <strong>/<em> spans
// for runs that carry <w:b>/<w:i> formatting. Tables, lists, and complex
// formatting collapse to plain paragraphs (the preview is a fidelity
// guide, not a WYSIWYG editor — final formatting comes from Word at
// export).
//
// 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) {
if missing == nil {
missing = DefaultMissingMarker("de")
}
cleanBytes, err := ConvertDotmToDocx(templateBytes)
if err != nil {
return "", fmt.Errorf("submission render html: pre-pass convert: %w", err)
}
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
if err != nil {
return "", fmt.Errorf("submission render html: open zip: %w", err)
}
var docXML []byte
for _, entry := range zr.File {
if entry.Name != "word/document.xml" {
continue
}
docXML, err = readMergeZipEntry(entry)
if err != nil {
return "", fmt.Errorf("submission render html: read document.xml: %w", err)
}
break
}
if docXML == nil {
return "", fmt.Errorf("submission render html: word/document.xml missing")
}
merged := substituteInDocumentXML(docXML, vars, missing)
return docXMLToHTML(merged), nil
}
// isWordXMLEntry returns true for the .docx parts that contain
// substitutable text. We touch document.xml plus header*.xml and
// footer*.xml (templates may put firm letterhead in a header) but
// skip styles, theme, settings, comments, footnotes — none of which
// should carry merge placeholders in a well-formed template.
func isWordXMLEntry(name string) bool {
switch {
case name == "word/document.xml":
return true
case strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml"):
return true
case strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml"):
return true
}
return false
}
// readMergeZipEntry slurps a zip entry's bytes. Named distinctly from
// the helper in submission_render.go (readZipFile) to keep this file
// self-contained — the two are functionally identical.
func readMergeZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// substituteInDocumentXML walks document XML and replaces every
// {{placeholder}} occurrence inside <w:t> text nodes. Handles both
// single-run placeholders (the common case for freshly authored
// templates) and cross-run placeholders (where Word's autocorrect or
// manual editing has split a placeholder across runs).
//
// Two-pass strategy:
//
// 1. Pass 1: replace placeholders that fit entirely within one
// <w:t>…</w:t>. This is the 99% case and preserves all run-level
// formatting (bold, italic, font runs).
// 2. Pass 2: for paragraphs that still contain orphan "{{" or "}}"
// markers after pass 1, merge the text of every <w:t> inside the
// 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) []byte {
replaced := substituteInTextNodes(body, vars, missing)
if !needsCrossRunMerge(replaced) {
return replaced
}
return substituteAcrossRuns(replaced, vars, missing)
}
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
// the contents.
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) []byte {
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
sub := wTextNodeRegex.FindSubmatch(match)
attrs := string(sub[1])
contents := xmlDecode(string(sub[2]))
replaced := replacePlaceholders(contents, vars, missing)
if replaced == contents {
return match
}
if !strings.Contains(attrs, "xml:space") &&
(strings.HasPrefix(replaced, " ") || strings.HasSuffix(replaced, " ")) {
attrs += ` xml:space="preserve"`
}
return []byte(`<w:t` + attrs + `>` + xmlEncode(replaced) + `</w:t>`)
})
}
// needsCrossRunMerge returns true when the body still contains an
// unmatched "{{" or "}}" inside any <w:t> after pass 1.
func needsCrossRunMerge(body []byte) bool {
for _, m := range wTextNodeRegex.FindAllSubmatch(body, -1) {
t := string(m[2])
if strings.Contains(t, "{{") || strings.Contains(t, "}}") {
return true
}
}
return false
}
// wParagraphRegex matches one <w:p>…</w:p> paragraph block. Greedy
// inner-content match is safe — <w:p> elements do not nest.
var wParagraphRegex = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
// wRunPropsRegex pulls the first <w:rPr>…</w:rPr> block from a paragraph.
var wRunPropsRegex = regexp.MustCompile(`(?s)<w:rPr>.*?</w:rPr>`)
// wParagraphPropsRegex pulls the optional <w:pPr>…</w:pPr>.
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) []byte {
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
if len(textNodes) == 0 {
return para
}
var merged strings.Builder
for _, m := range textNodes {
merged.WriteString(xmlDecode(string(m[2])))
}
original := merged.String()
if !strings.Contains(original, "{{") {
return para
}
replaced := replacePlaceholders(original, vars, missing)
if replaced == original {
return para
}
pPr := wParagraphPropsRegex.Find(para)
rPr := wRunPropsRegex.Find(para)
var rebuilt bytes.Buffer
rebuilt.WriteString(`<w:p>`)
if pPr != nil {
rebuilt.Write(pPr)
}
rebuilt.WriteString(`<w:r>`)
if rPr != nil {
rebuilt.Write(rPr)
}
rebuilt.WriteString(`<w:t xml:space="preserve">`)
rebuilt.WriteString(xmlEncode(replaced))
rebuilt.WriteString(`</w:t></w:r></w:p>`)
return rebuilt.Bytes()
})
}
// replacePlaceholders performs the actual substitution on a plain
// string. Unbound placeholders render the missing marker.
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
sub := placeholderRegex.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
key := sub[1]
if value, ok := vars[key]; ok {
return value
}
return missing(key)
})
}
// xmlDecode reverses the five standard XML entities Word emits in
// <w:t> content.
func xmlDecode(s string) string {
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
s = strings.ReplaceAll(s, "&quot;", `"`)
s = strings.ReplaceAll(s, "&apos;", "'")
s = strings.ReplaceAll(s, "&amp;", "&")
return s
}
// xmlEncode escapes for safe insertion back into <w:t> content. & first
// to avoid double-encoding the entity prefixes.
func xmlEncode(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}
// docXMLToHTML walks the post-merge document XML and emits HTML for
// the preview pane. One <p> per <w:p>; <strong>/<em> spans for runs
// carrying <w:b>/<w:i>. Tables/lists/images collapse to text. Output
// is HTML-escaped except for the structural <p>/<strong>/<em> tags
// this function emits.
func docXMLToHTML(docXML []byte) string {
paragraphs := wParagraphRegex.FindAll(docXML, -1)
var out bytes.Buffer
for _, para := range paragraphs {
out.WriteString("<p>")
out.WriteString(paragraphToHTML(para))
out.WriteString("</p>\n")
}
if out.Len() == 0 {
return "<p></p>"
}
return out.String()
}
// wRunRegex matches one <w:r>…</w:r> run. Greedy match safe — <w:r>
// elements do not nest.
var wRunRegex = regexp.MustCompile(`(?s)<w:r\b[^>]*>.*?</w:r>`)
// wBoldRegex / wItalicRegex detect the bold/italic flags inside a run's
// <w:rPr>. Word emits <w:b/> or <w:b w:val="true"/>; matching the open
// tag covers both forms.
var (
wBoldRegex = regexp.MustCompile(`<w:b\b[^>]*/?>`)
wItalicRegex = regexp.MustCompile(`<w:i\b[^>]*/?>`)
)
// paragraphToHTML extracts the text from each <w:r> inside a paragraph,
// wraps runs flagged bold/italic with the corresponding HTML tags, and
// HTML-escapes the text content.
func paragraphToHTML(para []byte) string {
runs := wRunRegex.FindAll(para, -1)
if len(runs) == 0 {
// Empty paragraph (line break).
return ""
}
var out bytes.Buffer
for _, run := range runs {
text := extractRunText(run)
if text == "" {
continue
}
// Check for bold/italic on the run's <w:rPr>.
rPr := wRunPropsRegex.Find(run)
bold := rPr != nil && wBoldRegex.Match(rPr) && !isFalseFlag(rPr, wBoldRegex)
italic := rPr != nil && wItalicRegex.Match(rPr) && !isFalseFlag(rPr, wItalicRegex)
if bold {
out.WriteString("<strong>")
}
if italic {
out.WriteString("<em>")
}
out.WriteString(htmlEscape(text))
if italic {
out.WriteString("</em>")
}
if bold {
out.WriteString("</strong>")
}
}
return out.String()
}
// extractRunText concatenates every <w:t> inside a run, XML-decoding
// the content as it goes.
func extractRunText(run []byte) string {
var out strings.Builder
for _, m := range wTextNodeRegex.FindAllSubmatch(run, -1) {
out.WriteString(xmlDecode(string(m[2])))
}
return out.String()
}
// isFalseFlag returns true if the matched tag explicitly carries
// w:val="false" or w:val="0" — Word's way of turning off an inherited
// format. The default match (just `<w:b/>` or `<w:b w:val="true"/>`)
// is "on".
func isFalseFlag(rPr []byte, rx *regexp.Regexp) bool {
match := rx.Find(rPr)
if match == nil {
return false
}
s := string(match)
return strings.Contains(s, `w:val="false"`) || strings.Contains(s, `w:val="0"`)
}
// htmlEscape escapes the five HTML-significant characters for safe
// insertion into the preview pane.
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
return s
}

View File

@@ -0,0 +1,307 @@
package services
// Submission merge-engine tests — resurrected from the original
// t-paliad-215 Slice 1 (commit 8ea3509) + Slice 2 (commit 1765d5e).
// Adapted: helper names suffixed with "Merge" so they don't collide
// with the convert tests in submission_render_test.go (minimalDOTM,
// unzipEntries) that test the format-only ConvertDotmToDocx path.
import (
"archive/zip"
"bytes"
"io"
"strings"
"testing"
)
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
// contains the given body. Just enough to exercise the merge engine.
func minimalMergeDOCX(t *testing.T, documentBody string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("word/document.xml")
if err != nil {
t.Fatalf("create document.xml: %v", err)
}
if _, err := io.WriteString(w, documentBody); err != nil {
t.Fatalf("write document.xml: %v", err)
}
w2, err := zw.Create("[Content_Types].xml")
if err != nil {
t.Fatalf("create content types: %v", err)
}
// Use a docx-compatible content type so the convert pre-pass treats
// the input as already-clean (no .dotm rewrites needed).
body := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Override PartName="/word/document.xml" ContentType="` + docxMainContentType + `"/></Types>`
if _, err := io.WriteString(w2, body); err != nil {
t.Fatalf("write content types: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}
// readMergeDocumentXML pulls word/document.xml out of a rendered .docx.
func readMergeDocumentXML(t *testing.T, b []byte) string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
t.Fatalf("open rendered 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()
body, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read document.xml: %v", err)
}
return string(body)
}
t.Fatal("rendered .docx had no word/document.xml")
return ""
}
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)
if err != nil {
t.Fatalf("render: %v", err)
}
body := readMergeDocumentXML(t, out)
if !strings.Contains(body, ">HLC<") {
t.Errorf("expected HLC in body, got %q", body)
}
if strings.Contains(body, "{{") {
t.Errorf("unreplaced placeholder marker in body: %q", body)
}
}
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{
"parties.claimant.name": "Acme Inc.",
"parties.claimant.representative": "Kanzlei Müller",
}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
body := readMergeDocumentXML(t, out)
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
t.Errorf("expected both party values, got %q", body)
}
if strings.Contains(body, "{{") {
t.Errorf("unreplaced placeholder marker in body: %q", body)
}
}
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"))
if err != nil {
t.Fatalf("render: %v", err)
}
body := readMergeDocumentXML(t, out)
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"))
if err != nil {
t.Fatalf("render en: %v", err)
}
bodyEN := readMergeDocumentXML(t, outEN)
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
}
}
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)
if err != nil {
t.Fatalf("render: %v", err)
}
body := readMergeDocumentXML(t, out)
if !strings.Contains(body, "7 O 1234/26") {
t.Errorf("expected case number after cross-run merge, got %q", body)
}
if strings.Contains(body, "{{") {
t.Errorf("orphan placeholder marker remained: %q", body)
}
}
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{
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
body := readMergeDocumentXML(t, out)
if !strings.Contains(body, "Müller &amp; Söhne &lt;GmbH&gt; &quot;Special&quot;") {
t.Errorf("expected escaped value, got %q", body)
}
}
func TestPlaceholderRegex_Boundaries(t *testing.T) {
tests := []struct {
in string
matches []string
}{
{"plain text", nil},
{"{{foo}}", []string{"{{foo}}"}},
{"{{ foo }}", []string{"{{ foo }}"}},
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
{"{{1bad}}", nil},
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
got := placeholderRegex.FindAllString(tc.in, -1)
if len(got) != len(tc.matches) {
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
}
for i := range got {
if got[i] != tc.matches[i] {
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
}
}
})
}
}
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>.
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Bold line</w:t></w:r></w:p>` +
`<w:p><w:r><w:rPr><w:i/></w:rPr><w:t>Italic line</w:t></w:r></w:p>` +
`</w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
if !strings.Contains(html, "<p>Hello HLC</p>") {
t.Errorf("expected merged paragraph, got %q", html)
}
if !strings.Contains(html, "<strong>Bold line</strong>") {
t.Errorf("expected bold span, got %q", html)
}
if !strings.Contains(html, "<em>Italic line</em>") {
t.Errorf("expected italic span, got %q", html)
}
}
// TestRenderHTML_EscapesContent confirms the preview emitter HTML-escapes
// special characters in placeholder values.
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{
"user.display_name": `M&S <Inc> "X"`,
}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
if !strings.Contains(html, "M&amp;S &lt;Inc&gt; &quot;X&quot;") {
t.Errorf("expected escaped value in HTML, got %q", html)
}
}

View File

@@ -0,0 +1,554 @@
package services
// Submission variable bag — builds the PlaceholderMap that
// SubmissionRenderer fills into a template (t-paliad-215, design doc
// docs/design-submission-generator-2026-05-19.md §6.2).
//
// Variables span six namespaces:
//
// firm.* process-wide (branding.Name)
// user.* caller's user row
// today.* server time in Europe/Berlin, locale-aware
// project.* paliad.projects + joined proceeding type
// parties.* paliad.parties grouped by role
// rule.* paliad.deadline_rules row keyed by submission_code
// deadline.* next open paliad.deadlines row for (project, rule), if any
//
// Locale handling: every long-form date string is computed in both DE
// and EN; the renderer picks based on the user's lang preference. The
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
//
// Visibility: caller passes userID; ProjectService.GetByID enforces
// paliad.can_see_project — unauthorised callers get the standard
// ErrNotFound before any variable construction runs.
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/models"
)
// SubmissionVarsService assembles the placeholder map.
type SubmissionVarsService struct {
db *sqlx.DB
projects *ProjectService
parties *PartyService
users *UserService
}
// NewSubmissionVarsService wires the service.
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
return &SubmissionVarsService{
db: db,
projects: projects,
parties: parties,
users: users,
}
}
// SubmissionVarsContext is the input bundle that produces a render.
//
// ProjectID is optional since t-paliad-243 — a global Schriftsatz draft
// started from /submissions/new without picking a project carries
// nil here and the project / parties / deadline lookups are skipped.
type SubmissionVarsContext struct {
UserID uuid.UUID
ProjectID *uuid.UUID
SubmissionCode string
}
// SubmissionVarsResult bundles the placeholder map with the lookup
// values the handler needs for the audit row + file naming
// (rule.Name, project.case_number, etc.).
type SubmissionVarsResult struct {
Placeholders PlaceholderMap
// Resolved entities for audit + naming.
User *models.User
Project *models.Project
Rule *models.DeadlineRule
ProceedingType *models.ProceedingType
Parties []models.Party
NextDeadline *models.Deadline
// Lang is the user's UI language used to pick locale-aware values
// during the build. Returned so the renderer can use the matching
// missing-marker function.
Lang string
}
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
// matches the requested submission_code. Maps to 404 in the handler.
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
// Build resolves every entity and assembles the placeholder map. A nil
// ProjectID skips project / parties / deadline lookups — the resolved
// bag carries only firm.*, today.*, user.* and rule.* in that case;
// every other placeholder falls through to the lawyer's overrides via
// SubmissionDraftService.BuildRenderBag.
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
if s.projects == nil || s.users == nil {
return nil, fmt.Errorf("submission vars: required services not wired")
}
user, err := s.users.GetByID(ctx, in.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrNotVisible
}
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
if err != nil {
return nil, err
}
lang := user.Lang
if lang == "" {
lang = "de"
}
bag := PlaceholderMap{}
addFirmVars(bag)
addTodayVars(bag, time.Now())
addUserVars(bag, user)
addRuleVars(bag, rule, lang)
out := &SubmissionVarsResult{
Placeholders: bag,
User: user,
Rule: rule,
Lang: lang,
}
if in.ProjectID == nil {
// Project-less draft (t-paliad-243): no project / parties /
// deadline state to resolve. The lawyer's overrides will fill
// the placeholder map; missing keys render as
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
return out, nil
}
// Visibility gate — GetByID returns ErrNotFound when the user
// can't see the project, which is exactly the 404 the handler
// wants to propagate.
project, err := s.projects.GetByID(ctx, in.UserID, *in.ProjectID)
if err != nil {
return nil, err
}
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
if err != nil {
return nil, err
}
parties, err := s.parties.ListForProject(ctx, in.UserID, *in.ProjectID)
if err != nil {
return nil, err
}
next, err := s.nextOpenDeadline(ctx, *in.ProjectID, rule.ID)
if err != nil {
return nil, err
}
addProjectVars(bag, project, pt, lang)
addPartyVars(bag, parties)
addDeadlineVars(bag, next, project, lang)
out.Project = project
out.ProceedingType = pt
out.Parties = parties
out.NextDeadline = next
return out, nil
}
// loadPublishedRule fetches the deadline_rule that owns the given
// submission_code. Restricts to lifecycle_state='published' so drafts
// never end up shaping a real submission.
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, ErrSubmissionRuleNotFound
}
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true
ORDER BY sequence_order
LIMIT 1`, submissionCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionRuleNotFound
}
if err != nil {
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
}
return &rule, nil
}
// loadProceedingType fetches the proceeding type row for the project's
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
// without a bound proceeding still render a meaningful template — the
// {{project.proceeding.*}} placeholders just resolve to the missing
// marker.
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
if id == nil {
return nil, nil
}
var pt models.ProceedingType
err := s.db.GetContext(ctx, &pt,
`SELECT `+proceedingTypeColumns+`
FROM paliad.proceeding_types
WHERE id = $1`, *id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
}
return &pt, nil
}
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
// the given project that maps to the chosen rule. Returns (nil, nil)
// when no matching deadline exists — common when the lawyer is drafting
// the submission before the system has computed its deadline row.
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
var d models.Deadline
err := s.db.GetContext(ctx, &d,
`SELECT id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, status, completed_at,
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at
FROM paliad.deadlines
WHERE project_id = $1
AND rule_id = $2
AND status = 'pending'
ORDER BY due_date ASC
LIMIT 1`, projectID, ruleID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
}
return &d, nil
}
// addFirmVars populates the firm.* namespace.
func addFirmVars(bag PlaceholderMap) {
bag["firm.name"] = branding.Name
// firm.signature_block is reserved for Phase 2; emit empty so
// templates that already reference it don't render the missing
// marker (less noisy for the lawyer).
bag["firm.signature_block"] = ""
}
// addTodayVars populates today.* in both DE and EN long forms. ISO
// short form is the default {{today}}.
func addTodayVars(bag PlaceholderMap, now time.Time) {
loc, _ := time.LoadLocation("Europe/Berlin")
if loc != nil {
now = now.In(loc)
}
bag["today"] = now.Format("2006-01-02")
bag["today.iso"] = now.Format("2006-01-02")
bag["today.long_de"] = formatLongDateDE(now)
bag["today.long_en"] = formatLongDateEN(now)
}
// addUserVars populates user.*.
func addUserVars(bag PlaceholderMap, u *models.User) {
bag["user.display_name"] = u.DisplayName
bag["user.email"] = u.Email
bag["user.office"] = u.Office
}
// addProjectVars populates project.* — title / case_number / court /
// patent_number / dates / our_side / proceeding metadata.
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
bag["project.title"] = p.Title
bag["project.reference"] = derefString(p.Reference)
bag["project.case_number"] = derefString(p.CaseNumber)
bag["project.court"] = derefString(p.Court)
bag["project.patent_number"] = derefString(p.PatentNumber)
// project.patent_number_upc is the UPC-brief convention — kind code
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
// kind code is present so the lawyer's draft never sees a worse
// number than the source value.
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
bag["project.our_side"] = derefString(p.OurSide)
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
bag["project.instance_level"] = derefString(p.InstanceLevel)
bag["project.client_number"] = derefString(p.ClientNumber)
bag["project.matter_number"] = derefString(p.MatterNumber)
if pt != nil {
bag["project.proceeding.code"] = pt.Code
if strings.EqualFold(lang, "en") {
bag["project.proceeding.name"] = pt.NameEN
} else {
bag["project.proceeding.name"] = pt.Name
}
bag["project.proceeding.name_de"] = pt.Name
bag["project.proceeding.name_en"] = pt.NameEN
}
}
// addPartyVars populates parties.* using the first row of each role.
// Multi-claimant / multi-defendant suits use the first row in Slice 1
// per design §13.6; expanded grouping is Phase 2.
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
var claimant, defendant, other *models.Party
for i := range parties {
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
switch role {
case "claimant", "kläger", "klaeger":
if claimant == nil {
claimant = &parties[i]
}
case "defendant", "beklagter", "beklagte":
if defendant == nil {
defendant = &parties[i]
}
default:
if other == nil {
other = &parties[i]
}
}
}
if claimant != nil {
bag["parties.claimant.name"] = claimant.Name
bag["parties.claimant.representative"] = derefString(claimant.Representative)
}
if defendant != nil {
bag["parties.defendant.name"] = defendant.Name
bag["parties.defendant.representative"] = derefString(defendant.Representative)
}
if other != nil {
bag["parties.other.name"] = other.Name
bag["parties.other.representative"] = derefString(other.Representative)
}
}
// addRuleVars populates rule.* — submission_code, name(_en),
// legal_source (+ pretty form), primary_party, event_type.
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
bag["rule.submission_code"] = derefString(r.SubmissionCode)
if strings.EqualFold(lang, "en") {
bag["rule.name"] = r.NameEN
} else {
bag["rule.name"] = r.Name
}
bag["rule.name_de"] = r.Name
bag["rule.name_en"] = r.NameEN
bag["rule.legal_source"] = derefString(r.LegalSource)
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
bag["rule.primary_party"] = derefString(r.PrimaryParty)
bag["rule.event_type"] = derefString(r.EventType)
}
// addDeadlineVars populates deadline.* from the next pending row. When
// no row exists the values fall through to the missing marker — the
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
if d == nil {
return
}
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
if d.OriginalDueDate != nil {
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
}
// computed_from carries the human-readable anchor description
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
// the closest existing field — the calculator stores anchor
// metadata there. If empty we leave the placeholder unresolved.
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
}
bag["deadline.title"] = d.Title
bag["deadline.source"] = d.Source
_ = p // reserved for future shape decisions where the deadline
// var depends on project context.
_ = lang
}
// derefString returns *s or "" when s is nil.
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
// formatDatePtr formats a *time.Time, returning "" for nil.
func formatDatePtr(t *time.Time, layout string) string {
if t == nil {
return ""
}
return t.Format(layout)
}
// ourSideDE returns the German legal-prose form of an our_side value.
func ourSideDE(side string) string {
switch strings.ToLower(side) {
case "claimant":
return "Klägerin"
case "defendant":
return "Beklagte"
case "court":
return "Gericht"
case "both":
return "Klägerin und Beklagte"
}
return ""
}
// ourSideEN returns the English legal-prose form of an our_side value.
func ourSideEN(side string) string {
switch strings.ToLower(side) {
case "claimant":
return "Claimant"
case "defendant":
return "Defendant"
case "court":
return "Court"
case "both":
return "Claimant and Defendant"
}
return ""
}
// formatLongDateDE renders a date in the German long form
// ("19. Mai 2026"). Pure function for unit testing.
func formatLongDateDE(t time.Time) string {
months := []string{
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
}
idx := int(t.Month()) - 1
if idx < 0 || idx >= len(months) {
return t.Format("2006-01-02")
}
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
}
// formatLongDateEN renders a date in the English long form
// ("19 May 2026").
func formatLongDateEN(t time.Time) string {
return t.Format("2 January 2006")
}
// legalSourcePretty rewrites the shorthand stored on deadline_rules
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
// prefixes pass through unchanged — preferring the raw shorthand over
// an incorrect prettification.
//
// Lang controls the language of connective words (Abs / Section,
// Regel / Rule, …). The pretty table covers the prefixes used by the
// 254 published rules in the corpus today; new prefixes default to
// pass-through and a follow-up CL extends the table.
func legalSourcePretty(src, lang string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
en := strings.EqualFold(lang, "en")
switch {
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
if en {
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
}
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
if en {
return fmt.Sprintf("Section %s ZPO", parts[2])
}
return fmt.Sprintf("§ %s ZPO", parts[2])
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
if en {
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
}
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
if en {
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
}
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
if en {
return fmt.Sprintf("Section %s PatG", parts[2])
}
return fmt.Sprintf("§ %s PatG", parts[2])
case len(parts) == 2 && parts[0] == "EPC":
if en {
return fmt.Sprintf("Art. %s EPC", parts[1])
}
return fmt.Sprintf("Art. %s EPÜ", parts[1])
}
return src
}
// patentNumberKindCodeRegex matches a trailing kind code on a patent
// number: a whitespace-separated single uppercase letter followed by
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
// groups split the base from the kind code so the formatter can
// parenthesise the kind without touching the rest of the number.
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
// patentNumberUPC reformats a patent number from the DE convention
// ("EP 1 234 567 B1") to the UPC-brief convention
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
// else is preserved verbatim. Numbers without a recognised trailing
// kind code pass through unchanged so a lawyer's draft never sees a
// number worse than the source value.
//
// Recognised inputs:
//
// "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 B1 " → "EP 1 234 567 (B1)" (trimmed)
//
// Pass-through:
//
// "EP 1 234 567" → "EP 1 234 567"
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
// "" → ""
//
// Pure function; unit-tested in submission_vars_test.go.
func patentNumberUPC(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
base := strings.TrimSpace(m[1])
kind := m[2]
if base == "" {
return s
}
return base + " (" + kind + ")"
}
return s
}

View File

@@ -0,0 +1,346 @@
// Demo submission template generator (t-paliad-241).
//
// One-shot authoring tool that emits a minimal but Word-compatible
// .docx file exercising every placeholder SubmissionVarsService
// resolves. Drop the output into m/mWorkRepo at
//
// 6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx
//
// so paliad's submission-draft editor (t-paliad-238 Slice A) can fetch
// it via the per-submission_code fallback chain wired into
// handlers/files.go. The structure is a fake Klageerwiderung skeleton
// in German — fake legal prose, real placeholder tokens.
//
// Why a generator instead of authoring in Word: the per-placeholder
// docx grammar is `{{[A-Za-z][A-Za-z0-9_.]*}}` and Word's autocorrect
// happily fragments such tokens across <w:r> runs ({{ → "{", "{",
// project.case_number, "}", "}"). A programmatic emitter writes each
// placeholder as a single run so the renderer's pass-1 substitution
// (format-preserving) catches it cleanly. The merge engine handles
// cross-run cases too (pass 2) but pass 1 is the cheaper path.
//
// Run:
//
// go run ./scripts/gen-demo-submission-template -out /tmp/de.inf.lg.erwidg.docx
//
// Output is deterministic so re-generating to the same path produces a
// byte-identical file (modulo zip mtime — we pin those to a fixed UTC
// timestamp so the bytes are reproducible).
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"os"
"strings"
"time"
)
func main() {
out := flag.String("out", "de.inf.lg.erwidg.docx", "output .docx path")
flag.Parse()
docx, err := buildDocx()
if err != nil {
fmt.Fprintln(os.Stderr, "gen-demo-submission-template:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-demo-submission-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
}
// fixedTime is the zip mtime stamp baked into every entry so the output
// is byte-reproducible.
var fixedTime = time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC)
// buildDocx assembles the four-part .docx zip Word needs to open the
// file cleanly: Content_Types, root rels, document.xml, and document
// rels. Everything else (styles, theme, fonts) is optional — Word
// supplies sane defaults when absent.
func buildDocx() ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) error {
hdr := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
return nil
}
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
return nil, err
}
if err := add("_rels/.rels", rootRelsXML); err != nil {
return nil, err
}
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
return nil, err
}
if err := add("word/styles.xml", stylesXML); err != nil {
return nil, err
}
if err := add("word/document.xml", buildDocumentXML()); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`
// stylesXML provides minimal Heading1 + Heading2 paragraph styles so
// the section headings render with visual weight. Body text falls
// through to Word's Normal style.
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
</w:style>
</w:styles>`
// Document body — a fake Klageerwiderung skeleton with every placeholder
// SubmissionVarsService resolves embedded in natural positions. Each
// placeholder is in its own run so pass-1 substitution catches it without
// fragmentation worries. The DEMO marker in the header makes it obvious
// this is not approved firm content.
//
// Structure mirrors a real submission:
//
// 1. Firm letterhead + author block (firm.*, user.*, today.*)
// 2. Court caption (project.*, project.proceeding.*)
// 3. Parties block (parties.*)
// 4. Submission title + legal source (rule.*)
// 5. Deadline (deadline.*)
// 6. Boilerplate body + signature
//
// Order matches what a lawyer drafting a real Klageerwiderung would put
// at the top of the document, so when the lawyer customises this
// template later they don't have to restructure.
func buildDocumentXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
b.WriteString(`<w:body>`)
demoBanner(&b)
heading1(&b, "{{firm.name}} — Patentstreitsachen")
plain(&b, "Bearbeiter: {{user.display_name}}")
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
heading1(&b, "{{project.court}}")
plain(&b, "Aktenzeichen: {{project.case_number}}")
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
plain(&b, "Instanz: {{project.instance_level}}")
heading2(&b, "In der Patentstreitsache")
plain(&b, "{{parties.claimant.name}}")
plain(&b, "vertreten durch {{parties.claimant.representative}}")
bold(&b, "— Klägerin —")
plain(&b, "")
plain(&b, "gegen")
plain(&b, "")
plain(&b, "{{parties.defendant.name}}")
plain(&b, "vertreten durch {{parties.defendant.representative}}")
bold(&b, "— Beklagte —")
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
heading2(&b, "Betreff")
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
plain(&b, "Projekttitel: {{project.title}}")
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
heading1(&b, "{{rule.name}}")
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
heading2(&b, "Frist")
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
heading2(&b, "I. Anträge")
plain(&b, "Die Beklagte beantragt,")
plain(&b, "")
plain(&b, "1. die Klage abzuweisen;")
plain(&b, "2. der Klägerin die Kosten des Rechtsstreits aufzuerlegen.")
heading2(&b, "II. Sachverhalt")
plain(&b, "[DEMO-Platzhalter] Hier folgt der Sachvortrag der Beklagten zum Streitpatent {{project.patent_number}} und zu den von der Klägerin geltend gemachten Ansprüchen.")
heading2(&b, "III. Rechtsausführungen")
plain(&b, "[DEMO-Platzhalter] Die Beklagte tritt der Klage aus den nachfolgenden Gründen entgegen.")
heading2(&b, "Schlussformel")
plain(&b, "{{today.long_de}}")
plain(&b, "")
plain(&b, "{{user.display_name}}")
plain(&b, "{{firm.name}}")
plainOptional(&b, "{{firm.signature_block}}")
// English-locale exercise — lets the lawyer verify the EN long-form
// date and EN proceeding name resolve correctly when the user's
// preference is en. Also exercises the bare {{today}} alias
// (identical to {{today.iso}}; included so every key the variable
// bag carries appears at least once in this demo template).
heading2(&b, "Locale-aware variants (DEMO)")
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
plain(&b, "Today (bare alias): {{today}}")
b.WriteString(`</w:body></w:document>`)
return b.String()
}
// demoBanner writes a clearly-marked DEMO header so the file can't be
// mistaken for approved firm content (HLC branding compliance has not
// reviewed this — it's a developer-authored placeholder fixture).
func demoBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">DEMO — interne Vorlage (nicht freigegeben)</w:t></w:r></w:p>`)
}
// heading1 emits a styled "Heading 1" paragraph with placeholder runs
// emitted intact (one run per placeholder so pass-1 substitution works).
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
// heading2 emits a "Heading 2" paragraph.
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
// plain emits a Normal-style paragraph.
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
// plainOptional is a Normal paragraph rendered as italic so the lawyer
// recognises rows that contain placeholders which may be empty
// (parties.other.*, deadline.original_due_date, firm.signature_block).
// Visual cue only; the merge engine still substitutes the same way.
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
// bold emits a Normal paragraph with bold run formatting.
func bold(b *strings.Builder, text string) {
b.WriteString(`<w:p>`)
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
b.WriteString(xmlEscape(text))
b.WriteString(`</w:t></w:r></w:p>`)
}
// paragraph splits text on placeholder boundaries and emits one <w:r>
// per segment. Each placeholder occupies a dedicated run so the
// renderer's pass-1 substitution (format-preserving, single-run) hits
// the placeholder without the cross-run fallback. Italic runs are
// flagged via the italic argument.
func paragraph(b *strings.Builder, style, text string, italic bool) {
b.WriteString(`<w:p>`)
if style != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(style)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if italic {
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlEscape(seg))
b.WriteString(`</w:t></w:r>`)
}
b.WriteString(`</w:p>`)
}
// splitOnPlaceholders returns the input split into alternating text /
// placeholder segments while keeping each placeholder intact in its own
// segment. Empty input yields a single empty segment so the paragraph
// still emits a (visible) blank line.
func splitOnPlaceholders(s string) []string {
if s == "" {
return []string{""}
}
var out []string
for {
open := strings.Index(s, "{{")
if open < 0 {
out = append(out, s)
return out
}
close := strings.Index(s[open:], "}}")
if close < 0 {
out = append(out, s)
return out
}
end := open + close + 2
if open > 0 {
out = append(out, s[:open])
}
out = append(out, s[open:end])
s = s[end:]
if s == "" {
return out
}
}
}
// xmlEscape handles the five XML-significant characters for <w:t>
// content. Whitespace is preserved by the xml:space="preserve" attr we
// always emit on text runs.
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

View File

@@ -0,0 +1,303 @@
// Universal-skeleton submission template generator (t-paliad-259).
//
// One-shot authoring tool that emits a minimal but Word-compatible
// .docx file exercising every placeholder SubmissionVarsService
// resolves — without baking in any submission_code-specific prose.
//
// Drop the output into m/mWorkRepo at
//
// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx
//
// so paliad's submission generator picks it up via the fallback chain
// slotted between the per-submission_code template and the bare
// universal HL Patents Style .dotm. Any submission_code that has no
// per-firm template still gets a draft populated with variables
// instead of the macro-only letterhead.
//
// Why a separate file from de.inf.lg.erwidg.docx: that one is a
// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt /
// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA
// appeal, that body structure is wrong. The universal skeleton drops
// the structure and leaves a single neutral body block the lawyer
// replaces — every variable still resolves regardless of code.
//
// Run:
//
// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx
//
// Output is byte-reproducible (zip mtimes pinned to a fixed UTC
// timestamp).
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"os"
"strings"
"time"
)
func main() {
out := flag.String("out", "_skeleton.docx", "output .docx path")
flag.Parse()
docx, err := buildDocx()
if err != nil {
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
}
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
func buildDocx() ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) error {
hdr := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
return nil
}
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
return nil, err
}
if err := add("_rels/.rels", rootRelsXML); err != nil {
return nil, err
}
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
return nil, err
}
if err := add("word/styles.xml", stylesXML); err != nil {
return nil, err
}
if err := add("word/document.xml", buildDocumentXML()); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
</w:style>
</w:styles>`
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
// case caption + parties + submission heading + deadline + a single
// neutral body block. Mirrors the variable bag from SubmissionVarsService
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
// structure. A lawyer customising this template for a UPC SoC, EPO
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
// renames the party labels — every placeholder still resolves regardless
// of the submission_code chosen.
//
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
// (format-preserving, single-run) substitution catches it. The
// DEMO/SKELETON banner makes it obvious this is a starter template and
// not approved firm content.
func buildDocumentXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
b.WriteString(`<w:body>`)
skeletonBanner(&b)
heading1(&b, "{{firm.name}}")
plain(&b, "Bearbeiter: {{user.display_name}}")
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
plainOptional(&b, "{{firm.signature_block}}")
heading1(&b, "{{project.court}}")
plain(&b, "Aktenzeichen: {{project.case_number}}")
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
plain(&b, "Instanz: {{project.instance_level}}")
heading2(&b, "In der Sache")
plain(&b, "{{parties.claimant.name}}")
plain(&b, "vertreten durch {{parties.claimant.representative}}")
bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
plain(&b, "")
plain(&b, "gegen")
plain(&b, "")
plain(&b, "{{parties.defendant.name}}")
plain(&b, "vertreten durch {{parties.defendant.representative}}")
bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
heading2(&b, "Betreff")
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
plain(&b, "Projekttitel: {{project.title}}")
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
heading1(&b, "{{rule.name}}")
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
heading2(&b, "Frist")
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
heading2(&b, "Schriftsatztext")
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
plain(&b, "")
plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]")
heading2(&b, "Schlussformel")
plain(&b, "{{today.long_de}}")
plain(&b, "")
plain(&b, "{{user.display_name}}")
plain(&b, "{{firm.name}}")
// Locale-aware verification block — exercises every EN/DE alias the
// variable bag carries (today.long_en, deadline.due_date_long_en,
// project.our_side_en, project.proceeding.name_en, rule.name_en) and
// the bare {{today}} alias. A lawyer customising the template can
// delete this block; the renderer round-trips it cleanly today.
heading2(&b, "Locale-aware variants (SKELETON)")
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
plain(&b, "Today (bare alias): {{today}}")
b.WriteString(`</w:body></w:document>`)
return b.String()
}
func skeletonBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)</w:t></w:r></w:p>`)
}
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
func bold(b *strings.Builder, text string) {
b.WriteString(`<w:p>`)
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
b.WriteString(xmlEscape(text))
b.WriteString(`</w:t></w:r></w:p>`)
}
func paragraph(b *strings.Builder, style, text string, italic bool) {
b.WriteString(`<w:p>`)
if style != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(style)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if italic {
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlEscape(seg))
b.WriteString(`</w:t></w:r>`)
}
b.WriteString(`</w:p>`)
}
func splitOnPlaceholders(s string) []string {
if s == "" {
return []string{""}
}
var out []string
for {
open := strings.Index(s, "{{")
if open < 0 {
out = append(out, s)
return out
}
close := strings.Index(s[open:], "}}")
if close < 0 {
out = append(out, s)
return out
}
end := open + close + 2
if open > 0 {
out = append(out, s[:open])
}
out = append(out, s[open:end])
s = s[end:]
if s == "" {
return out
}
}
}
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

View File

@@ -0,0 +1,568 @@
// Seed Example Projects (t-paliad-256 / m/paliad#87).
//
// Re-runnable test-data reset:
//
// 1. Wipes every row in paliad.projects (FK CASCADE handles the
// dependent rows: deadlines, appointments, parties, notes,
// project_events, project_teams, submission_drafts, approval_*,
// project_partner_units, user_pinned_projects, documents,
// user_calendar_bindings).
//
// 2. Inserts a small but realistic example tree (3 clients, 4
// litigations, 4 patents, 8 cases — 19 projects total) that
// exercises the auto-derived chain code: Client.Litigation.Patent.Case
// → e.g. SIEMENS.HUAW.789.INF.CFI.
//
// 3. Re-reads the projects and prints each row's chain code so the
// operator can eyeball the result without bouncing to SQL.
//
// Reference tables (proceeding_types, deadline_rules, event_types,
// gerichte, checklists templates, firms, profiles) are untouched.
//
// Run:
//
// DATABASE_URL='postgres://...' go run ./scripts/seed-example-projects
//
// One transaction wraps both wipe and seed so the DB is never in a
// half-wiped state. Re-running drops the previous example tree and
// reseeds fresh UUIDs — handy when project-code semantics change.
//
// Owner: m (matthias.siebels@hoganlovells.com). The script looks the
// auth user up by email so it works on any environment where that
// account exists; on a brand-new DB it falls back to NULL created_by.
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/services"
)
// ownerEmail is the auth.users email the seed assigns as created_by.
// Living in code (not a flag) because the example tree is m-owned by
// convention; flip if the example data ever needs a service-account
// owner.
const ownerEmail = "matthias.siebels@hoganlovells.com"
// Proceeding-type IDs used by the seed. Resolved by code (not pinned
// to integer IDs in source) to survive DB renumbering. Loaded once at
// startup; missing codes fail fast with a clear message.
var proceedingCodes = []string{
"upc.inf.cfi",
"upc.ccr.cfi",
"upc.apl.merits",
"de.inf.lg",
"epa.opp.opd",
"de.null.bpatg",
"dpma.opp.dpma",
}
func main() {
dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "Postgres DSN (defaults to $DATABASE_URL)")
dryRun := flag.Bool("dry-run", false, "print intended actions, roll back transaction")
flag.Parse()
if *dsn == "" {
fmt.Fprintln(os.Stderr, "seed-example-projects: DATABASE_URL not set and -dsn empty")
os.Exit(1)
}
db, err := sqlx.Connect("postgres", *dsn)
if err != nil {
fmt.Fprintln(os.Stderr, "connect:", err)
os.Exit(1)
}
defer db.Close()
ctx := context.Background()
if err := run(ctx, db, *dryRun); err != nil {
fmt.Fprintln(os.Stderr, "seed-example-projects:", err)
os.Exit(1)
}
}
func run(ctx context.Context, db *sqlx.DB, dryRun bool) error {
ownerID, err := lookupOwner(ctx, db, ownerEmail)
if err != nil {
return fmt.Errorf("lookup owner: %w", err)
}
if ownerID == uuid.Nil {
fmt.Printf("note: %s not found in auth.users — created_by will be NULL\n", ownerEmail)
} else {
fmt.Printf("owner resolved: %s = %s\n", ownerEmail, ownerID)
}
procIDs, err := lookupProceedingTypes(ctx, db, proceedingCodes)
if err != nil {
return fmt.Errorf("lookup proceeding_types: %w", err)
}
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }() // no-op if Commit ran first
if err := wipe(ctx, tx); err != nil {
return fmt.Errorf("wipe: %w", err)
}
tree, err := seed(ctx, tx, ownerID, procIDs)
if err != nil {
return fmt.Errorf("seed: %w", err)
}
if dryRun {
fmt.Println("\n--- DRY RUN — rolling back ---")
return nil
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
fmt.Println("seed committed.")
if err := report(ctx, db, tree); err != nil {
return fmt.Errorf("report: %w", err)
}
return nil
}
func lookupOwner(ctx context.Context, db *sqlx.DB, email string) (uuid.UUID, error) {
var id uuid.UUID
err := db.GetContext(ctx, &id, `SELECT id FROM auth.users WHERE email = $1`, email)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil
}
if err != nil {
return uuid.Nil, err
}
return id, nil
}
func lookupProceedingTypes(ctx context.Context, db *sqlx.DB, codes []string) (map[string]int, error) {
rows, err := db.QueryxContext(ctx,
`SELECT id, code FROM paliad.proceeding_types WHERE code = ANY($1)`,
pgTextArray(codes))
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int, len(codes))
for rows.Next() {
var id int
var code string
if err := rows.Scan(&id, &code); err != nil {
return nil, err
}
out[code] = id
}
for _, c := range codes {
if _, ok := out[c]; !ok {
return nil, fmt.Errorf("proceeding_types row missing for code=%q", c)
}
}
return out, nil
}
// pgTextArray is the lib/pq array adapter, repackaged inline so the
// script doesn't need a separate util import.
func pgTextArray(xs []string) any {
type arr = []string
return arr(xs)
}
// wipe deletes every paliad.projects row. FK CASCADE handles the
// dependent tables (verified live 2026-05-25 against information_schema:
// appointments, approval_requests, approval_policies, deadlines,
// documents, notes, parties, project_events, project_partner_units,
// project_teams, submission_drafts, user_pinned_projects,
// user_calendar_bindings, checklist_shares all cascade; projects.
// counterclaim_of and checklist_instances SET NULL; policy_audit_log
// SET NULL).
//
// Reference tables (proceeding_types, deadline_rules, event_types,
// gerichte, checklists, firms, partner_units, profiles) are not
// referenced from this delete.
func wipe(ctx context.Context, tx *sqlx.Tx) error {
res, err := tx.ExecContext(ctx, `DELETE FROM paliad.projects`)
if err != nil {
return err
}
n, _ := res.RowsAffected()
fmt.Printf("wiped: %d project rows (FK CASCADE handled dependents)\n", n)
return nil
}
// seededNode is one row of the seed result, kept so we can print the
// chain code after commit without re-querying for IDs.
type seededNode struct {
id uuid.UUID
title string
}
// seed inserts the example tree. Order matters because parent_id FKs
// must already exist — clients first, then litigations under them, then
// patents, then cases (with the CCR case referencing its sibling
// Klage case via counterclaim_of).
func seed(ctx context.Context, tx *sqlx.Tx, ownerID uuid.UUID, procIDs map[string]int) ([]seededNode, error) {
var nodes []seededNode
insertProject := func(p projectInsert) (uuid.UUID, error) {
id := uuid.New()
var createdBy any
if ownerID != uuid.Nil {
createdBy = ownerID
}
_, err := tx.ExecContext(ctx, `
INSERT INTO paliad.projects (
id, type, parent_id, title, reference, description, status,
created_by, industry, country, client_number, matter_number,
patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id,
our_side, opponent_code, instance_level, counterclaim_of
) VALUES (
$1, $2, $3, $4, $5, $6, 'active',
$7, $8, $9, $10, $11,
$12, $13, $14,
$15, $16, $17,
$18, $19, $20, $21
)`,
id, p.Type, nullUUID(p.ParentID), p.Title, nullStr(p.Reference), nullStr(p.Description),
createdBy, nullStr(p.Industry), nullStr(p.Country), nullStr(p.ClientNumber), nullStr(p.MatterNumber),
nullStr(p.PatentNumber), nullDate(p.FilingDate), nullDate(p.GrantDate),
nullStr(p.Court), nullStr(p.CaseNumber), nullInt(p.ProceedingTypeID),
nullStr(p.OurSide), nullStr(p.OpponentCode), nullStr(p.InstanceLevel), nullUUID(p.CounterclaimOf),
)
if err != nil {
return uuid.Nil, fmt.Errorf("insert %s %q: %w", p.Type, p.Title, err)
}
nodes = append(nodes, seededNode{id: id, title: p.Title})
return id, nil
}
// --- Client 1: Siemens AG ----------------------------------------
siemens, err := insertProject(projectInsert{
Type: "client", Title: "Siemens AG", Reference: "SIEMENS",
Industry: "Telekommunikation / Industrieelektronik", Country: "DE",
Description: "Beispiel-Mandant — Telekommunikation & Halbleiter.",
})
if err != nil {
return nil, err
}
siemensHuawei, err := insertProject(projectInsert{
Type: "litigation", ParentID: siemens,
Title: "Siemens ./. Huawei Technologies", OpponentCode: "HUAW",
Description: "Patentstreit Mobilfunk-Standardpatent.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
siemensHuaweiPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: siemensHuawei,
Title: "EP3456789 — Funkkommunikationssystem mit Mehrfachantenne",
PatentNumber: "EP3456789",
FilingDate: "2018-03-12", GrantDate: "2022-11-09",
})
if err != nil {
return nil, err
}
upcInfCFI, err := insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC CFI München — Klage Siemens ./. Huawei (EP3456789)",
Court: "UPC Lokalkammer München",
CaseNumber: "UPC_CFI_123/2026",
ProceedingTypeID: procIDs["upc.inf.cfi"],
OurSide: "claimant",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC CFI München — Widerklage Huawei ./. Siemens (EP3456789)",
Court: "UPC Lokalkammer München",
CaseNumber: "UPC_CFI_123/2026 (CCR)",
ProceedingTypeID: procIDs["upc.ccr.cfi"],
OurSide: "defendant", // we're respondent on the CCR
InstanceLevel: "first",
CounterclaimOf: upcInfCFI,
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC Berufungsgericht — Berufung Huawei (EP3456789)",
Court: "UPC Court of Appeal",
CaseNumber: "UPC_CoA_45/2027",
ProceedingTypeID: procIDs["upc.apl.merits"],
OurSide: "respondent",
InstanceLevel: "appeal",
})
if err != nil {
return nil, err
}
siemensBosch, err := insertProject(projectInsert{
Type: "litigation", ParentID: siemens,
Title: "Siemens ./. Robert Bosch GmbH", OpponentCode: "BOSCH",
Description: "Sensorik / autonomes Fahren.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
siemensBoschPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: siemensBosch,
Title: "EP1111222 — Sensoreinrichtung für autonomes Fahren",
PatentNumber: "EP1111222",
FilingDate: "2017-06-21", GrantDate: "2021-08-04",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensBoschPatent,
Title: "LG München I — Klage Siemens ./. Bosch (EP1111222)",
Court: "Landgericht München I",
CaseNumber: "7 O 12345/26",
ProceedingTypeID: procIDs["de.inf.lg"],
OurSide: "claimant",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
// --- Client 2: Bayer AG ------------------------------------------
bayer, err := insertProject(projectInsert{
Type: "client", Title: "Bayer AG", Reference: "BAYER",
Industry: "Pharma / Life Sciences", Country: "DE",
Description: "Beispiel-Mandant — pharmazeutische Wirkstoffe.",
})
if err != nil {
return nil, err
}
bayerNova, err := insertProject(projectInsert{
Type: "litigation", ParentID: bayer,
Title: "Bayer ./. Novartis Pharma", OpponentCode: "NOVA",
Description: "Wirkstoffverbindung X — Einspruch + Nichtigkeit.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
bayerNovaPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: bayerNova,
Title: "EP2222333 — Wirkstoffverbindung X",
PatentNumber: "EP2222333",
FilingDate: "2015-09-30", GrantDate: "2020-04-22",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: bayerNovaPatent,
Title: "EPA Einspruch — Novartis ./. EP2222333",
Court: "Europäisches Patentamt — Einspruchsabteilung",
CaseNumber: "OPP-2026-0042",
ProceedingTypeID: procIDs["epa.opp.opd"],
OurSide: "respondent", // Bayer is patent owner defending the patent
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: bayerNovaPatent,
Title: "BPatG — Nichtigkeitsklage Novartis ./. EP2222333",
Court: "Bundespatentgericht",
CaseNumber: "5 Ni 12/26",
ProceedingTypeID: procIDs["de.null.bpatg"],
OurSide: "respondent",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
// --- Client 3: Beispiel AG (intentionally sparse) ----------------
// Demonstrates the empty-segment skip in BuildProjectCode — the
// case row has a proceeding_type set so the tail is present, but
// no instance_level / our_side, and the patent's number is national
// (DE) so the last-3-digits segment shows DE-style behaviour.
beispiel, err := insertProject(projectInsert{
Type: "client", Title: "Beispiel AG", Reference: "BEISPL",
Industry: "Unspezifiziert", Country: "DE",
Description: "Sparse-Beispiel — zeigt, wie fehlende Segmente übersprungen werden.",
})
if err != nil {
return nil, err
}
beispielWtb, err := insertProject(projectInsert{
Type: "litigation", ParentID: beispiel,
Title: "Beispiel ./. Wettbewerber GmbH", OpponentCode: "WTB",
Description: "Demo-Litigation ohne große Detailtiefe.",
})
if err != nil {
return nil, err
}
beispielWtbPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: beispielWtb,
Title: "DE10987654 — Demo-Erfindung",
PatentNumber: "DE10987654",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: beispielWtbPatent,
Title: "DPMA Einspruch — Wettbewerber ./. DE10987654",
Court: "Deutsches Patent- und Markenamt",
CaseNumber: "DPMA-EIN-987/26",
ProceedingTypeID: procIDs["dpma.opp.dpma"],
OurSide: "respondent",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
fmt.Printf("seeded: %d projects\n", len(nodes))
return nodes, nil
}
// projectInsert is the typed input for one insertProject call. Pointer
// fields are kept as plain strings here and converted via nullStr at
// bind time; keeps the call sites readable.
type projectInsert struct {
Type string
ParentID uuid.UUID
Title string
Reference string
Description string
Industry string
Country string
ClientNumber string
MatterNumber string
PatentNumber string
FilingDate string // YYYY-MM-DD
GrantDate string
Court string
CaseNumber string
ProceedingTypeID int
OurSide string
OpponentCode string
InstanceLevel string
CounterclaimOf uuid.UUID
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
func nullInt(i int) any {
if i == 0 {
return nil
}
return i
}
func nullUUID(u uuid.UUID) any {
if u == uuid.Nil {
return nil
}
return u
}
func nullDate(s string) any {
if s == "" {
return nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return nil
}
return t
}
// reportRow is one row of the post-seed report — only the fields the
// printout needs.
type reportRow struct {
ID uuid.UUID `db:"id"`
Type string `db:"type"`
Title string `db:"title"`
Path string `db:"path"`
}
// report prints the seeded tree with the auto-derived chain code for
// each row. Uses services.BuildProjectCode so the script verifies the
// same helper the live app uses (catches drift if the algorithm
// changes).
func report(ctx context.Context, db *sqlx.DB, _ []seededNode) error {
var rows []reportRow
err := db.SelectContext(ctx, &rows, `
SELECT id, type, title, path
FROM paliad.projects
ORDER BY path
`)
if err != nil {
return err
}
fmt.Println("\nresulting chain codes:")
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "TYPE\tTITLE\tCODE")
for _, r := range rows {
code, err := services.BuildProjectCode(ctx, db, r.ID)
if err != nil {
return fmt.Errorf("build code for %s: %w", r.ID, err)
}
indent := strings.Repeat(" ", pathDepth(r.Path)-1)
fmt.Fprintf(tw, "%s\t%s%s\t%s\n", r.Type, indent, r.Title, code)
}
return tw.Flush()
}
func pathDepth(p string) int {
if p == "" {
return 1
}
d := 1
for _, c := range p {
if c == '.' {
d++
}
}
return d
}