Compare commits

...

39 Commits

Author SHA1 Message Date
mAi
293e612582 feat(projection): IsConditional for uncertain-anchor rules (t-paliad-289)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Rules anchored on uncertain triggers (R.109 backward-anchor without
oral-hearing date; R.118(4) without validity decision; R.262(2)
without recorded Vertraulichkeitsantrag) previously rendered concrete
dates fabricated off the trigger date. Add IsConditional projection
flag so the SmartTimeline + Verfahrensablauf surfaces "abhängig von
<parent>" instead of a misleading date.

Backend (fristenrechner.go):
- Add IsConditional + ParentRuleCode/Name/NameEN to UIDeadline.
- Pre-pass populates courtSet from rule.is_court_set=true BEFORE the
  main loop, so order-of-evaluation in sequence_order no longer matters
  for the parent-court-set check. Fixes R.109(1) "Antrag auf
  Simultanübersetzung" (sequence_order=45 < Mündliche Verhandlung's
  sequence_order=50): the timing='before' backward arithmetic was
  computing 1 month before the trigger date because the court-set
  parent hadn't been classified yet.
- Set IsConditional=true on every IsCourtSetIndirect branch (catches
  R.109 backward + R.118(4) cons_orders chain off the decision).
- Set IsConditional=true for priority='optional' + primary_party='both'
  rules whose data-model parent is the trigger anchor (covers R.262(2)
  confidentiality_response: the data anchors on SoC, but the real
  trigger is the opposing party's confidentiality motion which may
  never happen). Suppressed by IsOverridden so user anchors win.

Backend (projection_service.go):
- Add IsConditional to TimelineEvent + propagate from UIDeadline.
- New Status="conditional" for projected rows; clears Date, populates
  DependsOnRuleCode/Name from UIDeadline.ParentRule* so the row
  carries the "abhängig von <parent>" payload even when the parent
  has no computed date for annotateDependsOn to discover.

Frontend (verfahrensablauf-core.ts + CSS + i18n):
- CalculatedDeadline gains isConditional + parentRule* fields.
- deadlineCardHtml renders "abhängig von <parent>" chip with
  click-to-edit affordance in place of the date column when
  isConditional=true. IsConditional wins over IsCourtSet for the
  date column (they overlap; "abhängig von <parent>" names the
  specific blocker).
- .timeline-item--conditional / .fr-col-item--conditional CSS:
  dotted border + faded text so the conditional state reads at glance.
- Replaced escHtml's DOM-backed implementation with a pure-JS regex
  escape so the module is testable in bun test without jsdom (the
  old form forced fixtures to leave several fields empty just to
  avoid the DOM dependency).

Tests:
- TestApplyLookaheadCap_ConditionalRowsPassThrough: pure-function lock
  that conditional rows pass through applyLookaheadCap untouched
  (don't count against ProjectedTotal/Shown, don't get capped).
- TestUIDeadline_IsConditional_UncertainAnchors (TEST_DATABASE_URL):
  asserts R.109(1)/(4), R.118(4) chain, and R.262(2) all render
  IsConditional=true with empty DueDate + populated ParentRule*; SoD
  stays non-conditional; override on the oral hearing flips R.109(1)
  back to concrete date.
- 4 new bun tests for the conditional rendering branches in
  deadlineCardHtml.

UX path verified by tests + manual review of the live rule corpus:
opening a UPC inf project without oral-hearing date now surfaces
R.109(1) + R.109(4) as conditional; recording the Vertraulichkeitsantrag
(anchoring R.262(2) via the existing "Datum setzen" flow) flips it
back to a concrete date.

go build / go test / bun test / bun run build all clean.
2026-05-26 09:56:15 +02:00
mAi
9d3325bd88 Merge: t-paliad-291 — dark-mode lime-chip contrast fix across 6 selectors (m/paliad#123)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:47:02 +02:00
mAi
18d2e743ba fix(styles): dark-mode contrast on lime-active chips (t-paliad-291)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Six surfaces paired a lime background with var(--color-text), which
flips to cream in dark mode and collapses contrast on the high-luminance
brand lime. Switch them to var(--color-accent-dark) — the design token
already defined to stay midnight in both themes as the WCAG-AA fg on
lime.

Affected:
  - .event-card-choices-option--active  (Berufung durch … popover —
    m's primary report on m/paliad#123)
  - .fristen-row.is-active .fristen-row-num
  - .form-hint-badge
  - .paliadin-widget-send-btn
  - .smart-timeline-anchor-submit
  - .admin-rules-chip.active

Lime hue and non-active states untouched.

Refs: m/paliad#123
2026-05-26 09:45:59 +02:00
mAi
07d2eb472c Merge: t-paliad-287 — submission form revision (Frist drop + grouped sections + Add Party + DB picker) (m/paliad#119)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:42:58 +02:00
mAi
7cdccd55ae feat(submission-draft): grouped sections + per-side Add Party with DB picker (t-paliad-287)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Restructures the submission-draft sidebar per m's m/paliad#119 review.

Three changes on the variable form (Part B):
- VARIABLE_GROUPS collapses into four lawyer-facing sections: Mandant
  & Verfahren (firm.* + project.* + procedural_event.*), Parteien
  (manual {{parties.<role>.*}} overrides), Frist (the now-internal
  deadline.* block, COLLAPSED by default since the skeletons no
  longer render it), Sonstiges (today.* / user.* trim).
- Group sections are click-to-collapse via a sticky state map; the
  Frist + Parteien-override sections open closed so the visible form
  stays tight on first load.
- The legacy {{rule.*}} aliases drop off the sidebar — still resolved
  by SubmissionVarsService for old templates, no longer surfaced as
  override rows (they cluttered the form and the canonical
  procedural_event.* names cover the same ground).

Multi-party + Add Party (Part C):
- The party picker now renders all three role buckets (claimants /
  defendants / others) even when empty, so the lawyer can populate via
  Add Party. The block is hidden only when no project is attached.
- Each side gets a "+ Partei hinzufügen (Klägerseite / Beklagtenseite
  / Weitere Parteien)" button that opens an inline panel with two
  tabs:
  - Manual entry — name, role (pre-filled from side), representative.
    Submits to POST /api/projects/{id}/parties, creating a real
    paliad.parties row that immediately surfaces in available_parties.
  - Aus DB übernehmen — debounced (200ms) search against the new
    GET /api/parties/search endpoint. Returns hits across every
    visible project with project_title + reference for context.
    Already-on-this-project rows are filtered out client-side. Picking
    a hit clones name/role/representative into a fresh row on the
    current project — the simplest semantics that survives the
    paliad.parties.project_id NOT NULL contract while honouring m's
    "no manual re-typing" requirement.
- Newly-added parties land in selected_parties immediately so the new
  party is rendered in the next preview round-trip without an extra
  click. Implicit-"all" default is preserved (empty selected_parties
  still means "every party on the project, including this new one").
- Search-result repaints reach only into the <ul>, not the whole
  picker — keeps focus + selection on the search input across
  keystrokes.

CSS:
- Collapsible-section caret rotation, busy/disabled form states, tab
  highlights, DB-picker result rows with project chip + hover, all
  inherit the existing lime-tint accent so the new affordances look
  native to the editor.

TSX:
- Comment update on the parties block; no structural change. The
  bilingual hint copy in i18n.ts now nudges towards Add Party.
2026-05-26 09:41:36 +02:00
mAi
d4ed989b8f feat(parties): cross-project party search endpoint for submission picker (t-paliad-287)
Adds PartyService.Search returning paliad.parties rows from every
project the caller can see, matched by case-insensitive substring on
name or representative. Wired via GET /api/parties/search?q=... — used
by the submission-draft Add-Party panel's "Aus DB übernehmen" tab.

Visibility flows through the same visibilityPredicatePositional helper
every project-scoped read uses; invisible projects' parties never
surface. Capped at 25 hits per call (no pagination — typical lookup is
"the party I'm thinking of by name", not a browse).

Result shape carries project_title + project_reference so the picker
can disambiguate identically-named parties across cases.
2026-05-26 09:41:07 +02:00
mAi
54fb676db5 chore(templates): drop 'Frist' block from skeleton + HL-firm-skeleton (t-paliad-287)
Per m's m/paliad#119 report: the {{deadline.*}} block was leaking
internal/admin context (Frist-Bezeichnung, Fälligkeit, "berechnet aus",
Quelle) into court-bound submissions. The dedicated Frist heading and
its 4 body lines are removed from both gen-skeleton-submission-template
(_skeleton.docx) and gen-hl-skeleton-template (_firm-skeleton.docx).
The {{deadline.due_date_long_en}} reference in the locale-aware
verification footer is also dropped. {{deadline.*}} placeholders stay
resolvable in SubmissionVarsService — a custom template can still pick
them up — but the default skeletons no longer render them in the body.

Regenerated .docx files uploaded to HL/mWorkRepo:
- 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx → d0ecc0e
- 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx → 25954c9
2026-05-26 09:41:01 +02:00
mAi
c3eaa9b1d4 Merge: t-paliad-290 — show-hidden toggle + un-hide chip on Verfahrensablauf (m/paliad#122)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:39:52 +02:00
mAi
80883eaac5 feat(verfahrensablauf): re-surface hidden optional events — show-hidden toggle + un-hide chip (t-paliad-290)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
m/paliad#122. atlas's #96 Slice A added per-card 'Überspringen' but no
un-skip path — hidden cards just disappeared from the timeline. This
adds the missing return path:

- CalcOptions.IncludeHidden (default false) tells the calculator to
  re-surface skipRules entries as faded rows instead of dropping them.
  When true, the rule renders with UIDeadline.IsHidden=true and the
  descendant-suppression cascade is bypassed so children compute their
  dates off the un-suppressed parent.
- UIResponse.HiddenCount always reflects the projection's hide count
  (gate-passed rules whose submission_code is in skipRules) so the
  "Ausgeblendete (N)" badge stays accurate regardless of toggle state.
- /tools/verfahrensablauf gets a "Ausgeblendete anzeigen" checkbox next
  to the perspective + appellant selectors. URL-driven (?show_hidden=1)
  so the state is shareable and survives reload. The row hides itself
  on projections with zero hidden cards.
- Hidden cards render via .timeline-item--hidden / .fr-col-item--hidden
  (opacity 0.55 + dotted border, mirroring the existing
  --skipped fade) and carry an inline "Wieder einblenden" chip. Clicking
  the chip removes the skip choice via the page's existing
  attachEventCardChoices remove callback (URL state + recalc included)
  and runs through a new delegated handler in event-card-choices.ts.
- 3 new i18n keys (DE+EN): choices.show_hidden.label,
  choices.show_hidden.count, choices.unhide.chip.

The skip-choice storage shape (paliad.project_event_choices, atlas's
table) is unchanged — un-hide is just a delete of the skip row.

Tests: 3 new bun-test cases pin the chip contract (emits on isHidden=
true with submission_code, suppressed otherwise); go test ./internal/...
+ bun run build clean.
2026-05-26 09:38:31 +02:00
mAi
5e17de6e07 Merge: t-paliad-288 — Verfahrensablauf 'Beide' → 'Nicht festgelegt' (m/paliad#120)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:35:19 +02:00
mAi
0e1f62e375 feat(verfahrensablauf): replace 'Beide' chip with 'Nicht festgelegt' (t-paliad-288)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The Verfahrensablauf side selector offered Klägerseite / Beklagtenseite /
Beide. 'Beide' is legally impossible (no party is on both sides) — the
state being modelled is "perspective not yet picked", not "both sides".
Rename the chip to 'Nicht festgelegt' (DE) / 'Undefined' (EN) without
changing the underlying state value or projection behaviour.

- frontend/src/verfahrensablauf.tsx: chip label flips to
  deadlines.side.undefined; add inline hint chip
  "Wählen Sie eine Seite, um die Spalten zu fokussieren." next to the
  radio cluster, shown only while no side is picked.
- frontend/src/client/verfahrensablauf.ts: sideLabelI18n() returns the
  new key for null; syncSideHintVisibility() toggles hint display from
  initPerspectiveControls, the side-radio change handler, and
  showSideRadioCluster (chip→radio override path).
- frontend/src/client/i18n.ts: rename deadlines.side.both →
  deadlines.side.undefined (DE: Nicht festgelegt, EN: Undefined); add
  deadlines.side.hint in both languages.
- frontend/src/i18n-keys.ts: rename in the union, keep alphabetical
  order.
- frontend/src/styles/global.css: .side-radio-cluster becomes inline-flex
  so the hint sits next to the toggle; .side-hint styled muted+italic.

URL backward-compat: ?side=both is already silently treated as null by
readSideFromURL (only accepts claimant|defendant) — same column
behaviour as before, no migration needed. projects.field.our_side.both
is a different concept (a project being a multi-party participant) and
stays untouched.

Tests: 17/17 in verfahrensablauf-core.test.ts still pass; the
"default (no opts) mirrors 'both' rules into ours AND opponent" case
already covers the unchanged null-side projection. Go build + tests
clean. Frontend build clean (i18n scan: 2901 keys, data-i18n
attributes clean).

m/paliad#120
2026-05-26 09:33:00 +02:00
mAi
cca5e72c57 ci: trigger workflow run to verify Slice A pre-deploy gate (post DOKPLOY_TOKEN setup)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:19:27 +02:00
mAi
4d923562f5 Merge: t-paliad-283 — /views/any filter-bar Predicates flatten fix (m/paliad#115)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-25 17:47:57 +02:00
mAi
c70914c2a0 fix(filter-bar): flatten FilterSpec.Predicates wire shape (t-paliad-283)
The bar's chip clicks POST a payload shaped as `predicates: {<source>:
<per-source>}` — flat, one entry per data source. Go declared
`Predicates map[DataSource]Predicates` — a doubled-nested wrapper where
each map value was itself a Predicates struct with named per-source
fields. The JSON shape Go expected was
`{"deadline": {"deadline": {"status": [...]}}}`; the shape the bar
emitted was `{"deadline": {"status": [...]}}`. Go silently unmarshalled
the bar's payload as `Predicates{}` (all source fields nil), so every
chip click on /views/any was a server-side no-op — the regression in
#115.

The latent contract bug was present since t-paliad-144 A1 (b516201) but
only surfaced now: /inbox uses the InboxSystemView's code-resident
predicates (built in Go directly, doubled shape works) and saved views
never carried predicates in the DB, so chip-click overlays were the
only path that exercised the wire-format wrong way. /views/any made
that path visible because all four sources need narrowing.

Fix: align Go to the flat shape the frontend already emits.

- FilterSpec.Predicates: `map[DataSource]Predicates` → `*Predicates`.
- All `spec.Predicates[SourceX]` access sites in view_service.go +
  approvalStatusMatches + allowed* helpers + system_views literals
  + tests rewritten to `spec.Predicates.X` with a nil-spec.Predicates
  guard.
- Frontend FilterSpec.predicates type tightened from
  `Partial<Record<DataSource, Predicates>>` (which silently allowed
  the wrong runtime write) to `Predicates`.

Regression coverage:

- `filter_spec_predicates_test.go` (new, Go) pins three contracts:
  the bar's exact wire payload unmarshals into a non-nil per-source
  predicate; marshalling a Go-constructed spec produces the same flat
  shape; the "Erledigt" chip's request narrows to completed deadlines.
- `compute-effective.test.ts` (new, bun:test) pins 12 chip-overlay
  cases for /views/any (every axis the saved view's sources expose).

Build hygiene:
- `go build ./...` clean.
- `go test ./... -count 1` clean (existing inbox + filter_spec tests
  updated for the new struct shape; new tests pass).
- `cd frontend && bun run build` clean.
- `cd frontend && bun test src/` — 169 pass, 0 fail.

No migration: paliad.user_views.filter_spec jsonb rows live with
`predicates: {}` or no predicates field; both unmarshal as nil
*Predicates under the new type, identical to the no-narrowing behaviour
the old map type produced for the same rows.
2026-05-25 17:46:58 +02:00
mAi
016ac2532a Merge: t-paliad-282 Slice A — CI/CD pre-deploy gate + snapshot-based migration smoke (m/paliad#114)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-25 17:42:51 +02:00
mAi
c901293c9c feat(cicd): Slice A — pre-deploy gate + role-split migration smoke
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Adds .gitea/workflows/test.yaml that gates every push on `go build`,
`bun run build`, `go vet`, the migration coordination check, and the
role-split end-to-end migration smoke. On push to main + green, calls
Dokploy's compose.deploy API and polls /health/ready until 200.

t-paliad-282 / m/paliad#114. Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md
(inventor shift on mai/cronus/inventor-ci-cd-pre).

Catches all three of today's outage classes:

  brunel (~13:20) slot collision     -> TestMigrations_NoDuplicateSlot
  hermes (~16:05) dropped-col refs   -> TestBootSmoke
  mig 129 (~14:56) 42501 ownership   -> TestMigrations_EndToEndAsAppRole

Snapshot approach. internal/db/testdata/prod-snapshot.sql is a pg_dump
of youpc-supabase paliad schema + applied_migrations rows. CI restores
this into a fresh `supabase/postgres:15.8.1.060` (same image, same role
topology as prod) and runs ApplyMigrations as the `postgres` role
(which is NOT a superuser on supabase/postgres, matching prod). Existing
migrations are skipped (already in applied_migrations); only NEW migs
from the PR run end-to-end. This sidesteps the fresh-DB idempotence
debt in some historical migrations (mig 037 missing pg_trgm, mig 051
inner COMMIT) — those are tracked separately and don't block the gate.

Sub-changes:

- internal/handlers/handlers.go — new /health/ready endpoint distinct
  from /healthz. /healthz stays liveness (process alive, no DB); /ready
  is readiness (DB pool pings within 2 s). Returns 503 when svc or pool
  is nil (DB-less deploys are intentionally not-ready). svc.Pool added
  to handlers.Services, wired in cmd/server/main.go.

- internal/db/migrate_test.go — TestMigrations_NoDuplicateSlot (pure
  unit, catches brunel) and TestMigrations_EndToEndAsAppRole (snapshot-
  gated, catches the 42501 class).

- cmd/server/main_smoke_test.go — TestBootSmoke now also asserts
  /health/ready returns 503 with a nil svc. New TestHealthReady_Live
  asserts 200 against a live pool.

- internal/db/migrations/024_rename_department_columns.up.sql and
  027_rename_to_partner_units.up.sql — ALTER INDEX / ALTER POLICY
  exception handlers now catch undefined_object OR undefined_table OR
  duplicate_object. Old handler only caught undefined_object; Postgres
  raises undefined_table when source object never existed, and
  duplicate_object when destination already exists. The expanded
  handlers make these migrations truly idempotent across all plausible
  starting states.

- Makefile — verify-mig-app, test-frontend, refresh-snapshot targets.
  refresh-snapshot pg_dumps youpc-supabase prod (needs PALIAD_PROD_DATABASE_URL),
  strips pg16 \restrict commands for pg15 restore compat, and filters
  applied_migrations rows to this branch's max on-disk version.

- internal/db/testdata/README.md — explains the snapshot's purpose,
  refresh procedure, and how to verify locally.

- docs/cicd-runner-setup-2026-05-25.md — one-time admin steps for
  registering a Gitea Actions runner on mriver and wiring DOKPLOY_TOKEN
  as a repo secret. Documents soft-launch plan per m's Q11.4 (keep
  Dokploy's autoDeploy=true webhook alive for one week, disable after
  the workflow has gated 5 successful deploys).

Build clean. Full go test ./internal/... ./cmd/... green without
TEST_DATABASE_URL. With TEST_DATABASE_URL + TEST_APP_DATABASE_URL set
to a supabase/postgres scratch + snapshot restored:
TestMigrations_NoDuplicateSlot, TestMigrations_EndToEndAsAppRole,
TestBootSmoke, TestHealthReady_Live all pass. Live-DB service tests in
internal/services/* fail under supabase/postgres 15.8 with a 42P08
parameter-binding error (unrelated to Slice A — tracked as a follow-up).
2026-05-25 17:42:06 +02:00
mAi
0b1653c2bf Merge: t-paliad-284 — Wave 1 Tier 1 rule additions + Q6 archived cleanup + audit FK fix (mig 132) (m/paliad#116) 2026-05-25 17:38:42 +02:00
mAi
a6cf6ff4c9 feat: t-paliad-284 Wave 1 Tier 1 deadline-rule additions (mig 132)
Add 12 Tier 1 procedural deadline rules from curie's audit §10
(docs/research-deadlines-completeness-2026-05-25.md), backfill the
UPC R.104/R.105 Interim Conference citation on upc.inf.cfi.interim
(m/paliad#116 / m's 2026-05-25 report), and fold in the audit Q6
cleanup of the 40 _archived_litigation.* rows.

New rules:
  T1.1  upc.inf.cfi.cmo_review           15d / R.333.2
  T1.2  upc.inf.cfi.confidentiality_response 14d / R.262.2 (trigger 25)
  T1.3  upc.apl.order.grounds_orders     15d / R.224.2(b)
  T1.4  upc.apl.order.response_orders    15d / R.235.2
  T1.5  upc.inf.cfi.cons_orders          2mo / R.118.4
  T1.6  upc.inf.cfi.rectification        1mo / R.353
  T1.7  upc.pi.cfi.deficiency            14d / R.207.6(a)
  T1.8  upc.pi.cfi.merits_start          31d OR 20wd (max) / R.213 + R.198.1
  T1.9  upc.inf.cfi.translation_request  1mo BEFORE oral / R.109.1
  T1.10 upc.inf.cfi.interpreter_cost     2wk BEFORE oral / R.109.4
  T1.11 upc.inf.cfi.translations_lodge   2wk / R.109.5 (trigger 113)
  T1.12 upc.pi.cfi.response              UPDATE: re-anchor on .app, court-set

T1.8 uses Wave 2 Slice A primitives (mig 128: working_days unit +
combine_op='max'). T1.9/T1.10 use timing='before' with the
backward-snap path in deadline_calculator.go.

Also drops the deadline_rule_audit.rule_id FK constraint. The mig 079
audit trigger had a latent bug — it could not log DELETEs because the
FK rejected the post-delete INSERT (count(*) WHERE action='delete'
was 0 across the entire history). Audit tables are append-only
history and should not FK-constrain on live entity tables; before_json
preserves the full row state. Unblocking this also unblocks the §13b
Q6 cleanup.

Verified on Supabase: 13 rows present in post-fix shape, all
assertions in the DO-block pass, audit log now records 11 creates +
2 updates + 40 deletes for this migration.
2026-05-25 17:29:13 +02:00
mAi
191d8e7268 Merge: t-paliad-285+286 — UPC Damages + PM appeal route deadlines (mig 133) (m/paliad#117, m/paliad#118) 2026-05-25 17:26:04 +02:00
mAi
cb44b3b8cc mAi: #117 + #118 - t-paliad-285/-286 UPC dmgs+pi court followup (mig 133)
Adds the post-submission court phase to upc.dmgs.cfi and the appeal
route to upc.pi.cfi. The Verfahrensablauf timeline currently stops at
the last party submission (dmgs.rejoin / pi.order); without these rows
the interim conference / oral hearing / decision / appeal sub-tree
never renders, even though atlas's #96 spawn mechanism is in place.

Migration 133 (single slot, coordinated with knuth's #116 on 132):

Section A — UPC Damages tree end (#117):
- upc.dmgs.cfi.interim       court-set, R.105
- upc.dmgs.cfi.oral          court-set, R.118 / R.250
- upc.dmgs.cfi.decision      court-set, R.118 / R.144
- upc.dmgs.cfi.appeal_spawn  2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits

Section B — UPC PI appeal route (#118):
- upc.pi.cfi.appeal_spawn    2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
  PI orders under R.211 dispose of the urgent question and ride the
  main 2-month track; the 15-day R.220.1(c) order track does not apply.

Same shape as mig 095 inf.appeal_spawn and the upc.inf.cfi
interim/oral/decision rows from mig 012. Court-set rows reuse the
shared interim-conference / oral-hearing / decision concepts.

Citations: docs/research-deadlines-completeness-2026-05-25.md §D + Tier 4 (R.144), docs/audit-upc-rop-deadlines-2026-05-08.md §D R.144 + §F R.220.1(a)/R.224.1(a). Per-row RoP citation in the migration header.

Idempotent INSERT NOT EXISTS guards per row + post-insert DO block that RAISEs EXCEPTION if any expected row is missing or the spawn shape (is_spawn / spawn_proceeding_type_id / parent_id) is wrong.

go build ./... clean, go test ./internal/... clean, bun run build clean.
2026-05-25 17:25:19 +02:00
mAi
c4e9875ff4 Merge: t-paliad-276 — submission generator DE/EN language selector, post-rebase (mig 130) (m/paliad#108) 2026-05-25 17:04:23 +02:00
mAi
e4c694e01c mAi: #108 - t-paliad-276 submission generator language selector (DE/EN)
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).

Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
  CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
  preserving every legacy draft's behaviour byte-for-byte.

Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
  uses it when set; falls back to user.Lang otherwise — Slice 1's
  format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
  through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
  outside {de,en}. Project-scoped + global PATCH endpoints both
  surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
  predecessor. Returns the matched tier (per_code_lang / per_code /
  skeleton_lang / skeleton / letterhead) so the editor knows whether
  to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
  alongside the DE one; per-code EN variants land in a parallel
  submissionTemplateENRegistry (empty for now — EN templates land per
  HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
  `?language=de|en` query override (one-shot path, no draft row to
  pull the column from); defaults to the user's UI lang.

Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
  Switching the radio PATCHes `language` and the server returns the
  freshly-resolved bag + preview HTML so the lawyer sees EN values
  immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
  sprachspezifische Vorlage)") shows when the resolved tier doesn't
  match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.

Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
  rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.

Build hygiene: go build/vet/test clean; bun run build clean.
2026-05-25 17:03:34 +02:00
mAi
5efb9f5098 Merge: t-paliad-281 — admin rules list 'undefined' proceeding name fix (m/paliad#113) 2026-05-25 16:54:39 +02:00
mAi
c6267e4e6d Merge: t-paliad-277 — submission party selector + import-from-project (mig 131) (m/paliad#109) 2026-05-25 16:53:50 +02:00
mAi
8e696487e0 Merge: t-paliad-279 — Verfahrensablauf form reorder, party-after-proceeding-type (m/paliad#111)
# Conflicts:
#	frontend/src/client/verfahrensablauf.ts
2026-05-25 16:53:32 +02:00
mAi
001542a3ce mAi: #113 - fix admin rules list 'undefined' proceeding name
ProceedingType TS interface in admin-rules-list.ts and admin-rules-edit.ts
declared `name_de` but the Go ProceedingType model serialises `db:"name"`
as JSON key `name` (DE is the primary on the wire). Result: `pt.name_de`
was undefined for every row, so `${pt.code} · ${pt.name_de}` produced the
literal "upc.apl.cost · undefined" in the list (and the same in proceeding
selects of the edit page).

Frontend-only fix:
- Rename the field to `name` to match the API contract.
- Guard the label builder: if the active-language name is missing, fall
  back to just the proceeding code rather than rendering "code · " (or
  worse, the original "code · undefined" string).

Other admin pages that fetch /api/proceeding-types-db (deadlines-new,
deadlines-detail, project-form, fristenrechner) already read `pt.name`
correctly, so the bug was scoped to these two files. TriggerEvent's
`name_de` field is real and stays untouched.
2026-05-25 16:52:07 +02:00
mAi
4fc3005db8 mAi: #109 - t-paliad-277 submission generator party selector + import-from-project
Multi-select party picker on the dedicated submission draft editor —
lawyer picks which of the project's parties to mention in this
specific submission. Adds the t-paliad-277 variable-bag multi-party
shape ({{parties.claimants}}, {{parties.claimant.0.name}}) while
keeping the legacy flat aliases ({{parties.claimant.name}}) for every
existing .docx template authored before the rename.

Surfaces an explicit "Aus Projekt importieren" button + last-imported
timestamp at the top of the variable sidebar so the lawyer can re-pull
project-derived variables (project.*, parties.*, deadline.*,
procedural_event.*, rule.*) when the project data drifts away from the
saved draft overrides. firm.*, today.*, user.* overrides survive the
import — those values aren't sourced from the project record.

Schema: mig 131 adds two columns to paliad.submission_drafts:
  - selected_parties uuid[] DEFAULT '{}'::uuid[]
    Empty = include every party (legacy default).
    Non-empty = restrict to the subset, grouped by role at substitution.
  - last_imported_at timestamptz NULL
    Bumped each "Aus Projekt importieren" click; surfaced in UI.

Backend:
  - SubmissionVarsContext gains SelectedParties — filterPartiesBySelection
    restricts the resolved bag before role bucketing.
  - addPartyVars emits THREE coexisting forms per role: comma-joined
    (parties.claimants), indexed (parties.claimant.0.name), and flat
    legacy (parties.claimant.name → first selected claimant). Flat
    aliases are kept forever per the issue's backward-compat contract.
  - SubmissionDraftService.ImportFromProject strips overrides for
    project-derived prefixes and bumps last_imported_at; rejects
    project-less drafts (nothing to import from).
  - New endpoint POST /api/submission-drafts/{id}/import-from-project.
  - DraftPatch + PATCH handlers accept selected_parties.
  - submissionDraftView now ships available_parties so the editor can
    render the picker without an extra round-trip.

Frontend:
  - submission-draft.tsx: new import-row + parties block in the sidebar.
  - client/submission-draft.ts: paintImportRow / paintPartyPicker /
    onPartySelectionChange / onImportFromProject; group parties by
    role bucket (claimant / defendant / other) with DE+EN role-string
    matching to mirror the backend bucketing.
  - 3 new i18n keys (DE+EN): import.button, parties.title, parties.hint.
  - CSS for the picker + import row in global.css.

Tests: 6 new unit tests in submission_vars_parties_test.go covering
the multi-party bag emission, German role-string bucketing, flat-alias
first-of-role resolution, empty-selection-means-all default, non-empty
restriction, and the isProjectDerivedKey policy that powers the
import path.

Build hygiene: go build/vet clean; go test -short ./internal/... pass;
bun run build clean (2876 i18n keys, scan clean).
2026-05-25 16:51:35 +02:00
mAi
a6d0acbcb4 mAi: #111 - t-paliad-279 — Verfahrensablauf form reorder + project auto-fill chip
Reorder Verfahrensablauf 'Browse a proceeding' so the user-input flow
matches the importance hierarchy: proceeding-type → side → appellant →
date / court / flags. Side was previously below the date input; it is
the most-defining input after proceeding-type, so it belongs above.

- frontend/src/verfahrensablauf.tsx: move .verfahrensablauf-perspective
  block above .date-input-group inside step-2. Wrap the side radio
  cluster in #side-radio-cluster and add a sibling #side-chip (hidden by
  default) that the client swaps in when a project pre-fills the side.
  Add a 1px divider between perspective and date-input groups. Update
  step-2 heading from "Ausgangsdatum eingeben" → "Perspektive und Datum"
  to honestly describe both controls now under the heading.

- frontend/src/client/verfahrensablauf.ts: read ?project=<id> on init,
  fetch /api/projects/<id>, map our_side onto the side axis (mirrors
  fristenrechner.ts ourSideToPerspective: claimant/applicant/appellant
  → claimant, defendant/respondent → defendant, else null) and render
  the side row as a read-only chip + "Andere Seite wählen" override
  link. The chip respects ?side= as an explicit user pick — URL wins
  over project auto-fill, same precedence as fristenrechner. Override
  swaps back to the radio cluster and drops ?project= from the URL.
  Side-chip label is language-aware via onLangChange.

- frontend/src/styles/global.css: .verfahrensablauf-step2-divider
  (1px hr between perspective and date blocks); .side-chip / -tag /
  -value / -override styles mirror .proceeding-summary's chip look so
  the two read as the same visual family.

- frontend/src/client/i18n.ts + i18n-keys.ts: 3 new keys
  (deadlines.step2.perspective, deadlines.side.from_project,
  deadlines.side.override) in DE + EN.

URL state stays backward-compatible: ?side= and ?appellant= survive
the reorder unchanged. Adding ?project= opts in to auto-fill; without
it the page behaves identically to before.

No backend / projection logic change.
2026-05-25 16:51:27 +02:00
mAi
96eab90044 Merge: t-paliad-280 — search input icon-text padding fix (m/paliad#112) 2026-05-25 16:51:26 +02:00
mAi
5348cb548f mAi: #112 - fix Fristenrechner Akte-picker icon overlap
The Akte-picker (Step 1) wraps its magnifying-glass icon + input in a
flexbox row (`.fristen-step1-search-row`) with `gap: 0.5rem`, expecting
the icon to participate in the flex layout. But the shared
`.fristen-search-icon` rule (used by the B2 search input) sets
`position: absolute; left: 0.875rem;` — and the step1-scoped override
only tweaked color + flex-shrink without resetting `position`.

Result: the icon was absolutely-positioned out of the flex flow and
overlapped the input text (since `.fristen-akte-search` has no
padding-left). Resetting `position: static` for the step1 context lets
flexbox + gap handle the spacing naturally — same pattern as
`.fristen-row-search-panel-input-wrap`, which already works.

Audited other search inputs with leading magnifying-glass icons:

- `.glossar-search` (Glossary, Courts, Links, Team, AdminTeam,
  AdminEventTypes) — wrap `.glossar-search-wrap` is `position: relative`,
  input has `padding: 0.65rem 4.5rem 0.65rem 2.5rem`. Fine.
- `.projects-search-input` (/projects index) — wrap is
  `position: relative`, input has `padding: 0.5rem 0.75rem 0.5rem 2.4rem`.
  Fine.
- `.fristen-search-input` (Fristenrechner B2) — wrap `.fristen-search-row`
  is `position: relative`, input has
  `padding: 0.75rem 2.5rem 0.75rem 2.6rem`. Fine.
- `.fristen-row-search-panel-input` (Fristenrechner row-search panel) —
  pure flex layout with `gap`, icon non-positioned. Fine.
- `.sidebar-search-input` (global sidebar search) — pure flex layout.
  Fine.
- Other search inputs (`event-search-input`, `event-type-search`,
  `submissions-new-search`, submissions index) have no leading icon.
  N/A.
2026-05-25 16:50:05 +02:00
mAi
b1340e2be4 Merge: t-paliad-278 — date-range picker 3-column layout Past/NOW/Future (m/paliad#110) 2026-05-25 16:46:59 +02:00
mAi
1292aa575d Merge: t-paliad-265 — per-event-card choices Slice A+B (popover + CCR + projection engine, mig 129) (m/paliad#96) 2026-05-25 16:46:15 +02:00
mAi
87c200a47e feat(t-paliad-265): caret + popover + chip on Verfahrensablauf cards
m/paliad#96 — frontend wiring of the per-event-card choice flow on
both consumer surfaces.

Shared rendering core (verfahrensablauf-core.ts):
- CalculatedDeadline gains choicesOffered + appellantContext (mirror
  the new server fields).
- deadlineCardHtml emits a ▾ caret next to the date when a rule
  carries a non-empty choicesOffered, plus an inert chip span next to
  the title that the popover module rehydrates after every render.
- bucketDeadlinesIntoColumns prefers appellantContext over the
  page-level appellant for "both" rows when the per-card context is
  set to claimant or defendant. "both" / "none" / "" all fall back to
  the existing collapse logic. New test cases cover all three paths.
- CalcParams + calculateDeadlines pass projectId / perCardChoices
  through to the backend.

New module (client/views/event-card-choices.ts):
- attachEventCardChoices wires a delegated click handler on the
  result container; the caret opens a body-anchored popover with one
  block per choice-kind the rule offers (appellant: 4 radio-style
  buttons; include_ccr + skip: 2-way toggle).
- Active picks render as small chips on the card title; reseedChips()
  repaints them after every renderResults() innerHTML rewrite.
- Skipped rows fade to 55% opacity via the timeline-item--skipped
  class.

Page wiring:
- /tools/verfahrensablauf (unbound): commits mutate an in-memory list
  + the ?event_choices= URL param, then schedule a recalc. Shareable
  via link, no persistence — same idiom as ?side= / ?appellant=.
- /tools/fristenrechner (project-bound): commits POST/DELETE to
  /api/projects/{id}/event-choices. The next calculate() call sends
  projectId so the server folds the persisted choices in.

i18n: 17 new keys under choices.* (DE primary + EN secondary). Caret
title, appellant/include_ccr/skip block titles + value labels, chip
labels, reset action, commit error toast.

CSS: caret, popover, options, chip parts, skipped-row fade.

Tests: 3 new bucketer cases covering AppellantContext propagation
(157 frontend tests pass).
2026-05-25 16:45:39 +02:00
mAi
bf60fc1400 feat(t-paliad-265): projection engine + HTTP handlers for per-card choices
m/paliad#96 — slice A engine + slice B engine wired together (per
m's Q4 bundling decision in §11 of the design doc).

Engine (internal/services/fristenrechner.go):
- CalcOptions gains PerCardAppellant map, SkipRules set, IncludeCCRFor
  set. All three keyed by paliad.deadline_rules.submission_code (same
  key AnchorOverrides uses).
- UIDeadline gains AppellantContext (per-decision pick that propagates
  to descendants via parent_id chain) + ChoicesOffered (passes the
  jsonb through to the frontend so the caret renders).
- Calculate honours all three:
  * IncludeCCRFor non-empty → append with_ccr to flag set before gate
    evaluation (v1 simplification documented in CalcOptions comment;
    correct for single-CCR-entry-point proceedings).
  * SkipRules suppression via submission_code match AND parent_id
    cascade (descendants suppress too — one-pass walk in sequence_order).
  * AppellantContext: each rule with its own per-card pick stamps its
    UUID; descendants inherit via parent_id lookup; "" = no override.

HTTP:
- /api/projects/{id}/event-choices GET / PUT / DELETE — full CRUD
  with visibility gate, audit-logged via paliad.system_audit_log.
- POST /api/tools/fristenrechner accepts either projectId (server
  pulls choices from project_event_choices) OR inline perCardChoices
  (unbound /tools/verfahrensablauf surface). Inline wins when both.

Services wiring:
- EventChoiceService instantiated in cmd/server/main.go; threaded into
  handlers.dbServices.eventChoice.
2026-05-25 16:45:21 +02:00
mAi
dc47ea7f43 feat(t-paliad-265): migration 129 + EventChoiceService (Slice A foundation)
m/paliad#96 — per-event-card optional choices on the Verfahrensablauf
timeline. This commit lands the schema + service layer.

Migration 129:
- paliad.project_event_choices table (project_id, submission_code,
  choice_kind ∈ {appellant, include_ccr, skip}, choice_value) with
  UNIQUE(project_id, submission_code, choice_kind) for idempotent
  re-pick, RLS via paliad.can_see_project.
- paliad.deadline_rules.choices_offered jsonb — opt-in declaration of
  which choice-kinds each rule offers. Seeded for every decision rule
  (appellant), every priority='optional' rule (skip), and the two
  Klageerwiderung rules (upc.inf.cfi.sod + de.inf.lg.erwidg) with
  include_ccr.

Live verification before authoring:
- rule_code is NULL on every decision row → submission_code is the
  join key (matches AnchorOverrides plumbing in fristenrechner.go).
- upc.inf.cfi.sod is the UPC Klageerwiderung, not upc.inf.cfi.def
  (rejected the design doc's first guess; SELECT name ILIKE
  'Klageerwiderung' confirmed).

Go service:
- models.ProjectEventChoice + DeadlineRule.ChoicesOffered.
- EventChoiceService: ListForProject / Upsert (with audit-log row to
  paliad.system_audit_log) / Delete. Pure-helper ToCalcOptionsAddendum
  + per-kind value validation + unit tests.

Design: docs/design-event-card-choices-2026-05-25.md §3 + §6.
2026-05-25 16:45:07 +02:00
mAi
930771a898 Merge: t-paliad-275 — HL-formatted skeleton template with placeholders (m/paliad#107) 2026-05-25 16:37:34 +02:00
mAi
f2fbf93adf feat(submissions): HL-formatted skeleton template with placeholders (t-paliad-275)
Adds a firm-formatted Schriftsatz skeleton between the per-submission_code
template and the generic universal skeleton in the fallback chain. Carries
every HL paragraph + character style from the HL Patents Style .dotm
(HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
HLpat-Table-Recitals-Party/Details/Roles/Sequencers, HLpat-Signature,
HLpat-Requests-Intro/Level1, HLpat-EvidenceOffering, …) and the firm
letterhead (header logo + firm-address footer), plus the full 48-key
SubmissionVarsService placeholder bag exercised in a real Schriftsatz
layout (rubrum → Betreff → Anträge → Sachverhalt → Rechtsausführungen →
Beweis → Schlussformel) with a locale-aware verification footer covering
every DE/EN alias and the rule.* legacy keys.

Resolved fallback chain after this CL:

  1. per-firm per-submission_code template (submissionTemplateRegistry)
  2. _firm-skeleton.docx — HL styles + placeholders (NEW)
  3. universal _skeleton.docx — placeholders only
  4. HL Patents Style.dotm — letterhead only

scripts/gen-hl-skeleton-template/main.go reads the source .dotm,
strips VBA macros + ribbon customizations + glossary parts, patches
[Content_Types].xml and the document rels, and replaces document.xml
with HL-styled paragraphs containing the placeholders. Keeps styles.xml,
theme/, header[12].xml, footer[12].xml, numbering.xml, settings.xml,
fontTable.xml, and media untouched so the firm typography survives.

Template uploaded to HL/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
(commit 0a41b45, blob SHA 07f7547d).

Verified end-to-end against the in-house renderer with a 48-key sample
project: every placeholder substitutes cleanly, no orphan {{ markers,
no VBA / glossary / customUI leftovers, header/footer rIds resolve.
2026-05-25 16:35:38 +02:00
mAi
169ace5d26 design(t-paliad-265): fold m's decisions into the design doc
Four open questions answered via AskUserQuestion (2026-05-25):

  Q1 State location       → persisted table          (matches (R))
  Q2 Affordance           → caret + popover          (matches (R))
  Q3 Appellant layer      → per-card overrides       (matches (R))
  Q4 Slice order          → bundle A + B             (over (R) of "A first")

Q4 captured with rationale: cohesive PR, single user-visible release,
no half-shipped state where the include-CCR popover would exist
without the engine wire-through. Coder still organises commits per
slice internally; one branch, one ship.
2026-05-25 16:27:30 +02:00
mAi
ac7bc27fb7 design(t-paliad-265): per-event-card optional choices on Verfahrensablauf
Draft inventor design for m/paliad#96 — per-card affordances driving
projection state: appellant per decision, include-CCR on Klageerwiderung,
skip optional events.

Persisted choices in new paliad.project_event_choices table; opt-in
declared via choices_offered jsonb on paliad.deadline_rules. Caret +
popover affordance; chip indicators on cards with non-default picks.
Two-slice plan: A=appellant+skip (engine-stable), B=include-CCR.

m's decisions section to be filled after the AskUserQuestion round.
2026-05-25 16:27:30 +02:00
66 changed files with 14601 additions and 386 deletions

242
.gitea/workflows/test.yaml Normal file
View File

@@ -0,0 +1,242 @@
# Paliad CI gate (t-paliad-282 / m/paliad#114).
#
# Single workflow, two purposes:
#
# - On every push: gate tier — build + unit + migration smoke. Red gate
# means no further work and (on main) no deploy.
# - On push to main with gate green: deploy step — calls the Dokploy
# compose-deploy API for paliad's compose Zx147ycurfYagKRl_Zzyo, then
# polls /health/ready until the new container reports 200.
#
# The deploy step REPLACES the previous Gitea-push → Dokploy webhook path
# (per m's Q11.4 pick: soft-launch with both alive for ~1 week, then
# disable the Dokploy auto-deploy toggle). Soft-launch leaves Dokploy's
# autoDeploy=true intact today — the workflow's deploy step is additive
# and idempotent (Dokploy's deploy is itself idempotent).
#
# Catches the three failure classes from 2026-05-25:
#
# - brunel slot collision (~13:20) — TestMigrations_NoDuplicateSlot,
# pure unit, no DB needed.
# - hermes dropped-col refs (~16:05) — TestBootSmoke, applies all NEW
# migrations (those not in the snapshot) end-to-end against a
# scratch DB restored from internal/db/testdata/prod-snapshot.sql.
# - mig 129 42501 ownership (~14:56→) — TestMigrations_EndToEndAsAppRole,
# applies new migrations as the prod-shaped `postgres` role (which
# is NOT a superuser on supabase/postgres — same shape as
# youpc-supabase prod, see internal/db/testdata/README.md).
#
# Snapshot approach: dump paliad schema + applied_migrations rows from
# prod, commit them. CI restores → ApplyMigrations sees existing migs as
# applied, only runs NEW migs (the ones this PR adds). This sidesteps the
# fresh-DB idempotence requirement on historical migrations (some of
# which use raw COMMIT or pre-installed extensions and can't be replayed
# from scratch). To refresh: `make refresh-snapshot`.
#
# Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md (cronus inventor
# shift, t-paliad-282).
name: Paliad CI gate
on:
push:
branches:
- main
- 'mai/**'
pull_request:
branches: [main]
env:
GO_VERSION: '1.24'
BUN_VERSION: '1.2'
jobs:
# Gate job 1 — pure build. Catches go/bun build breakage that local
# `go build` would catch but which a worker might have skipped before
# pushing. Fast (~60 s) so a red here surfaces immediately.
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: go build
run: go build ./...
- name: go vet
run: go vet ./...
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: bun install + build
working-directory: frontend
run: |
bun install --frozen-lockfile
bun run build
# Gate job 2 — Go test suite + migration smoke against snapshot-restored
# scratch DB.
#
# The Postgres service container uses the same supabase/postgres image
# as youpc-supabase prod. The CI scratch DB starts empty; a setup step
# installs pg_trgm + restores the snapshot. After restore, paliad
# schema is at HEAD-of-snapshot and applied_migrations covers every
# migration up to (and including) the snapshot's max version.
#
# ApplyMigrations called in TestBootSmoke / TestMigrations_EndToEndAsAppRole
# sees the snapshot's applied set, finds whatever NEW migrations this
# PR added on top, and applies only those. The role-split smoke runs as
# `postgres` (which is NOT a superuser on supabase/postgres, matching
# the prod role topology) — any new migration that needs supabase_admin
# privilege fails here as it would in prod.
test-go:
runs-on: ubuntu-latest
services:
# supabase/postgres baked-in auth schema + supabase role topology
# matches youpc-supabase prod. `postgres` here is NOT a superuser
# (verified live: \du postgres shows "Create role, Create DB,
# Replication, Bypass RLS" — no Superuser). This is the prod-shaped
# role the deploy uses.
postgres:
image: supabase/postgres:15.8.1.060
env:
POSTGRES_PASSWORD: ci
POSTGRES_DB: paliad_scratch
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 30
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Install postgresql-client
run: |
apt-get update -qq && apt-get install -y -qq postgresql-client
# Snapshot restore. Two prep steps as supabase_admin (the actual
# superuser): GRANT CREATE so the `postgres` role can later create
# schemas if a new mig needs it; install pg_trgm so the snapshot's
# trigram indexes restore. Snapshot itself loads as `postgres`.
- name: Provision + restore snapshot
env:
PGPASSWORD: ci
run: |
set -euo pipefail
psql -h localhost -U supabase_admin -d paliad_scratch -v ON_ERROR_STOP=1 \
-c "GRANT CREATE ON DATABASE paliad_scratch TO postgres;" \
-c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
psql -h localhost -U postgres -d paliad_scratch -v ON_ERROR_STOP=1 \
-f internal/db/testdata/prod-snapshot.sql
# Pre-flight: catches brunel slot collision in seconds, no DB
# contact (still useful even though the test-go job has Postgres
# running, because the failure mode is independent).
- name: Migration coordination check
run: go test -count=1 -run TestMigrations_NoDuplicateSlot ./internal/db/
# Role-split end-to-end apply. Connects as `postgres` (NOT a
# superuser on supabase/postgres) and runs ApplyMigrations against
# the snapshot-restored DB. Existing migs are skipped (already in
# applied_migrations); NEW migs in this PR apply here. If a new
# migration assumes supabase_admin privilege, fails with the same
# 42501 error class that took paliad.de offline on 2026-05-25.
- name: Migration end-to-end (deploy role)
env:
TEST_APP_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
run: go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
# Boot smoke. Confirms ApplyMigrations succeeds + applied set
# matches on-disk set + /healthz returns 200 + /health/ready
# returns 200 (the live-pool variant via TestHealthReady_Live).
- name: Boot smoke + readiness
env:
TEST_DATABASE_URL: postgres://postgres:ci@localhost:5432/paliad_scratch?sslmode=disable
run: go test -count=1 -run 'TestBootSmoke|TestHealthReady_Live' ./cmd/server/
# Full Go test suite WITHOUT TEST_DATABASE_URL so live-DB service
# tests skip (same shape as a developer laptop without a scratch
# DB). Live-DB tests in internal/services/* will be activated by a
# follow-up shift once the snapshot is verified stable across
# multiple PRs — they need investigation against supabase/postgres
# 15.8 (parameter type inference differs subtly from youpc-supabase).
- name: go test ./... (pure + skip-on-no-DB)
run: go test -count=1 ./internal/... ./cmd/...
# Deploy step. Only runs on push to main and only after both gate jobs
# are green. Calls Dokploy's compose.deploy with the paliad compose ID
# (Zx147ycurfYagKRl_Zzyo) and polls /health/ready until it returns 200
# or times out.
#
# Skipped on PR / feature branch pushes — those run the gate tier as
# a status check but don't trigger a prod deploy. Dokploy's existing
# autoDeploy=true webhook continues to fire during the soft-launch
# window (per Q11.4); it can be disabled in the Dokploy UI once this
# workflow has gated ≥5 successful green deploys.
deploy:
runs-on: ubuntu-latest
needs: [build, test-go]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Trigger Dokploy compose deploy
env:
DOKPLOY_KEY: ${{ secrets.DOKPLOY_TOKEN }}
DOKPLOY_API: http://100.99.98.201:3000/api/trpc
COMPOSE_ID: Zx147ycurfYagKRl_Zzyo
run: |
set -euo pipefail
if [ -z "${DOKPLOY_KEY:-}" ]; then
echo "ERROR: DOKPLOY_TOKEN secret is not configured."
echo " Set the secret in Gitea repo settings before this step can deploy."
exit 2
fi
echo "==> POST compose.deploy"
curl -sS --connect-timeout 5 --max-time 30 \
-X POST \
-H "x-api-key: $DOKPLOY_KEY" \
-H "Content-Type: application/json" \
-d "{\"json\":{\"composeId\":\"$COMPOSE_ID\"}}" \
"$DOKPLOY_API/compose.deploy"
echo
- name: Wait for /health/ready
run: |
set -euo pipefail
echo "==> polling https://paliad.de/health/ready"
# Up to 5 minutes (60 × 5 s) — paliad's cold-start is normally
# ≤30 s; the longer budget covers slow image pulls + migration
# apply.
for i in $(seq 1 60); do
status=$(curl -sS --connect-timeout 3 --max-time 5 \
-o /dev/null -w '%{http_code}' \
https://paliad.de/health/ready || echo "000")
if [ "$status" = "200" ]; then
echo "ready after ${i} poll(s)"
exit 0
fi
echo " [$i/60] status=$status — sleeping 5s"
sleep 5
done
echo "ERROR: /health/ready did not return 200 within 5 minutes."
echo " The deploy fired but the new container is not serving."
echo " Investigate: ssh mlake 'docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1'"
exit 1

View File

@@ -21,18 +21,24 @@
# the test runner's working dirs. None of them touch internal/db/migrations/
# files.
.PHONY: help verify-migrations verify-mig test test-go
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot
help:
@echo "Paliad — developer targets"
@echo ""
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
@echo " verify-mig Alias for verify-migrations"
@echo " verify-mig-app End-to-end migration smoke as non-superuser role"
@echo " (needs TEST_APP_DATABASE_URL — t-paliad-282 / m/paliad#114)"
@echo " test Short test pass — covers gate tier"
@echo " test-go Full Go suite with race detector"
@echo " test-frontend Frontend bun:test suite"
@echo ""
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
@echo ""
@echo "Set TEST_APP_DATABASE_URL to enable the role-split smoke. Example:"
@echo " export TEST_APP_DATABASE_URL=postgres://paliad_app:...@localhost:5432/paliad_scratch"
# Gate target — the test that would have caught mig 098 / mig 099 before
# deploy. Combines:
@@ -71,3 +77,67 @@ test:
# (full suite, not per-PR).
test-go:
go test -race ./...
# Frontend bun:test suite. Runs the 4 existing pure-TS tests today; will
# grow as mendel's Slice 3 (frontend test infill) lands.
test-frontend:
cd frontend && bun test
# Role-split end-to-end migration smoke — the catch for the mig 129 42501
# ownership class (m/paliad#114). Runs ApplyMigrations as a non-superuser
# role against TEST_APP_DATABASE_URL. Fails the build if any migration
# assumes more privilege than the deploy role has.
#
# Developer setup (local):
# psql -c "CREATE ROLE paliad_app LOGIN PASSWORD 'ci' NOSUPERUSER;"
# psql -c "CREATE DATABASE paliad_scratch OWNER paliad_app;"
# export TEST_APP_DATABASE_URL=postgres://paliad_app:ci@localhost:5432/paliad_scratch
verify-mig-app:
@if [ -z "$$TEST_APP_DATABASE_URL" ]; then \
echo "ERROR: TEST_APP_DATABASE_URL is not set."; \
echo " The role-split migration smoke cannot run without a non-superuser scratch DB."; \
echo " See Makefile comments above this target for setup."; \
exit 2; \
fi
go test -count=1 -run TestMigrations_EndToEndAsAppRole ./internal/db/
# Refresh the prod schema snapshot used by CI's migration smoke
# (t-paliad-282 / m/paliad#114). Connects to youpc-supabase prod, dumps
# the paliad schema + applied_migrations rows, strips rows beyond the
# current branch's max on-disk version, and writes
# internal/db/testdata/prod-snapshot.sql.
#
# When to refresh:
# - After merging a PR that added a new migration to main.
# - When CI's migration smoke starts spuriously failing because the
# snapshot's applied set diverges from on-disk by more than this
# branch's worth of new migs.
#
# Requires PALIAD_PROD_DATABASE_URL env var (a Postgres URL with
# pg_dump rights on youpc-supabase). Example:
# export PALIAD_PROD_DATABASE_URL='postgres://postgres:PW@100.99.98.201:11833/postgres'
refresh-snapshot:
@if [ -z "$$PALIAD_PROD_DATABASE_URL" ]; then \
echo "ERROR: PALIAD_PROD_DATABASE_URL is not set."; \
echo " Refresh requires read access to youpc-supabase prod."; \
exit 2; \
fi
@echo "==> dumping paliad schema (no owner, no privs)..."
@pg_dump --schema-only --schema=paliad --no-owner --no-privileges \
--no-publications --no-subscriptions \
"$$PALIAD_PROD_DATABASE_URL" > internal/db/testdata/prod-snapshot.sql.tmp
@echo "==> appending applied_migrations rows..."
@pg_dump --data-only --table=paliad.applied_migrations \
--no-owner --no-privileges \
"$$PALIAD_PROD_DATABASE_URL" >> internal/db/testdata/prod-snapshot.sql.tmp
@echo "==> stripping pg16 \\restrict / \\unrestrict commands for pg15 compat..."
@sed -i.bak '/^\\restrict /d; /^\\unrestrict /d' internal/db/testdata/prod-snapshot.sql.tmp
@rm -f internal/db/testdata/prod-snapshot.sql.tmp.bak
@echo "==> stripping applied_migrations rows beyond branch's max on-disk version..."
@MAX_VER=$$(ls internal/db/migrations/*.up.sql | xargs -I{} basename {} | sed 's/_.*//' | sort -n | tail -1); \
awk -v max=$$MAX_VER ' \
/^[0-9]+\t/ { split($$0, a, "\t"); if (a[1]+0 > max) next; } \
{ print } \
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
@rm internal/db/testdata/prod-snapshot.sql.tmp
@wc -l internal/db/testdata/prod-snapshot.sql

View File

@@ -165,6 +165,7 @@ func main() {
sysAuditSvc := services.NewSystemAuditLogService(pool)
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
svcBundle = &handlers.Services{
Pool: pool,
Project: projectSvc,
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
@@ -218,6 +219,8 @@ func main() {
// is captured into __meta of every export and printed in the
// embedded README.
Export: services.NewExportService(pool, branding.Name),
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when

View File

@@ -98,6 +98,51 @@ func TestBootSmoke(t *testing.T) {
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
}
// (4) Readiness probe. With a nil Services bundle the endpoint MUST
// report 503 — that's the contract documented in handlers/handlers.go.
// A separate svc-with-Pool case is exercised in TestHealthReady (live).
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/health/ready", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("GET /health/ready (nil svc): status=%d; want 503", rec.Code)
}
}
// TestHealthReady_Live asserts the readiness probe answers 200 when the
// pool is reachable, 503 when it isn't. Requires TEST_DATABASE_URL.
//
// Why a separate test: TestBootSmoke runs Register with svc=nil to keep
// its setup minimal; the pool-reachable path needs the pool wired in
// through svc.Pool. Two tests, two assertions, no entanglement.
func TestHealthReady_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live readiness probe")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("db.ApplyMigrations: %v", err)
}
pool, err := db.OpenPool(url)
if err != nil {
t.Fatalf("open pool: %v", err)
}
mux := http.NewServeMux()
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
handlers.Register(mux, authClient, "", &handlers.Services{Pool: pool})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health/ready", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /health/ready (live pool): status=%d, body=%q; want 200", rec.Code, rec.Body.String())
}
if body := strings.TrimSpace(rec.Body.String()); body != "ready" {
t.Errorf("GET /health/ready (live pool): body=%q; want \"ready\"", body)
}
}
// embeddedMigrationVersions returns every N where N_*.up.sql exists in

View File

@@ -0,0 +1,181 @@
# CI/CD runner setup — paliad
**Companion to:** `docs/design-cicd-pre-deploy-gate-2026-05-25.md` (Slice A, t-paliad-282 / m/paliad#114)
**Date:** 2026-05-25
**Audience:** mlake / mriver admin (m or head)
Slice A's `.gitea/workflows/test.yaml` requires (a) at least one online Gitea Actions runner and (b) a Dokploy API token wired as a repo secret. Both are one-time setup actions that paliad's source tree cannot perform itself — they live on infra-side. This doc lists them so the workflow can go green on its first run.
---
## 0. Pre-flight: what already exists
Verified live (2026-05-25 cronus inventor shift):
- Gitea 1.24.4 on `mgit.msbls.de`, `has_actions: true` on `m/paliad`.
- `/api/v1/admin/actions/runners` reports **2 runners** registered. They are likely the shared runners used by `m/mGreen` and `m/mGeo` (both have `.gitea/workflows/deploy.yml` with `runs-on: self-hosted`).
- `m/paliad/actions/tasks` reports `total_count=0` — paliad has never run a workflow yet.
The existing runners may already be capable of running paliad's workflow without further setup. The verification step (§3) below tells you whether they are.
---
## 1. Runner placement decision (m's Q11.1)
m's pick: **mriver**.
Rationale: mriver hosts the mai worker fleet but workers spend most of their time waiting on Anthropic. mlake's Dokploy + Swarm workload is more contended. A new runner on mriver adds the least pressure to either box.
If mriver is offline or saturated when CI first fires, fall back to the existing mlake-side runners (they're already registered; no provisioning needed).
---
## 2. One-time setup (admin steps)
### 2.1 Register a new Gitea Actions runner on mriver
```bash
# On mriver, as m:
# 1. Download the act_runner binary (matching Gitea 1.24.x)
curl -L -o /usr/local/bin/act_runner \
https://gitea.com/gitea/act_runner/releases/download/v0.2.13/act_runner-0.2.13-linux-amd64
chmod +x /usr/local/bin/act_runner
# 2. Get a runner registration token. In the Gitea UI:
# /admin → Actions → Runners → "Create new Runner"
# (or org-scope: /m/paliad/settings/actions/runners)
# Copy the token.
# 3. Register
mkdir -p ~/act_runner && cd ~/act_runner
act_runner register --no-interactive \
--instance https://mgit.msbls.de \
--token <REGISTRATION_TOKEN> \
--name mriver-paliad-1 \
--labels ubuntu-latest:docker://node:20-bookworm
# 4. Run as a systemd unit (preferred) or as a session daemon
# Systemd unit example: /etc/systemd/system/act_runner.service
# [Unit]
# Description=Gitea Actions runner
# After=network.target
# [Service]
# User=m
# WorkingDirectory=/home/m/act_runner
# ExecStart=/usr/local/bin/act_runner daemon
# Restart=on-failure
# [Install]
# WantedBy=multi-user.target
sudo systemctl enable --now act_runner
sudo systemctl status act_runner
```
**Why `ubuntu-latest:docker://node:20-bookworm` for the label?** Gitea Actions' `runs-on: ubuntu-latest` resolves via the runner's label map. Mapping it to a Docker image gives the workflow a sandbox with Docker available — required for our Postgres service container in `test.yaml`. mriver should have Docker (for `paliadin-shim`); if not, install it.
### 2.2 Register the Dokploy API token as a repo secret
The workflow's `deploy` job needs `secrets.DOKPLOY_TOKEN`. Use the existing project-wide Dokploy API key (the one stored in `~/.claude/skills/mai-dokploy/SKILL.md`).
In the Gitea UI:
- Navigate to `https://mgit.msbls.de/m/paliad/settings/actions/secrets`
- Click "Add secret"
- Name: `DOKPLOY_TOKEN`
- Value: `mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz`
Or via API (mAi identity):
```bash
curl --netrc-file ~/.netrc-mai -sS -X POST \
-H "Content-Type: application/json" \
https://mgit.msbls.de/api/v1/repos/m/paliad/actions/secrets/DOKPLOY_TOKEN \
-d '{"data":"mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz"}'
```
(Requires repo-owner permission. If mAi lacks it, m runs it.)
---
## 3. Verify the runner sees the workflow
After (2.1) + (2.2):
```bash
# Push the Slice A branch (the one this doc lives on)
git push origin mai/cronus/coder-cicd-slice-a
# Confirm the runner picked up the job
curl --netrc-file ~/.netrc-mai -sS \
"https://mgit.msbls.de/api/v1/repos/m/paliad/actions/tasks?limit=5" | jq '.'
```
A new task per job should appear (build, test-go). If `total_count` stays 0, the runner labels don't match the workflow's `runs-on`. Re-register with `--labels ubuntu-latest` (no docker:// suffix) and the existing runners on mlake will pick it up via shell mode.
---
## 4. Soft-launch (m's Q11.4)
m's pick: **keep both Dokploy auto-deploy and the workflow's deploy step alive for ~1 week. After ≥5 successful green deploys via the workflow, disable Dokploy's autoDeploy in the Dokploy UI for the paliad compose.**
While both are live, every push to main fires:
1. Dokploy webhook (existing path) → deploys immediately, no gate.
2. Gitea workflow → on green, ALSO calls `compose.deploy`.
The second call is idempotent — if Dokploy already deployed the same commit, this is a no-op. The workflow's value during soft-launch is the **gate signal**: a red workflow on a green main = the bad migration shipped via the unguarded webhook and broke prod, and the workflow is shouting about it.
After confidence builds:
1. In the Dokploy UI, navigate to the paliad compose → Settings.
2. Toggle "Auto Deploy" off.
3. Save.
From this point, the only path to deploy is the workflow's deploy job. Red workflow = no deploy.
---
## 5. What Slice A catches today — and what it doesn't
After this branch (`mai/cronus/coder-cicd-slice-a`) merges to main:
### Catches (active in CI)
- **Build breakage** — `go build`, `go vet`, `bun run build`. Red gate, no deploy.
- **Slot collisions** — `TestMigrations_NoDuplicateSlot` runs without a DB. A PR adding migration N when version N already exists fails at gate time. This is the brunel-class catch (m/paliad#114 ~13:20 outage).
- **New-migration shape errors (hermes class)** — `TestBootSmoke` runs `ApplyMigrations` against the snapshot-restored DB. New migs from this PR get applied for real; any column/relation/syntax error fails the gate before merge.
- **New-migration ownership errors (mig 129 42501 class)** — `TestMigrations_EndToEndAsAppRole` runs `ApplyMigrations` connected as `postgres` (NON-superuser on `supabase/postgres:15.8.1.060`, same role topology as youpc-supabase prod). Any migration that assumes supabase_admin privilege fails with the same `42501 must be owner` error class that took paliad.de offline on 2026-05-25.
- **Readiness probe regressions** — `TestHealthReady_Live` confirms `/health/ready` returns 200 against a live pool, 503 against a nil pool.
- **Pure-Go test regressions** — `go test ./internal/... ./cmd/...` runs without `TEST_DATABASE_URL` (live-DB service tests skip the same way they do on a developer laptop without a scratch DB).
### Mechanism — the snapshot approach
CI's scratch DB starts from a `pg_dump` of youpc-supabase paliad schema +
`paliad.applied_migrations` rows, committed to `internal/db/testdata/prod-snapshot.sql`. After restore, the scratch DB is at "paliad HEAD of snapshot" and `ApplyMigrations` sees only this PR's new migrations as pending.
This sidesteps the fresh-DB idempotence problem: several historical migrations (notably mig 037's missing `CREATE EXTENSION pg_trgm`, mig 051's inner `COMMIT;`) can't be replayed from scratch against `supabase/postgres:15.8.1.060`. The snapshot pins everything that's already applied in prod and lets CI focus on what's new — which is what we actually care about for outage prevention.
Snapshot refresh: `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL` set (see `internal/db/testdata/README.md`).
### Known gap — live-DB service tests don't run in CI
`internal/services/*_test.go` tests with `TEST_DATABASE_URL` set fail against `supabase/postgres:15.8.1.060` with `42P08 inconsistent types deduced for parameter` errors on some INSERT bind paths. The same tests pass against youpc-supabase prod. Cause is unconfirmed — likely subtle differences in type inference between the dockerized image and the prod cluster's configuration. CI today runs `go test ./...` without `TEST_DATABASE_URL` so these tests skip. Not blocking outage prevention; tracked as a follow-up for the post-Slice-A coder.
### Migration cleanup also bundled in this PR
Two surgical migration improvements that surfaced during snapshot debugging — kept here because they're small and harmless:
- **mig 024 + 027** — `ALTER INDEX` / `ALTER POLICY` exception handlers now catch `undefined_object` OR `undefined_table` OR `duplicate_object`. Old handler caught only `undefined_object`; Postgres raises `undefined_table` when the source object never existed and `duplicate_object` when the destination already exists. The expanded handler makes the migrations truly idempotent across the three plausible states: source-still-German (rename succeeds), already-renamed (catches duplicate_object), and fresh-DB-never-had-German (catches undefined_table).
Other migration history bugs (mig 037 missing pg_trgm, mig 051 inner COMMIT) are tracked as a separate cleanup task — not blocking, because the snapshot bypasses them.
### Verification checklist (after Slice A merges)
1. **Workflow green on its first PR run?** Check `/m/paliad/actions`. If not, fix before merging.
2. **Dokploy `compose.deploy` call succeeds?** The workflow's `deploy` job logs the POST response. A successful response is a Dokploy job ID; a 4xx is an auth or compose-id problem.
3. **`/health/ready` returns 200 within 5 minutes after a green deploy?** The workflow polls this. If it times out, the migration may have failed silently inside the new container — check `docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1` on mlake.
4. **Reproduce the slot-collision catch locally:** rename `131_…up.sql` to `129_…` (duplicate slot) → workflow MUST fail at `Migration coordination check`. Revert before pushing.
5. **Reproduce the role-split catch locally:** add a no-op migration `132_test_supersedes.up.sql` containing `REINDEX SYSTEM paliad_scratch;` (requires superuser). Workflow MUST fail at `Migration end-to-end (deploy role)`. Revert before pushing.
---
## 6. Future polish (Slice D, m's Q4 R-pick)
`mai-test` post-merge shift: once Slice A is stable, wire a Gitea webhook on push-to-main that fires `/mai-test` as a follow-up shift. It runs the broader smoke + integration suite and posts results as a Gitea commit status. Not blocking; the gate doesn't depend on it.
Implementation belongs in `m/mAi` (the mai webhook handler), not in paliad. Out of scope for Slice A.

View File

@@ -0,0 +1,492 @@
# Design — Per-event-card optional choices on the Verfahrensablauf timeline
**Author:** atlas (inventor)
**Date:** 2026-05-25
**Task:** t-paliad-265 (m/paliad#96)
**Branch:** `mai/atlas/inventor-per-event-card`
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
---
## 0. TL;DR
> **m's decisions landed 2026-05-25** — see §11. Persisted table, caret+popover, per-card-overrides-page-level, and m chose to bundle Slice A + Slice B into one coder shift (over the inventor (R) of "Slice A first"). All other picks matched inventor recommendations.
The Verfahrensablauf timeline today carries **two** projection knobs at the page level — `side` (who-we-are) and `appellant` (who-initiated). Both are **global** for the whole timeline. m wants three more knobs, but **per event card**, not page-level:
1. **Appellant per decision card** — if a decision is appealable, the user picks which side appealed (Claimant / Defendant / Both / None). Different decisions in the same timeline can have different appellants.
2. **Include Nichtigkeitswiderklage on Klageerwiderung** — toggling this on a single Klageerwiderung card flips on the existing `with_ccr` flag for everything downstream of that card.
3. **Skip an optional event** — for any rule marked `priority='optional'`, a per-card "don't consider for this case" toggle hides downstream consequences.
The flow these choices drive is **already there**`condition_expr` jsonb gates (`with_ccr`, `with_amend`, `with_cci`) plus the page-level appellant selector. What's missing is (a) **per-card** scope and (b) **per-project persistence**.
Recommendation: persist choices in a new `paliad.project_event_choices` table; expose them through a popover-on-caret affordance on the relevant cards only; map them into the existing `CalcOptions.Flags` + a new per-rule `Appellants` map at projection time. Two slices: **Slice A** (appellant-per-decision + skip-optional, narrow + bounded), **Slice B** (include-CCR-on-Klageerwiderung, requires per-card flag-scoping in the projection engine — bigger).
---
## 1. Premises verified live (before designing)
CLAUDE.md / memory / issue text can drift; the live system can't. Each load-bearing premise below was probed against the live DB or live source on 2026-05-25.
### Schema
- **Migration tracker at 127** (`paliad.paliad_schema_migrations`). Next migration: 128. No new table for `project_event_choices` exists today.
- **`paliad.deadline_rules` carries `condition_expr jsonb`** already. The flag-evaluation engine (`internal/services/fristenrechner.go:208 Calculate`, `evalConditionExpr` at line ~947) walks the jsonb tree and skips rules whose gate is unsatisfied. Today's gates are `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}`, and `{"op":"and","args":[…]}` combinations.
- **`with_ccr` is the existing Nichtigkeitswiderklage gate.** Verified live: 7 upc.inf.cfi rules gate on it (`upc.inf.cfi.reply`, `…rejoin`, `…ccr`, `…def_to_ccr`, `…reply_def_ccr`, `…rejoin_reply_ccr`, plus `upc.inf.cfi.app_to_amend` which additionally requires `with_amend`).
- **`priority` column** has 4 values: `mandatory`, `recommended`, `optional`, `informational`. Live counts (deadline_rules table-wide): 230 mandatory / 18 recommended / 6 optional / (informational not in count, must be 0 or absent). The "skip optional" affordance keys off `priority='optional'`.
- **`event_type` discriminator** exists with values `filing`, `decision`, `hearing`. The "appellant-per-decision" affordance keys off `event_type='decision'`. Live: every decision rule has `primary_party='court'`.
- **`paliad.projects.our_side`** exists (column added before mig 112; values today include `claimant|defendant|applicant|appellant|respondent|third_party|other`). It is the broad project-level side axis t-paliad-257 / #88 hooked into.
- **NO `appellant` column on `paliad.projects`** — the appellant axis lives only in the URL query (`?appellant=claimant|defendant`) in `client/verfahrensablauf.ts:73-89`.
### Frontend
- `frontend/src/client/views/verfahrensablauf-core.ts` is the **shared rendering core** for both `/tools/verfahrensablauf` and `/tools/fristenrechner`. Per-card UI affordances added here surface on both pages automatically.
- `bucketDeadlinesIntoColumns(deadlines, {side, appellant})` (line 496) is the **pure routing primitive**; column placement is computed without DOM. Unit-tested in `verfahrensablauf-core.test.ts`.
- `deadlineCardHtml(dl, {showParty, editable, showNotes})` (line 254) is the **per-card renderer**. There is no per-card props channel for "choices" yet — that's the surface this design extends.
- `client/verfahrensablauf.ts` and `client/fristenrechner.ts` both manage `currentSide` + `currentAppellant` in-memory and round-trip them through the URL (`writeSideToURL` / `writeAppellantToURL`). The pattern is mature; this design mirrors it for the new state when state stays URL-bound, and lifts it into a server-persisted store when state stays per-project.
- `APPELLANT_AXIS_PROCEEDINGS` set (verfahrensablauf.ts:52-62) gates the page-level appellant selector to appeal-flavoured proceedings only. The per-card appellant affordance MUST NOT depend on this set — any first-instance decision is a potential appeal trigger (e.g. LG-Urteil → Berufung, BPatG-Entscheidung → BGH-Rechtsbeschwerde).
### Surfaces in scope
- **`/tools/verfahrensablauf`** — abstract browse, no project context. Per-card choices here are ephemeral (URL-bound) — there's no project to persist into.
- **`/tools/fristenrechner`** — concrete projection, optionally project-bound via `?project=<id>` (`currentStep1Context.kind === "project"`). When project-bound, per-card choices persist to `paliad.project_event_choices`. When unbound, URL only.
- **`/projects/{id}` Verlauf tab (SmartTimeline)** — separate widget (per `docs/design-smart-timeline-2026-05-08.md`); does **NOT** use `renderColumnsBody`. Per-card choices are NOT in scope for the SmartTimeline in v1 — the Verfahrensablauf core is.
### What is NOT premised
- The deadline_rules → procedural_events rename (#93) is **not assumed shipped**. This design uses `deadline_rules`/`rule_code` vocabulary throughout and flags the rename touch-points in §6.
- The per-card UI does NOT require new server-side priority/event_type semantics. Both `priority='optional'` and `event_type='decision'` exist on every row.
---
## 2. Vision + scope
m's vision (verbatim 2026-05-25 15:12):
> We still have no choice to say that a specific party appealed. We may need selections within the event cards on the timeline to change it? For example for a decision we could check Appeal by... or in Klageerwiderung we can chose to include a Nichtigkeitswiderklage. Or with any optional event we can select not to consider it (because someone decided not to file it).
### What changes
- A **caret affordance** (▾) appears on the right edge of cards that have at least one applicable choice-kind. Click → small popover with the choices. Cards without an applicable choice render unchanged.
- A **`choices_offered` jsonb column** on `paliad.deadline_rules` declares which choice-kinds each rule offers. Three kinds in v1:
- `appellant` — applicable to rules with `event_type='decision'` (no static list; engine decides).
- `include_ccr` — applicable to the single Klageerwiderung rule per proceeding (today: `upc.inf.cfi.def`, `de.inf.lg.erwidg`).
- `skip` — applicable to any rule with `priority='optional'`.
- A **new persistence table** `paliad.project_event_choices(project_id, rule_code, choice_kind, choice_value)` holds the user's choices. Per-project, audit-logged via `paliad.system_audit_log`.
- A **projection-time merge** turns the persisted choices into `CalcOptions.Flags` and a new `PerCardAppellants map[ruleCode]string` field, then re-runs the existing projection engine. No new flag types; `with_ccr` is the same `with_ccr`.
### What stays
- `bucketDeadlinesIntoColumns` and `renderColumnsBody` are extended (new opts), not replaced.
- `condition_expr` jsonb gating semantics are unchanged. Per-card `include_ccr` choice simply means "set `with_ccr` in the flag set for this projection" — same engine.
- Page-level `side` / `appellant` selectors stay. The per-card appellant choice is an **override layer** on top of the page-level appellant (Q4 below).
- URL-state plumbing (`?side=…`, `?appellant=…`) stays. The page-level URL params remain the only state for unbound `/tools/verfahrensablauf`.
### Out of scope (v1)
- Per-card choices on the SmartTimeline (project Verlauf tab). Deferred to a follow-up when SmartTimeline matures.
- Versioning of choices over time ("the appellant changed mid-case", "the CCR was withdrawn"). Choices are last-write-wins.
- Cross-project propagation of choices.
- Implementing the choice flow (coder task per slice; this is design-only).
- A "what-if scenarios" mode (saved named scenarios).
---
## 3. Data model
### 3.1 The new table
```sql
-- migration 128_project_event_choices.up.sql
CREATE TABLE paliad.project_event_choices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
rule_code text NOT NULL, -- e.g. "RoP.029.a" or "de.inf.lg.urteil"
choice_kind text NOT NULL, -- 'appellant' | 'include_ccr' | 'skip'
choice_value text NOT NULL, -- value namespace per kind (see §3.3)
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
-- One choice per (project, rule_code, kind). Re-pick is an UPDATE.
UNIQUE (project_id, rule_code, choice_kind)
);
CREATE INDEX project_event_choices_project_idx
ON paliad.project_event_choices (project_id);
-- RLS: same `paliad.can_see_project(project_id)` predicate as paliad.deadlines.
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
FOR SELECT USING (paliad.can_see_project(project_id));
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
FOR ALL USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
```
**Why this shape:**
- Tall not wide — adding a 4th choice-kind in slice C means one more allowed `choice_kind` value, no DDL.
- `rule_code` is the join key against `paliad.deadline_rules` (which already uses `rule_code` widely — `Calculate`, `AnchorOverrides`, the projection). Stable across rule renames provided the rename keeps the same `rule_code`.
- UNIQUE per `(project, rule_code, kind)` makes the choice idempotent — re-picking the appellant overwrites, doesn't accumulate.
- ON DELETE CASCADE follows the project — when a project is hard-deleted (rare; usually soft-status), the choices go with it.
### 3.2 The opt-in column on `paliad.deadline_rules`
```sql
-- migration 128_project_event_choices.up.sql (same migration)
ALTER TABLE paliad.deadline_rules
ADD COLUMN choices_offered jsonb;
-- Example seeded values (in the same migration's data-fix block):
--
-- upc.inf.cfi.def → '{"include_ccr": [true, false]}'
-- de.inf.lg.erwidg → '{"include_ccr": [true, false]}'
-- upc.inf.cfi.decision → '{"appellant": ["claimant", "defendant", "both", "none"]}'
-- de.inf.lg.urteil → '{"appellant": ["claimant", "defendant", "both", "none"]}'
-- (every event_type='decision' rule)
-- upc.inf.cfi.ccr (priority='optional') → '{"skip": [true, false]}'
-- (every priority='optional' rule)
```
**Alternative considered + rejected:** infer offering at projection-time from `(event_type, priority, submission_code)` heuristics. Rejected because:
- The Klageerwiderung rule is identified only by its `submission_code` slug. Tying the engine to a hardcoded slug list inside the projection service is brittle (mig 124 + future Wave-1 fixes rename slugs); declaring `choices_offered` in data lets the audit ship them without a code change.
- A `skip` toggle that's automatically derived from `priority='optional'` is consistent today but may diverge tomorrow (an optional rule we DON'T want skippable, or a non-optional rule we DO want skippable). The opt-in jsonb keeps the choice axis decoupled from `priority`.
### 3.3 Value namespaces per kind
| `choice_kind` | `choice_value` valid set | Default when no row exists |
|---|---|---|
| `appellant` | `"claimant"` / `"defendant"` / `"both"` / `"none"` | inherits page-level appellant (URL `?appellant=`), else `null` (treated as "not yet picked" — render appeal-deadlines greyed) |
| `include_ccr` | `"true"` / `"false"` | `"false"` (no CCR until user opts in — matches current default flag set) |
| `skip` | `"true"` / `"false"` | `"false"` (rule renders normally) |
Values are stored as `text` not `boolean` so the same column scales to multi-valued kinds (appellant has 4 values; future kinds may have N). Coercion lives in the service layer.
### 3.4 Audit trail
Every INSERT / UPDATE / DELETE on `project_event_choices` writes a row to `paliad.system_audit_log` (the standard sink mig 102 introduced) with `event_type='project_event_choice.set'` and the changed `(rule_code, kind, value)` in `metadata jsonb`. Pattern mirrors `paliad.deadlines.status_changed` audit rows.
---
## 4. Projection flow
The existing projection engine is a single Go function: `FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)`. Two changes:
### 4.1 Extending `CalcOptions`
```go
type CalcOptions struct {
// ...existing fields...
Flags []string // <-- already exists
AnchorOverrides map[string]string // <-- already exists
// NEW — per-card overrides surfaced by the per-event-card choices.
// Keyed by deadline_rules.rule_code.
//
// PerCardAppellant: when a decision rule's rule_code is in this map,
// the appellant for downstream rules whose parent is THAT decision
// is set to the value here. Overrides any global Appellant.
//
// SkipRules: when a rule's rule_code is in this set, the rule is
// suppressed AND its descendants are suppressed. Same suppression
// path as a failed condition_expr gate.
//
// IncludeCCRFor: when a rule's rule_code is in this set, the with_ccr
// flag is treated as set in the flag context FROM that rule
// onward (i.e. for that rule's descendants). On v1 with a single
// Klageerwiderung-per-proceeding, this is equivalent to a project-
// wide with_ccr — but the per-card scope leaves room for future
// proceedings with multiple CCR entry points.
PerCardAppellant map[string]string // rule_code → "claimant"|"defendant"|"both"|"none"
SkipRules map[string]struct{} // set of rule_code
IncludeCCRFor map[string]struct{} // set of rule_code
}
```
The handler reads `project_event_choices` for the project (if project-bound) and folds them into these fields before calling `Calculate`. When called unbound (URL-only, `/tools/verfahrensablauf` without project), the maps come from URL params instead (see §5.2).
### 4.2 Three engine changes
1. **SkipRules suppression**: in the post-condition_expr filter pass (`Calculate` around line 333 where the gate is evaluated), additionally drop any rule whose `rule_code ∈ opts.SkipRules`. Also drop its descendants (existing `parent_id` walk already handles cascading; just add the new predicate to the keep/drop decision).
2. **IncludeCCRFor scope**: rather than threading a per-rule flag context (expensive change to engine), implement v1 as: **if any rule_code in IncludeCCRFor exists at all, append `"with_ccr"` to `opts.Flags`** before the gate-evaluation pass. This is correct for the v1 surface (Klageerwiderung is the only CCR-entry-point per proceeding) but loses the per-card scoping for multi-CCR cases. The full per-rule scope is **Slice B** (§7).
3. **PerCardAppellant routing**: when `bucketDeadlinesIntoColumns` collapses `party=both` rows in the appellant's column, today it consults the global `opts.appellant`. Extend to consult `PerCardAppellant[ruleCode]` first — if present, that drives the collapse for descendants of that decision. Out-of-band: this changes the projection contract subtly. We surface this as **server-computed metadata** on the response (`CalculatedDeadline.AppellantContext`) so the frontend bucketer doesn't need to know about parent-chain walks — the server already does the walk.
### 4.3 Wire shape
The `CalculatedDeadline` Go struct + TS mirror grow one optional field:
```go
type CalculatedDeadline struct {
// ...existing fields...
AppellantContext string `json:"appellantContext,omitempty"`
// "claimant" | "defendant" | "both" | "none" | "" (default).
// Filled by the projection from the user's per-decision choice.
// Frontend bucketer prefers this over the page-level appellant.
}
```
This keeps the bucketer logic local — no second pass needed.
---
## 5. UI / i18n
### 5.1 Caret + popover affordance
Each rendered card gets, when `choices_offered IS NOT NULL`, a `▾` caret on the right edge of the title line. Click → popover anchored to the caret. Popover renders one block per choice-kind the rule offers (typically one, occasionally two if a rule has both `appellant` and `skip` — none today; design holds for the future).
DOM-wise: `frontend/src/client/views/verfahrensablauf-core.ts` `deadlineCardHtml` grows a `choicesCaret` segment, and a sibling module `client/views/event-card-choices.ts` (new) owns the popover open/close + commit handler. The popover commits via `POST /api/projects/{id}/event-choices` with body `{rule_code, kind, value}`; the response is the updated choice row.
**Why a popover and not inline checkboxes:**
- Inline would put a checkbox on every decision card + every optional card. ~6 decision cards + ~6 optional cards on a typical UPC.INF.CFI projection is ~12 always-on widgets per timeline. Visual noise + scan cost.
- Popover defaults to hidden; the caret is a low-noise affordance. The selected choice surfaces as a small chip on the card title line ("Berufung: Beklagter") so the choice is glanceable without re-opening.
- Mobile + touch: the caret is a 24×24 tap target; the popover is keyboard-dismissable.
**Why not card-hover-reveal:** discoverability + touch failure (no hover on iOS).
### 5.2 URL fallback (no project context)
When `/tools/verfahrensablauf` is opened without a project (the abstract-browse case), per-card choices have no persistence layer. The popover still works, but commits update an **in-memory + URL** state instead:
```
?event_choices=RoP.029.a:appellant=defendant,upc.inf.cfi.ccr:skip=true
```
Compact CSV in one URL param. Read at page load, applied to `CalcOptions` via the same `PerCardAppellant` / `SkipRules` / `IncludeCCRFor` route. Shareable, ephemeral. Matches the existing `?side=` + `?appellant=` URL idiom.
### 5.3 Chip indicators
A card with a non-default choice gets a small chip next to the title:
- Appellant chosen: `Berufung: Beklagter` / `Appeal: Defendant`
- Include CCR: `mit Nichtigkeitswiderklage` / `with CCR`
- Skipped: card itself fades to 50% opacity, body adds class `timeline-item--skipped`, chip reads `übersprungen` / `skipped` with an undo arrow.
### 5.4 i18n keys (new)
```
choices.caret.title "Optionen für dieses Ereignis" "Options for this event"
choices.appellant.title "Berufung durch ..." "Appealed by ..."
choices.appellant.claimant "Klägerseite" "Claimant side"
choices.appellant.defendant "Beklagtenseite" "Defendant side"
choices.appellant.both "beide Parteien" "both parties"
choices.appellant.none "keine Berufung" "no appeal"
choices.include_ccr.title "Nichtigkeitswiderklage einbeziehen" "Include nullity counterclaim"
choices.skip.title "Für diese Akte überspringen" "Skip for this case"
choices.skipped.chip "übersprungen" "skipped"
choices.reset "Auswahl zurücksetzen" "Reset choice"
```
### 5.5 What's removed
The page-level appellant selector (URL `?appellant=`) stays for **non-decision proceedings** (the Appeal-CoA case where the appellant axis is the whole-timeline framing, not a per-decision choice). But for first-instance proceedings (UPC.INF, DE.INF.LG, etc.), the appellant axis migrates from page-level to per-decision card. The page-level selector hides when the proceeding has decision rules with `choices_offered.appellant` declared — which is the cleaner UX (one knob, in the right place).
---
## 6. Services + handlers (new surface)
### 6.1 Go service
```go
// internal/services/event_choice_service.go (new)
type EventChoiceService struct {
db *sqlx.DB
}
func (s *EventChoiceService) ListForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectEventChoice, error)
func (s *EventChoiceService) Upsert(ctx context.Context, c ProjectEventChoice) error
func (s *EventChoiceService) Delete(ctx context.Context, projectID uuid.UUID, ruleCode, kind string) error
// Used by ProjectionService to fold choices into CalcOptions.
func (s *EventChoiceService) ToCalcOptions(choices []ProjectEventChoice) CalcOptionsAddendum
```
The `CalcOptionsAddendum` type wraps the three new map/set fields so the merge into the parent `CalcOptions` is one call from the projection handler.
### 6.2 HTTP routes
```
GET /api/projects/{id}/event-choices → []ProjectEventChoice
PUT /api/projects/{id}/event-choices → upsert one (body: {rule_code, kind, value})
DELETE /api/projects/{id}/event-choices/{rule_code}/{kind} → remove
```
All gated by `gateOnboarded` + visibilityPredicate (project-team membership).
### 6.3 Projection handler
The existing `POST /api/tools/fristenrechner` handler accepts `flags`, `anchorOverrides`, `priorityDate`, `courtId`. Extend the request shape:
```json
{
"proceedingType": "upc.inf.cfi",
"triggerDate": "2026-01-15",
"flags": ["with_ccr"],
"perCardChoices": [
{"rule_code": "RoP.029.a", "kind": "appellant", "value": "defendant"},
{"rule_code": "upc.inf.cfi.ccr", "kind": "skip", "value": "true"}
]
}
```
Or, when project-bound:
```json
{
"proceedingType": "upc.inf.cfi",
"triggerDate": "2026-01-15",
"projectId": "abc-123"
// server pulls perCardChoices from paliad.project_event_choices
}
```
The handler merges either source into `CalcOptions` and runs `Calculate`.
### 6.4 Touch points — files coder will edit
- **DB**: new migration `128_project_event_choices.up.sql` + `.down.sql`. Add `choices_offered` column + seed data.
- **Go**: `internal/services/event_choice_service.go` (new), `internal/services/fristenrechner.go` (extend `CalcOptions`, projection logic), `internal/handlers/event_choices.go` (new HTTP routes), `internal/handlers/fristenrechner.go` (request shape extension).
- **Models**: `internal/models/models.go``ProjectEventChoice` struct, `CalculatedDeadline.AppellantContext` field.
- **Frontend**: `frontend/src/client/views/verfahrensablauf-core.ts` (caret + chip in deadlineCardHtml), `frontend/src/client/views/event-card-choices.ts` (new popover module), `frontend/src/client/verfahrensablauf.ts` + `frontend/src/client/fristenrechner.ts` (URL-state plumbing for the unbound case; load project choices for the bound case).
- **i18n**: `frontend/src/client/i18n.ts` + `frontend/src/i18n-keys.ts` — new keys per §5.4.
- **Tests**: `internal/services/event_choice_service_test.go` (new), `internal/services/fristenrechner_test.go` (extend with PerCardAppellant + SkipRules cases), `frontend/src/client/views/verfahrensablauf-core.test.ts` (extend bucketing with `perCardAppellant` opt).
### 6.5 Coordination with #93 procedural-events rename
When #93 lands (and the rename ships), this design's `rule_code` references become `procedural_event.code` — same string namespace, cleaner name. Join points:
- `project_event_choices.rule_code``project_event_choices.procedural_event_code` (or stays as a generic string column if #93 keeps `rule_code` as the join key).
- `deadline_rules.choices_offered``procedural_events.choices_offered`.
If #93 ships first, this design's migration applies to `procedural_events` instead. The data shape (jsonb + new join table) is unaffected. If THIS ships first, #93 absorbs the column in its rename.
---
## 7. Slice plan
### Slice A — Appellant per decision + Skip optional event
Two choice-kinds, narrow + bounded, do not change the gate-evaluation engine.
- **DB**: migration 128 adds `project_event_choices` + `choices_offered`. Seed `choices_offered` on all `event_type='decision'` rules and all `priority='optional'` rules.
- **Service**: `EventChoiceService` CRUD; `CalcOptions.PerCardAppellant` + `CalcOptions.SkipRules`; `Calculate` extension to honour SkipRules suppression + AppellantContext metadata.
- **HTTP**: 3 new routes (GET / PUT / DELETE on project_event_choices); fristenrechner request extension.
- **Frontend**: caret + popover on decision cards + optional cards; chip indicators; URL-state for the unbound case; load-on-mount for the bound case.
- **Tests**: bucketing with PerCardAppellant; service CRUD; gate-suppression with SkipRules.
Ship this slice first. It validates the popover affordance + the persistence layer end-to-end without touching the flag-evaluation engine.
### Slice B — Include Nichtigkeitswiderklage on Klageerwiderung
Wires `IncludeCCRFor` through the flag-evaluation engine. v1 simplification (§4.2 #2) makes this **almost** a no-op for the engine — but the per-card scope semantics need a separate inventor pass to nail down whether the simplification holds for de.inf.lg's CCR analogue (Widerklage auf Nichtigkeit) and for any future proceedings with multiple CCR entry points.
- **DB**: add `include_ccr` to allowed `choice_kind` values + seed `choices_offered = '{"include_ccr": [true, false]}'` on the Klageerwiderung rows (`upc.inf.cfi.def`, `de.inf.lg.erwidg`).
- **Service**: `CalcOptions.IncludeCCRFor`; the "if non-empty, append with_ccr to Flags" simplification.
- **Frontend**: the include_ccr popover block (already designed; just enabling the row).
- **Cross-flow audit**: confirm that the existing 7 upc.inf.cfi cross-flow rules + de.inf.lg analogues fire correctly when with_ccr is set via the per-card path vs. the existing page-level flag checkbox. Existing checkbox stays in v1; deprecation is a Slice C decision.
### Bundling note (per m's Q4 decision 2026-05-25)
A + B ship together. The slice headings above remain as a logical breakdown for the coder to follow when sequencing commits inside the single shift; they are not separate PRs. See §11 Q4 for rationale.
### Slice C — Future choice-kinds
Open-ended; not designed here. Examples surfaced by the t-paliad-067 audit:
- "Bilateral hearing requested" toggle on hearing rules.
- "Cost orders requested" toggle on cost-related rules.
- "Stay applied" toggle on procedural events.
Each new kind = one new allowed `choice_kind` value + one seed row + one popover block. Schema-stable.
---
## 8. Risk assessment
- **Migration risk**: new table + new column, both additive. Down-migration drops table + column + reverts seed. No data loss path. Low risk.
- **Projection correctness**: PerCardAppellant changes the bucket routing for "both" rows in chains downstream of a decision card. The unit-tested `bucketDeadlinesIntoColumns` carries the existing appellant semantics; extending it without breaking the existing test suite means new tests, not changes to existing ones. Coder MUST add the new tests before changing the bucketer.
- **Flag-context vs per-rule-flag aliasing**: §4.2 #2 (Slice B) trades per-card precision for engine simplicity. Acceptable in v1 (Klageerwiderung is the only entry point per proceeding) but a known limitation. Document it in `internal/services/fristenrechner.go` doc comment so the next Wave-2 inventor doesn't think it's bug-free.
- **Page-level vs per-card appellant interaction**: when both are set, per-card wins for descendants of the decision the per-card was set on; page-level still drives descendants of decisions without a per-card pick. Could confuse a user. Mitigation: the page-level appellant selector hides for first-instance proceedings (per §5.5). For appeal proceedings, the selector stays — but those proceedings have a single root decision so the conflict surface is small.
- **Cross-proceeding consistency** (where #93's rename lives) — coordinate with the inventor on #93 if both ship in parallel.
---
## 9. Out of scope (recap)
- SmartTimeline (project Verlauf tab) per-card choices.
- Versioning / time-machine of choices.
- Cross-project propagation.
- Coder implementation (separate task per slice).
- A "saved scenarios" feature.
- Removal of the page-level `?appellant=` URL param for appeal proceedings.
---
## 10. Open questions for m
The following 4 questions need m's pick. Inventor recommendations marked **(R)**. After m answers via AskUserQuestion, the picks land in §11 below as the historical record.
### Q1 — State location
Where do per-card choices live?
- **(R) A. `paliad.project_event_choices` persisted (with URL override for what-if).** Per-case choices are real, not exploratory. Persist by default; what-if exploration handled later as a URL-override layer.
- B. URL query state only. Ephemeral, shareable, no persistence.
- C. Both from day one. Persisted default + URL-overridable for what-if scenarios.
### Q2 — Affordance
How do the choices surface on a card?
- **(R) A. Caret (▾) + popover on click.** Off-by-default visual, on-tap reveal. Selected choice surfaces as a chip on the card title.
- B. Inline checkbox/radio on every relevant card. Higher discoverability, more visual noise.
- C. Card-hover reveals the choices. Discoverability + touch issues.
### Q3 — Page-level appellant interaction
When a per-card appellant is set on a decision, what happens to the page-level `?appellant=` selector?
- **(R) A. Per-card overrides page-level for descendants of THAT decision.** Decisions without a per-card pick still use page-level. Most expressive.
- B. Per-card inherits page-level unless explicitly set. Less surprising default but loses the per-decision expressiveness.
### Q4 — Slice order
Which slice ships first?
- **(R) A. Slice A first (appellant per decision + skip optional).** Bounded, validates the popover + persistence layer without touching the flag-evaluation engine. Slice B (include-CCR) follows.
- B. Slice B first. Higher-impact user feature but requires the engine change.
- C. Bundle A + B in one coder shift. Slower to ship, lower per-coder load, but one less round trip.
---
## 11. m's decisions (2026-05-25)
- **Q1 (State location):** Persisted table — `paliad.project_event_choices` per §3.1. Matches inventor (R).
- **Q2 (Affordance):** Caret + popover with chip indicator on chosen cards per §5.1, §5.3. Matches inventor (R).
- **Q3 (Appellant layer):** Per-card overrides page-level for descendants of that decision. Page-level still drives decisions without a per-card pick. Matches inventor (R). Implementation: `CalculatedDeadline.AppellantContext` (§4.3) carries the per-decision pick down the parent chain so the bucketer reads one field.
- **Q4 (Slice order):** **Bundle Slice A + Slice B in one coder shift** (m picked over inventor (R) of "A first"). Reasoning: keeps the popover, persistence layer, AND the engine extension for `IncludeCCRFor` in one cohesive PR — coder + reviewer hold the full mental model once; one user-visible release; no half-shipped state where the caret exists on Klageerwiderung cards but the include-CCR pick doesn't yet wire through. Trade-off: larger PR. Mitigation: coder still organises commits per slice internally (separate test files, separate handler additions) so review can read them sequentially. See §7 slice plan — both slices implemented; ship as one.
### Coder-shift implications of Q4 bundling
- Migration 128 carries ALL three choice-kinds (`appellant`, `skip`, `include_ccr`) in the seed of `choices_offered`, plus the Klageerwiderung rows seeded with `{"include_ccr": [true, false]}`.
- `CalcOptions` gains all three new fields (`PerCardAppellant`, `SkipRules`, `IncludeCCRFor`) in the same Go change.
- The `IncludeCCRFor` v1 simplification (§4.2 #2 — "any non-empty set means append `with_ccr` to Flags") documents the per-card-scope limitation up front. Multi-CCR proceedings are a future expansion, not a v1 ship blocker.
- Frontend popover renders all three blocks the rule offers in one render path; coder cannot half-ship by leaving include_ccr's popover branch as a TODO.
- Tests cover the full matrix on the same branch.
---
## 12. Hard rules for the coder shift
- Migration is 128, not anything else. Verify against `paliad.paliad_schema_migrations` MAX before authoring.
- Tests added BEFORE projection-engine changes in fristenrechner.go (bucketer, gate, AppellantContext).
- `go build ./... && go test ./internal/... && cd frontend && bun run build` clean.
- No regression on `?side=` + `?appellant=` URL state.
- DE primary, EN secondary for all new i18n keys.
- Branch per slice: `mai/<coder>/event-card-choices-slice-a` etc.
---
## 13. Reporting
When ready, the coder reports completion with the URL of the test project that exercises the feature, a screenshot of the popover, and the deadline-rules SQL UPDATE counts for the seeded `choices_offered` rows. Standard slice-completion shape.

View File

@@ -51,7 +51,10 @@ interface Rule {
interface ProceedingType {
id: number;
code: string;
name_de: string;
// `name` is the German display name on the wire; the Go `ProceedingType`
// model serialises `db:"name"` as JSON key `name`. Don't reach for
// `name_de` — that field does not exist in this payload (m/paliad#113).
name: string;
name_en: string;
}
@@ -169,7 +172,8 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
for (const pt of list) {
const opt = document.createElement("option");
opt.value = String(pt.id);
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
const name = getLang() === "en" ? pt.name_en : pt.name;
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
sel.appendChild(opt);
}
}

View File

@@ -29,7 +29,11 @@ interface Rule {
interface ProceedingType {
id: number;
code: string;
name_de: string;
// `name` is the German display name on the wire; the Go `ProceedingType`
// model serialises `db:"name"` as JSON key `name` (the schema treats DE
// as primary). EN lives in `name_en`. Don't reach for `name_de` — that
// field does not exist in this payload (cf. m/paliad#113).
name: string;
name_en: string;
category: string;
}
@@ -125,7 +129,12 @@ function proceedingLabel(id: number | null | undefined): string {
if (id == null) return "—";
const pt = proceedings.find((p) => p.id === id);
if (!pt) return `#${id}`;
const name = getLang() === "en" ? pt.name_en : pt.name_de;
const name = getLang() === "en" ? pt.name_en : pt.name;
// Guard against a proceeding row that's missing the active-language
// name (or against a stale field-name mismatch slipping back in).
// Show the code on its own rather than "code · undefined" — that
// literal string is the smell that surfaced this bug (m/paliad#113).
if (!name) return pt.code;
return `${pt.code} · ${name}`;
}
@@ -153,7 +162,8 @@ async function loadProceedings(): Promise<void> {
for (const pt of proceedings) {
const opt = document.createElement("option");
opt.value = String(pt.id);
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
const name = getLang() === "en" ? pt.name_en : pt.name;
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
sel.appendChild(opt);
}
}

View File

@@ -0,0 +1,126 @@
// Unit tests for the FilterBar's computeEffective() overlay. These pin
// the contract that any chip the user clicks ends up as a predicate the
// server can see — the t-paliad-283 regression had four sources picking
// up zero narrowing for /views/any because the bar's chip click didn't
// produce a non-empty `filter.predicates` for that source.
//
// Run with `bun test`.
import { test, expect, describe } from "bun:test";
import { computeEffective } from "./index";
import type { FilterSpec, RenderSpec } from "../views/types";
import type { BarState } from "./types";
// Mirrors paliad.user_views row {slug: "any"} — the saved Custom View
// that triggered the t-paliad-283 regression report.
const ANY_VIEW_FILTER: FilterSpec = {
version: 1,
sources: ["deadline", "appointment", "project_event", "approval_request"],
scope: { projects: { mode: "all_visible" } },
time: { field: "auto", horizon: "past_30d" },
};
const ANY_VIEW_RENDER: RenderSpec = {
shape: "list",
list: { sort: "date_asc", density: "comfortable" },
};
describe("filter-bar/computeEffective — /views/any (all 4 sources)", () => {
test("empty state leaves base spec intact (no overlays)", () => {
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
expect(eff.filter.sources).toEqual([
"deadline", "appointment", "project_event", "approval_request",
]);
expect(eff.filter.time).toEqual({ field: "auto", horizon: "past_30d" });
// predicates may be {} (the bar zero-fills it) but never carries a
// stray narrowing on any source — that would silently filter
// results the user never asked to filter.
for (const src of ANY_VIEW_FILTER.sources) {
expect(eff.filter.predicates?.[src]).toBeUndefined();
}
});
test("deadline_status chip narrows deadline predicate", () => {
const state: BarState = { deadline_status: ["pending"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
});
test("appointment_type chip narrows appointment predicate", () => {
const state: BarState = { appointment_type: ["hearing"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
});
test("approval_viewer_role chip narrows approval predicate", () => {
const state: BarState = { approval_viewer_role: "any_visible" };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.viewer_role).toBe("any_visible");
});
test("approval_status chip narrows approval predicate", () => {
const state: BarState = { approval_status: ["pending", "approved"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending", "approved"]);
});
test("approval_entity_type chip narrows approval predicate", () => {
const state: BarState = { approval_entity_type: ["deadline"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.entity_types).toEqual(["deadline"]);
});
test("project_event_kind chip narrows project_event predicate", () => {
const state: BarState = { project_event_kind: ["deadline_created"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
});
test("time chip overrides base horizon", () => {
const state: BarState = { time: { horizon: "past_7d" } };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.time.horizon).toBe("past_7d");
expect(eff.filter.time.field).toBe("auto"); // preserved from base
});
test("personal_only chip flips scope flag", () => {
const state: BarState = { personal_only: true };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.scope.personal_only).toBe(true);
});
test("multiple chips combine into the same effective spec", () => {
const state: BarState = {
time: { horizon: "past_7d" },
deadline_status: ["pending"],
appointment_type: ["hearing"],
approval_status: ["pending"],
project_event_kind: ["deadline_created"],
};
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.time.horizon).toBe("past_7d");
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending"]);
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
});
test("overlay does not mutate the caller's base filter", () => {
const base: FilterSpec = JSON.parse(JSON.stringify(ANY_VIEW_FILTER));
const state: BarState = { deadline_status: ["pending"], time: { horizon: "past_7d" } };
computeEffective(base, ANY_VIEW_RENDER, state);
// The bar deep-clones; the base must come back unchanged so a
// second click doesn't compound the previous click's overlay.
expect(base).toEqual(ANY_VIEW_FILTER);
});
test("inbox-only axes do not affect a /views/any spec (no inbox axis exposed)", () => {
// /views/any's axes don't include unread_only or inbox_focus, so
// those keys never appear in state. Verify that even if they did,
// the bar's overlay doesn't silently mutate sources or predicates
// in a way that would break a 4-source Custom View.
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
expect(eff.filter.sources).toHaveLength(4);
expect(eff.filter.unread_only ?? false).toBe(false);
});
});

View File

@@ -24,6 +24,12 @@ import {
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
import {
attachEventCardChoices,
reseedChips,
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
let lastResponse: DeadlineResponse | null = null;
@@ -162,6 +168,13 @@ async function calculate() {
? courtPicker.value
: "";
// t-paliad-265 — when project-bound, the server pulls per-card
// choices from paliad.project_event_choices. The frontend has
// already pre-fetched them into perCardChoicesCache so chip
// indicators repaint in step with the calc; sending projectId here
// is the persistence path.
const projectIdForCalc = currentStep1Context.kind === "project" ? currentStep1Context.projectId : "";
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
@@ -169,6 +182,7 @@ async function calculate() {
flags,
anchorOverrides: overrides,
courtId,
projectId: projectIdForCalc || undefined,
});
if (seq !== procCalcSeq) return;
if (!data) return;
@@ -439,6 +453,10 @@ function renderProcedureResults(data: DeadlineResponse) {
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
// t-paliad-265: rehydrate per-event-card chip indicators after the
// innerHTML rewrite. Safe to call before attachEventCardChoices() —
// it no-ops when no state was attached yet.
reseedChips(container);
printBtn.style.display = "block";
if (saveBtn) {
// Ad-hoc explore-mode has no project to save against — show the
@@ -461,6 +479,49 @@ function renderProcedureResults(data: DeadlineResponse) {
applyPendingFocus();
}
// initEventCardChoicesForFristenrechner attaches the per-event-card
// popover to the timeline container. The fristenrechner page is the
// project-bound surface: commits POST/DELETE to the persistence
// endpoint; the next calculate() pulls the fresh state from the
// server. (t-paliad-265)
async function initEventCardChoicesForFristenrechner(container: HTMLElement): Promise<void> {
// Load the current persisted state for the project context, if any.
const initial: EventChoice[] = [];
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices`);
if (resp.ok) {
const rows = (await resp.json()) as EventChoice[];
for (const r of rows) initial.push(r);
}
} catch (e) {
console.error("event-choices: initial load failed", e);
}
}
attachEventCardChoices({
container,
initial,
commit: async (choice) => {
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
const resp = await fetch(`/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(choice),
});
if (!resp.ok) throw new Error(`event-choices PUT ${resp.status}`);
scheduleProcCalc(0);
},
remove: async (submissionCode, kind) => {
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
const url = `/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices/${encodeURIComponent(submissionCode)}/${encodeURIComponent(kind)}`;
const resp = await fetch(url, { method: "DELETE" });
if (!resp.ok && resp.status !== 404) throw new Error(`event-choices DELETE ${resp.status}`);
scheduleProcCalc(0);
},
});
}
// onDateEditCommit is the click-to-edit callback handed to the shared
// wireDateEditClicks() helper: persist the per-rule override (empty value
// clears it) then recompute so downstream rules re-anchor.
@@ -648,6 +709,15 @@ document.addEventListener("DOMContentLoaded", () => {
const timelineContainer = document.getElementById("timeline-container");
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
// t-paliad-265 — per-event-card choices. Project-bound surface, so
// commits POST to /api/projects/{id}/event-choices. The popover
// module owns the popover; this page owns the recalc trigger. When
// there's no project context yet (Step 1 not picked), the popover
// still works but commits silently no-op (project_id missing).
if (timelineContainer) {
void initEventCardChoicesForFristenrechner(timelineContainer);
}
// Reset button
document.getElementById("reset-btn")!.addEventListener("click", reset);

View File

@@ -207,6 +207,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step2.perspective": "Perspektive und Datum",
"deadlines.step3": "Ergebnis",
"deadlines.upc": "UPC",
"deadlines.de": "Deutsche Gerichte",
@@ -253,6 +254,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
"deadlines.conditional.depends_on": "abhängig von {parent}",
"deadlines.conditional.unset": "abhängig von vorgelagertem Ereignis",
"deadlines.optional.badge": "auf Antrag",
"deadlines.priority.mandatory": "Pflicht",
"deadlines.priority.recommended": "empfohlen",
@@ -306,6 +309,28 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.col.court": "Gericht",
"deadlines.col.opponent": "Gegnerseite",
"deadlines.col.both": "Beide Parteien",
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
"choices.caret.title": "Optionen für dieses Ereignis",
"choices.appellant.title": "Berufung durch …",
"choices.appellant.claimant": "Klägerseite",
"choices.appellant.defendant": "Beklagtenseite",
"choices.appellant.both": "beide Parteien",
"choices.appellant.none": "keine Berufung",
"choices.include_ccr.title": "Nichtigkeitswiderklage einbeziehen",
"choices.include_ccr.true": "Ja",
"choices.include_ccr.false": "Nein",
"choices.skip.title": "Für diese Akte überspringen",
"choices.skip.true": "Überspringen",
"choices.skip.false": "Einbeziehen",
"choices.skipped.chip": "übersprungen",
"choices.appellant.chip": "Berufung:",
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
"choices.reset": "Auswahl zurücksetzen",
"choices.commit.error": "Konnte Auswahl nicht speichern",
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
"choices.show_hidden.label": "Ausgeblendete anzeigen",
"choices.show_hidden.count": "Ausgeblendete ({n})",
"choices.unhide.chip": "Wieder einblenden",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
"deadlines.mode.event": "Was kommt nach\u2026",
@@ -420,7 +445,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.label": "Seite:",
"deadlines.side.claimant": "Klägerseite",
"deadlines.side.defendant": "Beklagtenseite",
"deadlines.side.both": "Beide",
"deadlines.side.undefined": "Nicht festgelegt",
"deadlines.side.from_project": "Aus Akte:",
"deadlines.side.override": "Andere Seite wählen",
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
@@ -1473,6 +1501,15 @@ const translations: Record<Lang, Record<string, string>> = {
"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-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Aus Projekt importieren",
"submissions.draft.parties.title": "Parteien",
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
@@ -3274,6 +3311,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step2.perspective": "Perspective and Date",
"deadlines.step3": "Result",
"deadlines.upc": "UPC",
"deadlines.de": "German Courts",
@@ -3320,6 +3358,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
"deadlines.conditional.depends_on": "depends on {parent}",
"deadlines.conditional.unset": "depends on an upstream event",
"deadlines.optional.badge": "on request",
"deadlines.priority.mandatory": "Mandatory",
"deadlines.priority.recommended": "Recommended",
@@ -3373,6 +3413,28 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.col.court": "Court",
"deadlines.col.opponent": "Opponent Side",
"deadlines.col.both": "Both parties",
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
"choices.caret.title": "Options for this event",
"choices.appellant.title": "Appeal by …",
"choices.appellant.claimant": "Claimant side",
"choices.appellant.defendant": "Defendant side",
"choices.appellant.both": "both parties",
"choices.appellant.none": "no appeal",
"choices.include_ccr.title": "Include nullity counterclaim",
"choices.include_ccr.true": "Yes",
"choices.include_ccr.false": "No",
"choices.skip.title": "Skip for this case",
"choices.skip.true": "Skip",
"choices.skip.false": "Include",
"choices.skipped.chip": "skipped",
"choices.appellant.chip": "Appeal:",
"choices.include_ccr.chip": "with nullity counterclaim",
"choices.reset": "Reset choice",
"choices.commit.error": "Could not save selection",
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
"choices.show_hidden.label": "Show hidden",
"choices.show_hidden.count": "Hidden ({n})",
"choices.unhide.chip": "Show again",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
"deadlines.adjusted.weekend": "weekend",
@@ -3494,7 +3556,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.label": "Side:",
"deadlines.side.claimant": "Claimant",
"deadlines.side.defendant": "Defendant",
"deadlines.side.both": "Both",
"deadlines.side.undefined": "Undefined",
"deadlines.side.from_project": "From case:",
"deadlines.side.override": "Choose other side",
"deadlines.side.hint": "Pick a side to focus the columns.",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
@@ -4520,7 +4585,16 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.switcher.label": "Draft",
"submissions.draft.name.placeholder": "Name of this draft",
"submissions.draft.preview.title": "Preview",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Language",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",
"submissions.draft.parties.title": "Parties",
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",

View File

@@ -20,13 +20,25 @@ interface SubmissionDraftJSON {
submission_code: string;
user_id: string;
name: string;
// t-paliad-276 — per-draft output language ("de" or "en"). Drives the
// template-variant lookup and language-aware variable resolution.
language: string;
variables: Record<string, string>;
selected_parties: string[];
last_exported_at?: string | null;
last_exported_sha?: string | null;
last_imported_at?: string | null;
created_at: string;
updated_at: string;
}
interface AvailablePartyJSON {
id: string;
name: string;
role?: string;
representative?: string;
}
interface SubmissionRuleSummary {
name: string;
name_en: string;
@@ -46,6 +58,12 @@ interface SubmissionDraftView {
lang: string;
has_template: boolean;
template_missing?: boolean;
available_parties: AvailablePartyJSON[];
// t-paliad-276 — template-tier metadata used to surface the
// "Fallback: universelles Skelett" notice when the requested draft
// language has no per-firm language-matched template.
template_tier?: string;
language_fallback?: boolean;
}
interface SubmissionDraftListResponse {
@@ -119,6 +137,13 @@ interface VariableGroup {
id: string;
label: VariableLabel;
keys: string[];
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
// initial state is collapsed iff collapsedByDefault. Used for the
// Frist section which lawyers rarely need to override (the variables
// stay resolvable in the bag for the few templates that still want
// them, but render no body content by default).
collapsible?: boolean;
collapsedByDefault?: boolean;
}
const VARIABLE_LABELS: Record<string, VariableLabel> = {
@@ -187,33 +212,19 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
};
// t-paliad-287 — variable groups restructured into four lawyer-facing
// sections: Mandant/Verfahren up top (the case identity), then Parteien
// (where the picker UI lives — this group only carries the manual
// {{parties.*}} overrides for power-users), then Frist collapsed by
// default (the deadline.* keys still resolve in the bag but the default
// templates don't render them in the body any more), then Sonstiges for
// the firm/date/user trim. The legacy procedural_event/rule namespaces
// fold into Mandant/Verfahren so the lawyer reads them in their natural
// context.
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "procedural_event",
label: { de: "Verfahrensschritt", en: "Procedural event" },
keys: [
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.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" },
id: "mandant_verfahren",
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
keys: [
"project.title",
"project.case_number",
@@ -228,11 +239,43 @@ const VARIABLE_GROUPS: VariableGroup[] = [
"project.matter_number",
"project.reference",
"project.instance_level",
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
id: "parties",
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
// Manual overrides for {{parties.<role>.*}} placeholders — power-
// user escape hatch when the lawyer wants the rendered string to
// differ from the picker selection (e.g. honourific prefix on
// representative). Collapsed by default because the picker above
// is the canonical surface; these rows exist only as a safety
// valve.
collapsible: true,
collapsedByDefault: true,
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "deadline",
label: { de: "Frist", en: "Deadline" },
label: { de: "Frist (intern)", en: "Deadline (internal)" },
// t-paliad-287 — the {{deadline.*}} placeholders no longer render
// in the default skeleton body (internal context that doesn't
// belong in a court-bound submission). The values still resolve
// here so a custom template can pick them up if needed; collapsed
// because most drafts never touch them.
collapsible: true,
collapsedByDefault: true,
keys: [
"deadline.due_date",
"deadline.due_date_long_de",
@@ -243,10 +286,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
],
},
{
id: "firm",
label: { de: "Kanzlei & Datum", en: "Firm & date" },
id: "sonstiges",
label: { de: "Sonstiges", en: "Other" },
keys: [
"firm.name",
"firm.signature_block",
"user.display_name",
"user.email",
"user.office",
@@ -273,6 +317,29 @@ interface State {
saveTimer: number | null;
pendingOverrides: Record<string, string> | null;
inFlight: AbortController | null;
// t-paliad-287 — per-section collapse memory. Sticky across repaints
// so autosave (which calls paintVariables) doesn't snap an open
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
collapsedGroups: Record<string, boolean>;
// t-paliad-287 — which side the Add-Party panel is currently open for
// (one panel can be open at a time; clicking the other side's button
// toggles). null means closed.
addPartyOpen: PartySide | null;
addPartyMode: "manual" | "search";
addPartySearchHits: PartySearchHit[];
addPartyBusy: boolean;
}
type PartySide = "claimant" | "defendant" | "other";
interface PartySearchHit {
id: string;
project_id: string;
project_title: string;
project_reference?: string | null;
name: string;
role?: string;
representative?: string;
}
const state: State = {
@@ -282,6 +349,11 @@ const state: State = {
saveTimer: null,
pendingOverrides: null,
inFlight: null,
collapsedGroups: {},
addPartyOpen: null,
addPartyMode: "manual",
addPartySearchHits: [],
addPartyBusy: false,
};
// ─────────────────────────────────────────────────────────────────────
@@ -401,7 +473,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -451,6 +523,10 @@ function paint(): void {
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintImportRow();
paintPartyPicker();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
}
@@ -562,6 +638,397 @@ function paintNameRow(): void {
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
}
// t-paliad-277 — "Aus Projekt importieren" + last-imported-at stamp.
// Hidden when the draft has no project (no project state to import).
function paintImportRow(): void {
const row = document.getElementById("submission-draft-import-row");
const btn = document.getElementById("submission-draft-import-btn") as HTMLButtonElement | null;
const stamp = document.getElementById("submission-draft-import-stamp");
if (!row || !btn || !stamp || !state.view) return;
if (!state.view.draft.project_id) {
row.style.display = "none";
return;
}
row.style.display = "";
const last = state.view.draft.last_imported_at;
if (last) {
stamp.textContent = (isEN() ? "Last imported: " : "Zuletzt importiert: ") + formatStamp(last);
} else {
stamp.textContent = isEN() ? "Never imported" : "Noch nicht importiert";
}
btn.onclick = () => { void onImportFromProject(btn); };
}
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
// Party affordance per side. Lists every party on the draft's project
// (view.available_parties), grouped by role, with one checkbox per
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
// an "+ Partei hinzufügen" button that opens an inline panel with two
// modes: manual entry (creates a fresh paliad.parties row) or DB
// picker (searches every visible project, clones the row into THIS
// project on selection). Empty selection still falls back to the
// legacy "include every party" default.
function paintPartyPicker(): void {
const block = document.getElementById("submission-draft-parties");
const list = document.getElementById("submission-draft-parties-list");
if (!block || !list || !state.view) return;
// t-paliad-287 — picker is now shown even on empty-roster projects so
// the lawyer can use Add Party to populate. Still hidden when there
// is no project attached (no row to attach a party to).
if (!state.view.draft.project_id) {
block.style.display = "none";
list.innerHTML = "";
return;
}
block.style.display = "";
const parties = state.view.available_parties ?? [];
const selected = new Set(state.view.draft.selected_parties ?? []);
// Empty selection is the implicit "all" default — pre-check every
// party so the lawyer can see what's currently being mentioned and
// then deselect what they want to drop. This matches the issue's
// "default = all parties on the project, lawyer can deselect" line.
const effective = selected.size === 0
? new Set(parties.map((p) => p.id))
: selected;
const grouped = groupPartiesByRole(parties);
let html = "";
for (const group of grouped) {
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
html += `<legend>${escapeHtml(group.label)}</legend>`;
if (group.parties.length === 0) {
html += `<p class="submission-draft-parties-empty">${escapeHtml(
isEN() ? "No parties yet." : "Noch keine Parteien.",
)}</p>`;
}
for (const p of group.parties) {
const checked = effective.has(p.id) ? " checked" : "";
const chip = p.role
? `<span class="submission-draft-party-chip">${escapeHtml(p.role)}</span>`
: "";
const rep = p.representative
? `<span class="submission-draft-party-rep">${escapeHtml(
(isEN() ? "Repr.: " : "Vertr.: ") + p.representative,
)}</span>`
: "";
html += `<label class="submission-draft-party-row">`;
html += `<input type="checkbox" class="submission-draft-party-check"`;
html += ` data-party-id="${escapeHtml(p.id)}"${checked} />`;
html += `<span class="submission-draft-party-name">${escapeHtml(p.name)}</span>`;
html += chip;
html += rep;
html += `</label>`;
}
html += renderAddPartyControls(group.bucket);
html += `</fieldset>`;
}
list.innerHTML = html;
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
inp.addEventListener("change", () => onPartySelectionChange());
});
wireAddPartyControls(list);
}
// renderAddPartyControls emits the per-side "+ Add party" button and
// (when expanded) the inline panel offering manual entry OR DB search.
// Sticky panel state lives in state.addPartyOpen so a repaint after
// search-fetch / autosave / language-switch doesn't snap the panel
// shut mid-edit.
function renderAddPartyControls(side: PartySide): string {
const open = state.addPartyOpen === side;
const mode = state.addPartyMode;
const sideLabel = sideLabelFor(side);
const btnLabel = isEN()
? `+ Add party (${sideLabel})`
: `+ Partei hinzufügen (${sideLabel})`;
let html = `<div class="submission-draft-addparty">`;
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
html += escapeHtml(btnLabel);
html += `</button>`;
if (!open) {
html += `</div>`;
return html;
}
// Tabs — manual / search.
html += `<div class="submission-draft-addparty-panel">`;
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
html += `</button>`;
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
html += `</button>`;
html += `</div>`;
if (mode === "manual") {
html += renderAddPartyManualForm(side);
} else {
html += renderAddPartySearchPanel(side);
}
html += `</div></div>`;
return html;
}
function renderAddPartyManualForm(side: PartySide): string {
const defaultRole = defaultRoleFor(side);
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
html += `<input type="text" name="name" required class="entity-form-input"`;
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
html += `</label>`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
html += `<input type="text" name="role" class="entity-form-input"`;
html += ` value="${escapeHtml(defaultRole)}"`;
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
html += `</label>`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
html += `<input type="text" name="representative" class="entity-form-input"`;
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
html += `</label>`;
html += `<div class="submission-draft-addparty-actions">`;
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
html += `</button>`;
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
html += `</button>`;
html += `</div>`;
html += `</form>`;
return html;
}
function renderAddPartySearchPanel(side: PartySide): string {
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
html += ` data-side="${side}"`;
html += ` placeholder="${escapeHtml(
isEN()
? "Search across projects (name or representative)…"
: "In allen Projekten suchen (Name oder Vertreter)…",
)}" />`;
html += renderPartySearchResultsList();
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
isEN()
? "Picking a row clones it as a fresh party on this project — no typing."
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
)}</p>`;
html += `<div class="submission-draft-addparty-actions">`;
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
html += `</button>`;
html += `</div>`;
html += `</div>`;
return html;
}
function wireAddPartyControls(root: HTMLElement): void {
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const side = (btn.dataset.side as PartySide) ?? "other";
if (state.addPartyOpen === side) {
// Toggle off.
state.addPartyOpen = null;
state.addPartySearchHits = [];
} else {
state.addPartyOpen = side;
state.addPartyMode = "manual";
state.addPartySearchHits = [];
}
paintPartyPicker();
});
});
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
if (tab !== "manual" && tab !== "search") return;
state.addPartyMode = tab;
if (tab === "manual") state.addPartySearchHits = [];
paintPartyPicker();
if (tab === "search") {
// Pre-load most-recent matches with empty query so the lawyer
// sees options without typing first.
void runPartySearch("");
}
});
});
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
btn.addEventListener("click", () => {
state.addPartyOpen = null;
state.addPartySearchHits = [];
paintPartyPicker();
});
});
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
form.addEventListener("submit", (ev) => {
ev.preventDefault();
const side = (form.dataset.side as PartySide) ?? "other";
const data = new FormData(form);
const name = String(data.get("name") ?? "").trim();
if (!name) return;
const role = String(data.get("role") ?? "").trim();
const representative = String(data.get("representative") ?? "").trim();
void onAddPartyManualSubmit(side, { name, role, representative });
});
});
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
let timer: number | null = null;
inp.addEventListener("input", () => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
void runPartySearch(inp.value.trim());
}, 200);
});
// Pre-load on first render of the search tab.
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
void runPartySearch("");
}
});
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
li.addEventListener("click", () => {
const hitID = li.dataset.hitId;
if (!hitID) return;
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
if (!hit) return;
const side = state.addPartyOpen ?? "other";
void onAddPartySearchPick(side, hit);
});
});
}
function sideLabelFor(side: PartySide): string {
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
return isEN() ? "Other parties" : "Weitere Parteien";
}
function defaultRoleFor(side: PartySide): string {
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
return "";
}
interface PartyRoleGroup {
bucket: "claimant" | "defendant" | "other";
label: string;
parties: AvailablePartyJSON[];
}
function groupPartiesByRole(parties: AvailablePartyJSON[]): PartyRoleGroup[] {
const claimants: AvailablePartyJSON[] = [];
const defendants: AvailablePartyJSON[] = [];
const others: AvailablePartyJSON[] = [];
for (const p of parties) {
const role = (p.role ?? "").trim().toLowerCase();
if (role === "claimant" || role === "kläger" || role === "klaeger"
|| role === "klägerin" || role === "klaegerin") {
claimants.push(p);
} else if (role === "defendant" || role === "beklagter" || role === "beklagte") {
defendants.push(p);
} else {
others.push(p);
}
}
return [
{
bucket: "claimant",
label: isEN() ? "Claimants" : "Klägerinnen",
parties: claimants,
},
{
bucket: "defendant",
label: isEN() ? "Defendants" : "Beklagte",
parties: defendants,
},
{
bucket: "other",
label: isEN() ? "Other parties" : "Weitere Parteien",
parties: others,
},
];
}
function formatStamp(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(isEN() ? "en-GB" : "de-DE");
}
// paintLanguageRow syncs the DE/EN radio with the loaded draft's
// language. Switching the radio fires onLanguageChange which PATCHes
// the draft and lets the server return the freshly-resolved bag +
// preview HTML (so the lawyer sees the EN form names appear without a
// manual reload). t-paliad-276.
function paintLanguageRow(): void {
if (!state.view) return;
const lang = (state.view.draft.language || "de").toLowerCase();
const de = document.getElementById("submission-draft-language-de") as HTMLInputElement | null;
const en = document.getElementById("submission-draft-language-en") as HTMLInputElement | null;
if (de) {
de.checked = lang === "de";
de.onchange = () => { void onLanguageChange("de"); };
}
if (en) {
en.checked = lang === "en";
en.onchange = () => { void onLanguageChange("en"); };
}
}
// paintLanguageFallback shows / hides the "no language-matched
// template" notice. The server sets language_fallback=true when the
// resolved template tier doesn't match the draft's language
// (e.g. EN draft → DE per-code template, or no skeleton EN sibling).
function paintLanguageFallback(): void {
const el = document.getElementById("submission-draft-language-fallback");
if (!el) return;
const fallback = !!state.view?.language_fallback;
el.style.display = fallback ? "" : "none";
}
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
if (!state.view) return;
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ language: lang });
state.view = view;
// Repaint everything that depends on language: the DE/EN form
// values in the resolved bag, the localized rule name in the
// header, and the fallback notice.
paintHeader();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft language switch:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
// Revert the radio to the persisted value so the UI doesn't lie
// about which language is active.
paintLanguageRow();
}
}
function paintVariables(): void {
const host = document.getElementById("submission-draft-variables");
if (!host || !state.view) return;
@@ -572,8 +1039,27 @@ function paintVariables(): void {
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>`;
// Re-use the user's prior toggle state across paintVariables calls
// (autosave / language switch trigger a repaint). Default sticky
// state lives in state.collapsedGroups; on first render the
// collapsedByDefault flag seeds it.
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
}
const collapsed = !!state.collapsedGroups[group.id];
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
if (group.collapsible) {
html += `<button type="button" class="submission-draft-var-group-toggle"`;
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
html += `</button>`;
} else {
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
}
html += `<div class="submission-draft-var-group-body">`;
for (const key of group.keys) {
const label = labelFor(key);
const override = overrides[key];
@@ -604,10 +1090,19 @@ function paintVariables(): void {
// Visual hint: marker text appears in preview when override is "".
void mergedVal;
}
html += `</div>`;
html += `</section>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.toggleGroup;
if (!id) return;
state.collapsedGroups[id] = !state.collapsedGroups[id];
paintVariables();
});
});
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
// t-paliad-274 (B) — focus into a sidebar field highlights every
@@ -777,6 +1272,238 @@ function flashVarRow(input: HTMLElement): void {
// Event handlers
// ─────────────────────────────────────────────────────────────────────
async function onPartySelectionChange(): Promise<void> {
if (!state.view) return;
const host = document.getElementById("submission-draft-parties-list");
if (!host) return;
const checks = host.querySelectorAll<HTMLInputElement>(".submission-draft-party-check");
const selectedIDs: string[] = [];
checks.forEach((c) => {
if (c.checked && c.dataset.partyId) selectedIDs.push(c.dataset.partyId);
});
// If the lawyer has checked every party, persist that as an empty
// array so the row matches the "implicit all" default semantics — a
// future party added to the project will then be picked up
// automatically rather than silently dropped from this submission.
// If they've unchecked some, persist the actual subset.
const available = state.view.available_parties ?? [];
const allChecked = selectedIDs.length === available.length;
const payload = allChecked ? [] : selectedIDs;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ selected_parties: payload });
state.view = view;
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft party selection:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
}
}
async function runPartySearch(query: string): Promise<void> {
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
const resp = await fetch(`/api/parties/search?${params.toString()}`);
if (!resp.ok) throw new Error(`search ${resp.status}`);
const data = (await resp.json()) as { results: PartySearchHit[] };
// Filter out parties already on THIS project — picking one of them
// would be a no-op clone that doubles the row.
const existingIDs = new Set(
(state.view?.available_parties ?? []).map((p) => p.id),
);
state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));
// Refresh ONLY the results <ul> in place — repainting the whole
// picker would steal focus from the search input on every
// keystroke. The input keeps its value/selection and the lawyer
// can keep typing.
const ul = document.querySelector<HTMLUListElement>(
".submission-draft-addparty-search-results",
);
if (ul) {
ul.outerHTML = renderPartySearchResultsList();
const fresh = document.querySelector<HTMLUListElement>(
".submission-draft-addparty-search-results",
);
if (fresh) {
fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
li.addEventListener("click", () => {
const hitID = li.dataset.hitId;
if (!hitID) return;
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
if (!hit) return;
const side = state.addPartyOpen ?? "other";
void onAddPartySearchPick(side, hit);
});
});
}
} else {
// First load (panel just opened) — full picker paint to wire up
// every control. Subsequent keystroke updates take the cheaper
// path above.
paintPartyPicker();
}
} catch (err) {
console.error("submission-draft party-search:", err);
}
}
function renderPartySearchResultsList(): string {
let html = `<ul class="submission-draft-addparty-search-results">`;
if (state.addPartySearchHits.length === 0) {
html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
isEN() ? "No matches." : "Keine Treffer.",
)}</li>`;
} else {
for (const hit of state.addPartySearchHits) {
const ref = hit.project_reference
? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
: "";
const role = hit.role
? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
: "";
const rep = hit.representative
? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
(isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
)}</span>`
: "";
html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
html += role;
html += rep;
html += `<span class="submission-draft-addparty-search-projwrap">`;
html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
html += ref;
html += `</span>`;
html += `</li>`;
}
}
html += `</ul>`;
return html;
}
async function onAddPartyManualSubmit(
side: PartySide,
payload: { name: string; role: string; representative: string },
): Promise<void> {
if (!state.view) return;
const projectID = state.view.draft.project_id;
if (!projectID) return;
// Disable the submit button in-place rather than repainting the form
// mid-flight (a repaint would blow away the lawyer's typed values on
// error and reset focus). The post-success/-error repaint runs once
// the call settles.
const submitBtn = document.querySelector<HTMLButtonElement>(
`.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
);
if (submitBtn) submitBtn.disabled = true;
state.addPartyBusy = true;
try {
const body: Record<string, unknown> = { name: payload.name };
if (payload.role) body.role = payload.role;
if (payload.representative) body.representative = payload.representative;
const resp = await fetch(`/api/projects/${projectID}/parties`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`create party ${resp.status}`);
const created = (await resp.json()) as { id: string };
await refreshDraftViewAndSelect(created.id);
state.addPartyOpen = null;
setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
state.addPartyBusy = false;
paintPartyPicker();
} catch (err) {
console.error("submission-draft add-party manual:", err);
setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
if (submitBtn) submitBtn.disabled = false;
state.addPartyBusy = false;
}
}
async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
// DB picks clone the row into the current project — the simplest
// semantics that survive paliad.parties' project_id-NOT-NULL schema.
// The lawyer asked for "no manual re-typing"; this honours that
// without bending the data model.
await onAddPartyManualSubmit(side, {
name: hit.name,
role: hit.role ?? defaultRoleFor(side),
representative: hit.representative ?? "",
});
}
// refreshDraftViewAndSelect refetches the editor payload (so
// available_parties picks up the new row) and ensures the newly-added
// party is checked in selected_parties. If the lawyer was on the
// implicit-all default (empty selected_parties), the new party comes
// in pre-selected via the "empty=all" rule and no PATCH is needed.
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;
const view = state.view.draft.project_id
? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
: await fetchGlobalView(draftID);
state.view = view;
// If the previous draft had a non-empty selected_parties subset,
// explicitly add the new party so it isn't silently dropped from the
// submission. Empty selected_parties = "all" → no PATCH needed.
const currentSel = state.view.draft.selected_parties ?? [];
if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
const next = [...currentSel, newPartyID];
try {
const patched = await patchDraft({ selected_parties: next });
state.view = patched;
} catch (err) {
console.error("submission-draft select new party:", err);
}
}
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
}
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;
const originalLabel = btn.textContent ?? "";
btn.disabled = true;
btn.textContent = isEN() ? "Importing…" : "Importiert…";
setSaveStatus(isEN() ? "Importing from project…" : "Importiere aus Projekt…");
try {
const resp = await fetch(`/api/submission-drafts/${draftID}/import-from-project`, {
method: "POST",
});
if (!resp.ok) throw new Error(`import ${resp.status}`);
const view = (await resp.json()) as SubmissionDraftView;
state.view = view;
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Imported" : "Importiert");
} catch (err) {
console.error("submission-draft import-from-project:", err);
setSaveStatus(isEN() ? "Import failed" : "Import fehlgeschlagen", true);
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
function onVarChange(input: HTMLInputElement): void {
const key = input.dataset.var;
if (!key || !state.view) return;

View File

@@ -21,6 +21,13 @@ import {
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
import {
attachEventCardChoices,
reseedChips,
currentChoices,
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
@@ -38,6 +45,13 @@ let lastResponse: DeadlineResponse | null = null;
let currentSide: Side = null;
let currentAppellant: Side = null;
// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
// page is opened with ?project=<id> and that project has our_side set,
// the side row renders as a read-only chip instead of the radio cluster.
// The user can flip to free-pick via the "Andere Seite wählen" override
// link, which clears this flag (radio cluster takes over again).
let sidePrefilledFromProject = false;
// 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
@@ -98,6 +112,56 @@ function writeAppellantToURL(a: Side) {
const anchorOverrides = new Map<string, string>();
function clearAnchorOverrides() { anchorOverrides.clear(); }
// Per-event-card choices (t-paliad-265). Unbound on this page (no
// project context), so persistence is URL-only via `?event_choices=`.
// Format: comma-separated `submission_code:kind=value` tuples. Same
// idiom as `?side=` + `?appellant=`.
let perCardChoices: EventChoice[] = [];
function readChoicesFromURL(): EventChoice[] {
const raw = new URLSearchParams(window.location.search).get("event_choices");
if (!raw) return [];
const out: EventChoice[] = [];
for (const tuple of raw.split(",")) {
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
if (!m) continue;
const kind = m[2] as ChoiceKind;
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
}
return out;
}
function writeChoicesToURL(choices: EventChoice[]) {
const url = new URL(window.location.href);
if (choices.length === 0) {
url.searchParams.delete("event_choices");
} else {
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
url.searchParams.set("event_choices", enc);
}
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
// calculator re-surfaces cards whose submission_code is in the active
// skipRules set; they render faded with a "Wieder einblenden" chip.
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
// the visibility. Default OFF — m's not asking to see hidden by
// default, just to be able to.
function readShowHiddenFromURL(): boolean {
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
}
function writeShowHiddenToURL(on: boolean) {
const url = new URL(window.location.href);
if (on) url.searchParams.set("show_hidden", "1");
else url.searchParams.delete("show_hidden");
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
let showHidden = readShowHiddenFromURL();
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
@@ -210,14 +274,34 @@ async function doCalc() {
flags: readFlags(),
anchorOverrides: overrides,
courtId,
perCardChoices,
includeHidden: showHidden,
});
if (seq !== calcSeq) return;
if (!data) return;
lastResponse = data;
renderResults(data);
syncHiddenBadge(data.hiddenCount ?? 0);
showStep(3);
}
// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
// toggle. Visible regardless of toggle state so the user knows whether
// there's anything to re-surface even when the toggle is OFF. Hides the
// whole row when the projection has zero hidden cards — no clutter on
// a project that's never used the skip feature. (t-paliad-290)
function syncHiddenBadge(count: number) {
const row = document.getElementById("show-hidden-row");
const badge = document.getElementById("show-hidden-count");
if (!row || !badge) return;
if (count <= 0) {
row.style.display = "none";
return;
}
row.style.display = "";
badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. Precedence:
//
@@ -302,6 +386,11 @@ function renderResults(data: DeadlineResponse) {
if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
// t-paliad-265: rehydrate per-event-card chip indicators after every
// re-render so the popover-driven active state survives the
// innerHTML rewrite the timeline body just did.
reseedChips(container);
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
@@ -388,6 +477,138 @@ function syncRadioGroup(name: string, value: string) {
});
}
// Project context (t-paliad-279 / m/paliad#111). When the page is opened
// with ?project=<id> and the project carries an our_side value, the side
// row renders as a read-only chip with an "Andere Seite wählen" override
// link. The proceeding picker + appellant axis stay untouched — only the
// side selector pre-fills.
interface ProjectOurSide {
id: string;
our_side?:
| "claimant"
| "defendant"
| "applicant"
| "appellant"
| "respondent"
| "third_party"
| "other"
| null;
}
function readProjectFromURL(): string {
return new URLSearchParams(window.location.search).get("project") || "";
}
// ourSideToSide maps the project-level our_side enum (t-paliad-222) onto
// the side-selector's two-value axis. Active roles (claimant / applicant /
// appellant) collapse to "claimant"; reactive roles (defendant /
// respondent) collapse to "defendant"; everything else (third_party /
// other / NULL) returns null = no pre-fill. Mirrors fristenrechner.ts
// ourSideToPerspective() so projects render consistently across both
// surfaces.
function ourSideToSide(os: ProjectOurSide["our_side"] | undefined): Side {
switch (os) {
case "claimant":
case "applicant":
case "appellant":
return "claimant";
case "defendant":
case "respondent":
return "defendant";
default:
return null;
}
}
async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}`, {
credentials: "same-origin",
});
if (!resp.ok) return null;
return (await resp.json()) as ProjectOurSide;
} catch {
return null;
}
}
function sideLabelI18n(s: Side): string {
if (s === "claimant") return t("deadlines.side.claimant");
if (s === "defendant") return t("deadlines.side.defendant");
return t("deadlines.side.undefined");
}
// syncSideHintVisibility shows the "pick a side" hint chip only while
// currentSide is unset (m/paliad#120). When the user has picked
// claimant / defendant the columns are already focused, so the prompt
// would be misleading.
function syncSideHintVisibility() {
const hint = document.getElementById("side-hint");
if (!hint) return;
hint.style.display = currentSide === null ? "" : "none";
}
// renderSideChip swaps the radio cluster for a read-only chip showing
// the auto-filled side + an "Andere Seite wählen" override link. Called
// after fetchProjectOurSide resolves to a side. The override link clears
// the prefilled flag and swaps back to the radio cluster — the user can
// then pick any side freely.
function renderSideChip(side: Side) {
const cluster = document.getElementById("side-radio-cluster");
const chip = document.getElementById("side-chip");
const value = document.getElementById("side-chip-value");
if (!cluster || !chip || !value) return;
cluster.style.display = "none";
chip.style.display = "";
value.textContent = sideLabelI18n(side);
}
function showSideRadioCluster() {
const cluster = document.getElementById("side-radio-cluster");
const chip = document.getElementById("side-chip");
if (!cluster || !chip) return;
cluster.style.display = "";
chip.style.display = "none";
// Cluster re-appears after override → re-evaluate hint visibility so
// we don't leave a stale "pick a side" prompt above a checked radio.
syncSideHintVisibility();
}
// applySidePrefill takes a project's our_side, maps it to the side axis,
// and locks the side row to a read-only chip if a mapping exists. URL
// wins — if ?side= is already explicit, the user (or shared link) has
// already chosen and we never overwrite. When we do prefill, write the
// derived side to the URL so reload + back/forward round-trip cleanly.
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
if (readSideFromURL() !== null) return;
const next = ourSideToSide(os);
if (next === null) return;
currentSide = next;
writeSideToURL(next);
syncRadioGroup("side", next);
sidePrefilledFromProject = true;
renderSideChip(next);
if (lastResponse) renderResults(lastResponse);
}
function clearSidePrefill() {
sidePrefilledFromProject = false;
showSideRadioCluster();
// Drop ?project= from the URL so a reload doesn't re-lock the side.
// ?side= stays — that's the user's last pick at this point.
const url = new URL(window.location.href);
url.searchParams.delete("project");
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
async function initProjectAutofill() {
const projectID = readProjectFromURL();
if (!projectID) return;
const project = await fetchProjectOurSide(projectID);
if (!project) return;
applySidePrefill(project.our_side);
}
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
// Mirrors the events.ts pattern (body.events-view-*). The print
// stylesheet keys `body.verfahrensablauf-view-timeline` to
@@ -436,6 +657,7 @@ function initPerspectiveControls() {
currentAppellant = readAppellantFromURL();
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
syncSideHintVisibility();
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
input.addEventListener("change", () => {
@@ -443,6 +665,7 @@ function initPerspectiveControls() {
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
syncSideHintVisibility();
if (lastResponse) renderResults(lastResponse);
});
});
@@ -526,9 +749,61 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
// to URL + recalc (the backend reshapes the response — we can't just
// re-render lastResponse since the hidden rows aren't in it when the
// toggle was OFF).
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
if (showHiddenCb) {
showHiddenCb.checked = showHidden;
showHiddenCb.addEventListener("change", () => {
showHidden = showHiddenCb.checked;
writeShowHiddenToURL(showHidden);
scheduleCalc(0);
});
}
initViewToggle();
initPerspectiveControls();
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
// mutate the in-memory list + URL, then trigger a recalc. The
// popover module owns the popover lifecycle; this page owns the
// recalc + URL plumbing.
perCardChoices = readChoicesFromURL();
const timelineEl = document.getElementById("timeline-container");
if (timelineEl) {
attachEventCardChoices({
container: timelineEl,
initial: perCardChoices,
commit: (choice) => {
perCardChoices = perCardChoices.filter(
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
);
perCardChoices.push(choice);
writeChoicesToURL(perCardChoices);
scheduleCalc(0);
},
remove: (submissionCode, kind) => {
perCardChoices = perCardChoices.filter(
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
);
writeChoicesToURL(perCardChoices);
scheduleCalc(0);
},
});
}
// t-paliad-279 — override link on the prefilled side chip — swaps back
// to the radio cluster and clears ?project= from the URL.
document.getElementById("side-chip-override")?.addEventListener("click", clearSidePrefill);
// Project autofill — runs after the radio cluster has its URL-driven
// state so we never clobber an explicit ?side= pick. Fire-and-forget;
// the chip swap happens once the project resolves.
void initProjectAutofill();
onLangChange(() => {
// Active-button name updates with language change (the data-i18n
// pass swaps the inner <strong>'s text). Re-collapse the summary
@@ -539,6 +814,12 @@ document.addEventListener("DOMContentLoaded", () => {
const summary = document.getElementById("proceeding-summary-name");
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
}
// Side-chip label tracks language so a DE/EN flip while the chip is
// visible re-renders the inferred side in the active language.
if (sidePrefilledFromProject) {
const value = document.getElementById("side-chip-value");
if (value) value.textContent = sideLabelI18n(currentSide);
}
if (lastResponse) renderResults(lastResponse);
syncTriggerEventLabel();
});

View File

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

View File

@@ -66,7 +66,13 @@ export interface FilterSpec {
sources: DataSource[];
scope: ScopeSpec;
time: TimeSpec;
predicates?: Partial<Record<DataSource, Predicates>>;
// Per-source narrowing. Flat shape — one entry per data source. The
// Go side (internal/services/filter_spec.go: FilterSpec.Predicates)
// mirrors this exactly; the previous Partial<Record<DataSource,
// Predicates>> spelling was a latent contract bug (t-paliad-283)
// where every chip click sent a single-nested shape the server
// unmarshalled to no-op.
predicates?: Predicates;
// Inbox unread-only overlay (t-paliad-249). When true, the view
// service drops project_event rows older than the caller's
// users.inbox_seen_at cursor. Pending approval_requests always

View File

@@ -67,6 +67,92 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
});
});
// t-paliad-290 (m/paliad#122): the "Ausgeblendete anzeigen" toggle
// surfaces hidden cards via UIDeadline.IsHidden=true. The renderer
// must (a) emit an inline "Wieder einblenden" chip carrying the
// submission_code (so the delegated handler in event-card-choices.ts
// can resolve which skip to clear) and (b) NOT emit the chip when
// either isHidden is false or the rule has no submission_code (no
// hide target to undo).
describe("deadlineCardHtml — isHidden inline 'Wieder einblenden' chip (t-paliad-290)", () => {
test("isHidden=true with submission_code emits unhide chip with data-submission-code", () => {
const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
expect(html).toContain("event-card-choices-unhide");
expect(html).toContain('data-submission-code="upc-rop-12"');
});
test("isHidden=false (default) suppresses unhide chip", () => {
const html = deadlineCardHtml(dl(), { showParty: true });
expect(html).not.toContain("event-card-choices-unhide");
});
test("isHidden=true on a rule with no submission_code suppresses unhide chip", () => {
const html = deadlineCardHtml(dl({ code: "", isHidden: true }), { showParty: true });
expect(html).not.toContain("event-card-choices-unhide");
});
});
// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
// chip in place of the date column, and the chip keeps the click-to-edit
// affordance so the user can pin a real date once the upstream anchor
// resolves (oral hearing scheduled, opposing party's motion received, …).
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
// the issue.
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
const html = deadlineCardHtml(
dl({
code: "upc.inf.cfi.translation_request",
isConditional: true,
parentRuleCode: "upc.inf.cfi.oral",
parentRuleName: "Mündliche Verhandlung",
}),
{ showParty: true, editable: true },
);
expect(html).toContain("timeline-conditional");
expect(html).toContain("abhängig von Mündliche Verhandlung");
expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
expect(html).toContain('role="button"');
expect(html).not.toContain("timeline-court-set");
});
test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
const html = deadlineCardHtml(
dl({ isConditional: true }),
{ showParty: true, editable: true },
);
expect(html).toContain("timeline-conditional");
expect(html).toContain("abhängig von vorgelagertem Ereignis");
});
test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
// Court-set ancestor without override sets BOTH isCourtSet=true AND
// isConditional=true on the wire. The renderer must pick the
// conditional chip; otherwise the row keeps the legacy "wird vom
// Gericht bestimmt" label and the user can't see WHICH upstream
// event blocks them.
const html = deadlineCardHtml(
dl({
isConditional: true,
isCourtSet: true,
isCourtSetIndirect: true,
parentRuleName: "Entscheidung",
}),
{ showParty: true, editable: true },
);
expect(html).toContain("abhängig von Entscheidung");
expect(html).not.toContain("timeline-court-set");
});
test("isConditional=false keeps the normal date span (regression guard)", () => {
const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
expect(html).toContain("timeline-date");
expect(html).not.toContain("timeline-conditional");
});
});
// 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
@@ -191,6 +277,48 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
});
test("appellantContext overrides the page-level appellant for descendants (t-paliad-265)", () => {
// A per-decision pick stamps AppellantContext on descendants of
// that decision. The bucketer prefers it over the page-level
// appellant: if a "both" row carries appellantContext='defendant',
// it collapses to defendant's column regardless of the global
// appellant opt.
const dl: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "defendant",
};
const rows = bucketDeadlinesIntoColumns([dl], { appellant: "claimant" });
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("appellantContext='claimant' + side='defendant' lands the row in opponent (claimant ≠ us)", () => {
// The user is on the defendant side; per-card pick says the
// claimant appealed. The "both" row collapses to the claimant's
// column, which after the side-swap is opponent (right).
const dl: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "claimant",
};
const rows = bucketDeadlinesIntoColumns([dl], { side: "defendant", appellant: "defendant" });
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("appellantContext='both' or 'none' falls back to page-level mirror (t-paliad-265)", () => {
// 'both' and 'none' aren't side-collapse values — they're
// statements about who appealed but don't pick a column. The
// bucketer treats them as no override, so the page-level
// appellant (or default mirror) applies.
const both1: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "both",
};
const rowsBoth = bucketDeadlinesIntoColumns([both1]);
expect(rowsBoth[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rowsBoth[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("court", "Oral Hearing", ""),

View File

@@ -61,6 +61,40 @@ export interface CalculatedDeadline {
// Frontend save-modal logic doesn't read this; the rule editor
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
conditionExpr?: unknown;
// choicesOffered (t-paliad-265): declares which per-card choice-kinds
// this rule offers on the Verfahrensablauf timeline. Object shape:
// { appellant?: string[], include_ccr?: [true,false], skip?: [true,false] }.
// null/undefined = no caret affordance.
choicesOffered?: Record<string, unknown>;
// appellantContext (t-paliad-265): the per-decision appellant pick
// that applies to descendants of the closest ancestor decision card
// with a per-card appellant set. Empty = no per-card override (the
// page-level appellant axis still applies in that case). The bucketer
// reads this in preference to the page-level appellant.
appellantContext?: string;
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
// a previously-hidden card is re-surfaced via the "Ausgeblendete
// anzeigen" toggle. The renderer fades the card and exposes an
// inline "Wieder einblenden" chip that deletes the skip choice.
isHidden?: boolean;
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
// no concrete date is projected. Set by the calculator when the rule
// depends on a court-set ancestor without override, when a backward-
// anchored rule's forward anchor isn't set, or for optional rules
// whose true triggering event sits outside the rule data (e.g.
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
// in the data, but the real trigger is the opposing party's
// confidentiality motion). The renderer drops the date column entry
// and shows an "abhängig von <parentRuleName>" chip instead.
isConditional?: boolean;
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
// parent rule's identity so the renderer can label the
// "abhängig von <parent>" chip on conditional rows. Populated for
// every rule with a parent (not just conditional ones), so the
// dependency-footer logic can reuse it. Empty for root rules.
parentRuleCode?: string;
parentRuleName?: string;
parentRuleNameEN?: string;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -120,6 +154,13 @@ export interface DeadlineResponse {
// (m/paliad#81)
triggerEventLabel?: string;
triggerEventLabelEN?: string;
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
// would have been hidden in this projection (i.e. their
// submission_code is in skipRules and they passed the condition_expr
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
// when the toggle is OFF — so users know there's something to
// re-surface.
hiddenCount?: number;
}
export interface CourtRow {
@@ -139,6 +180,21 @@ export interface CalcParams {
flags?: string[];
anchorOverrides?: Record<string, string>;
courtId?: string;
// t-paliad-265: per-event-card choices. Either pass `projectId` for
// server-side lookup against paliad.project_event_choices, OR pass
// an inline list (for the unbound /tools/verfahrensablauf surface).
// When both are supplied the inline list wins server-side.
projectId?: string;
perCardChoices?: Array<{
submission_code: string;
choice_kind: string;
choice_value: string;
}>;
// includeHidden (t-paliad-290): when true the calculator returns
// previously-skipped rules as faded cards instead of dropping them.
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
// ON.
includeHidden?: boolean;
}
const PARTY_CLASS: Record<string, string> = {
@@ -154,10 +210,20 @@ export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
// Pure-string HTML escape — keeps the module testable in bun test
// (plain Node, no jsdom). Used to be backed by document.createElement,
// which forced fixtures to leave any field that flowed through it
// empty just to exercise unrelated branches; the regex form is safe
// for arbitrary text including the per-rule name strings that the
// conditional-row chip ("abhängig von <parent>") now exposes.
// (t-paliad-289)
export function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function formatDate(dateStr: string): string {
@@ -258,12 +324,31 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
// Conditional rows (t-paliad-289) replace the date column with an
// "abhängig von <parent>" chip. The chip remains click-to-edit so
// the user can pin a real date once known (e.g. once the oral
// hearing date is set, or the opposing party's Vertraulichkeits-
// antrag arrives) — the same data-rule-code wiring fires the
// existing inline date editor. IsConditional wins over IsCourtSet:
// they overlap (court-set ancestor without override produces both),
// and "abhängig von <parent>" is the clearer user-facing signal.
const parentLabel = (getLang() === "en"
? (dl.parentRuleNameEN || dl.parentRuleName)
: dl.parentRuleName) || "";
let dateStr: string;
if (dl.isConditional) {
const chipText = parentLabel
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
: t("deadlines.conditional.unset");
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
} else if (dl.isCourtSet) {
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
} else {
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
}
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
// priority directly. Optional badge fires only on 'optional'
@@ -272,6 +357,34 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? '<span class="optional-badge">optional</span>'
: "";
// t-paliad-265 — caret affordance + chip indicator when this rule
// offers per-card choices and the user has made a pick. The popover
// open/commit lifecycle lives in client/views/event-card-choices.ts;
// the data-* attributes here are the wire contract between the two.
const choicesHtml = dl.code !== "" && dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0
? `<button type="button" class="event-card-choices-caret"
data-submission-code="${escAttr(dl.code)}"
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
aria-label="${escAttr(t("choices.caret.title"))}"
title="${escAttr(t("choices.caret.title"))}">▾</button>`
: "";
// t-paliad-290 — inline "Wieder einblenden" chip on re-surfaced
// hidden cards. Click deletes the skip choice (mirroring the popover
// reset path). The chip only renders when the card is hidden in the
// current projection (IsHidden=true on the wire) so it's always
// pointing at a real skip entry. The chip text is a static i18n
// value (no user input), so we use escAttr-only for attribute safety
// and inline the translated label directly — matches the renderer's
// pattern for the deadline name (also a known-safe string).
const unhideLabel = t("choices.unhide.chip");
const unhideHtml = dl.isHidden && dl.code !== ""
? `<button type="button" class="event-card-choices-unhide"
data-submission-code="${escAttr(dl.code)}"
aria-label="${escAttr(unhideLabel)}"
title="${escAttr(unhideLabel)}">${unhideLabel}</button>`
: "";
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
@@ -310,12 +423,23 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
</div>`
: "";
// Chip indicator surfaces the active per-card pick (t-paliad-265).
// The popover module rehydrates this on commit so it stays in sync.
const chipHtml = dl.code !== ""
? `<span class="event-card-choices-chip"
data-submission-code="${escAttr(dl.code)}"
data-empty="true"></span>`
: "";
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
${chipHtml}
</span>
${dateStr}
${choicesHtml}
${unhideHtml}
</div>
${meta}
${adjustedNote}
@@ -406,8 +530,20 @@ export function wireDateEditClicks(
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
const itemClasses = [
"timeline-item",
dl.isRootEvent ? "timeline-root" : "",
// t-paliad-290: re-surfaced hidden cards render faded via the
// shared timeline-item--hidden modifier (same modifier the columns
// view uses; see fr-col-item--hidden below).
dl.isHidden ? "timeline-item--hidden" : "",
// t-paliad-289: dotted-border + faded styling for conditional rows
// so the "abhängig von <parent>" state is visually distinct from
// both anchored deadlines and direct court-set rows.
dl.isConditional ? "timeline-item--conditional" : "",
].filter(Boolean).join(" ");
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="${itemClasses}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
@@ -532,7 +668,15 @@ export function bucketDeadlinesIntoColumns(
row.court.push(dl);
break;
case "both":
if (appellantColumn !== null) {
// t-paliad-265: a per-card appellant set on a decision
// ancestor propagates as appellantContext on this rule. When
// present, it overrides the page-level appellant for the
// collapse decision on THIS row. Falls through to page-level
// when empty.
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
row[perCardCol].push(dl);
} else if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
@@ -578,7 +722,17 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
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" : ""}">
const itemClasses = [
"fr-col-item",
dl.isRootEvent ? "fr-col-root" : "",
// t-paliad-290: re-surfaced hidden cards render faded via the
// shared fr-col-item--hidden modifier.
dl.isHidden ? "fr-col-item--hidden" : "",
// t-paliad-289: same conditional treatment as the linear
// timeline-item — dotted border + faded styling.
dl.isConditional ? "fr-col-item--conditional" : "",
].filter(Boolean).join(" ");
return `<div class="${itemClasses}">
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
@@ -625,6 +779,11 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
? params.anchorOverrides
: undefined,
courtId: params.courtId || undefined,
projectId: params.projectId || undefined,
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
? params.perCardChoices
: undefined,
includeHidden: params.includeHidden ? true : undefined,
}),
});
if (!resp.ok) {

View File

@@ -1008,6 +1008,26 @@ export type I18nKey =
| "checklisten.tab.mine"
| "checklisten.tab.templates"
| "checklisten.title"
| "choices.appellant.both"
| "choices.appellant.chip"
| "choices.appellant.claimant"
| "choices.appellant.defendant"
| "choices.appellant.none"
| "choices.appellant.title"
| "choices.caret.title"
| "choices.commit.error"
| "choices.include_ccr.chip"
| "choices.include_ccr.false"
| "choices.include_ccr.title"
| "choices.include_ccr.true"
| "choices.reset"
| "choices.show_hidden.count"
| "choices.show_hidden.label"
| "choices.skip.false"
| "choices.skip.title"
| "choices.skip.true"
| "choices.skipped.chip"
| "choices.unhide.chip"
| "common.cancel"
| "common.close"
| "common.forbidden"
@@ -1218,6 +1238,8 @@ export type I18nKey =
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.complete.confirm"
| "deadlines.conditional.depends_on"
| "deadlines.conditional.unset"
| "deadlines.court.indirect"
| "deadlines.court.label"
| "deadlines.court.set"
@@ -1443,10 +1465,13 @@ 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.from_project"
| "deadlines.side.hint"
| "deadlines.side.label"
| "deadlines.side.override"
| "deadlines.side.undefined"
| "deadlines.source.caldav"
| "deadlines.source.fristenrechner"
| "deadlines.source.imported"
@@ -1477,6 +1502,7 @@ export type I18nKey =
| "deadlines.step2.happened.desc"
| "deadlines.step2.happened.title"
| "deadlines.step2.heading"
| "deadlines.step2.perspective"
| "deadlines.step3"
| "deadlines.step3a.back"
| "deadlines.step3a.draft.desc"
@@ -2599,9 +2625,16 @@ export type I18nKey =
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.import.button"
| "submissions.draft.language"
| "submissions.draft.language.de"
| "submissions.draft.language.en"
| "submissions.draft.language.fallback_notice"
| "submissions.draft.loading"
| "submissions.draft.name.placeholder"
| "submissions.draft.notfound"
| "submissions.draft.parties.hint"
| "submissions.draft.parties.title"
| "submissions.draft.preview.hint"
| "submissions.draft.preview.title"
| "submissions.draft.switcher.label"

View File

@@ -1917,7 +1917,11 @@ input[type="range"]::-moz-range-thumb {
.fristen-row.is-active .fristen-row-num {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-text, #111);
/* Lime is high-luminance; foreground stays midnight in both themes via
--color-accent-dark (light: midnight by default, dark: midnight
explicit). Using --color-text here would flip to cream in dark mode
and collapse contrast on lime. */
color: var(--color-accent-dark);
}
.fristen-row.is-prefilled .fristen-row-num {
@@ -3476,6 +3480,206 @@ input[type="range"]::-moz-range-thumb {
color: var(--status-amber-fg);
}
/* t-paliad-265 — per-event-card optional choices. The caret sits in
* the card header next to the date; the chip surfaces the active pick
* inline with the title; the popover is body-attached and positioned
* by the JS module. Skipped rows fade to 50% opacity. */
.event-card-choices-caret {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-left: 0.4rem;
border: 1px solid var(--color-border, #d4d4d4);
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
line-height: 1;
}
.event-card-choices-caret:hover,
.event-card-choices-caret:focus-visible {
background: var(--color-accent-bg, rgba(198, 244, 28, 0.18));
color: var(--color-text);
}
.event-card-choices-chip {
display: inline-flex;
gap: 0.3rem;
margin-left: 0.4rem;
}
.event-card-choices-chip[data-empty="true"] {
display: none;
}
.event-card-choices-chip-part {
font-size: 0.7rem;
font-weight: 500;
padding: 0.05rem 0.4rem;
border-radius: 99px;
background: var(--color-accent-bg, rgba(198, 244, 28, 0.22));
color: var(--color-text);
}
.event-card-choices-chip-part--skipped {
background: var(--color-bg-soft, #f1f1f1);
color: var(--color-text-muted);
text-decoration: line-through;
}
.timeline-item--skipped {
opacity: 0.55;
}
/* t-paliad-290 (m/paliad#122) — re-surfaced "hidden" cards. The user
* has previously marked these optional events as "Überspringen"; the
* "Ausgeblendete anzeigen" toggle on /tools/verfahrensablauf returns
* them with a faded + dotted-border treatment so they're visually
* distinct from the active timeline. The inline "Wieder einblenden"
* chip cancels the skip on click. */
.timeline-item--hidden .timeline-content,
.fr-col-item--hidden {
opacity: 0.55;
border: 1px dotted var(--color-border, #d4d4d4);
border-radius: 6px;
padding: 0.3rem 0.5rem;
}
.event-card-choices-unhide {
margin-left: 0.4rem;
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 99px;
border: 1px solid var(--color-accent, #c6f41c);
background: var(--color-accent, #c6f41c);
color: var(--color-text);
cursor: pointer;
/* Cancel the wrapper fade so the action remains a clear, high-
* contrast affordance even though the rest of the card is muted. */
opacity: 1;
}
.event-card-choices-unhide:hover,
.event-card-choices-unhide:focus-visible {
background: var(--color-bg, #fff);
}
.show-hidden-count {
font-size: 0.78rem;
color: var(--color-text-muted);
margin-left: 0.4rem;
}
/* t-paliad-289: rules whose anchor is uncertain (court-set ancestor
without override, backward-anchor with unset forward date, optional
event not recorded). The "abhängig von <parent>" chip on the date
column makes the conditional state explicit; the dotted border on
the content panel + slight desaturation reinforces it at glance so
the row reads as "pending an upstream input" rather than as a real
scheduled item. The frist-date-edit affordance on the chip still
wires through — the user can pin a concrete date once the anchor
resolves. */
.timeline-item--conditional .timeline-content,
.fr-col-item--conditional {
border: 1px dashed var(--color-border, #d4d4d4);
border-radius: 4px;
padding: 0.35rem 0.55rem;
background: var(--color-bg-soft, #fafafa);
}
.timeline-item--conditional .timeline-name,
.fr-col-item--conditional .timeline-name {
opacity: 0.85;
}
.timeline-conditional {
font-size: 0.82rem;
color: var(--color-text-muted);
font-style: italic;
text-align: right;
}
.event-card-choices-popover {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d4d4);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 0.6rem 0.7rem;
min-width: 240px;
max-width: 320px;
}
.event-card-choices-block + .event-card-choices-block {
margin-top: 0.7rem;
padding-top: 0.6rem;
border-top: 1px solid var(--color-border-soft, #ececec);
}
.event-card-choices-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
margin-bottom: 0.4rem;
}
.event-card-choices-options {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.event-card-choices-option {
font-size: 0.78rem;
padding: 0.25rem 0.55rem;
border-radius: 4px;
border: 1px solid var(--color-border, #d4d4d4);
background: var(--color-bg, #fff);
color: var(--color-text);
cursor: pointer;
}
.event-card-choices-option:hover,
.event-card-choices-option:focus-visible {
background: var(--color-bg-soft, #f1f1f1);
}
.event-card-choices-option--active {
background: var(--color-accent, #c6f41c);
border-color: var(--color-accent, #c6f41c);
/* Foreground stays midnight in both themes — --color-text would flip
to cream in dark mode and leave the active "Berufung durch …"
chip unreadable on lime (m/paliad#123). */
color: var(--color-accent-dark);
font-weight: 600;
}
.event-card-choices-reset {
margin-top: 0.5rem;
font-size: 0.72rem;
background: transparent;
border: none;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.event-card-choices-reset:hover {
color: var(--color-text);
}
.event-card-choices-error {
margin-top: 0.5rem;
font-size: 0.74rem;
color: var(--status-red-fg, #b00020);
}
.timeline-rule {
font-family: var(--font-mono);
font-size: 0.72rem;
@@ -3572,6 +3776,75 @@ input[type="range"]::-moz-range-thumb {
margin-bottom: 0;
}
/* Visual divider between the perspective block (side + appellant)
and the date / court / flag knobs below. t-paliad-279 reorder put
the most-defining inputs (side, appellant) at the top of step-2; the
divider keeps the date block from reading as a continuation of the
perspective rows. */
.verfahrensablauf-step2-divider {
height: 1px;
margin: 1rem 0;
background: var(--color-border, #e5e5e5);
border: 0;
}
/* "Pick a side" hint that sits next to the side-radio cluster while
currentSide is null (m/paliad#120). Both columns still render every
rule in that state — the chip just nudges the user that picking a
side focuses their column. Hidden by JS once a side is picked. */
.side-radio-cluster {
display: inline-flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.side-hint {
color: var(--color-text-muted, #666);
font-size: 0.85rem;
font-style: italic;
}
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
resolves a project whose our_side is set: shows the inferred side
with a small "Andere Seite wählen" override link that swaps the row
back to the radio cluster. t-paliad-279 / m/paliad#111. */
.side-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 0.5rem;
background: var(--color-bg-subtle, #fafafa);
font-size: 0.95rem;
}
.side-chip-tag {
color: var(--color-text-muted, #666);
font-size: 0.85rem;
}
.side-chip-value {
color: var(--color-text, #222);
}
.side-chip-override {
margin-left: 0.3rem;
padding: 0.15rem 0.55rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 9999px;
background: var(--color-bg, #fff);
color: var(--color-text-muted, #555);
font-size: 0.8rem;
cursor: pointer;
transition: background 120ms, border-color 120ms;
}
.side-chip-override:hover {
background: var(--color-bg-subtle, #f4f4f4);
border-color: var(--color-text-muted, #aaa);
}
.side-chip-override:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 1px;
}
/* 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
@@ -5774,6 +6047,40 @@ dialog.modal::backdrop {
color: var(--color-danger, #c00);
}
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
as the rest of the sidebar mini-controls; muted label + inline radios
so it doesn't compete with the editor's primary inputs. */
.submission-draft-language-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.25rem 0 0.5rem 0;
font-size: 0.9em;
}
.submission-draft-language-label {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.85em;
}
.submission-draft-language-option {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.submission-draft-language-fallback {
font-size: 0.85em;
color: var(--color-text-muted);
margin: 0 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border-left: 2px solid var(--color-warning, #d4a017);
background: var(--color-warning-bg, rgba(212, 160, 23, 0.08));
}
.submission-draft-variables {
display: flex;
flex-direction: column;
@@ -6072,6 +6379,284 @@ dialog.modal::backdrop {
font-size: 0.92rem;
}
/* t-paliad-277 — "Aus Projekt importieren" row + multi-select party
picker block on the submission draft editor sidebar. */
.submission-draft-import-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.5rem 0.6rem;
margin-bottom: 0.75rem;
background: var(--color-surface-alt, #f7f7f0);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.submission-draft-import-stamp {
font-size: 0.8em;
color: var(--color-text-muted);
}
.submission-draft-parties {
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
margin-bottom: 0.75rem;
}
.submission-draft-parties-hint {
font-size: 0.85em;
color: var(--color-text-muted);
margin: 0 0 0.6rem;
}
.submission-draft-parties-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submission-draft-parties-group {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.4rem 0.6rem 0.5rem;
margin: 0;
}
.submission-draft-parties-group > legend {
padding: 0 0.4rem;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.submission-draft-party-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
padding: 0.25rem 0;
cursor: pointer;
}
.submission-draft-party-check {
margin: 0;
accent-color: var(--color-accent, #c6f41c);
}
.submission-draft-party-name {
font-size: 0.92em;
color: var(--color-text);
}
.submission-draft-party-chip {
font-size: 0.72em;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1em 0.5em;
border-radius: 999px;
background: var(--color-bg-lime-tint, #f0fac6);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.submission-draft-party-rep {
font-size: 0.78em;
color: var(--color-text-muted);
margin-left: 0.25rem;
}
/* t-paliad-287 — collapsible variable-group section (Frist + Parteien
override). The toggle button is the section header; clicking it
flips state.collapsedGroups[id] and re-renders. The visible caret
rotates via the parent's --collapsed class. */
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle {
all: unset;
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0;
margin: 0 0 0.5rem 0;
cursor: pointer;
color: var(--color-text-muted);
}
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 2px;
}
.submission-draft-var-group-caret {
display: inline-block;
transition: transform 120ms ease;
font-size: 0.85em;
line-height: 1;
}
.submission-draft-var-group--collapsible:not(.submission-draft-var-group--collapsed)
.submission-draft-var-group-caret {
transform: rotate(90deg);
}
.submission-draft-var-group--collapsed .submission-draft-var-group-body {
display: none;
}
/* t-paliad-287 — Add Party affordance per side. */
.submission-draft-addparty {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-panel {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.6rem;
background: var(--color-surface-alt, #f7f7f0);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submission-draft-addparty-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.25rem;
}
.submission-draft-addparty-tab {
all: unset;
cursor: pointer;
padding: 0.3rem 0.6rem;
font-size: 0.85em;
border-radius: 4px 4px 0 0;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
}
.submission-draft-addparty-tab--active {
color: var(--color-text);
border-bottom-color: var(--color-accent, #c6f41c);
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.submission-draft-addparty-form--busy {
opacity: 0.6;
pointer-events: none;
}
.submission-draft-addparty-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.submission-draft-addparty-field > span {
font-size: 0.82em;
color: var(--color-text-muted);
}
.submission-draft-addparty-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-search-results {
list-style: none;
padding: 0;
margin: 0;
max-height: 14rem;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface, #fff);
}
.submission-draft-addparty-search-row {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search-row:last-child {
border-bottom: none;
}
.submission-draft-addparty-search-row:hover {
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-search-empty {
padding: 0.6rem;
font-size: 0.85em;
color: var(--color-text-muted);
text-align: center;
}
.submission-draft-addparty-search-name {
font-weight: 500;
color: var(--color-text);
}
.submission-draft-addparty-search-rep {
font-size: 0.78em;
color: var(--color-text-muted);
}
.submission-draft-addparty-search-projwrap {
font-size: 0.78em;
color: var(--color-text-muted);
width: 100%;
}
.submission-draft-addparty-search-proj {
color: var(--color-text);
}
.submission-draft-addparty-search-projref {
margin-left: 0.3rem;
padding: 0 0.4em;
border-radius: 3px;
background: var(--color-surface-alt, #f7f7f0);
color: var(--color-text-muted);
}
.submission-draft-addparty-search-hint {
font-size: 0.78em;
color: var(--color-text-muted);
margin: 0;
}
.submission-draft-parties-empty {
font-size: 0.82em;
color: var(--color-text-muted);
margin: 0.2rem 0;
}
.checklist-instance-actions {
display: flex;
gap: 0.35rem;
@@ -7672,7 +8257,7 @@ dialog.modal::backdrop {
padding: 0.05rem 0.45rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-text);
color: var(--color-accent-dark);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
@@ -8302,6 +8887,7 @@ input.rule-mode-custom {
}
.fristen-step1-search-row .fristen-search-icon {
position: static;
color: var(--color-muted, #666);
flex-shrink: 0;
}
@@ -15601,7 +16187,7 @@ dialog.quick-add-sheet::backdrop {
border-radius: 6px;
border: 1px solid var(--color-border-strong);
background: var(--color-accent);
color: var(--color-text);
color: var(--color-accent-dark);
cursor: pointer;
transition: background 120ms ease;
}
@@ -16209,7 +16795,7 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-anchor-submit {
background: var(--color-accent, #c6f41c);
border: 1px solid var(--color-accent, #c6f41c);
color: var(--color-text, #333);
color: var(--color-accent-dark);
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
@@ -17147,7 +17733,7 @@ dialog.quick-add-sheet::backdrop {
.admin-rules-chip.active {
background: var(--color-accent, #BFF355);
border-color: var(--color-accent, #BFF355);
color: var(--color-text, #000);
color: var(--color-accent-dark);
}
.admin-rules-pill {

View File

@@ -109,8 +109,96 @@ export function renderSubmissionDraft(): string {
</button>
</div>
{/* t-paliad-276 — output language toggle (DE/EN).
Hydrated by client/submission-draft.ts; switching
autosaves the draft and re-renders the preview. */}
<div
className="submission-draft-language-row"
id="submission-draft-language-row"
role="radiogroup"
aria-labelledby="submission-draft-language-label">
<span
id="submission-draft-language-label"
className="submission-draft-language-label"
data-i18n="submissions.draft.language">
Sprache
</span>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="de"
id="submission-draft-language-de"
/>
<span data-i18n="submissions.draft.language.de">DE</span>
</label>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="en"
id="submission-draft-language-en"
/>
<span data-i18n="submissions.draft.language.en">EN</span>
</label>
</div>
<p
className="submission-draft-language-fallback"
id="submission-draft-language-fallback"
style="display:none"
data-i18n="submissions.draft.language.fallback_notice">
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
</p>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
{/* t-paliad-277: "Aus Projekt importieren" + last-
imported-at timestamp. Only visible when the
draft has a project_id attached. */}
<div
id="submission-draft-import-row"
className="submission-draft-import-row"
style="display:none">
<button
type="button"
id="submission-draft-import-btn"
className="btn-small btn-secondary"
data-i18n="submissions.draft.import.button">
Aus Projekt importieren
</button>
<span
id="submission-draft-import-stamp"
className="submission-draft-import-stamp"
/>
</div>
{/* t-paliad-277 / t-paliad-287: multi-select party
picker plus per-side Add-Party affordance.
Populated from view.available_parties; checkbox
per party, grouped by role. Hidden when no
project is attached; visible even on empty
rosters so the lawyer can use Add Party to
populate. */}
<div
id="submission-draft-parties"
className="submission-draft-parties"
style="display:none">
<h3
className="submission-draft-var-group-title"
data-i18n="submissions.draft.parties.title">
Parteien
</h3>
<p
className="submission-draft-parties-hint"
data-i18n="submissions.draft.parties.hint">
Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.
</p>
<div
id="submission-draft-parties-list"
className="submission-draft-parties-list"
/>
</div>
<div className="submission-draft-variables" id="submission-draft-variables" />
</aside>

View File

@@ -158,9 +158,101 @@ export function renderVerfahrensablauf(): string {
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
</h3>
{/* Perspective strip (t-paliad-250 / m/paliad#81, reordered
in t-paliad-279 / m/paliad#111). Side defines whose
perspective the columns project; appellant collapses
party=both rows for role-swap proceedings (Appeal etc.).
Moved above .date-input-group because party-side is the
most-defining input after proceeding-type — without
side, the column labels can't pick "your filings". Both
selectors are URL-driven (?side= + ?appellant=) so the
perspective survives reload and is shareable.
When the page is opened with ?project=<id> and that
project's our_side is set, side-row renders as a
read-only chip with an "Andere Seite wählen" override
link — see client/verfahrensablauf.ts. */}
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="side-radio-cluster" id="side-radio-cluster">
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
</label>
</div>
{/* Prompt shown while the user hasn't picked a side
(m/paliad#120). Hidden by client when side is
claimant or defendant. Both columns still
render every rule in this state — picking a
side just focuses the user's column. */}
<span className="side-hint" id="side-hint"
data-i18n="deadlines.side.hint">
W&auml;hlen Sie eine Seite, um die Spalten zu fokussieren.
</span>
</div>
{/* Auto-fill chip — populated by the client when a
?project=<id> URL resolves a project with our_side
set. Hidden by default; the radio cluster above is
hidden whenever this chip is shown. */}
<div className="side-chip" id="side-chip" style="display:none">
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
<strong className="side-chip-value" id="side-chip-value">&mdash;</strong>
<button type="button" className="side-chip-override" id="side-chip-override"
data-i18n="deadlines.side.override">
Andere Seite w&auml;hlen
</button>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="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>
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
Re-surfaces optional cards the user has previously
marked "Überspringen" via the per-card popover.
The row hides itself when the projection has no
hidden cards (handled in client/verfahrensablauf.ts).
Default OFF; URL state ?show_hidden=1. */}
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
<label className="fristen-view-option">
<input type="checkbox" id="show-hidden-toggle" />
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
</label>
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite">&nbsp;</span>
</div>
</div>
{/* Visual divider — keeps the perspective block (most-
defining inputs after proceeding-type) optically
separate from the date / court / flag knobs below. */}
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
<div className="date-input-group">
<div className="date-field-row">
{/* Read-only caption labelling the value <span>. Not a
@@ -210,53 +302,6 @@ 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

@@ -116,6 +116,57 @@ func TestMigrations_DryRun(t *testing.T) {
}
}
// TestMigrations_NoDuplicateSlot is a free-standing pre-flight check that
// scanEmbeddedMigrations refuses to walk a tree where two *.up.sql files
// claim the same NNN slot. This is the brunel-slot-collision class of
// outage (m/paliad#114, 2026-05-25 ~13:20): a worker writes a migration
// at slot N while another shipped slot N from a separate branch, both
// merge, both end up in the embed.FS, and the runner refuses to start.
//
// Catching this at CI time (no DB needed) lets the second PR fail before
// it merges, instead of breaking prod at the next deploy. Pure unit test;
// runs even on developer laptops that don't set TEST_DATABASE_URL.
func TestMigrations_NoDuplicateSlot(t *testing.T) {
if _, err := scanEmbeddedMigrations(); err != nil {
t.Fatalf("scanEmbeddedMigrations: %v "+
"(two migrations share the same NNN slot — coordinate with head "+
"and rename one of them before merging)", err)
}
}
// TestMigrations_EndToEndAsAppRole applies every embedded migration in
// numeric order against a scratch DB connected as a NON-SUPERUSER role.
// This is the prod-shape smoke that the per-mig BEGIN/ROLLBACK dry-run
// (TestMigrations_DryRun) cannot deliver: the dry-run runs each
// statement in isolation and rolls back, so it cannot reproduce the
// mig-129-class outage (m/paliad#114, 2026-05-25 ~14:56 — pq: must be
// owner of table project_event_choices, SQLSTATE 42501) where a
// migration assumes ownership the deploy role doesn't have.
//
// Requires TEST_APP_DATABASE_URL — a Postgres URL whose role is NOT a
// superuser and does NOT own the `paliad` schema (m's Q11.2 pick:
// generic two-role model, see docs/design-cicd-pre-deploy-gate-2026-05-25.md
// §6.2(a)). The CI workflow creates the role + schema split before
// invoking the test; a developer who wants to reproduce the gate locally
// runs the same SQL preamble (see Makefile target `verify-migrations`).
//
// Skipped without TEST_APP_DATABASE_URL — keeps `go test ./...` green
// on machines that haven't set up the role split.
func TestMigrations_EndToEndAsAppRole(t *testing.T) {
url := os.Getenv("TEST_APP_DATABASE_URL")
if url == "" {
t.Skip("TEST_APP_DATABASE_URL not set — skipping role-split end-to-end migration smoke")
}
if err := ApplyMigrations(url); err != nil {
t.Fatalf("ApplyMigrations as app role failed: %v "+
"(a migration assumes more privilege than the deploy role has — "+
"common cases: ALTER TABLE on a schema-owner table, CREATE EXTENSION "+
"without grants, SET ROLE without permission. Fix the migration to "+
"work as the deploy role, or arrange for the schema to be owned by "+
"the deploy role)", err)
}
}
// readAppliedVersions returns the set of versions present in
// paliad.applied_migrations on the scratch DB. Missing table → empty set
// (fresh-DB path; the table only exists after the runner has been called).

View File

@@ -26,24 +26,24 @@ DO $$ BEGIN ALTER TABLE paliad.department_members RENAME COLUMN dezernat_id TO d
-- Constraints (primary key + foreign keys + check). Renaming a pkey
-- constraint also renames the underlying index of the same name.
-- ---------------------------------------------------------------------------
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_pkey TO departments_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_office_check TO departments_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_dezernat_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_pkey TO departments_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_lead_user_id_fkey TO departments_lead_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.departments RENAME CONSTRAINT dezernate_office_check TO departments_office_check; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_pkey TO department_members_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_dezernat_id_fkey TO department_members_department_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.department_members RENAME CONSTRAINT dezernat_mitglieder_user_id_fkey TO department_members_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
-- ---------------------------------------------------------------------------
-- Standalone indexes (non-pkey).
-- ---------------------------------------------------------------------------
DO $$ BEGIN ALTER INDEX paliad.dezernate_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.dezernate_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.dezernat_mitglieder_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.dezernate_office_idx RENAME TO departments_office_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.dezernate_lead_idx RENAME TO departments_lead_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.dezernat_mitglieder_user_idx RENAME TO department_members_user_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
-- ---------------------------------------------------------------------------
-- RLS policies
-- ---------------------------------------------------------------------------
DO $$ BEGIN ALTER POLICY dezernate_select ON paliad.departments RENAME TO departments_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY dezernate_write ON paliad.departments RENAME TO departments_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_select ON paliad.department_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_write ON paliad.department_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY dezernate_select ON paliad.departments RENAME TO departments_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY dezernate_write ON paliad.departments RENAME TO departments_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_select ON paliad.department_members RENAME TO department_members_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY dezernat_mitglieder_write ON paliad.department_members RENAME TO department_members_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;

View File

@@ -63,27 +63,27 @@ ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_u
-- 5. Rename constraints. Postgres auto-renames the underlying index for
-- pkey/uniq constraints; standalone indexes are renamed in step 6.
-- ---------------------------------------------------------------------------
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
-- ---------------------------------------------------------------------------
-- 6. Rename non-pkey indexes.
-- ---------------------------------------------------------------------------
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
-- ---------------------------------------------------------------------------
-- 7. Rename RLS policies.
-- ---------------------------------------------------------------------------
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write; EXCEPTION WHEN undefined_object OR undefined_table OR duplicate_object THEN NULL; END $$;
-- ---------------------------------------------------------------------------
-- 8. Audit table for partner-unit events. Mutations on partner_units +

View File

@@ -0,0 +1,11 @@
-- t-paliad-265 — drop per-event-card choices schema.
SELECT set_config(
'paliad.audit_reason',
'mig 129 down: drop project_event_choices + deadline_rules.choices_offered',
true);
DROP TABLE IF EXISTS paliad.project_event_choices;
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS choices_offered;

View File

@@ -0,0 +1,116 @@
-- t-paliad-265 / m/paliad#96 — per-event-card optional choices on the
-- Verfahrensablauf timeline.
--
-- Design: docs/design-event-card-choices-2026-05-25.md
-- Decisions: see §11 of the design doc.
--
-- Two schema changes:
--
-- 1. paliad.project_event_choices — new persistence table holding the
-- user's per-card picks scoped to a project. One row per
-- (project, submission_code, choice_kind). Re-picking is an UPDATE
-- (UNIQUE constraint enforces idempotence).
--
-- 2. paliad.deadline_rules.choices_offered jsonb — opt-in declaration
-- of which choice-kinds each rule offers. The projection engine
-- reads this to decide whether to render the caret affordance on
-- a card. Seeded for every event_type='decision' rule (appellant),
-- every priority='optional' rule (skip), and the two Klageerwiderung
-- rows (include_ccr).
--
-- NOTE on join key: the design doc named the join column "rule_code".
-- Live verification (2026-05-25 SELECT against paliad.deadline_rules)
-- showed `rule_code` is NULL on every decision row — it's the legal-
-- source citation column, not a stable identifier. The
-- AnchorOverrides plumbing in internal/services/fristenrechner.go
-- already keys on `submission_code` (UIDeadline.Code populates from
-- submission_code, lines 351-352), so we mirror that decision here:
-- the join column is `submission_code`. Same intent, correct field.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS +
-- UPDATEs guarded by WHERE choices_offered IS NULL so re-applying
-- against an already-seeded DB no-ops.
SELECT set_config(
'paliad.audit_reason',
'mig 129: add paliad.project_event_choices + deadline_rules.choices_offered for per-event-card optional choices (t-paliad-265 / m/paliad#96)',
true);
-- 1. The choice-storage table ----------------------------------------------
CREATE TABLE IF NOT EXISTS paliad.project_event_choices (
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,
choice_kind text NOT NULL CHECK (choice_kind IN ('appellant', 'include_ccr', 'skip')),
choice_value text NOT NULL,
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, submission_code, choice_kind)
);
CREATE INDEX IF NOT EXISTS project_event_choices_project_idx
ON paliad.project_event_choices (project_id);
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS project_event_choices_select ON paliad.project_event_choices;
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
FOR SELECT USING (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS project_event_choices_mutate ON paliad.project_event_choices;
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
FOR ALL
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
COMMENT ON TABLE paliad.project_event_choices IS
'Per-event-card user picks scoped to a project. choice_kind ∈ {appellant, include_ccr, skip}. '
'choice_value namespace per kind: appellant=claimant|defendant|both|none; include_ccr=true|false; '
'skip=true|false. Join key submission_code matches paliad.deadline_rules.submission_code (the same key '
'AnchorOverrides uses). UNIQUE(project,submission_code,kind) keeps re-picks idempotent. '
'Audit-logged via paliad.system_audit_log (event_type=project_event_choice.set).';
-- 2. The choices_offered opt-in column ------------------------------------
ALTER TABLE paliad.deadline_rules
ADD COLUMN IF NOT EXISTS choices_offered jsonb;
COMMENT ON COLUMN paliad.deadline_rules.choices_offered IS
'Declares which per-card choice-kinds this rule offers on the Verfahrensablauf timeline. '
'NULL = no caret affordance (default). Example shapes: '
'{"appellant": ["claimant","defendant","both","none"]} on decision rules, '
'{"skip": [true, false]} on optional rules, '
'{"include_ccr": [true, false]} on Klageerwiderung rules. '
'Engine and frontend read it; storing per-kind value lists keeps the contract self-describing.';
-- 3. Seed -----------------------------------------------------------------
-- 3a. Every published decision rule offers the appellant choice.
UPDATE paliad.deadline_rules
SET choices_offered = '{"appellant": ["claimant", "defendant", "both", "none"]}'::jsonb
WHERE event_type = 'decision'
AND lifecycle_state = 'published'
AND choices_offered IS NULL;
-- 3b. Every published optional rule offers the skip choice.
UPDATE paliad.deadline_rules
SET choices_offered = '{"skip": [true, false]}'::jsonb
WHERE priority = 'optional'
AND lifecycle_state = 'published'
AND choices_offered IS NULL;
-- 3c. Klageerwiderung rules offer the include_ccr choice. Two rows
-- today (upc.inf.cfi.sod + de.inf.lg.erwidg) — verified live
-- (2026-05-25 SELECT FROM paliad.deadline_rules WHERE name ILIKE
-- 'Klageerwiderung'); the UPC INF Klageerwiderung is `sod` (Statement
-- of Defence, R.24 RoP), not `def`. Slice B (Q4 bundle) is the
-- user-visible feature.
UPDATE paliad.deadline_rules
SET choices_offered = '{"include_ccr": [true, false]}'::jsonb
WHERE submission_code IN ('upc.inf.cfi.sod', 'de.inf.lg.erwidg')
AND lifecycle_state = 'published'
AND choices_offered IS NULL;

View File

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

View File

@@ -0,0 +1,17 @@
-- t-paliad-276 / m/paliad#108: per-draft output language for the
-- Submissions generator.
--
-- The submission editor lets the lawyer pick DE or EN per draft so the
-- generator selects the matching template variant + resolves language-
-- aware variables ({{procedural_event.name_de}} vs _en). Default is
-- 'de' to match the primary-language convention in CLAUDE.md and to
-- keep existing rows behaving exactly as before (every legacy draft
-- was implicitly DE; the resolved bag for those drafts is unchanged
-- under language='de').
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'de'
CONSTRAINT submission_drafts_language_check CHECK (language IN ('de', 'en'));
COMMENT ON COLUMN paliad.submission_drafts.language IS
't-paliad-276: output language for the generated .docx. ''de'' or ''en''. Drives template variant selection ({code}.{lang}.docx fallback chain) and language-aware variable resolution.';

View File

@@ -0,0 +1,4 @@
-- t-paliad-277 rollback.
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS selected_parties,
DROP COLUMN IF EXISTS last_imported_at;

View File

@@ -0,0 +1,32 @@
-- t-paliad-277 / m/paliad#109: per-draft party selection + import provenance.
--
-- Adds two columns to paliad.submission_drafts:
--
-- selected_parties uuid[] — IDs of paliad.parties rows the lawyer
-- has chosen to mention in this specific submission. An empty
-- array (the default) means "include every party on the project"
-- so all existing drafts keep their current rendering. Non-empty
-- restricts the variable bag to the chosen subset, grouped by
-- role in SubmissionVarsService.
--
-- last_imported_at timestamptz — when the lawyer last clicked
-- "Aus Projekt importieren" on the draft editor (or NULL if they
-- never did). The frontend surfaces this timestamp next to the
-- button so a stale draft is obvious at a glance.
--
-- Both columns are purely additive and nullable / default-bearing —
-- the migration is safe to apply with active drafts in the table.
-- No FK on selected_parties: paliad.parties is project-scoped and we
-- prune stale references on read inside SubmissionVarsService rather
-- than chasing FK cascades across two tables (the variable bag silently
-- drops any uuid that no longer matches a row in paliad.parties).
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS selected_parties uuid[] NOT NULL DEFAULT '{}'::uuid[],
ADD COLUMN IF NOT EXISTS last_imported_at timestamptz;
COMMENT ON COLUMN paliad.submission_drafts.selected_parties IS
't-paliad-277: party IDs (paliad.parties) the lawyer has chosen to mention in this submission. Empty array = include every party on the project (backward-compat default). Non-empty = restrict to subset, grouped by role.';
COMMENT ON COLUMN paliad.submission_drafts.last_imported_at IS
't-paliad-277: timestamp of the last "Aus Projekt importieren" click — surfaced next to the button so the lawyer can see staleness at a glance. NULL = never imported.';

View File

@@ -0,0 +1,76 @@
-- Rollback of mig 132 (t-paliad-284 Wave 1 + m/paliad#116).
--
-- Reverses §0 (R.104/R.105 citation backfill) + §1..§11 (11 Tier 1
-- INSERTs) + §12 (T1.12 re-anchor of upc.pi.cfi.response).
--
-- Does NOT reverse §13b (Q6 archived-litigation cleanup) — those rows
-- were already in lifecycle_state='archived' before deletion and are not
-- surfaced by any product code path. Restoring them would require the
-- pre-mig-132 backup. Leaving them gone is the correct rollback choice;
-- emergency restore goes via mig 123 backup snapshot.
--
-- DOES restore §13a (re-add the deadline_rule_audit.rule_id FK) so the
-- audit-table schema returns to its pre-mig-132 shape on rollback. Any
-- orphan audit rows accumulated under mig 132 (rule_id pointing at
-- now-deleted rules) would block the FK re-add; the rollback DELETE
-- below removes them first.
SELECT set_config(
'paliad.audit_reason',
'mig 132 down: rollback Wave 1 Tier 1 rule additions + R.105 citation backfill + T1.12 re-anchor (t-paliad-284 / m/paliad#116)',
true);
-- §12 down — un-re-anchor upc.pi.cfi.response back to its broken root state.
UPDATE paliad.deadline_rules
SET parent_id = NULL,
is_court_set = false,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.pi.cfi.response'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true
AND rule_code = 'RoP.211.2';
-- §1..§11 down — delete the 11 Tier 1 INSERTs by submission_code.
DELETE FROM paliad.deadline_rules
WHERE submission_code IN (
'upc.inf.cfi.cmo_review',
'upc.inf.cfi.confidentiality_response',
'upc.apl.order.response_orders', -- delete child first (FK to grounds_orders)
'upc.apl.order.grounds_orders',
'upc.inf.cfi.cons_orders',
'upc.inf.cfi.rectification',
'upc.pi.cfi.deficiency',
'upc.pi.cfi.merits_start',
'upc.inf.cfi.translation_request',
'upc.inf.cfi.interpreter_cost',
'upc.inf.cfi.translations_lodge'
)
AND lifecycle_state = 'published';
-- §0 down — clear the R.104/R.105 citation on upc.inf.cfi.interim.
UPDATE paliad.deadline_rules
SET rule_code = NULL,
legal_source = NULL,
rule_codes = NULL,
updated_at = now()
WHERE submission_code = 'upc.inf.cfi.interim'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.104'
AND legal_source = 'UPC.RoP.104';
-- §13a down — re-add the deadline_rule_audit.rule_id FK with the
-- original ON DELETE CASCADE shape. Purge any orphan audit rows first
-- (audit entries pointing at rule_ids that no longer exist in
-- deadline_rules) so the FK re-add doesn't fail validation.
DELETE FROM paliad.deadline_rule_audit a
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr WHERE dr.id = a.rule_id
);
ALTER TABLE paliad.deadline_rule_audit
ADD CONSTRAINT deadline_rule_audit_rule_id_fkey
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE;

View File

@@ -0,0 +1,659 @@
-- t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions
-- (12 high-frequency procedural events) + UPC RoP R.104/R.105 Interim
-- Conference citation backfill + Q6 archived-litigation cleanup.
--
-- Source: docs/research-deadlines-completeness-2026-05-25.md
-- • §10 Tier 1 table (T1.1 .. T1.12)
-- • §3.1 missing-rules catalogue (per-rule statutory citations)
-- • §9.7 / Q6 (drop the _archived_litigation.* rows — m's design ack
-- locked in 2026-05-25)
--
-- m's report (2026-05-25 17:12) also explicitly named "Zwischenverfahren /
-- Interim Conference 105" as missing a rule citation. The audit does not
-- list R.105 as a Tier 1 item (the row upc.inf.cfi.interim already exists
-- as a court-set anchor), so the fix is to BACKFILL rule_code/legal_source
-- on that row rather than to insert a new rule. Done here as a separate
-- §0 section, with both RoP.104 (Aims of the interim conference) and
-- RoP.105 (Holding of the interim conference) cited via rule_codes[].
--
-- Wave 2 Slice A primitives (mig 128: working_days unit + combine_op +
-- timing='before' backward snap in deadline_calculator.go) are used by:
-- • T1.8 upc.pi.cfi.merits_start — 31d OR 20wd, combine_op=max
-- • T1.9 upc.inf.cfi.translation_request — 1 month BEFORE oral hearing
-- • T1.10 upc.inf.cfi.interpreter_cost — 2 weeks BEFORE oral hearing
-- Wave 2 Slice A landed mig 128 (`deadline_rules_unit_check`) — these
-- rules are no longer blocked.
--
-- Slot 132 reserved: 127 brunel Wave 0, 128 knuth W2-A, 129 demeter,
-- 130 atlas, 131 artemis → 132 this migration.
--
-- Idempotency:
-- • INSERTs guarded with `WHERE NOT EXISTS (... submission_code = ...)`
-- so re-applying matches zero rows on the second run.
-- • UPDATEs guarded with `WHERE` clauses that match the pre-fix row
-- state only (mig 095 convention).
-- • DELETE guarded by lifecycle_state='archived' AND prefix — repeats
-- match zero rows after first run.
--
-- audit_reason set_config is required at the top (mig 079 trigger on
-- paliad.deadline_rules raises EXCEPTION 'audit reason required' for
-- any INSERT / UPDATE / DELETE without it).
SELECT set_config(
'paliad.audit_reason',
'mig 132: t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions (12 rules) from curie''s audit §10 + UPC RoP R.104/105 Interim Conference citation backfill (m''s 2026-05-25 17:12 report) + Q6 archived-litigation cleanup (audit §9.7)',
true);
-- =============================================================================
-- §0 R.104/R.105 — Backfill citation on the existing Interim Conference row.
-- m's report flagged that `upc.inf.cfi.interim` (Zwischenverfahren) renders
-- with no rule reference at /admin/rules. The row exists as a court-set
-- anchor (duration=0, parent_id=NULL, primary_party='court'). The
-- governing UPC Rules of Procedure are:
-- • R.104 — Aims of the interim conference (the substantive rule)
-- • R.105 — Holding of the interim conference (procedural)
-- Both cited via the rule_codes[] array; rule_code/legal_source carry
-- the primary citation (R.104 — Aims).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.104',
legal_source = 'UPC.RoP.104',
rule_codes = ARRAY['RoP.104', 'RoP.105'],
updated_at = now()
WHERE submission_code = 'upc.inf.cfi.interim'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code IS NULL
AND legal_source IS NULL;
-- =============================================================================
-- §1 T1.1 upc.inf.cfi.cmo_review — Review of case-management order.
-- 15 days from CMO service. UPC RoP R.333.2: "Any party adversely
-- affected by a case management order may within 15 days of service
-- of the order apply to the panel for a review." Routine in busy LDs
-- (Munich CMO traffic ~weekly). Anchor: the Interim Conference row,
-- which is where CMOs are typically issued.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8, -- upc.inf.cfi
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interim'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.cmo_review',
'Überprüfung Verfahrensanordnung',
'Review of Case-Management Order',
'both', 15, 'days', 'after', 'RoP.333.2', 'UPC.RoP.333.2',
'optional', false, 'published', true, 42,
'Frist 15 Tage ab Zustellung der Verfahrensanordnung (R.333.2). Jede beschwerte Partei kann beim Spruchkörper Überprüfung beantragen.',
'15-day period from service of the case-management order (R.333.2). Any adversely-affected party may apply to the panel for a review.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.cmo_review'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §2 T1.2 upc.inf.cfi.confidentiality_response — Response to opposing
-- party's confidentiality application. 14 days from receipt of the
-- opposing party's R.262.2 application: "Within 14 days of service
-- … the other party may lodge an Application to the contrary."
-- Trigger event 25 (paliad.trigger_events) maps 1:1 to this rule.
-- Daily occurrence in HLC infringement work. Anchor: Statement of
-- Claim row as proceeding root — actual trigger date supplied via
-- 'Datum setzen' when the opp party files, since the confidentiality
-- app is not itself modelled as a deadline_rules row.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.soc'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.confidentiality_response',
'Erwiderung auf Vertraulichkeitsantrag',
'Response to Confidentiality Application',
'both', 14, 'days', 'after', 'RoP.262.2', 'UPC.RoP.262.2',
'optional', false, 'published', true, 8,
25,
'Frist 14 Tage ab Zustellung des Vertraulichkeitsantrags der Gegenseite (R.262.2). Datum bei Eingang des Antrags manuell setzen.',
'14-day period from service of the opposing party''s confidentiality application (R.262.2). Set trigger date manually on receipt of the application.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §3 T1.3 upc.apl.order.grounds_orders — Statement of Grounds on the
-- orders-track appeal. 15 days from service of the appealed
-- order/decision. UPC RoP R.224.2(b): "A Statement of grounds of
-- appeal shall be lodged … within 15 days of service of the
-- decision/order in cases referred to in Rule 220.1(c), Rule 220.2
-- and Rule 221.3." Existing upc.apl.order tree has the with_leave
-- notice but no separate grounds row — adding it.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 20, -- upc.apl.order
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.order'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.apl.order.grounds_orders',
'Berufungsbegründung (Orders Track)',
'Statement of Grounds (Orders Track)',
'both', 15, 'days', 'after', 'RoP.224.2.b', 'UPC.RoP.224.2.b',
'mandatory', false, 'published', true, 2,
'Frist 15 Tage ab Zustellung der angegriffenen Anordnung/Entscheidung (R.224.2(b)).',
'15-day period from service of the appealed order/decision (R.224.2(b)).'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.grounds_orders'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §4 T1.4 upc.apl.order.response_orders — Statement of Response on the
-- orders-track appeal. 15 days from service of the grounds. UPC RoP
-- R.235.2: "Within 15 days of service of grounds of appeal pursuant
-- to Rule 224.2(b), any other party … may lodge a Statement of
-- response." Parent: the grounds_orders row inserted in §3, looked
-- up by submission_code so this INSERT works either against a fresh
-- DB or a partially-applied state.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 20,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.grounds_orders'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.apl.order.response_orders',
'Berufungserwiderung (Orders Track)',
'Statement of Response (Orders Track)',
'both', 15, 'days', 'after', 'RoP.235.2', 'UPC.RoP.235.2',
'optional', false, 'published', true, 3,
'Frist 15 Tage ab Zustellung der Berufungsbegründung (R.235.2).',
'15-day period from service of the Statement of grounds of appeal (R.235.2).'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.response_orders'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §5 T1.5 upc.inf.cfi.cons_orders — Application for orders consequential
-- on validity. 2 months from service of the validity decision. UPC
-- RoP R.118.4: "The Court may, upon a reasoned request by one of
-- the parties, … give a decision granting consequential orders.
-- The application … shall be made within two months of service of
-- the decision …". Common after central-division revocation in
-- bifurcated UPC matters.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.decision'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.cons_orders',
'Antrag auf Folgeentscheidungen',
'Application for Consequential Orders',
'both', 2, 'months', 'after', 'RoP.118.4', 'UPC.RoP.118.4',
'optional', false, 'published', true, 60,
'Frist 2 Monate ab Zustellung der Validitätsentscheidung (R.118.4). Antrag auf Folgeentscheidungen (z.B. nach Zentralkammer-Nichtigerklärung).',
'2-month period from service of the validity decision (R.118.4). Application for orders consequential on validity (e.g. after central-division revocation).'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.cons_orders'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §6 T1.6 upc.inf.cfi.rectification — Application for rectification of a
-- decision/order. 1 month from delivery of the decision. UPC RoP
-- R.353: "Clerical mistakes, errors arising from any accidental
-- slip or omission and obvious errors in a decision or order of
-- the Court may be corrected by the Court of its own motion or on
-- the application of a party. The application shall be made within
-- one month of the decision or order being notified."
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.decision'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.rectification',
'Antrag auf Berichtigung',
'Application for Rectification',
'both', 1, 'months', 'after', 'RoP.353', 'UPC.RoP.353',
'optional', false, 'published', true, 70,
'Frist 1 Monat ab Zustellung der Entscheidung/Anordnung (R.353). Berichtigung von Schreib-, Rechen- oder ähnlichen Versehen.',
'1-month period from notification of the decision/order (R.353). Rectification of clerical mistakes, accidental slips or obvious errors.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.rectification'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §7 T1.7 upc.pi.cfi.deficiency — Cure of PI-application deficiency.
-- 14 days from notification of the deficiency. UPC RoP R.207.6(a):
-- "The Registry shall as soon as practicable examine the
-- Application … and notify any deficiencies to the applicant. The
-- applicant shall be invited to correct the deficiencies … within
-- 14 days." Failure to cure leads to deemed-withdrawal.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 10, -- upc.pi.cfi
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.app'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.pi.cfi.deficiency',
'Mängelbeseitigung Antrag',
'Cure of Application Deficiency',
'claimant', 14, 'days', 'after', 'RoP.207.6.a', 'UPC.RoP.207.6.a',
'mandatory', false, 'published', true, 2,
'Frist 14 Tage ab Mängelmitteilung durch die Geschäftsstelle (R.207.6(a)). Bei Nichtbehebung gilt der Antrag als zurückgenommen.',
'14-day period from notification of deficiency by the Registry (R.207.6(a)). Failure to cure leads to deemed withdrawal of the application.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.deficiency'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §8 T1.8 upc.pi.cfi.merits_start — Start proceedings on the merits.
-- 31 calendar days OR 20 working days, whichever is the longer,
-- from grant of the PI. UPC RoP R.213.1 → R.198.1: "the applicant
-- shall start proceedings leading to a decision on the merits of
-- the case … within a period not exceeding 31 calendar days or
-- 20 working days, whichever is the longer." Combine-max wiring
-- via Wave 2 Slice A primitives (mig 128: working_days unit +
-- combine_op). Failure to commence on time → PI lapses (R.213.2).
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit,
alt_duration_value, alt_duration_unit, alt_rule_code,
combine_op, timing, rule_code, legal_source, priority,
is_court_set, lifecycle_state, is_active, sequence_order,
deadline_notes, deadline_notes_en)
SELECT 10,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.order'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.pi.cfi.merits_start',
'Klage in der Hauptsache erheben',
'Start Proceedings on the Merits',
'claimant', 31, 'days',
20, 'working_days', 'RoP.198.1',
'max', 'after', 'RoP.213', 'UPC.RoP.213',
'mandatory', false, 'published', true, 3,
'Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme (R.213 i.V.m. R.198.1). Bei Versäumnis erlischt die einstweilige Maßnahme.',
'31 calendar days OR 20 working days, whichever is the longer, from grant of the provisional measure (R.213 referring to R.198.1). Failure to commence within the period causes the provisional measure to lapse.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.merits_start'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §9 T1.9 upc.inf.cfi.translation_request — Request for simultaneous
-- translation at the oral hearing. 1 month BEFORE the oral hearing.
-- UPC RoP R.109.1: "A party requiring simultaneous interpretation
-- of the oral hearing into a language other than the language of
-- proceedings shall, no later than one month before the date of
-- the oral hearing, lodge a request with the Court." timing='before'
-- uses the backward-snap path in deadline_calculator.go (mig 128).
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.oral'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.translation_request',
'Antrag auf Simultanübersetzung',
'Request for Simultaneous Translation',
'both', 1, 'months', 'before', 'RoP.109.1', 'UPC.RoP.109.1',
'optional', false, 'published', true, 45,
'Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung in eine andere Sprache als die Verfahrenssprache.',
'1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation into a language other than the language of proceedings.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.translation_request'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §10 T1.10 upc.inf.cfi.interpreter_cost — Notification of interpreter
-- cost-bearing. 2 weeks BEFORE the oral hearing. UPC RoP R.109.4:
-- "Where … the party which made the request for interpretation is
-- not the party who has chosen the language of the proceedings,
-- the costs of the interpretation … shall be borne by the
-- requesting party, unless the Court orders otherwise. The party
-- shall be notified at least two weeks before the oral hearing."
-- timing='before' as in §9.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.oral'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.interpreter_cost',
'Mitteilung Dolmetscherkosten',
'Notification of Interpreter Costs',
'court', 2, 'weeks', 'before', 'RoP.109.4', 'UPC.RoP.109.4',
'mandatory', false, 'published', true, 46,
'Frist 2 Wochen VOR der mündlichen Verhandlung (R.109.4). Mitteilung, dass die antragstellende Partei die Dolmetscherkosten zu tragen hat.',
'2 weeks BEFORE the oral hearing (R.109.4). Notification to the requesting party that it shall bear the interpreter costs.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §11 T1.11 upc.inf.cfi.translations_lodge — Lodging of translations on
-- judge-rapporteur order. 2 weeks AFTER the JR's order. UPC RoP
-- R.109.5: "If the judge-rapporteur orders, the parties shall lodge
-- a translation of any pleading or other document into the language
-- of the proceedings within two weeks." trigger_event_id=113 maps
-- to the JR translation order. Anchor: Interim Conference row, where
-- such JR orders are typically issued.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interim'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.translations_lodge',
'Übersetzungen einreichen',
'Lodging of Translations',
'both', 2, 'weeks', 'after', 'RoP.109.5', 'UPC.RoP.109.5',
'mandatory', false, 'published', true, 47,
113,
'Frist 2 Wochen ab Anordnung des Berichterstatters, Übersetzungen einzureichen (R.109.5).',
'2-week period from the judge-rapporteur''s order to lodge translations (R.109.5).'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §12 T1.12 upc.pi.cfi.response — RE-ANCHOR of the existing PI Response
-- row. Currently broken: parent_id=NULL with is_court_set=false and
-- duration=0 makes the calculator treat this as a root anchor. UPC
-- RoP R.211.2 — judge sets the inter-partes hearing date and the
-- deadline for the response. Fix: set is_court_set=true and chain
-- parent_id on upc.pi.cfi.app (the proceeding root). Duration
-- remains 0 (court-set placeholder); the lawyer fills in the actual
-- date via 'Datum setzen'.
-- =============================================================================
UPDATE paliad.deadline_rules
SET parent_id = (SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.app'
AND lifecycle_state = 'published'
AND is_active = true
LIMIT 1),
is_court_set = true,
rule_code = 'RoP.211.2',
legal_source = 'UPC.RoP.211.2',
updated_at = now()
WHERE submission_code = 'upc.pi.cfi.response'
AND is_active = true
AND lifecycle_state = 'published'
AND parent_id IS NULL
AND is_court_set = false;
-- =============================================================================
-- §13a Pre-requisite for §13b — drop the deadline_rule_audit.rule_id FK.
-- The audit trigger (mig 079) tries to INSERT an audit row on AFTER
-- DELETE pointing at OLD.id, but the existing FK constraint
-- `deadline_rule_audit_rule_id_fkey` (FOREIGN KEY rule_id REFERENCES
-- paliad.deadline_rules(id) ON DELETE CASCADE) makes that INSERT fail
-- because by the time the trigger fires the parent row is gone. As a
-- result no DELETE on paliad.deadline_rules has ever succeeded in
-- production (`SELECT count(*) FROM paliad.deadline_rule_audit
-- WHERE action='delete'` returns 0). The trigger's DELETE branch was
-- dead code.
--
-- Standard audit-table design: the audit log is append-only history
-- and should NOT FK-constrain on the live entity table — before_json
-- captures the full row state at the time of the change, which is
-- all the audit trail needs. Dropping the FK fixes the latent bug
-- and unblocks legitimate cleanup work (here: §13b, plus any future
-- hard-delete migrations against deadline_rules).
--
-- Idempotent: DROP CONSTRAINT IF EXISTS no-ops on re-run.
-- =============================================================================
ALTER TABLE paliad.deadline_rule_audit
DROP CONSTRAINT IF EXISTS deadline_rule_audit_rule_id_fkey;
-- =============================================================================
-- §13b Q6 cleanup — drop the _archived_litigation.* deadline rules.
-- 40 rows at audit §9.7 flagged as obsolete Pipeline-A residue
-- (proceeding_type id=32 '_archived_litigation' — kept for FK
-- parity but the rules are no longer surfaced anywhere in the
-- product). m's Q6 design ack 2026-05-25 locked in their removal.
-- Idempotent: prefix + lifecycle_state='archived' match zero rows
-- after first run. The proceeding_type row itself is left in place
-- (referenced by historical deadline_rule_audit before_json blobs).
-- =============================================================================
DELETE FROM paliad.deadline_rules
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
AND lifecycle_state = 'archived';
-- =============================================================================
-- Hard assertions. Each new/changed row must end up in its post-fix
-- shape. Re-running the migration is a no-op for the data but the
-- assertions still pass because they check the post-fix state.
-- =============================================================================
DO $$
DECLARE
v_count integer;
BEGIN
-- §0 R.105 interim conference backfilled
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interim'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.104'
AND legal_source = 'UPC.RoP.104'
AND 'RoP.105' = ANY(rule_codes);
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 §0: upc.inf.cfi.interim citation backfill not in post-fix shape (got % matches)', v_count;
END IF;
-- §1 T1.1 cmo_review present
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.cmo_review'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.333.2' AND duration_value = 15
AND duration_unit = 'days' AND timing = 'after';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.1: upc.inf.cfi.cmo_review missing or wrong shape (got % matches)', v_count;
END IF;
-- §2 T1.2 confidentiality_response
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.262.2' AND duration_value = 14
AND duration_unit = 'days' AND trigger_event_id = 25;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.2: upc.inf.cfi.confidentiality_response missing or wrong shape (got % matches)', v_count;
END IF;
-- §3 T1.3 grounds_orders
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.grounds_orders'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.224.2.b' AND duration_value = 15
AND duration_unit = 'days';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.3: upc.apl.order.grounds_orders missing or wrong shape (got % matches)', v_count;
END IF;
-- §4 T1.4 response_orders chained on §3
SELECT count(*) INTO v_count
FROM paliad.deadline_rules dr
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
WHERE dr.submission_code = 'upc.apl.order.response_orders'
AND dr.is_active = true AND dr.lifecycle_state = 'published'
AND dr.rule_code = 'RoP.235.2' AND dr.duration_value = 15
AND p.submission_code = 'upc.apl.order.grounds_orders';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.4: upc.apl.order.response_orders missing or wrong parent chain (got % matches)', v_count;
END IF;
-- §5 T1.5 cons_orders
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.cons_orders'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.118.4' AND duration_value = 2
AND duration_unit = 'months';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.5: upc.inf.cfi.cons_orders missing or wrong shape (got % matches)', v_count;
END IF;
-- §6 T1.6 rectification
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.rectification'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.353' AND duration_value = 1
AND duration_unit = 'months';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.6: upc.inf.cfi.rectification missing or wrong shape (got % matches)', v_count;
END IF;
-- §7 T1.7 pi.deficiency
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.deficiency'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.207.6.a' AND duration_value = 14
AND duration_unit = 'days';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.7: upc.pi.cfi.deficiency missing or wrong shape (got % matches)', v_count;
END IF;
-- §8 T1.8 pi.merits_start — combine-max wiring
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.merits_start'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.213' AND duration_value = 31
AND duration_unit = 'days'
AND alt_duration_value = 20 AND alt_duration_unit = 'working_days'
AND alt_rule_code = 'RoP.198.1' AND combine_op = 'max';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.8: upc.pi.cfi.merits_start missing or wrong combine-max shape (got % matches)', v_count;
END IF;
-- §9 T1.9 translation_request — timing='before'
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.translation_request'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.109.1' AND duration_value = 1
AND duration_unit = 'months' AND timing = 'before';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.9: upc.inf.cfi.translation_request missing or wrong timing (got % matches)', v_count;
END IF;
-- §10 T1.10 interpreter_cost — timing='before'
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.109.4' AND duration_value = 2
AND duration_unit = 'weeks' AND timing = 'before';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.10: upc.inf.cfi.interpreter_cost missing or wrong timing (got % matches)', v_count;
END IF;
-- §11 T1.11 translations_lodge
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.109.5' AND duration_value = 2
AND duration_unit = 'weeks' AND trigger_event_id = 113;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.11: upc.inf.cfi.translations_lodge missing or wrong shape (got % matches)', v_count;
END IF;
-- §12 T1.12 pi.response re-anchor
SELECT count(*) INTO v_count
FROM paliad.deadline_rules dr
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
WHERE dr.submission_code = 'upc.pi.cfi.response'
AND dr.is_active = true AND dr.lifecycle_state = 'published'
AND dr.is_court_set = true
AND p.submission_code = 'upc.pi.cfi.app';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.12: upc.pi.cfi.response not re-anchored on app (got % matches)', v_count;
END IF;
-- §13 Q6 cleanup — no archived _archived_litigation rules left
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
AND lifecycle_state = 'archived';
IF v_count <> 0 THEN
RAISE EXCEPTION 'mig 132 §13: % archived _archived_litigation.* rules still present after cleanup', v_count;
END IF;
END $$;

View File

@@ -0,0 +1,33 @@
-- Reverses mig 133. Removes the 5 new rules:
-- * upc.dmgs.cfi.interim
-- * upc.dmgs.cfi.oral
-- * upc.dmgs.cfi.decision
-- * upc.dmgs.cfi.appeal_spawn
-- * upc.pi.cfi.appeal_spawn
--
-- The audit_reason is required by the mig 079 trigger for DELETE;
-- set_config at top supplies it.
--
-- Idempotent — if a rule is already missing the DELETE matches zero
-- rows and the audit log records nothing extra.
SELECT set_config(
'paliad.audit_reason',
'mig 133 (down): revert UPC Damages tree-end rows and UPC PI appeal-spawn (t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118)',
true);
-- Delete the spawn rows first so the parent_id reference goes away
-- before the parent decision row is removed.
DELETE FROM paliad.deadline_rules
WHERE submission_code IN (
'upc.dmgs.cfi.appeal_spawn',
'upc.pi.cfi.appeal_spawn')
AND lifecycle_state = 'published';
DELETE FROM paliad.deadline_rules
WHERE submission_code IN (
'upc.dmgs.cfi.interim',
'upc.dmgs.cfi.oral',
'upc.dmgs.cfi.decision')
AND proceeding_type_id = 17
AND lifecycle_state = 'published';

View File

@@ -0,0 +1,405 @@
-- t-paliad-285 (m/paliad#117) + t-paliad-286 (m/paliad#118) —
-- post-submission court followup for UPC Damages and appeal route
-- for UPC Provisional Measures.
--
-- m's 2026-05-25 report: the upc.dmgs.cfi proceeding stops at the
-- last party submission (rejoin) — no interim conference, no oral
-- hearing, no decision row, no appeal-spawn. The upc.pi.cfi
-- proceeding has its decision row (`pi.order`) but no spawn into
-- the appeal tree. Both gaps prevent the Verfahrensablauf timeline
-- from rendering the court phase plus any downstream appeal sub-
-- tree that atlas's #96 spawn-rendering mechanism is otherwise
-- ready to surface.
--
-- Two sections in one migration (slot 133 — knuth on 132, paliadin
-- coordinated):
--
-- A. UPC Damages tree-end rows (#117)
-- A1 upc.dmgs.cfi.interim UPC RoP R.105 court-set hearing
-- A2 upc.dmgs.cfi.oral UPC RoP R.118 / R.250 court-set hearing
-- A3 upc.dmgs.cfi.decision UPC RoP R.118 / R.144 court-set decision
-- A4 upc.dmgs.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
--
-- B. UPC Provisional Measures appeal route (#118)
-- B1 upc.pi.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
--
-- Source citations:
-- * docs/research-deadlines-completeness-2026-05-25.md
-- — §2.1 (upc.dmgs.cfi has only 4 rules: R.131.2 / R.137.2 / R.139)
-- — §D Damages table (R.144 tree-end row missing — listed
-- in Tier 4 as "cosmetic", upgraded to Tier-0 by m's
-- report once the wider follow-up gap was understood)
-- * docs/audit-upc-rop-deadlines-2026-05-08.md §D row R.144,
-- §F R.220.1(a) / R.224.1(a) (verified verbatim in youpc DB
-- under law_type=UPCRoP).
-- * UPC Rules of Procedure (consolidated):
-- R.105 — Interim conference (court fixes after written
-- procedure closes; same structural shape as the inf
-- interim conference, already modelled as `upc.inf.cfi.interim`).
-- R.118 — Decision after oral hearing; general rule for
-- deciding panels.
-- R.250 — Determination of damages decision; damages-
-- specific decision rule (chains R.144 indication →
-- damages award).
-- R.144 — Final decision on damages quantum (tree-end
-- anchor for §A3).
-- R.220.1(a) — Appeal lies from any final decision /
-- decision disposing of the case at first instance.
-- A PI order under R.211 disposes of the urgent question
-- and is therefore appealable on the main 2-month track
-- (not the 15-day order track of R.220.1(c), which covers
-- case-management and procedural orders requiring leave).
-- Curie's §F table confirms the main-track wiring for
-- decisions on merits / disposing orders.
-- R.224.1(a) — Statement of Appeal within 2 months of
-- service of the final decision; the deadline-notes text
-- mirrors mig 095's inf.appeal_spawn / rev.appeal_spawn.
-- R.224.2(a) — Statement of grounds within 4 months
-- (separate deadline in the spawned upc.apl.merits
-- proceeding; already present as upc.apl.merits.grounds).
--
-- Shape decisions (mirroring mig 012 / mig 095 conventions):
-- * Court-set rows (interim / oral / decision) carry
-- primary_party='court', event_type='hearing'|'decision',
-- duration_value=0, is_court_set=true, parent_id=NULL,
-- concept_id reuses the shared concepts already wired for
-- upc.inf.cfi (interim-conference / oral-hearing / decision).
-- * Spawn rows carry primary_party='both', is_spawn=true,
-- spawn_proceeding_type_id=11 (upc.apl.merits), spawn_label
-- identical to the merits spawn already in production. The
-- spawn row's parent_id is the spawning decision/order row
-- (so the audit log carries the trigger link).
-- * No condition_expr — m's F2.3 decision recorded in mig 095
-- §3: "the appeal deadline should always be triggered by a
-- decision … appeal is always a possibility." Visibility
-- filtering on the frontend hides appeals on projects where
-- no appeal is contemplated.
-- * sequence_order numbering follows the inf convention
-- (40=interim, 50=oral, 60=decision, 80=appeal_spawn) so the
-- Verfahrensablauf timeline orders consistently across
-- proceedings. For PI the existing pi.order sits at
-- sequence_order=3; the appeal_spawn lands at 10 (clear of
-- the writ phase, room for future court-phase rows).
--
-- Idempotency: every INSERT is gated by `WHERE NOT EXISTS (… same
-- submission_code, proceeding_type_id, lifecycle_state)`. Re-apply
-- against an already-migrated DB inserts zero rows and the audit
-- log carries no duplicate entries.
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on INSERT/UPDATE/DELETE without it.
SELECT set_config(
'paliad.audit_reason',
'mig 133: t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118 — UPC Damages tree-end rows (interim conference R.105, oral hearing R.118/R.250, decision R.118/R.144, appeal-spawn R.220.1(a)) and UPC Provisional Measures appeal-spawn R.220.1(a); see docs/research-deadlines-completeness-2026-05-25.md §D and docs/audit-upc-rop-deadlines-2026-05-08.md §D/§F',
true);
-- =============================================================================
-- A. UPC Damages — court-phase tree end (m/paliad#117)
-- =============================================================================
-- A1. upc.dmgs.cfi.interim — Interim conference (UPC RoP R.105).
-- Court-set hearing fixed by the judge-rapporteur once the
-- written procedure closes. Identical shape to
-- upc.inf.cfi.interim; reuses the shared interim-conference
-- concept node.
INSERT INTO paliad.deadline_rules
(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,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.interim',
'Zwischenverfahren',
'Interim Conference',
NULL,
'court',
'hearing',
0,
'months',
'after',
NULL,
'Termin vom Gericht bestimmt',
'Date set by the court',
40,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'e5071152-d408-4455-b644-9e79d86fd538'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.interim'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A2. upc.dmgs.cfi.oral — Oral hearing (UPC RoP R.118 / R.250).
-- Court-set hearing after the interim conference / close of
-- written procedure. Same shape as upc.inf.cfi.oral; reuses
-- the shared oral-hearing concept node.
INSERT INTO paliad.deadline_rules
(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,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.oral',
'Mündliche Verhandlung',
'Oral Hearing',
NULL,
'court',
'hearing',
0,
'months',
'after',
NULL,
NULL,
NULL,
50,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'd6e5b793-dcf1-4d83-81ff-34f42dbb3693'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.oral'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A3. upc.dmgs.cfi.decision — Damages decision (UPC RoP R.118 /
-- R.144 / R.250). Court-set decision delivered after oral
-- hearing; closes the §3.1 audit gap (R.144 tree-end). Same
-- shape as upc.inf.cfi.decision; reuses the shared decision
-- concept node.
INSERT INTO paliad.deadline_rules
(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,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.decision',
'Entscheidung',
'Decision',
NULL,
'court',
'decision',
0,
'months',
'after',
NULL,
NULL,
NULL,
60,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'472fc32d-cc4f-4aa4-8ace-e422031812de'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.decision'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A4. upc.dmgs.cfi.appeal_spawn — Appeal against damages decision
-- (UPC RoP R.220.1(a), 2-month main track; grounds R.224.2(a)
-- run as a separate deadline in the spawned upc.apl.merits
-- proceeding). Parent points at the freshly-inserted
-- upc.dmgs.cfi.decision; the SELECT subquery resolves it
-- after A3 lands. Same shape as the mig 095 inf.appeal_spawn.
INSERT INTO paliad.deadline_rules
(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,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
17,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.decision'
AND proceeding_type_id = 17
AND lifecycle_state = 'published'
AND is_active = true),
'upc.dmgs.cfi.appeal_spawn',
'Berufung gegen Schadensentscheidung',
'Appeal against damages decision',
'Berufung gegen die Entscheidung über die Schadensbemessung (R.118 / R.144). Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der Schadensentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
'Within 2 months of service of the damages decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
80,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.appeal_spawn'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- =============================================================================
-- B. UPC Provisional Measures — appeal route (m/paliad#118)
-- =============================================================================
-- B1. upc.pi.cfi.appeal_spawn — Appeal against PI order (UPC RoP
-- R.220.1(a), 2-month main track). PI orders under R.211
-- dispose of the urgent question and are appealable on the
-- main 2-month track (R.220.1(a)/R.224.1(a)); the 15-day
-- order track of R.220.1(c) is for case-management /
-- procedural orders requiring leave and does not apply to
-- PI dispositions. Parent points at the existing
-- upc.pi.cfi.order (sequence_order=3) so the spawn fires
-- once the order is anchored on a project's timeline.
INSERT INTO paliad.deadline_rules
(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,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
10,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.order'
AND proceeding_type_id = 10
AND lifecycle_state = 'published'
AND is_active = true),
'upc.pi.cfi.appeal_spawn',
'Berufung gegen Anordnung',
'Appeal against PI order',
'Berufung gegen die einstweilige Anordnung nach R.211. Eine PI-Anordnung erledigt die einstweilige Streitfrage und wird wie eine Endentscheidung im Hauptverfahren behandelt: statutarische Frist von 2 Monaten ab Zustellung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren). Die 15-Tage-Spur nach R.220.1(c) / R.220.2 gilt für Verfahrensanordnungen mit Zulassung und ist hier nicht einschlägig.',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der PI-Anordnung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
'Within 2 months of service of the PI order lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
10,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.appeal_spawn'
AND proceeding_type_id = 10
AND lifecycle_state = 'published');
-- =============================================================================
-- C. Post-insert verification — raise if any expected row is missing
-- (matches the mig 095 / 127 convention; protects against a future
-- re-shape of the table that silently drops one of the WHERE NOT
-- EXISTS predicates).
-- =============================================================================
DO $$
DECLARE
v_missing text;
BEGIN
SELECT string_agg(expected, ', ' ORDER BY expected)
INTO v_missing
FROM (VALUES
('upc.dmgs.cfi.interim'),
('upc.dmgs.cfi.oral'),
('upc.dmgs.cfi.decision'),
('upc.dmgs.cfi.appeal_spawn'),
('upc.pi.cfi.appeal_spawn')
) AS t(expected)
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = t.expected
AND dr.lifecycle_state = 'published'
AND dr.is_active = true);
IF v_missing IS NOT NULL THEN
RAISE EXCEPTION
'mig 133: expected published rules missing after insert: %', v_missing;
END IF;
IF NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = 'upc.dmgs.cfi.appeal_spawn'
AND dr.proceeding_type_id = 17
AND dr.spawn_proceeding_type_id = 11
AND dr.is_spawn = true
AND dr.parent_id IS NOT NULL
AND dr.lifecycle_state = 'published'
) THEN
RAISE EXCEPTION
'mig 133: upc.dmgs.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
END IF;
IF NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = 'upc.pi.cfi.appeal_spawn'
AND dr.proceeding_type_id = 10
AND dr.spawn_proceeding_type_id = 11
AND dr.is_spawn = true
AND dr.parent_id IS NOT NULL
AND dr.lifecycle_state = 'published'
) THEN
RAISE EXCEPTION
'mig 133: upc.pi.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
END IF;
END $$;

69
internal/db/testdata/README.md vendored Normal file
View File

@@ -0,0 +1,69 @@
# `internal/db/testdata/` — CI snapshot
## `prod-snapshot.sql`
Schema-only `pg_dump` of paliad's prod DB (youpc-supabase paliad schema)
plus the rows of `paliad.applied_migrations` that match this branch's
on-disk migration set.
**Purpose.** Lets CI's migration smoke (`.gitea/workflows/test.yaml`)
restore a Postgres scratch DB to "paliad at HEAD-of-snapshot" without
having to replay 131 migrations from scratch. ApplyMigrations on the
restored DB sees the applied set and only runs whatever NEW migrations
this PR adds — exactly the integration shape we want to test, and the
same shape prod sees on every deploy.
**Why a snapshot at all.** Running ApplyMigrations from scratch against a
fresh `supabase/postgres:15.8.1.060` surfaces multiple fresh-DB
idempotence bugs in historical migrations (raw `COMMIT;` in mig 051,
missing `CREATE EXTENSION pg_trgm` for mig 037, ALTER POLICY
exception-handler gaps in mig 024/027 — the last is fixed in this PR).
Fixing them all is a separate cleanup. The snapshot sidesteps them by
starting CI from a state where every historical migration is already
applied as it was in prod.
**Schema scope.** `--schema=paliad` only. Auth schema comes baked into
`supabase/postgres`; CI's setup step installs `pg_trgm` before restoring.
**Ownership.** `--no-owner --no-privileges` keeps the dump portable
across role topologies (CI's supabase_admin / postgres / authenticated /
anon don't have to match prod's exact role layout). The role-split smoke
relies on `postgres` being a non-superuser, which is true on
supabase/postgres by default.
**Refresh.** Run `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL`
set to a Postgres URL with `pg_dump` rights on youpc-supabase. The
target appends data rows for `paliad.applied_migrations`, strips
`\restrict` / `\unrestrict` commands (pg 16 dump → pg 15 restore), and
filters out applied-migrations rows for versions beyond the branch's
local max. The CI workflow consumes the resulting file verbatim.
**Verify a refresh.** Boot a local scratch:
```bash
docker run -d --rm --name paliad-snap \
-e POSTGRES_PASSWORD=ci -e POSTGRES_DB=paliad_scratch \
-p 15433:5432 supabase/postgres:15.8.1.060
sleep 5
docker exec -e PGPASSWORD=ci paliad-snap psql -h localhost -U supabase_admin -d paliad_scratch \
-c "GRANT CREATE ON DATABASE paliad_scratch TO postgres;" \
-c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
cat internal/db/testdata/prod-snapshot.sql | docker exec -i -e PGPASSWORD=ci paliad-snap \
psql -h localhost -U postgres -d paliad_scratch -v ON_ERROR_STOP=1
TEST_DATABASE_URL="postgres://postgres:ci@localhost:15433/paliad_scratch?sslmode=disable" \
TEST_APP_DATABASE_URL="postgres://postgres:ci@localhost:15433/paliad_scratch?sslmode=disable" \
go test -count=1 -run 'TestMigrations|TestBootSmoke|TestHealthReady_Live' ./internal/db/ ./cmd/server/
docker stop paliad-snap
```
All four named tests must pass. If any fails after a refresh,
investigate before merging — usually because a new migration was added
to prod that this branch doesn't have on disk yet.
**Why is the snapshot not gzipped?** Small enough (~200 KB) that the
diff stays human-readable in `git diff` reviews. If it crosses ~1 MB,
gzip + decompress-on-restore in CI.
**Privacy.** Schema-only dump, no row data from any paliad table (except
`paliad.applied_migrations`, which contains migration filenames +
checksums — public info already in the repo).

6278
internal/db/testdata/prod-snapshot.sql vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
package handlers
// HTTP handlers for paliad.project_event_choices (t-paliad-265 / m/paliad#96).
//
// Three endpoints:
// GET /api/projects/{id}/event-choices → list
// PUT /api/projects/{id}/event-choices → upsert one
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
//
// All three gated by visibility on the project (paliad.can_see_project)
// via EventChoiceService.
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/projects/{id}/event-choices
func handleListProjectEventChoices(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
rows, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// PUT /api/projects/{id}/event-choices — upsert one row.
func handlePutProjectEventChoice(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
var input services.UpsertEventChoiceInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
row, err := dbSvc.eventChoice.Upsert(r.Context(), uid, projectID, input)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
func handleDeleteProjectEventChoice(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
submissionCode := r.PathValue("submission_code")
choiceKind := r.PathValue("choice_kind")
if err := dbSvc.eventChoice.Delete(r.Context(), uid, projectID, submissionCode, choiceKind); err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -79,6 +79,38 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
// placeholder bag as the universal _skeleton.docx, but additionally
// preserves every HL paragraph + character style from the HL Patents
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
// (header logo + firm-address footer). Slotted ahead of the universal
// skeleton in the fallback chain so any submission_code without a
// dedicated per-code template still renders as a real firm-branded
// Schriftsatz with variables substituted, rather than a plain skeleton.
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
firmSkeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
},
// English skeleton variant (t-paliad-276). Sibling of
// `_skeleton.docx`; used when a draft's language='en' and no
// per-code EN template exists. If the file isn't authored yet in
// mWorkRepo, the Gitea fetch fails and resolveSubmissionTemplate
// falls through to the DE skeleton — visible to the user as the
// "Fallback: universelles Skelett" notice on the draft editor.
skeletonSubmissionENSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
DownloadName: branding.Name + " — Submission skeleton.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
@@ -87,6 +119,19 @@ var fileRegistry = map[string]fileEntry{
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
// surface as skeletonSubmissionSlug; carries HL paragraph + character
// styles from the source .dotm on top. Sits between the per-code
// template and the generic universal skeleton in the fallback chain so
// codes without a dedicated template still render with firm branding.
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
// skeletonSubmissionENSlug names the English skeleton variant used when
// a draft's language='en' and no per-code EN template exists
// (t-paliad-276). Same role as skeletonSubmissionSlug but in EN.
const skeletonSubmissionENSlug = "submission/_skeleton.en.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
@@ -96,14 +141,32 @@ const skeletonSubmissionSlug = "submission/_skeleton.docx"
// 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.
//
// t-paliad-276: codes that ship an EN sibling
// (e.g. `de.inf.lg.erwidg.en.docx`) also register it in
// submissionTemplateENRegistry; the language-aware lookup
// (resolveSubmissionTemplate(ctx, code, lang)) prefers the language-
// suffixed slug and falls back to the unsuffixed one when no per-firm
// EN variant exists.
var submissionTemplateRegistry = map[string]string{
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
}
// submissionTemplateENRegistry maps a submission_code to the EN
// variant slug. Empty when no EN template has been authored — the
// lookup falls through to the unsuffixed (DE-baked) template and the
// editor surfaces the "Fallback: universelles Skelett" notice when
// even the skeleton has no EN sibling.
var submissionTemplateENRegistry = map[string]string{}
// 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.
//
// Language-suffixed variants (t-paliad-276) are served via
// fetchSubmissionTemplateBytesForLang — this base function returns the
// unsuffixed registry entry only (the legacy DE-baked template).
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
slug, ok := submissionTemplateRegistry[submissionCode]
if !ok {
@@ -209,6 +272,113 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchSubmissionTemplateBytesForLang returns the per-(code, lang)
// template bytes when a language-suffixed variant is registered. Used
// only for the EN variant today; DE goes through the unsuffixed
// fetchSubmissionTemplateBytes (which is the legacy / authoritative
// DE registry). t-paliad-276.
//
// Returned bool = "variant registered AND fetched OK". A registered
// variant whose file 404s on Gitea returns (nil, "", false, nil) so
// the caller falls through to the unsuffixed template, mirroring the
// behaviour for unregistered codes.
func fetchSubmissionTemplateBytesForLang(ctx context.Context, submissionCode, lang string) ([]byte, string, bool, error) {
if lang != "en" {
// Only EN has a separate registry today. DE goes through the
// unsuffixed path which is the authoritative DE template.
return nil, "", false, nil
}
slug, ok := submissionTemplateENRegistry[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 {
// Treat upstream miss as "variant unavailable" so the
// resolver falls through to the DE template instead of
// surfacing a 502.
log.Printf("file proxy: EN variant fetch failed for %s (%s): %v — falling through", submissionCode, slug, err)
return nil, "", false, nil
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", false, nil
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, true, nil
}
// fetchSubmissionSkeletonBytesForLang returns the cached skeleton
// template bytes for the requested language. EN falls back to DE when
// the EN skeleton hasn't been authored yet (t-paliad-276). Returned
// bool flags whether the bytes match the requested language — false
// means the resolver should communicate "fallback" to the UI.
func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]byte, string, bool, error) {
if lang == "en" {
entry, ok := fileRegistry[skeletonSubmissionENSlug]
if ok {
ce := getCacheEntry(skeletonSubmissionENSlug)
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 {
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
} else {
log.Printf("file proxy: EN skeleton fetch failed (%s): %v — falling back to DE", skeletonSubmissionENSlug, err)
}
} else {
if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
}
}
}
// Fall through to the DE skeleton; bool=false flags that the
// returned bytes don't carry the requested language.
bytes, sha, err := fetchSubmissionSkeletonBytes(ctx)
if err != nil {
return nil, "", false, err
}
return bytes, sha, lang == "de", nil
}
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the
@@ -219,11 +389,28 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
// 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]
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
}
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
// template bytes (HL paragraph/character styles + 48-key placeholder
// bag) plus its provenance SHA. Sits between the per-code template and
// the generic universal skeleton in resolveSubmissionTemplate's
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
// as the other Gitea-backed template parts.
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
}
ce := getCacheEntry(skeletonSubmissionSlug)
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
@@ -241,7 +428,7 @@ func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)

View File

@@ -5,6 +5,9 @@ import (
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -51,6 +54,21 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
Flags []string `json:"flags,omitempty"`
AnchorOverrides map[string]string `json:"anchorOverrides,omitempty"`
CourtID string `json:"courtId,omitempty"`
// t-paliad-265: per-event-card choices. Two parallel inputs:
// - ProjectID lets the server pull persisted choices from
// paliad.project_event_choices (project-bound /tools/fristenrechner).
// - PerCardChoices lets the unbound /tools/verfahrensablauf
// send an inline-CSV-decoded list straight off the URL
// without persisting. When both are present the inline list
// wins (what-if exploration overrides the saved state).
ProjectID string `json:"projectId,omitempty"`
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
// t-paliad-290 (m/paliad#122): re-surface previously-hidden
// optional cards. When true the calculator marks skipped rows
// with UIDeadline.IsHidden instead of dropping them; descendants
// stay in the result list. Default false preserves the legacy
// suppression. HiddenCount on the response is independent.
IncludeHidden bool `json:"includeHidden,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -61,11 +79,43 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
return
}
// Fold per-card choices into the CalcOptions addendum. The inline
// PerCardChoices wins over the persisted ProjectID lookup when both
// are non-empty.
var addendum services.CalcOptionsAddendum
if len(req.PerCardChoices) > 0 {
choices := make([]models.ProjectEventChoice, 0, len(req.PerCardChoices))
for _, c := range req.PerCardChoices {
choices = append(choices, models.ProjectEventChoice{
SubmissionCode: c.SubmissionCode,
ChoiceKind: c.ChoiceKind,
ChoiceValue: c.ChoiceValue,
})
}
addendum = services.ToCalcOptionsAddendum(choices)
} else if req.ProjectID != "" && dbSvc.eventChoice != nil {
if pid, err := uuid.Parse(req.ProjectID); err == nil {
if uid, ok := requireUser(w, r); ok {
if choices, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, pid); err == nil {
addendum = services.ToCalcOptionsAddendum(choices)
}
// Visibility-filtered lookup: a non-visible project
// returns ErrNotVisible from ListForProject; in that
// case we project without per-card overlays rather
// than 404 — the timeline itself is non-PII data.
}
}
}
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {

View File

@@ -1,9 +1,13 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/services"
@@ -50,6 +54,12 @@ func noCachePages(h http.Handler) http.Handler {
// Services bundles the Phase B + C database-backed services. Pass nil if
// DATABASE_URL was unset; the matter-management endpoints will return 503.
type Services struct {
// Pool is the raw connection pool. Held so the readiness probe
// (/health/ready) can ping it without going through any individual
// service. nil when DATABASE_URL was unset — in that case
// /health/ready returns 503.
Pool *sqlx.DB
Project *services.ProjectService
Team *services.TeamService
PartnerUnit *services.PartnerUnitService
@@ -106,6 +116,10 @@ type Services struct {
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
SubmissionDraft *services.SubmissionDraftService
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
// 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
@@ -169,6 +183,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
export: svc.Export,
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
eventChoice: svc.EventChoice,
}
}
@@ -183,6 +198,38 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
_, _ = w.Write([]byte("ok\n"))
})
// Readiness probe. Public, no auth. Distinct from /healthz: this
// returns 200 only when the DB pool is reachable. Reaching Register
// at all implies db.ApplyMigrations succeeded (cmd/server/main.go
// calls it before constructing svc), so a 200 here means "migrations
// applied AND pool responsive" — the contract Dokploy / Traefik should
// gate on, not the bind-and-serve check that /healthz answers.
//
// Three outcomes:
// - svc == nil OR svc.Pool == nil → 503 (DB-less knowledge-platform
// deployments report not-ready so an external orchestrator can
// distinguish them from a full prod boot).
// - PingContext fails within 2 s → 503 (pool unreachable).
// - PingContext succeeds → 200 "ready".
//
// Used by docker-compose.yml's healthcheck (Slice B) and by the
// post-deploy verification step in .gitea/workflows/test.yaml.
mux.HandleFunc("GET /health/ready", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if svc == nil || svc.Pool == nil {
http.Error(w, "db not configured\n", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := svc.Pool.PingContext(ctx); err != nil {
http.Error(w, "db unreachable\n", http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte("ready\n"))
})
// API endpoints (JSON, public)
mux.HandleFunc("POST /api/login", handleAPILogin)
mux.HandleFunc("POST /api/register", handleAPIRegister)
@@ -355,6 +402,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
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)
// t-paliad-277 / m/paliad#109 — refresh project-derived variables on
// the draft. Strips overrides for project.* / parties.* / deadline.*
// / procedural_event.* / rule.* prefixes and bumps last_imported_at.
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/import-from-project", handleImportFromProject)
// /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)
@@ -390,6 +441,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/projects/{id}/partner-units", handleAttachPartnerUnit)
protected.HandleFunc("DELETE /api/projects/{id}/partner-units/{unit_id}", handleDetachPartnerUnit)
// t-paliad-265 — per-event-card choices on the Verfahrensablauf timeline.
protected.HandleFunc("GET /api/projects/{id}/event-choices", handleListProjectEventChoices)
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
@@ -402,6 +458,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-139 — set unit_role on a member.
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
protected.HandleFunc("GET /api/parties/search", handlePartiesSearch)
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
// Phase F — Appointments (appointments)

View File

@@ -68,6 +68,9 @@ type dbServices struct {
// t-paliad-238 — submission draft editor.
submissionDraft *services.SubmissionDraftService
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService
}
var dbSvc *dbServices
@@ -698,6 +701,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, p)
}
// GET /api/parties/search?q=...
//
// Cross-project party picker for the submission-draft editor
// (t-paliad-287). Returns up to 25 parties from every project the
// caller can see, matched by case-insensitive substring on name or
// representative. Empty q returns the 20 most-recently-updated rows so
// the picker isn't blank on first open. Visibility is enforced in the
// service layer via the same predicate every project-scoped read uses.
func handlePartiesSearch(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
q := r.URL.Query().Get("q")
hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": hits})
}
// DELETE /api/parties/{id}
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {

View File

@@ -60,38 +60,65 @@ const submissionDraftExportTimeout = 30 * time.Second
// raw row plus the resolved bag and the rule metadata the sidebar uses
// to label each variable group.
type submissionDraftView struct {
Draft submissionDraftJSON `json:"draft"`
Rule *submissionRuleSummary `json:"rule,omitempty"`
ResolvedBag services.PlaceholderMap `json:"resolved_bag"`
MergedBag services.PlaceholderMap `json:"merged_bag"`
PreviewHTML string `json:"preview_html"`
Lang string `json:"lang"`
HasTemplate bool `json:"has_template"`
TemplateMissing bool `json:"template_missing,omitempty"`
Draft submissionDraftJSON `json:"draft"`
Rule *submissionRuleSummary `json:"rule,omitempty"`
ResolvedBag services.PlaceholderMap `json:"resolved_bag"`
MergedBag services.PlaceholderMap `json:"merged_bag"`
PreviewHTML string `json:"preview_html"`
Lang string `json:"lang"`
HasTemplate bool `json:"has_template"`
TemplateMissing bool `json:"template_missing,omitempty"`
// TemplateTier identifies which tier of resolveSubmissionTemplate
// produced the bytes — one of per_code_lang, per_code, skeleton_lang,
// skeleton, letterhead. Lets the editor distinguish a perfect
// per-firm match from a skeleton fallback. t-paliad-276.
TemplateTier string `json:"template_tier,omitempty"`
// LanguageFallback is true when the requested draft.language has no
// per-firm per-code template (e.g. EN draft falls back to the DE
// per-code template, or to the universal skeleton). UI surfaces a
// notice so the lawyer knows the rendered body lacks language-
// matched code-specific prose. t-paliad-276.
LanguageFallback bool `json:"language_fallback,omitempty"`
// AvailableParties is the project's full party roster (t-paliad-277)
// so the frontend can render the multi-select picker in one round-
// trip. Empty when the draft has no project attached.
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
}
// submissionDraftPartyJSON is the minimal party row the editor sidebar
// needs to render a checkbox + role chip per party.
type submissionDraftPartyJSON struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
Representative string `json:"representative,omitempty"`
}
type submissionDraftJSON struct {
ID uuid.UUID `json:"id"`
ProjectID *uuid.UUID `json:"project_id"`
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Variables services.PlaceholderMap `json:"variables"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
ProjectID *uuid.UUID `json:"project_id"`
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Language string `json:"language"`
Variables services.PlaceholderMap `json:"variables"`
SelectedParties []uuid.UUID `json:"selected_parties"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type submissionRuleSummary struct {
Name string `json:"name"`
NameEN string `json:"name_en"`
SubmissionCode string `json:"submission_code"`
PrimaryParty string `json:"primary_party,omitempty"`
EventType string `json:"event_type,omitempty"`
LegalSource string `json:"legal_source,omitempty"`
LegalSourcePretty string `json:"legal_source_pretty,omitempty"`
LegalSourcePrettyEN string `json:"legal_source_pretty_en,omitempty"`
Name string `json:"name"`
NameEN string `json:"name_en"`
SubmissionCode string `json:"submission_code"`
PrimaryParty string `json:"primary_party,omitempty"`
EventType string `json:"event_type,omitempty"`
LegalSource string `json:"legal_source,omitempty"`
LegalSourcePretty string `json:"legal_source_pretty,omitempty"`
LegalSourcePrettyEN string `json:"legal_source_pretty_en,omitempty"`
}
type submissionDraftListResponse struct {
@@ -101,8 +128,10 @@ type submissionDraftListResponse struct {
}
type submissionDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
Language *string `json:"language,omitempty"`
}
// ─────────────────────────────────────────────────────────────────────
@@ -337,7 +366,12 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables}
patch := services.DraftPatch{
Name: input.Name,
Variables: input.Variables,
SelectedParties: input.SelectedParties,
Language: input.Language,
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -418,7 +452,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -467,7 +501,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -670,18 +704,24 @@ func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
type globalDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
// projectIDProvided is true when the JSON included the "project_id"
// key (regardless of value); needed to distinguish "no change" from
// "set to null". Set by the custom UnmarshalJSON below.
ProjectID *uuid.UUID `json:"project_id,omitempty"`
projectIDProvided bool
// SelectedParties: present-but-empty array resets to "all parties",
// present non-empty array restricts to subset, absent = no change.
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
@@ -689,7 +729,9 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
}
g.Name = a.Name
g.Variables = a.Variables
g.Language = a.Language
g.ProjectID = a.ProjectID
g.SelectedParties = a.SelectedParties
// Detect whether "project_id" was present in the JSON object.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
@@ -726,7 +768,12 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables}
patch := services.DraftPatch{
Name: in.Name,
Variables: in.Variables,
SelectedParties: in.SelectedParties,
Language: in.Language,
}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
@@ -748,6 +795,48 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, view)
}
// handleImportFromProject re-pulls every project-derived variable on
// the draft and bumps last_imported_at (t-paliad-277). The service-
// layer call strips overrides for project.* / parties.* / deadline.* /
// procedural_event.* / rule.* prefixes; firm.* / today.* / user.*
// overrides survive because those values aren't sourced from the
// project record.
//
// Idempotent on repeat clicks. Returns the full editor view so the
// frontend can refresh in one round-trip.
func handleImportFromProject(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
return
}
d, err := dbSvc.submissionDraft.ImportFromProject(r.Context(), uid, draftID)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
user, _ := dbSvc.users.GetByID(r.Context(), uid)
lang := userLang(user)
view, err := buildSubmissionDraftView(r.Context(), d, lang)
if err != nil {
log.Printf("submission_drafts: build view after import (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, view)
}
// handleGlobalDeleteSubmissionDraft removes a draft by id.
func handleGlobalDeleteSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
@@ -801,7 +890,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -859,9 +948,10 @@ func serveSubmissionDraftNotFound(w http.ResponseWriter) {
// per-rule heading.
func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft, lang string) (*submissionDraftView, error) {
view := &submissionDraftView{
Draft: draftToJSON(d),
Lang: lang,
HasTemplate: true,
Draft: draftToJSON(d),
Lang: lang,
HasTemplate: true,
AvailableParties: []submissionDraftPartyJSON{},
}
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
@@ -873,20 +963,33 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
if resolved.Lang != "" {
view.Lang = resolved.Lang
}
if len(resolved.Parties) > 0 {
view.AvailableParties = make([]submissionDraftPartyJSON, 0, len(resolved.Parties))
for _, p := range resolved.Parties {
row := submissionDraftPartyJSON{ID: p.ID, Name: p.Name}
if p.Role != nil {
row.Role = *p.Role
}
if p.Representative != nil {
row.Representative = *p.Representative
}
view.AvailableParties = append(view.AvailableParties, row)
}
}
if resolved.Rule != nil {
view.Rule = &submissionRuleSummary{
Name: derefStringHandler(resolved.Rule.SubmissionCode),
SubmissionCode: derefStringHandler(resolved.Rule.SubmissionCode),
NameEN: resolved.Rule.NameEN,
PrimaryParty: derefStringHandler(resolved.Rule.PrimaryParty),
EventType: derefStringHandler(resolved.Rule.EventType),
LegalSource: derefStringHandler(resolved.Rule.LegalSource),
Name: derefStringHandler(resolved.Rule.SubmissionCode),
SubmissionCode: derefStringHandler(resolved.Rule.SubmissionCode),
NameEN: resolved.Rule.NameEN,
PrimaryParty: derefStringHandler(resolved.Rule.PrimaryParty),
EventType: derefStringHandler(resolved.Rule.EventType),
LegalSource: derefStringHandler(resolved.Rule.LegalSource),
}
view.Rule.Name = resolved.Rule.Name
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
}
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
view.TemplateMissing = true
@@ -894,6 +997,12 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.PreviewHTML = `<p class="preview-error">Vorlage konnte nicht geladen werden.</p>`
return view, nil
}
view.TemplateTier = string(tier)
// LanguageFallback signals "no per-firm template in the requested
// language" — the editor surfaces a notice so the lawyer knows the
// rendered body lacks code-specific prose. The per-code DE template
// counts as a fallback when the requested language is EN.
view.LanguageFallback = languageFallback(d.Language, tier)
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
if err != nil {
return nil, err
@@ -902,41 +1011,101 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
return view, nil
}
// submissionTemplateTier enumerates which tier of the template
// fallback chain produced the bytes returned by resolveSubmissionTemplate.
// Used by the editor to surface "Fallback: universelles Skelett" when
// the requested (code, lang) didn't have a dedicated template.
type submissionTemplateTier string
const (
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
)
// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
// §8 plus the t-paliad-259 universal-skeleton slot:
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
//
// 1. per-firm per-submission_code template registered in
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
// specific structure plus the full variable bag.
// 2. universal _skeleton.docx — same variable bag, no submission_code-
// specific prose. Catches every code without a dedicated template
// so the editor preview / generate flow still has variables to
// substitute instead of falling through to the bare letterhead.
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even the skeleton is unreachable
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
// for resilience.
// 1. per-firm per-(code, lang) template — most specific. e.g.
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
// 2. per-firm per-code (unsuffixed) template — DE-baked baseline. The
// legacy registry shape from before the language selector landed.
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
// HL paragraph + character styles + letterhead, full placeholder
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
// Backstop when the firm skeleton is unreachable.
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Last-ditch when every skeleton tier is unreachable.
//
// The returned SHA is the cache entry's commit SHA so the export audit
// row can record provenance.
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", err
// The returned SHA pins the audit row's template provenance. The tier
// tells the editor whether the result language-matches the request so
// it can surface a "Fallback: universelles Skelett" notice.
func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string) ([]byte, string, submissionTemplateTier, error) {
if lang != "de" && lang != "en" {
lang = "de"
}
// 1. per-(code, lang)
if data, sha, found, err := fetchSubmissionTemplateBytesForLang(ctx, submissionCode, lang); err != nil {
return nil, "", "", err
} else if found {
return data, sha, nil
return data, sha, tplTierPerCodeLang, nil
}
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, nil
// 2. per-code (unsuffixed)
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", "", err
} else if found {
return data, sha, tplTierPerCode, nil
}
// 3. language-matched skeleton — only meaningful for EN drafts; DE
// drafts fall through to the firm/universal DE skeletons below.
if lang == "en" {
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
return data, sha, tplTierSkeletonLang, nil
}
}
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
// this is a first-class match; for EN drafts it counts as a
// language fallback (handled by languageFallback()).
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
}
// 5. universal plain DE skeleton.
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
}
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
bytes, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
return nil, "", err
return nil, "", "", err
}
sha := hlPatentsStyleSHA()
return bytes, sha, nil
return bytes, sha, tplTierLetterhead, nil
}
// languageFallback reports whether the resolved template tier failed
// to match the requested draft language. For an EN draft, anything
// other than per_code_lang or skeleton_lang is a fallback (per_code is
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
// draft, only `letterhead` counts as a fallback — the DE skeleton and
// per-code template are both first-class DE outputs. t-paliad-276.
func languageFallback(lang string, tier submissionTemplateTier) bool {
if tier == tplTierLetterhead {
return true
}
if strings.EqualFold(lang, "en") {
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
}
return false
}
// hlPatentsStyleSHA reads the current cache SHA for the universal
@@ -958,15 +1127,26 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
if vars == nil {
vars = services.PlaceholderMap{}
}
selected := d.SelectedParties
if selected == nil {
selected = []uuid.UUID{}
}
lang := d.Language
if lang == "" {
lang = "de"
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
SelectedParties: selected,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
LastImportedAt: d.LastImportedAt,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}

View File

@@ -0,0 +1,43 @@
package handlers
// Regression tests for the template-tier → language-fallback mapping
// (t-paliad-276). The editor surfaces a "Fallback: universelles
// Skelett" notice when the requested draft language has no per-firm
// language-matched template — these tests pin which tier counts as a
// fallback for each language so the UI signal stays stable.
import "testing"
func TestLanguageFallback(t *testing.T) {
t.Parallel()
cases := []struct {
name string
lang string
tier submissionTemplateTier
want bool
}{
// DE drafts: every non-letterhead tier is a first-class match.
{"de_per_code_lang", "de", tplTierPerCodeLang, false},
{"de_per_code", "de", tplTierPerCode, false},
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
{"de_skeleton", "de", tplTierSkeleton, false},
{"de_letterhead", "de", tplTierLetterhead, true},
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
// surface the fallback notice so the lawyer knows the rendered
// body lacks EN prose.
{"en_per_code_lang", "en", tplTierPerCodeLang, false},
{"en_per_code", "en", tplTierPerCode, true},
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
{"en_skeleton", "en", tplTierSkeleton, true},
{"en_letterhead", "en", tplTierLetterhead, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := languageFallback(c.lang, c.tier); got != c.want {
t.Errorf("languageFallback(%q, %q) = %v, want %v", c.lang, c.tier, got, c.want)
}
})
}
}

View File

@@ -304,14 +304,23 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
// One-shot /generate has no draft row to pull `language` from —
// accept `?language=de|en` as an explicit override (t-paliad-276)
// and otherwise fall back to the user's UI language.
user, _ := dbSvc.users.GetByID(ctx, uid)
lang := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("language")))
if lang != "de" && lang != "en" {
lang = userLang(user)
}
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, submissionCode, lang)
if err != nil {
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
}
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, lang, tplBytes)
if err != nil {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{

View File

@@ -682,6 +682,13 @@ type DeadlineRule struct {
// NULL while draft, set on publish, retained through archive.
// Distinct from UpdatedAt (moves on every edit).
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
// ChoicesOffered declares which per-event-card choice-kinds this
// rule offers on the Verfahrensablauf timeline (mig 129,
// t-paliad-265). NULL = no caret affordance (default). See the
// COMMENT on paliad.deadline_rules.choices_offered for the value
// shape. The engine and the frontend both read this column.
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
}
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
@@ -946,3 +953,24 @@ type ApprovalRequest struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ProjectEventChoice is one per-event-card pick scoped to a project
// (t-paliad-265 / m/paliad#96). The join key SubmissionCode matches
// paliad.deadline_rules.submission_code — the same identifier the
// AnchorOverrides plumbing in fristenrechner.go already uses.
//
// ChoiceKind ∈ {appellant, include_ccr, skip}. ChoiceValue namespace
// per kind: appellant=claimant|defendant|both|none; include_ccr=true|false;
// skip=true|false. UNIQUE(project_id, submission_code, choice_kind)
// makes re-picks idempotent (Upsert path).
type ProjectEventChoice struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
ChoiceKind string `db:"choice_kind" json:"choice_kind"`
ChoiceValue string `db:"choice_value" json:"choice_value"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -34,7 +34,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at,
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
priority, is_court_set, lifecycle_state, draft_of, published_at`
priority, is_court_set, lifecycle_state, draft_of, published_at,
choices_offered`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active`

View File

@@ -0,0 +1,272 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// EventChoiceService reads and writes paliad.project_event_choices —
// per-event-card user picks scoped to a project (t-paliad-265 /
// m/paliad#96). Three choice kinds today:
//
// appellant — claimant | defendant | both | none
// include_ccr — true | false
// skip — true | false
//
// Visibility follows paliad.can_see_project (via ProjectService.CanSee).
// Audits via paliad.system_audit_log with event_type=project_event_choice.set
// (insert/update) or .deleted (delete).
//
// The CRUD surface is intentionally tight: List for a project (one read),
// Upsert one (idempotent re-pick), Delete one (kind-scoped). The
// projection engine receives the choices via ToCalcOptionsAddendum,
// which folds them into CalcOptions before Calculate runs.
type EventChoiceService struct {
db *sqlx.DB
projects *ProjectService
users *UserService
}
func NewEventChoiceService(db *sqlx.DB, projects *ProjectService, users *UserService) *EventChoiceService {
return &EventChoiceService{db: db, projects: projects, users: users}
}
// Allowed choice kinds + per-kind value namespaces. Validated server-side
// before any write; the DB CHECK constraint catches the same shape but
// the early validation gives a friendlier error and short-circuits the
// transaction.
var (
allowedChoiceKinds = map[string]map[string]struct{}{
"appellant": {"claimant": {}, "defendant": {}, "both": {}, "none": {}},
"include_ccr": {"true": {}, "false": {}},
"skip": {"true": {}, "false": {}},
}
)
func validateChoice(kind, value string) error {
values, ok := allowedChoiceKinds[kind]
if !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, kind)
}
if _, ok := values[value]; !ok {
return fmt.Errorf("%w: invalid choice_value %q for kind %q", ErrInvalidInput, value, kind)
}
return nil
}
// ListForProject returns every choice row for the given project. Caller
// must hold visibility on the project.
func (s *EventChoiceService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
out := []models.ProjectEventChoice{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at
FROM paliad.project_event_choices
WHERE project_id = $1
ORDER BY submission_code, choice_kind`, projectID)
if err != nil {
return nil, fmt.Errorf("list event choices: %w", err)
}
return out, nil
}
// UpsertInput is the body shape for an upsert.
type UpsertEventChoiceInput struct {
SubmissionCode string `json:"submission_code"`
ChoiceKind string `json:"choice_kind"`
ChoiceValue string `json:"choice_value"`
}
// Upsert inserts or updates one (project, submission_code, choice_kind)
// row. Audit-log row written in the same tx.
func (s *EventChoiceService) Upsert(ctx context.Context, userID, projectID uuid.UUID, input UpsertEventChoiceInput) (*models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
if input.SubmissionCode == "" {
return nil, fmt.Errorf("%w: submission_code required", ErrInvalidInput)
}
if err := validateChoice(input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.set ('||$1||','||$2||','||$3||')', true)`,
input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, fmt.Errorf("set audit reason: %w", err)
}
var row models.ProjectEventChoice
err = tx.GetContext(ctx, &row,
`INSERT INTO paliad.project_event_choices
(project_id, submission_code, choice_kind, choice_value, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $5)
ON CONFLICT (project_id, submission_code, choice_kind)
DO UPDATE SET choice_value = EXCLUDED.choice_value,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at`,
projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue, userID)
if err != nil {
return nil, fmt.Errorf("upsert event choice: %w", err)
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.set", userID, actorEmail, projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit upsert: %w", err)
}
return &row, nil
}
// Delete removes the (project, submission_code, choice_kind) row.
// Returns ErrNotVisible if the project isn't visible OR the row didn't
// exist (no leak between the two).
func (s *EventChoiceService) Delete(ctx context.Context, userID, projectID uuid.UUID, submissionCode, choiceKind string) error {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return err
}
if submissionCode == "" || choiceKind == "" {
return fmt.Errorf("%w: submission_code + choice_kind required", ErrInvalidInput)
}
if _, ok := allowedChoiceKinds[choiceKind]; !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, choiceKind)
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.deleted ('||$1||','||$2||')', true)`,
submissionCode, choiceKind); err != nil {
return fmt.Errorf("set audit reason: %w", err)
}
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.project_event_choices
WHERE project_id = $1 AND submission_code = $2 AND choice_kind = $3`,
projectID, submissionCode, choiceKind)
if err != nil {
return fmt.Errorf("delete event choice: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotVisible
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.deleted", userID, actorEmail, projectID, submissionCode, choiceKind, ""); err != nil {
return err
}
return tx.Commit()
}
// CalcOptionsAddendum is the per-card slice of CalcOptions, built from
// the persisted choices. ProjectionService folds these into the parent
// CalcOptions before Calculate runs.
type CalcOptionsAddendum struct {
PerCardAppellant map[string]string // submission_code → appellant value
SkipRules map[string]struct{} // set of submission_code
IncludeCCRFor map[string]struct{} // set of submission_code
}
// ToCalcOptionsAddendum converts a list of choices into the calc-options
// shape. Empty input yields an addendum whose maps are non-nil but empty
// so callers can use map indexing without nil checks.
func ToCalcOptionsAddendum(choices []models.ProjectEventChoice) CalcOptionsAddendum {
out := CalcOptionsAddendum{
PerCardAppellant: map[string]string{},
SkipRules: map[string]struct{}{},
IncludeCCRFor: map[string]struct{}{},
}
for _, c := range choices {
switch c.ChoiceKind {
case "appellant":
out.PerCardAppellant[c.SubmissionCode] = c.ChoiceValue
case "skip":
if c.ChoiceValue == "true" {
out.SkipRules[c.SubmissionCode] = struct{}{}
}
case "include_ccr":
if c.ChoiceValue == "true" {
out.IncludeCCRFor[c.SubmissionCode] = struct{}{}
}
}
}
return out
}
// writeChoiceAudit inserts a project-scoped row into paliad.system_audit_log
// with the choice details in metadata. Same shape as the data-export +
// checklist audit writers.
func writeChoiceAudit(ctx context.Context, tx *sqlx.Tx, eventType string, actorID uuid.UUID, actorEmail string, projectID uuid.UUID, submissionCode, choiceKind, choiceValue string) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ($1, $2, $3, 'project', $4,
jsonb_build_object(
'submission_code', $5::text,
'choice_kind', $6::text,
'choice_value', $7::text
))`,
eventType, actorID, actorEmail, projectID, submissionCode, choiceKind, choiceValue); err != nil {
return fmt.Errorf("audit insert: %w", err)
}
return nil
}
func (s *EventChoiceService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
var email string
err := s.db.GetContext(ctx, &email,
`SELECT email FROM paliad.users WHERE id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotVisible
}
if err != nil {
return "", fmt.Errorf("lookup actor: %w", err)
}
return email, nil
}
func (s *EventChoiceService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
visible, err := s.projects.CanSee(ctx, userID, projectID)
if err != nil {
return err
}
if !visible {
return ErrNotVisible
}
return nil
}

View File

@@ -0,0 +1,108 @@
package services
import (
"testing"
"mgit.msbls.de/m/paliad/internal/models"
)
// Unit tests for the pure helpers in event_choice_service.go. The CRUD
// path needs a live DB and lives in the integration suite.
func TestValidateChoice_Appellant(t *testing.T) {
for _, value := range []string{"claimant", "defendant", "both", "none"} {
if err := validateChoice("appellant", value); err != nil {
t.Errorf("appellant=%q should pass, got %v", value, err)
}
}
for _, bad := range []string{"", "applicant", "true", "claimaant"} {
if err := validateChoice("appellant", bad); err == nil {
t.Errorf("appellant=%q should fail validation", bad)
}
}
}
func TestValidateChoice_IncludeCCR(t *testing.T) {
for _, value := range []string{"true", "false"} {
if err := validateChoice("include_ccr", value); err != nil {
t.Errorf("include_ccr=%q should pass, got %v", value, err)
}
}
for _, bad := range []string{"", "yes", "1", "True"} {
if err := validateChoice("include_ccr", bad); err == nil {
t.Errorf("include_ccr=%q should fail validation", bad)
}
}
}
func TestValidateChoice_Skip(t *testing.T) {
for _, value := range []string{"true", "false"} {
if err := validateChoice("skip", value); err != nil {
t.Errorf("skip=%q should pass, got %v", value, err)
}
}
if err := validateChoice("skip", "maybe"); err == nil {
t.Errorf("skip=maybe should fail")
}
}
func TestValidateChoice_UnknownKind(t *testing.T) {
if err := validateChoice("not_a_kind", "true"); err == nil {
t.Errorf("unknown choice_kind should fail")
}
}
func TestToCalcOptionsAddendum_PerCardAppellant(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.decision", ChoiceKind: "appellant", ChoiceValue: "defendant"},
{SubmissionCode: "de.inf.lg.urteil", ChoiceKind: "appellant", ChoiceValue: "both"},
}
out := ToCalcOptionsAddendum(choices)
if out.PerCardAppellant["upc.inf.cfi.decision"] != "defendant" {
t.Errorf("appellant pick for upc.inf.cfi.decision = %q, want defendant", out.PerCardAppellant["upc.inf.cfi.decision"])
}
if out.PerCardAppellant["de.inf.lg.urteil"] != "both" {
t.Errorf("appellant pick for de.inf.lg.urteil = %q, want both", out.PerCardAppellant["de.inf.lg.urteil"])
}
if len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
t.Errorf("appellant-only input should not populate skip/include_ccr maps")
}
}
func TestToCalcOptionsAddendum_SkipRules(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.ccr", ChoiceKind: "skip", ChoiceValue: "true"},
{SubmissionCode: "upc.inf.cfi.prelim", ChoiceKind: "skip", ChoiceValue: "false"},
}
out := ToCalcOptionsAddendum(choices)
if _, ok := out.SkipRules["upc.inf.cfi.ccr"]; !ok {
t.Errorf("skip=true should populate SkipRules")
}
if _, ok := out.SkipRules["upc.inf.cfi.prelim"]; ok {
t.Errorf("skip=false should NOT populate SkipRules")
}
}
func TestToCalcOptionsAddendum_IncludeCCRFor(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.sod", ChoiceKind: "include_ccr", ChoiceValue: "true"},
{SubmissionCode: "de.inf.lg.erwidg", ChoiceKind: "include_ccr", ChoiceValue: "false"},
}
out := ToCalcOptionsAddendum(choices)
if _, ok := out.IncludeCCRFor["upc.inf.cfi.sod"]; !ok {
t.Errorf("include_ccr=true should populate IncludeCCRFor")
}
if _, ok := out.IncludeCCRFor["de.inf.lg.erwidg"]; ok {
t.Errorf("include_ccr=false should NOT populate IncludeCCRFor")
}
}
func TestToCalcOptionsAddendum_EmptyInput(t *testing.T) {
out := ToCalcOptionsAddendum(nil)
if out.PerCardAppellant == nil || out.SkipRules == nil || out.IncludeCCRFor == nil {
t.Errorf("empty input should still produce non-nil maps for safe indexing")
}
if len(out.PerCardAppellant) != 0 || len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
t.Errorf("empty input should produce empty maps")
}
}

View File

@@ -51,13 +51,20 @@ const SpecVersion = 1
// can't bury an in-flight approval, per the design doc §3 carve-out).
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
// it false and the spec is a no-op.
//
// Predicates is a flat per-source narrowing record: keys at the top
// level are data sources ("deadline", "appointment", …) and values are
// the per-source predicate structs directly. The shape on the wire and
// the shape the frontend emits agree exactly — see t-paliad-283 for the
// latent contract bug (Go used to wrap each entry in another Predicates
// struct, so the frontend's overlay clicks parsed back as no-op).
type FilterSpec struct {
Version int `json:"version"`
Sources []DataSource `json:"sources"`
Scope ScopeSpec `json:"scope"`
Time TimeSpec `json:"time"`
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
UnreadOnly bool `json:"unread_only,omitempty"`
Version int `json:"version"`
Sources []DataSource `json:"sources"`
Scope ScopeSpec `json:"scope"`
Time TimeSpec `json:"time"`
Predicates *Predicates `json:"predicates,omitempty"`
UnreadOnly bool `json:"unread_only,omitempty"`
}
// ScopeSpec narrows which projects contribute rows. Resolved at query
@@ -147,7 +154,8 @@ const (
)
// Predicates is the per-source narrowing payload. Empty fields mean
// "no narrowing" — never "exclude all".
// "no narrowing" — never "exclude all". One field per data source;
// the wire shape is the same: `{"deadline": {...}, "appointment": {...}}`.
type Predicates struct {
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
@@ -305,14 +313,25 @@ func (s *FilterSpec) Validate() error {
return err
}
for src, preds := range s.Predicates {
if !isKnownSource(src) {
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
if s.Predicates != nil {
// Reject predicates set on a source the spec doesn't list — we'd
// silently drop the narrowing otherwise. Walk the set fields.
type srcCheck struct {
src DataSource
present bool
}
if !seen[src] {
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
checks := []srcCheck{
{SourceDeadline, s.Predicates.Deadline != nil},
{SourceAppointment, s.Predicates.Appointment != nil},
{SourceProjectEvent, s.Predicates.ProjectEvent != nil},
{SourceApprovalRequest, s.Predicates.ApprovalRequest != nil},
}
if err := preds.validate(); err != nil {
for _, c := range checks {
if c.present && !seen[c.src] {
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, c.src)
}
}
if err := s.Predicates.validate(); err != nil {
return err
}
}

View File

@@ -0,0 +1,125 @@
package services
import (
"encoding/json"
"testing"
)
// t-paliad-283 regression: the bar's chip clicks POST a `predicates`
// payload shaped as `{<source>: <per-source>}`. The Go side previously
// declared `Predicates map[DataSource]Predicates` — a doubled-nested
// shape — which silently unmarshalled the bar's payload as no-op
// narrowing. This test pins the wire shape so the contract can't drift
// again.
//
// Run with `go test ./internal/services/`.
func TestFilterSpec_FlatPredicatesWireShape(t *testing.T) {
// The shape every chip click in the FilterBar emits: predicates is
// keyed by data source, value is the per-source predicate struct
// directly. Doubled-nesting would unmarshal as empty Predicates.
const wire = `{
"version": 1,
"sources": ["deadline", "appointment", "project_event", "approval_request"],
"scope": {"projects": {"mode": "all_visible"}},
"time": {"field": "auto", "horizon": "past_30d"},
"predicates": {
"deadline": {"status": ["pending"]},
"appointment": {"appointment_types": ["hearing"]},
"project_event": {"event_types": ["deadline_created"]},
"approval_request": {"viewer_role": "any_visible", "status": ["pending"]}
}
}`
var spec FilterSpec
if err := json.Unmarshal([]byte(wire), &spec); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if err := spec.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}
if spec.Predicates == nil {
t.Fatal("predicates must be non-nil after unmarshalling the bar's shape")
}
if spec.Predicates.Deadline == nil || len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "pending" {
t.Errorf("deadline.status must round-trip, got %+v", spec.Predicates.Deadline)
}
if spec.Predicates.Appointment == nil || len(spec.Predicates.Appointment.AppointmentTypes) != 1 {
t.Errorf("appointment.appointment_types must round-trip, got %+v", spec.Predicates.Appointment)
}
if spec.Predicates.ProjectEvent == nil || len(spec.Predicates.ProjectEvent.EventTypes) != 1 {
t.Errorf("project_event.event_types must round-trip, got %+v", spec.Predicates.ProjectEvent)
}
if spec.Predicates.ApprovalRequest == nil || spec.Predicates.ApprovalRequest.ViewerRole != "any_visible" {
t.Errorf("approval_request.viewer_role must round-trip, got %+v", spec.Predicates.ApprovalRequest)
}
}
// The shipped FilterSpec must marshal back to exactly the flat shape
// the frontend declares in views/types.ts. Otherwise /api/views/system
// (which serializes the InboxSystemView's Filter for the bar) returns a
// shape the frontend can't consume without translation gymnastics.
func TestFilterSpec_MarshalFlatPredicatesShape(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: &Predicates{
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
},
}
b, err := json.Marshal(spec)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Parse back generically so the assertion is on the wire shape, not
// on the Go type system that produced it.
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
var preds map[string]json.RawMessage
if err := json.Unmarshal(raw["predicates"], &preds); err != nil {
t.Fatalf("predicates re-unmarshal: %v", err)
}
dl, ok := preds["deadline"]
if !ok {
t.Fatal("predicates.deadline missing — wire shape regressed")
}
var dlBody map[string]json.RawMessage
if err := json.Unmarshal(dl, &dlBody); err != nil {
t.Fatalf("deadline body unmarshal: %v", err)
}
if _, ok := dlBody["status"]; !ok {
t.Errorf("predicates.deadline.status must be a top-level field; doubled-nesting reappeared. Body: %s", string(dl))
}
if _, ok := dlBody["deadline"]; ok {
t.Errorf("predicates.deadline must NOT wrap a nested deadline key — that's the t-paliad-283 bug. Body: %s", string(dl))
}
}
// End-to-end pin: the bar's payload after the user clicks
// "Frist-Status: Erledigt" (completed) must produce a spec whose
// runDeadlines branch narrows to completed deadlines. Without the
// t-paliad-283 fix, the unmarshal silently produced an empty Predicates
// and the SQL ran without the `status='completed'` clause.
func TestFilterSpec_BarChipPayloadNarrowsDeadlineStatus(t *testing.T) {
const barPayload = `{
"version": 1,
"sources": ["deadline"],
"scope": {"projects": {"mode": "all_visible"}},
"time": {"field": "auto", "horizon": "past_30d"},
"predicates": {"deadline": {"status": ["completed"]}}
}`
var spec FilterSpec
if err := json.Unmarshal([]byte(barPayload), &spec); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if spec.Predicates == nil || spec.Predicates.Deadline == nil {
t.Fatal("deadline predicate must survive the round-trip")
}
if len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "completed" {
t.Errorf("deadline.status must be [\"completed\"], got %+v", spec.Predicates.Deadline.Status)
}
}

View File

@@ -180,8 +180,8 @@ func TestFilterSpec_NewSymmetricHorizonsValidate(t *testing.T) {
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline}
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
s.Predicates = &Predicates{
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("predicates on unselected source must reject, got %v", err)
@@ -190,8 +190,8 @@ func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Predicates = map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
s.Predicates = &Predicates{
Deadline: &DeadlinePredicates{Status: []string{"weird"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown deadline.status must reject, got %v", err)
@@ -201,8 +201,8 @@ func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = append(s.Sources, SourceAppointment)
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
s.Predicates = &Predicates{
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown appointment_type must reject, got %v", err)
@@ -212,8 +212,8 @@ func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceProjectEvent}
s.Predicates = map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
s.Predicates = &Predicates{
ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown project_event kind must reject, got %v", err)
@@ -223,8 +223,8 @@ func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
s.Predicates = &Predicates{
ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown viewer_role must reject, got %v", err)
@@ -234,8 +234,8 @@ func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
s.Predicates = &Predicates{
ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown approval_request.status must reject, got %v", err)
@@ -251,15 +251,15 @@ func TestFilterSpec_RoundTripJSON(t *testing.T) {
PersonalOnly: false,
},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{
Predicates: &Predicates{
Deadline: &DeadlinePredicates{
Status: []string{"pending"},
ApprovalStatus: []string{"approved", "pending"},
}},
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
},
ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
Status: []string{"pending"},
}},
},
},
}
b, err := MarshalFilterSpec(original)

View File

@@ -89,7 +89,54 @@ type UIDeadline struct {
// is computed off a parent date that the COURT sets, not by the
// court itself.
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
// IsConditional signals the rule's anchor is uncertain — no
// concrete date can be projected. Set when the rule depends on:
// - a court-set ancestor whose date isn't anchored
// (overlaps with IsCourtSetIndirect; the two are kept
// distinct because IsCourtSet wraps a specific UX message
// "wird vom Gericht bestimmt", whereas IsConditional is
// the broader "render as 'abhängig von <parent>'" signal)
// - timing='before' rules whose forward anchor isn't set
// (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month
// before the oral hearing — without the hearing date, the
// backward arithmetic against the trigger date is meaningless)
// - optional opposing-side rules whose true triggering event
// hasn't been recorded for this project (e.g. R.262(2)
// Erwiderung auf Vertraulichkeitsantrag — the data-model
// parent is the SoC, but the real trigger is the opposing
// party's confidentiality motion which may never happen)
// When true, DueDate and OriginalDate are empty and the frontend
// renders an "abhängig von <ParentRuleName>" chip in place of a
// date. Suppressed by an explicit user anchor (IsOverridden wins).
// (t-paliad-289)
IsConditional bool `json:"isConditional,omitempty"`
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
// parent's identity so the frontend can render
// "abhängig von <ParentRuleName>" when IsConditional=true.
// Populated whenever the rule has a parent_id, not only when
// conditional — keeps the wire shape stable. Empty for root rules.
ParentRuleCode string `json:"parentRuleCode,omitempty"`
ParentRuleName string `json:"parentRuleName,omitempty"`
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
IsOverridden bool `json:"isOverridden,omitempty"`
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
// the rule so the frontend knows whether to render the per-event-card
// caret affordance, and which choice-kinds to populate the popover
// with. NULL / empty for rules with no choices. (t-paliad-265)
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
// AppellantContext is the per-decision appellant pick that applies
// to descendants of the closest ancestor decision card with a
// PerCardAppellant set. Empty when no per-card override is in
// effect (page-level ?appellant= still applies in that case).
// Frontend bucketer prefers this over the page-level appellant when
// non-empty. (t-paliad-265)
AppellantContext string `json:"appellantContext,omitempty"`
// IsHidden marks a card the user has previously hidden via a
// skip choice. Only ever true when CalcOptions.IncludeHidden is
// set — the toggle re-surfaces these rows so the user can either
// keep them faded for context or un-hide them via the inline
// "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122)
IsHidden bool `json:"isHidden,omitempty"`
}
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
@@ -125,6 +172,14 @@ type UIResponse struct {
// is the appealable first-instance decision (m/paliad#81).
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
// HiddenCount is the number of rules whose submission_code is in
// CalcOptions.SkipRules AND whose condition_expr gate passes —
// i.e. how many rows the user has hidden in this projection
// regardless of the IncludeHidden toggle state. The frontend uses
// this to render the "Ausgeblendete (N)" badge on the toggle even
// when the toggle is OFF (so users know there's something to
// re-surface). (t-paliad-290 / m/paliad#122)
HiddenCount int `json:"hiddenCount"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -179,6 +234,42 @@ type CalcOptions struct {
// Empty / nil = no override (default). Overrides apply equally to
// the proceeding-tree and trigger-event branches.
RuleOverrides []models.DeadlineRule
// Per-event-card choice overlays (t-paliad-265 / m/paliad#96).
// Keyed by paliad.deadline_rules.submission_code — same key
// AnchorOverrides uses.
//
// - PerCardAppellant: maps a decision-card's submission_code to the
// user-picked appellant ("claimant"|"defendant"|"both"|"none").
// The engine walks the parent chain of each rule and stamps the
// resulting UIDeadline.AppellantContext from the closest ancestor
// decision with a pick. The frontend bucketer then prefers the
// per-rule context over the page-level appellant.
// - SkipRules: set of submission_code values whose rules (and any
// descendants) the user has opted out of for this projection.
// Same suppression path as a failed condition_expr gate.
// - IncludeCCRFor: set of submission_code values for rules where
// the user opted in to the include-CCR choice (Klageerwiderung
// cards). v1 simplification (design §4.2 #2): if non-empty,
// "with_ccr" is appended to the flag set before gate
// evaluation. Correct for single-CCR-entry-point proceedings
// (UPC INF + DE LG today). Multi-CCR scope is a future expansion.
PerCardAppellant map[string]string
SkipRules map[string]struct{}
IncludeCCRFor map[string]struct{}
// IncludeHidden re-surfaces rules whose submission_code is in
// SkipRules (t-paliad-290 / m/paliad#122). When true:
// - Skipped rules are NOT dropped from the result; they render
// with UIDeadline.IsHidden=true so the frontend can fade them.
// - Descendant suppression is bypassed (the skipped parent is
// present in the result, so children compute their dates off
// it as if the user had never hidden it).
// Default false preserves the original skip semantic (drop rule +
// suppress descendants). HiddenCount on UIResponse is independent
// of this flag — it always reflects the number of hide-eligible
// rows so the toggle's count badge stays accurate.
IncludeHidden bool
}
// Calculate renders the full UI timeline for a proceeding type + trigger date.
@@ -233,6 +324,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
for _, f := range opts.Flags {
flagSet[f] = struct{}{}
}
// v1 simplification (design §4.2 #2, t-paliad-265): when any
// IncludeCCRFor entry exists, we treat with_ccr as set in the flag
// context. Correct for single-CCR-entry-point proceedings (UPC INF +
// DE LG today). Multi-CCR scope is a future expansion that would
// thread the include set through the gate evaluator per-rule.
if len(opts.IncludeCCRFor) > 0 {
flagSet["with_ccr"] = struct{}{}
}
// Parse anchor overrides up-front so a malformed date errors out
// before we start walking rules.
@@ -329,6 +428,52 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]UIDeadline, 0, len(rules))
// Pre-pass: identify rules flagged is_court_set=true in the data so
// order-of-evaluation in sequence_order doesn't matter for the
// parent-court-set check below. Without this, a rule processed
// earlier than its court-set parent (e.g. R.109(1) Antrag auf
// Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung
// sequence_order=50 in upc.inf.cfi) misses the court-set propagation
// and computes a meaningless date — for timing='before' rules, that
// produces a backward offset from the trigger date, which has no
// semantic relationship to the rule. (t-paliad-289)
for _, r := range rules {
if r.IsCourtSet {
courtSet[r.ID] = true
}
}
// ruleByID lets the conditional-rendering branches resolve a parent
// rule's display fields (submission_code, name, name_en) for the
// "abhängig von <ParentRuleName>" chip without re-scanning the
// rules slice on every iteration.
ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
for _, r := range rules {
ruleByID[r.ID] = r
}
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
// for membership tests; the engine reads them but doesn't mutate.
skipRules := opts.SkipRules
perCardAppellant := opts.PerCardAppellant
// skippedIDs accumulates the set of rule UUIDs whose timeline entry
// the user has opted out of. Walking in sequence_order means a
// child rule's parent has already been classified — so descendant
// suppression is a one-pass parent_id lookup.
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
// hiddenCount counts rows whose submission_code is in skipRules
// AND that pass the condition_expr gate — i.e. rows the user has
// hidden in this projection. Surfaced on UIResponse.HiddenCount so
// the frontend's "Ausgeblendete (N)" badge stays accurate even when
// IncludeHidden is off and the rows aren't in the result list.
// (t-paliad-290 / m/paliad#122)
hiddenCount := 0
// appellantContext maps a rule UUID to the appellant value that
// applies to its descendants. A rule that has its own PerCardAppellant
// pick stamps itself with that value; a rule whose parent has a
// context inherits it.
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range rules {
// Phase-3 unified gate: evaluate condition_expr (jsonb).
// Suppression semantic preserved: when the gate fires false AND
@@ -341,12 +486,62 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
continue
}
// SkipRules suppression (t-paliad-265): the user has marked
// this rule (or one of its ancestors) as "don't consider for
// this case". Drop the row entirely AND record the rule ID so
// descendants suppress too.
//
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
// we re-surface the directly-skipped row (faded via IsHidden)
// instead of dropping it. Descendants are NOT cascade-suppressed
// in that mode either — the un-suppressed parent computes its
// date normally, so children compute off it as usual. Either
// way we count the hide for the toggle's badge.
var isHidden bool
if r.SubmissionCode != nil {
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
hiddenCount++
if !opts.IncludeHidden {
skippedIDs[r.ID] = struct{}{}
continue
}
isHidden = true
}
}
if r.ParentID != nil {
if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
skippedIDs[r.ID] = struct{}{}
continue
}
}
// AppellantContext propagation. A rule with its own PerCardAppellant
// pick stamps its UUID with that value. Otherwise inherit from
// parent if the parent had a context.
var ctxVal string
if r.SubmissionCode != nil {
if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
ctxVal = v
}
}
if ctxVal == "" && r.ParentID != nil {
if v, ok := appellantContext[*r.ParentID]; ok {
ctxVal = v
}
}
if ctxVal != "" {
appellantContext[r.ID] = ctxVal
}
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
AppellantContext: ctxVal,
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
IsHidden: isHidden,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
@@ -369,21 +564,34 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.NotesEN = *r.DeadlineNotesEn
}
// Resolve the parent rule once so every conditional-rendering
// branch (incl. the optional-not-recorded path below) can stamp
// ParentRule* on the wire without re-scanning. Populated even
// for non-conditional rows — the frontend dependency-footer
// ("Folgt aus …") already consumes this on regular projected
// rows. (t-paliad-289)
var parentRule *models.DeadlineRule
if r.ParentID != nil {
if pr, ok := ruleByID[*r.ParentID]; ok {
parentRule = &pr
if pr.SubmissionCode != nil {
d.ParentRuleCode = *pr.SubmissionCode
}
d.ParentRuleName = pr.Name
d.ParentRuleNameEN = pr.NameEN
}
}
// Propagate court-set status from a parent rule whose date the
// court determines: if the anchor itself has no real date,
// nothing downstream can be computed either — UNLESS the user
// has supplied an override date for the parent (which they can
// once they know the real decision date).
parentOverridden := false
if r.ParentID != nil && courtSet[*r.ParentID] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.SubmissionCode != nil {
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
parentOverridden = true
}
}
break
if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil {
if parentRule.SubmissionCode != nil {
if _, ok := overrideDates[*parentRule.SubmissionCode]; ok {
parentOverridden = true
}
}
}
@@ -435,6 +643,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// "unbestimmt", not "wird vom Gericht bestimmt".
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -468,6 +677,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// itself isn't a court action.
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -500,9 +710,19 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// should say "unbestimmt", not "wird vom Gericht bestimmt":
// the date isn't directly determined by the court, it's
// derived from a date the court sets.
//
// timing='before' rules end up here too — a rule with
// "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the
// oral hearing as its parent; if the hearing date isn't set,
// the backward arithmetic against the trigger date is
// meaningless. The pre-pass above ensures courtSet[oral.ID]
// is true even when the oral hearing rule is processed later
// in sequence_order. IsConditional surfaces the "abhängig
// von <ParentRuleName>" UX. (t-paliad-289)
if parentIsCourtSet {
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -605,6 +825,42 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
// Rules with priority='optional' AND primary_party='both' whose
// data-model parent is the proceeding's trigger anchor (parent
// has parent_id=NULL and is not court-set, i.e. the SoC root
// rule) represent a rule whose REAL triggering event sits
// outside the rule data — e.g. R.262(2) Erwiderung auf
// Vertraulichkeitsantrag anchors on SoC in the data, but the
// real trigger is the opposing party's confidentiality motion
// which may never happen. Without an explicit anchor on the
// rule itself (user clicks "Datum setzen" after the motion
// arrives), the projection must NOT claim a concrete date.
//
// In the live corpus this catches confidentiality_response;
// every other optional+both rule has a court-set ancestor and
// is already caught by the parentIsCourtSet branches above.
// Suppressed when IsOverridden (the user has anchored the rule
// — the date is real) or when the rule has already been marked
// IsConditional by an earlier branch.
if !d.IsOverridden && !d.IsConditional &&
r.Priority == "optional" &&
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
d.WasAdjusted = false
d.AdjustmentReason = nil
// Mark this rule's ID as having an uncertain anchor so
// rules chaining off it also surface conditional via the
// parentIsCourtSet path (no rule currently chains off
// confidentiality_response in the live corpus, but the
// extension keeps the propagation semantics consistent).
courtSet[r.ID] = true
}
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = adjusted
}
@@ -617,6 +873,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding` (e.g.

View File

@@ -450,3 +450,130 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
}
}
// t-paliad-289: rules anchored on uncertain triggers must render as
// conditional (IsConditional=true, empty DueDate, ParentRule* populated)
// rather than fabricating a date off the trigger.
//
// Three pillars from the issue:
// - Symptom A: R.109(1) Antrag auf Simultanübersetzung (timing='before',
// parent=Mündliche Verhandlung which is court-set). Pre-fix the rule
// computed a meaningless "1 month before today" because sequence_order
// places translation_request (45) before oral (50), so the parent
// hadn't been classified as court-set yet. The new pre-pass in
// Calculate seeds courtSet from is_court_set=true on the data, so
// order-of-evaluation no longer matters.
// - R.118(4) cons_orders (parent=Entscheidung, court-set) — already
// worked via the legacy IsCourtSetIndirect path; assertion ensures
// the new IsConditional flag rides alongside it.
// - Symptom B: R.262(2) confidentiality_response (priority='optional',
// primary_party='both', parent=SoC which is the trigger anchor).
// The data-model parent is "always certain" but the real triggering
// event (opposing party's confidentiality motion) sits outside the
// rule data — render conditional until the user anchors the rule.
func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
holidays := NewHolidayService(pool)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := map[string]UIDeadline{}
for _, d := range resp.Deadlines {
byCode[d.Code] = d
}
cases := []struct {
code string
wantConditional bool
wantParentCode string
}{
// Symptom A — backward-anchored on the court-set oral hearing.
// Pre-pass fix: order-of-evaluation no longer matters.
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
// R.118(4) chain — parent=decision (court-set).
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
// Symptom B — optional + both anchored on SoC (trigger anchor).
{"upc.inf.cfi.confidentiality_response", true, "upc.inf.cfi.soc"},
// Negative control — mandatory rule anchored on SoC must keep
// its concrete date (no IsConditional, real DueDate).
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
}
for _, c := range cases {
t.Run(c.code, func(t *testing.T) {
d, ok := byCode[c.code]
if !ok {
t.Fatalf("rule %s missing from response", c.code)
}
if d.IsConditional != c.wantConditional {
t.Errorf("IsConditional = %v, want %v", d.IsConditional, c.wantConditional)
}
if c.wantConditional {
if d.DueDate != "" {
t.Errorf("DueDate = %q, want empty (conditional)", d.DueDate)
}
if d.ParentRuleCode != c.wantParentCode {
t.Errorf("ParentRuleCode = %q, want %q", d.ParentRuleCode, c.wantParentCode)
}
if d.ParentRuleName == "" {
t.Errorf("ParentRuleName empty for conditional rule")
}
} else {
if d.DueDate == "" {
t.Errorf("non-conditional rule has empty DueDate")
}
}
})
}
// Override path: when the user anchors the oral hearing, the
// backward-anchored R.109(1) flips back to a concrete date and
// IsConditional clears. This is the click-to-edit unblock.
t.Run("override on court-set parent clears IsConditional", func(t *testing.T) {
resp2, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{
AnchorOverrides: map[string]string{
"upc.inf.cfi.oral": "2027-03-01",
},
})
if err != nil {
t.Fatalf("Calculate with override: %v", err)
}
var tr UIDeadline
for _, d := range resp2.Deadlines {
if d.Code == "upc.inf.cfi.translation_request" {
tr = d
break
}
}
if tr.IsConditional {
t.Errorf("translation_request IsConditional=true after oral override; want false")
}
if tr.DueDate == "" {
t.Errorf("translation_request DueDate empty after oral override")
}
// 1 month before 2027-03-01 = ~2027-02-01 (with weekend bump).
if tr.DueDate < "2027-01-25" || tr.DueDate > "2027-02-05" {
t.Errorf("translation_request DueDate=%q not within expected 2027-01-25..2027-02-05 window", tr.DueDate)
}
})
}

View File

@@ -38,6 +38,59 @@ type CreatePartyInput struct {
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
// PartySearchHit is one row of the cross-project party search — a real
// paliad.parties row enriched with the parent project's title and
// reference so the picker can render context the lawyer needs to
// disambiguate identically-named parties on different cases
// (t-paliad-287).
type PartySearchHit struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
ProjectTitle string `db:"project_title" json:"project_title"`
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
Name string `db:"name" json:"name"`
Role *string `db:"role" json:"role,omitempty"`
Representative *string `db:"representative" json:"representative,omitempty"`
}
// Search returns parties from every project the caller can see, matched
// by case-insensitive substring on name OR representative. Empty query
// returns the 20 most recently-updated parties so the picker isn't
// blank on first open. Capped at 25 rows; the frontend doesn't paginate
// (the typical PA looks for one party they remember by name, not browses).
//
// Visibility is enforced inline via visibilityPredicatePositional —
// invisible projects' parties never surface in the result set.
func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) {
if limit <= 0 || limit > 50 {
limit = 25
}
q := strings.TrimSpace(query)
args := []any{userID}
conds := []string{visibilityPredicatePositional("p", 1)}
if q != "" {
args = append(args, "%"+q+"%")
conds = append(conds,
fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`,
len(args), len(args)))
}
args = append(args, limit)
sqlStr := `
SELECT pa.id, pa.project_id, p.title AS project_title,
p.reference AS project_reference,
pa.name, pa.role, pa.representative
FROM paliad.parties pa
JOIN paliad.projects p ON p.id = pa.project_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY pa.updated_at DESC
LIMIT $` + fmt.Sprintf("%d", len(args))
hits := []PartySearchHit{}
if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil {
return nil, fmt.Errorf("search parties: %w", err)
}
return hits, nil
}
// ListForProject returns all Parties for the Project, visibility-checked.
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {

View File

@@ -97,6 +97,58 @@ func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
}
}
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
// pass through applyLookaheadCap untouched — they're not "future
// predicted" rows by either Status or Date semantics, so they belong in
// the pass-through bucket alongside court_set / undated rows. The cap
// must NOT consume one of its slots for a conditional row, and the
// row must survive even when projTotal exceeds the cap.
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// Three predicted future — cap=2 means the third drops.
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
// Two conditional — must survive uncapped, must NOT count
// against projTotal / projShown.
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
}
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
if total != 3 {
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
}
if shown != 2 {
t.Errorf("ProjectedShown = %d, want 2", shown)
}
if overdue != 0 {
t.Errorf("PredictedOverdue = %d, want 0", overdue)
}
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
if len(kept) != 4 {
t.Errorf("kept rows = %d, want 4", len(kept))
}
keptTitles := map[string]bool{}
for _, r := range kept {
keptTitles[r.Title] = true
}
for _, want := range []string{"F1", "F2", "C1", "C2"} {
if !keptTitles[want] {
t.Errorf("expected kept row %q missing", want)
}
}
if keptTitles["F3"] {
t.Errorf("F3 should have been dropped (cap=2)")
}
}
func TestRuleAnchorKind(t *testing.T) {
hearing := "hearing"
decision := "decision"

View File

@@ -147,6 +147,17 @@ type TimelineEvent struct {
// checkbox). At parent-node levels, rows with BubbleUp=true survive
// the levelPolicy kind/status filter unconditionally.
BubbleUp bool `json:"bubble_up,omitempty"`
// IsConditional marks projected rows whose anchor is uncertain —
// the projection layer mirrors UIDeadline.IsConditional from the
// fristenrechner so the SmartTimeline can render an "abhängig von
// <parent>" chip in place of the date column. When true, Date is
// nil and DependsOnRuleCode / DependsOnRuleName carry the parent
// reference (already populated by annotateDependsOn for projected
// rows; for conditional rows we additionally fall back to the
// UIDeadline-supplied ParentRule* when the parent has no
// computed date). Status is set to "conditional". (t-paliad-289)
IsConditional bool `json:"is_conditional,omitempty"`
}
// LaneInfo describes one column in the parent-node aggregated view.
@@ -933,12 +944,13 @@ func (s *ProjectionService) computeProjections(
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
RuleCode: ui.Code,
DeadlineRuleParty: ui.Party,
IsConditional: ui.IsConditional,
}
idCopy := ruleID
ev.DeadlineRuleID = &idCopy
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
// court-set rules whose date isn't bound yet.
// court-set / conditional rules whose date isn't bound yet.
if ui.DueDate != "" {
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
@@ -946,7 +958,38 @@ func (s *ProjectionService) computeProjections(
}
}
// Conditional rows from the fristenrechner (t-paliad-289):
// pre-stamp the dependency reference here so the row carries
// the "abhängig von <parent>" payload even when the parent has
// no computed date for annotateDependsOn to pick up later.
// annotateDependsOn won't overwrite a non-empty DependsOnRuleCode,
// and the parent's actual date (if anchored elsewhere) still
// flows into DependsOnDate via the actuals-first preference.
if ui.IsConditional && ui.ParentRuleCode != "" {
ev.DependsOnRuleCode = ui.ParentRuleCode
switch lang(opts.Lang) {
case "en":
if ui.ParentRuleNameEN != "" {
ev.DependsOnRuleName = ui.ParentRuleNameEN
} else {
ev.DependsOnRuleName = ui.ParentRuleName
}
default:
ev.DependsOnRuleName = ui.ParentRuleName
}
}
switch {
case ui.IsConditional:
// Anchor uncertain (court-set ancestor without override,
// backward-anchor without forward date, or optional event
// not recorded). Surface as conditional so the frontend
// renders "abhängig von <parent>" in place of a date.
// Conditional rows must not carry a date even if the
// calculator left one — clear it to match the wire contract.
// (t-paliad-289)
ev.Date = nil
ev.Status = "conditional"
case ui.IsCourtSet && ev.Date == nil:
// Pure court-set rule — date is bound by the court at
// hearing/decision time. Surface as undated court_set.

View File

@@ -0,0 +1,79 @@
package services
// Regression tests for the per-draft language column (t-paliad-276).
// The draft's `language` value drives both the placeholder-bag
// language pick (`procedural_event.name` → name_de vs name_en) and the
// template-variant lookup (`{code}.{lang}.docx` fallback chain). These
// tests pin the pure-function pieces — Build wiring needs DB fixtures
// and lives in the handler-layer smoke path.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestNormalizeDraftLanguage(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"de", "de"},
{"DE", "de"},
{" de ", "de"},
{"en", "en"},
{"EN", "en"},
{" en ", "en"},
{"fr", "de"}, // unknown collapses to de (the CHECK-allowed default)
{"", "de"},
{"english", "de"}, // strict — only the canonical two-letter code is accepted
}
for _, c := range cases {
if got := normalizeDraftLanguage(c.in); got != c.want {
t.Errorf("normalizeDraftLanguage(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// The placeholder bag picks the language-matched value for the
// canonical (procedural_event.name) and legacy (rule.name) keys based
// on the lang argument. This pins the wiring used by Build when a
// draft's language overrides the user's UI lang (t-paliad-276).
func TestAddRuleVars_LanguageSelectsMatchedName(t *testing.T) {
t.Parallel()
code := "de.inf.lg.erwidg"
rule := &models.DeadlineRule{
ID: uuid.New(),
SubmissionCode: &code,
Name: "Klageerwiderung",
NameEN: "Statement of Defence",
}
for _, lang := range []string{"de", "en"} {
bag := PlaceholderMap{}
addRuleVars(bag, rule, lang)
want := rule.Name
if strings.EqualFold(lang, "en") {
want = rule.NameEN
}
if got := bag["procedural_event.name"]; got != want {
t.Errorf("lang=%s: procedural_event.name = %q, want %q", lang, got, want)
}
if got := bag["rule.name"]; got != want {
t.Errorf("lang=%s: rule.name = %q, want %q (legacy alias must mirror canonical)", lang, got, want)
}
// The explicit *_de / *_en keys never change — both are always
// emitted so a template can pin one regardless of the draft's
// language. Regression guard against accidentally
// language-gating the explicit variants.
if bag["procedural_event.name_de"] != rule.Name {
t.Errorf("lang=%s: procedural_event.name_de = %q, want %q", lang, bag["procedural_event.name_de"], rule.Name)
}
if bag["procedural_event.name_en"] != rule.NameEN {
t.Errorf("lang=%s: procedural_event.name_en = %q, want %q", lang, bag["procedural_event.name_en"], rule.NameEN)
}
}
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -42,20 +43,33 @@ import (
// 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"`
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"`
// Language is the output language for the generated .docx — 'de' or
// 'en'. Drives the template-variant lookup ({code}.{lang}.docx
// fallback chain) and language-aware variable resolution
// ({{procedural_event.name}} → name_de or name_en). t-paliad-276.
Language string `db:"language" json:"language"`
VariablesRaw []byte `db:"variables" json:"-"`
SelectedPartiesRaw pq.StringArray `db:"selected_parties" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
LastImportedAt *time.Time `db:"last_imported_at" json:"last_imported_at,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"`
// SelectedParties is the parsed uuid form of SelectedPartiesRaw —
// populated on read by decodeSelectedParties(). An empty slice keeps
// the backward-compat "include every party" behaviour; a non-empty
// slice restricts the variable bag to the listed paliad.parties rows.
SelectedParties []uuid.UUID `json:"selected_parties"`
}
// SubmissionDraftService handles CRUD on submission_drafts and exposes
@@ -94,6 +108,15 @@ type DraftPatch struct {
Name *string
Variables *PlaceholderMap
ProjectID **uuid.UUID
// SelectedParties: nil = no change. A non-nil pointer always writes
// the column; pass *p = nil or an empty slice to reset to "include
// every party on the project" (the backward-compat default).
SelectedParties *[]uuid.UUID
// Language sets the output language. Valid values: "de", "en".
// Anything else returns ErrInvalidInput. t-paliad-276.
Language *string
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -106,8 +129,10 @@ var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already tak
// 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,
const draftColumns = `id, project_id, submission_code, user_id, name, language,
variables, selected_parties,
last_exported_at, last_exported_sha,
last_imported_at,
created_at, updated_at`
// List returns every draft for (project, submission_code, user)
@@ -127,7 +152,7 @@ func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uui
return nil, fmt.Errorf("list submission drafts: %w", err)
}
for i := range rows {
if err := rows[i].decodeVariables(); err != nil {
if err := rows[i].decode(); err != nil {
return nil, err
}
}
@@ -157,8 +182,9 @@ type DraftWithProject struct {
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,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
d.variables, d.selected_parties,
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
d.created_at, d.updated_at,
p.title AS project_title,
p.reference AS project_reference
@@ -175,7 +201,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
}
for i := range rows {
if err := rows[i].decodeVariables(); err != nil {
if err := rows[i].decode(); err != nil {
return nil, err
}
}
@@ -213,7 +239,7 @@ func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.U
return nil, err
}
}
if err := d.decodeVariables(); err != nil {
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
@@ -241,7 +267,7 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
if err != nil {
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
@@ -263,17 +289,22 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
if err != nil {
return nil, err
}
// Seed the new draft's output language from the user's UI lang so
// the editor opens in the language the lawyer is already working in.
// Anything other than "en" normalizes to "de" — matches the DB CHECK
// constraint and the project's primary-language default.
draftLang := normalizeDraftLanguage(lang)
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)
(project_id, submission_code, user_id, name, language)
VALUES ($1, $2, $3, $4, $5)
RETURNING `+draftColumns,
projectID, submissionCode, userID, name)
projectID, submissionCode, userID, name, draftLang)
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
@@ -394,6 +425,28 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.SelectedParties != nil {
ids := *patch.SelectedParties
strs := make([]string, 0, len(ids))
for _, id := range ids {
strs = append(strs, id.String())
}
setParts = append(setParts, fmt.Sprintf("selected_parties = $%d::uuid[]", idx))
args = append(args, pq.StringArray(strs))
idx++
}
if patch.Language != nil {
newLang := strings.ToLower(strings.TrimSpace(*patch.Language))
if newLang != "de" && newLang != "en" {
return nil, ErrInvalidInput
}
setParts = append(setParts, fmt.Sprintf("language = $%d", idx))
args = append(args, newLang)
idx++
}
if len(setParts) == 0 {
return existing, nil
}
@@ -415,7 +468,7 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
if err != nil {
return nil, fmt.Errorf("update submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
@@ -436,6 +489,82 @@ func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uui
return nil
}
// ImportFromProject re-pulls every project-derived variable on the
// draft by stripping the lawyer's overrides for those keys and bumping
// `last_imported_at`. Project-derived prefixes today are project.*,
// parties.*, deadline.* and (because the rule is keyed on
// submission_code) procedural_event.* / rule.*; the lawyer's overrides
// for firm.*, today.*, user.* survive because those values aren't
// "imported from the project" in any meaningful sense.
//
// Idempotent on repeat clicks: nothing else mutates on the second
// call apart from the new timestamp. The draft must be owned by the
// caller (Get() applies the same ErrNotFound semantics as the rest of
// the service).
func (s *SubmissionDraftService) ImportFromProject(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
existing, err := s.Get(ctx, userID, draftID)
if err != nil {
return nil, err
}
if existing.ProjectID == nil {
// No project to import from — surface as 400 via ErrInvalidInput.
return nil, fmt.Errorf("%w: cannot import from project on a project-less draft", ErrInvalidInput)
}
// Strip overrides that came from project state.
cleaned := PlaceholderMap{}
for k, v := range existing.Variables {
if isProjectDerivedKey(k) {
continue
}
cleaned[k] = v
}
raw, err := json.Marshal(cleaned)
if err != nil {
return nil, fmt.Errorf("marshal variables: %w", err)
}
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`UPDATE paliad.submission_drafts
SET variables = $1::jsonb,
last_imported_at = now()
WHERE id = $2 AND user_id = $3
RETURNING `+draftColumns,
string(raw), draftID, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionDraftNotFound
}
if err != nil {
return nil, fmt.Errorf("import from project: %w", err)
}
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
}
// isProjectDerivedKey reports whether a placeholder key sources its
// value from the project record (rather than firm-wide or user-wide
// state). The "Aus Projekt importieren" affordance strips overrides
// for exactly these keys so the lawyer's manual edits don't survive
// a re-pull.
func isProjectDerivedKey(key string) bool {
switch {
case strings.HasPrefix(key, "project."):
return true
case strings.HasPrefix(key, "parties."):
return true
case strings.HasPrefix(key, "deadline."):
return true
case strings.HasPrefix(key, "procedural_event."):
return true
case strings.HasPrefix(key, "rule."):
return true
}
return false
}
// 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 {
@@ -461,9 +590,9 @@ func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.
//
// 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)
// 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
@@ -473,9 +602,14 @@ func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.
// 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,
UserID: draft.UserID,
ProjectID: draft.ProjectID,
SubmissionCode: draft.SubmissionCode,
SelectedParties: draft.SelectedParties,
// The draft's language overrides the user's UI lang — the lawyer
// can author an EN draft in a DE-UI session and vice versa
// (t-paliad-276). Empty / unknown falls back to "de".
Lang: normalizeDraftLanguage(draft.Language),
})
if err != nil {
return nil, nil, err
@@ -530,12 +664,13 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
// 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) {
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
pid := projectID
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: &pid,
SubmissionCode: submissionCode,
Lang: normalizeDraftLanguage(lang),
})
if err != nil {
return nil, nil, err
@@ -547,8 +682,17 @@ func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, us
return out, resolved, nil
}
// decode fills the parsed views (Variables, SelectedParties) from the
// raw scan fields. Called by every fetch path so the caller sees both
// populated together.
func (d *SubmissionDraft) decode() error {
if err := d.decodeVariables(); err != nil {
return err
}
return d.decodeSelectedParties()
}
// 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{}
@@ -562,6 +706,41 @@ func (d *SubmissionDraft) decodeVariables() error {
return nil
}
// decodeSelectedParties parses the uuid[] payload from pq.StringArray
// into []uuid.UUID. Unparseable entries are dropped so a single bad
// row never bricks the fetch — the worst case is one extra party
// silently dropped from the selection, which surfaces as it not being
// rendered in the merged document.
func (d *SubmissionDraft) decodeSelectedParties() error {
if len(d.SelectedPartiesRaw) == 0 {
d.SelectedParties = nil
return nil
}
out := make([]uuid.UUID, 0, len(d.SelectedPartiesRaw))
for _, s := range d.SelectedPartiesRaw {
id, err := uuid.Parse(s)
if err != nil {
continue
}
out = append(out, id)
}
d.SelectedParties = out
return nil
}
// normalizeDraftLanguage maps any input to one of the two allowed
// language values for paliad.submission_drafts.language. Anything other
// than "en" (case-insensitive) collapses to "de" — matches the DB CHECK
// constraint, the project's primary-language default, and the seed
// behaviour for existing rows that came in before the column existed.
func normalizeDraftLanguage(lang string) string {
if strings.EqualFold(strings.TrimSpace(lang), "en") {
return "en"
}
return "de"
}
// Compile-time guard: ensure the *models.User reference in the import
// graph doesn't get optimised away by linters. The service doesn't
// dereference User directly — that happens in SubmissionVarsService —

View File

@@ -72,10 +72,24 @@ func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *Pa
// 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.
//
// SelectedParties is the t-paliad-277 multi-party selection: an empty
// or nil slice means "include every party on the project" (the
// backward-compat default that every legacy draft renders with); a
// non-empty slice restricts the variable bag to the listed parties so
// the submission only mentions the chosen subset.
type SubmissionVarsContext struct {
UserID uuid.UUID
ProjectID *uuid.UUID
SubmissionCode string
UserID uuid.UUID
ProjectID *uuid.UUID
SubmissionCode string
SelectedParties []uuid.UUID
// Lang pins the output language for this Build, overriding the
// caller's UI preference (user.Lang). When empty, Build falls back
// to user.Lang so existing callers (the format-only Slice 1 path)
// keep working unchanged. The draft editor passes the per-draft
// `language` column (t-paliad-276) so DE/EN can be picked
// independently of the UI session.
Lang string
}
// SubmissionVarsResult bundles the placeholder map with the lookup
@@ -125,7 +139,15 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return nil, err
}
lang := user.Lang
// Per-call Lang override (t-paliad-276) wins over the user's UI
// language so the draft editor can render an EN .docx from a DE-UI
// session and vice versa. Falls back to the user pref when the
// caller didn't specify, preserving the format-only Slice 1
// behaviour.
lang := strings.ToLower(strings.TrimSpace(in.Lang))
if lang != "de" && lang != "en" {
lang = user.Lang
}
if lang == "" {
lang = "de"
}
@@ -174,7 +196,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
}
addProjectVars(bag, project, pt, lang)
addPartyVars(bag, parties)
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties))
addDeadlineVars(bag, next, project, lang)
out.Project = project
@@ -184,6 +206,30 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return out, nil
}
// filterPartiesBySelection returns the subset of parties whose IDs
// appear in selected. An empty or nil `selected` slice is the
// backward-compat default — every party flows through unchanged. A
// non-empty slice preserves the input ordering of `parties` (which is
// stable by name from PartyService.ListForProject) so the bag's
// "first claimant / first defendant / first other" picks remain
// deterministic for a given project state.
func filterPartiesBySelection(parties []models.Party, selected []uuid.UUID) []models.Party {
if len(selected) == 0 {
return parties
}
allowed := make(map[uuid.UUID]struct{}, len(selected))
for _, id := range selected {
allowed[id] = struct{}{}
}
out := make([]models.Party, 0, len(parties))
for _, p := range parties {
if _, ok := allowed[p.ID]; ok {
out = append(out, p)
}
}
return out
}
// loadPublishedRule fetches the published procedural-event template
// (paliad.deadline_rules row) keyed by submission_code. Restricts to
// lifecycle_state='published' so drafts never end up shaping a real
@@ -324,42 +370,98 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
}
}
// 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.
// addPartyVars populates the parties.* namespace from the (already
// filtered) list of parties.
//
// Three forms coexist per role (claimant / defendant / other) so
// templates authored against any of them keep merging correctly:
//
// - Comma-joined list (t-paliad-277, primary form for multi-party
// suits):
//
// {{parties.claimants}} — all claimants' names
// {{parties.claimants.representatives}}
// {{parties.defendants}} / .representatives
// {{parties.others}} / .representatives
//
// - Indexed access (templates that need the primary individually):
//
// {{parties.claimant.0.name}} / .representative
// {{parties.defendant.0.name}} / .representative
// {{parties.other.0.name}} / .representative
//
// - Flat legacy (kept forever per the issue's backward-compat
// contract; resolves to the FIRST selected party of each role):
//
// {{parties.claimant.name}} / .representative
// {{parties.defendant.name}} / .representative
// {{parties.other.name}} / .representative
//
// Role bucketing matches the prior shape: German strings ("Kläger",
// "Beklagte") and their English equivalents fold into claimant /
// defendant; everything else (Streithelfer, Patentinhaberin, …) flows
// into "other".
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
var claimant, defendant, other *models.Party
var claimants, defendants, others []models.Party
for i := range parties {
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
switch role {
case "claimant", "kläger", "klaeger":
if claimant == nil {
claimant = &parties[i]
}
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
claimants = append(claimants, parties[i])
case "defendant", "beklagter", "beklagte":
if defendant == nil {
defendant = &parties[i]
}
defendants = append(defendants, parties[i])
default:
if other == nil {
other = &parties[i]
}
others = append(others, parties[i])
}
}
if claimant != nil {
bag["parties.claimant.name"] = claimant.Name
bag["parties.claimant.representative"] = derefString(claimant.Representative)
emitPartyGroup(bag, "claimant", "claimants", claimants)
emitPartyGroup(bag, "defendant", "defendants", defendants)
emitPartyGroup(bag, "other", "others", others)
}
// emitPartyGroup writes the three forms (joined list, indexed access,
// flat legacy first-of-role) for a single role bucket. `singular` is
// the legacy/indexed prefix (claimant / defendant / other); `plural`
// is the joined-list prefix (claimants / defendants / others).
func emitPartyGroup(bag PlaceholderMap, singular, plural string, group []models.Party) {
names := make([]string, 0, len(group))
reps := make([]string, 0, len(group))
for _, p := range group {
names = append(names, p.Name)
reps = append(reps, derefString(p.Representative))
}
if defendant != nil {
bag["parties.defendant.name"] = defendant.Name
bag["parties.defendant.representative"] = derefString(defendant.Representative)
bag["parties."+plural] = strings.Join(names, ", ")
bag["parties."+plural+".representatives"] = joinNonEmpty(reps, ", ")
for i, p := range group {
idx := fmt.Sprintf("parties.%s.%d", singular, i)
bag[idx+".name"] = p.Name
bag[idx+".representative"] = derefString(p.Representative)
}
if other != nil {
bag["parties.other.name"] = other.Name
bag["parties.other.representative"] = derefString(other.Representative)
if len(group) > 0 {
first := group[0]
bag["parties."+singular+".name"] = first.Name
bag["parties."+singular+".representative"] = derefString(first.Representative)
}
}
// joinNonEmpty joins a slice with sep but skips empty entries so a
// list of representatives where one party has no representative reads
// as "A, B" instead of "A, , B".
func joinNonEmpty(parts []string, sep string) string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if strings.TrimSpace(p) == "" {
continue
}
out = append(out, p)
}
return strings.Join(out, sep)
}
// addRuleVars populates the procedural-event variable namespace —
// code, name(_en), legal_source (+ pretty form), primary_party, kind.
//

View File

@@ -0,0 +1,200 @@
package services
// Multi-party variable bag tests (t-paliad-277 / m/paliad#109).
//
// Pins the three coexisting forms that addPartyVars emits per role:
//
// - Comma-joined list: parties.claimants / .defendants / .others
// - Indexed access: parties.claimant.0.name, parties.defendant.0.name, …
// - Flat legacy (first-of): parties.claimant.name, parties.defendant.name, …
//
// Also covers filterPartiesBySelection — the empty-selection default
// (every party included) and the non-empty restriction.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func mkParty(name, role, rep string) models.Party {
p := models.Party{
ID: uuid.New(),
Name: name,
}
if role != "" {
r := role
p.Role = &r
}
if rep != "" {
r := rep
p.Representative = &r
}
return p
}
func TestAddPartyVars_MultiPartyMixedRoles(t *testing.T) {
t.Parallel()
parties := []models.Party{
mkParty("Acme Inc.", "claimant", "Maria Schmidt"),
mkParty("Globex GmbH", "claimant", ""),
mkParty("Initech", "defendant", "John Doe"),
mkParty("Streithelferin", "intervenor", ""),
}
bag := PlaceholderMap{}
addPartyVars(bag, parties)
wants := map[string]string{
// Comma-joined per role.
"parties.claimants": "Acme Inc., Globex GmbH",
"parties.claimants.representatives": "Maria Schmidt", // Globex has no rep → skipped from join.
"parties.defendants": "Initech",
"parties.defendants.representatives": "John Doe",
"parties.others": "Streithelferin",
"parties.others.representatives": "",
// Indexed access.
"parties.claimant.0.name": "Acme Inc.",
"parties.claimant.0.representative": "Maria Schmidt",
"parties.claimant.1.name": "Globex GmbH",
"parties.claimant.1.representative": "",
"parties.defendant.0.name": "Initech",
"parties.defendant.0.representative": "John Doe",
"parties.other.0.name": "Streithelferin",
// Flat legacy: first-of-role.
"parties.claimant.name": "Acme Inc.",
"parties.claimant.representative": "Maria Schmidt",
"parties.defendant.name": "Initech",
"parties.defendant.representative": "John Doe",
"parties.other.name": "Streithelferin",
}
for key, want := range wants {
got, ok := bag[key]
if !ok {
t.Errorf("missing key %q in bag", key)
continue
}
if got != want {
t.Errorf("bag[%q] = %q, want %q", key, got, want)
}
}
}
func TestAddPartyVars_GermanRoleStrings(t *testing.T) {
t.Parallel()
// German role strings on real-world data must bucket the same as
// the English equivalents — "Kläger" / "Klägerin" → claimants.
parties := []models.Party{
mkParty("Erika Musterfrau", "Klägerin", ""),
mkParty("Max Mustermann", "Beklagter", ""),
}
bag := PlaceholderMap{}
addPartyVars(bag, parties)
if got := bag["parties.claimants"]; got != "Erika Musterfrau" {
t.Errorf("parties.claimants = %q, want %q", got, "Erika Musterfrau")
}
if got := bag["parties.defendants"]; got != "Max Mustermann" {
t.Errorf("parties.defendants = %q, want %q", got, "Max Mustermann")
}
// Backward-compat: legacy flat alias resolves to the first row of
// the German-bucketed group.
if got := bag["parties.claimant.name"]; got != "Erika Musterfrau" {
t.Errorf("parties.claimant.name = %q, want %q", got, "Erika Musterfrau")
}
}
func TestAddPartyVars_BackwardCompatFlatAliasResolvesFirstRow(t *testing.T) {
t.Parallel()
// Critical guarantee from m/paliad#109: templates that say
// {{parties.claimant.name}} (old shape) must keep merging — they
// resolve to the FIRST selected claimant. Pinning this stops a
// future refactor silently dropping the alias and breaking every
// .docx in the repo.
parties := []models.Party{
mkParty("FirstCo", "claimant", "Repr A"),
mkParty("SecondCo", "claimant", "Repr B"),
}
bag := PlaceholderMap{}
addPartyVars(bag, parties)
if got := bag["parties.claimant.name"]; got != "FirstCo" {
t.Errorf("parties.claimant.name (flat alias) = %q, want %q (first selected claimant)",
got, "FirstCo")
}
if got := bag["parties.claimant.representative"]; got != "Repr A" {
t.Errorf("parties.claimant.representative (flat alias) = %q, want %q",
got, "Repr A")
}
}
func TestFilterPartiesBySelection_EmptyMeansAll(t *testing.T) {
t.Parallel()
parties := []models.Party{
mkParty("A", "claimant", ""),
mkParty("B", "defendant", ""),
}
got := filterPartiesBySelection(parties, nil)
if len(got) != 2 {
t.Fatalf("empty selection should include every party, got %d/%d", len(got), len(parties))
}
got = filterPartiesBySelection(parties, []uuid.UUID{})
if len(got) != 2 {
t.Fatalf("empty []uuid selection should include every party, got %d/%d", len(got), len(parties))
}
}
func TestFilterPartiesBySelection_NonEmptyRestricts(t *testing.T) {
t.Parallel()
a := mkParty("Acme", "claimant", "")
b := mkParty("Initech", "defendant", "")
c := mkParty("Globex", "claimant", "")
parties := []models.Party{a, b, c}
got := filterPartiesBySelection(parties, []uuid.UUID{a.ID, c.ID})
if len(got) != 2 {
t.Fatalf("got %d parties, want 2", len(got))
}
// Order must match the input order (PartyService.ListForProject
// returns by name ascending; we preserve that to keep "first
// claimant" deterministic across renders).
if got[0].ID != a.ID || got[1].ID != c.ID {
t.Errorf("selection lost input order: got %v", []string{got[0].Name, got[1].Name})
}
// The "Initech" defendant was deselected; the bag should not list
// it under defendants.
bag := PlaceholderMap{}
addPartyVars(bag, got)
if v, ok := bag["parties.defendants"]; ok && v != "" {
t.Errorf("parties.defendants = %q after deselecting Initech, want empty", v)
}
if !strings.Contains(bag["parties.claimants"], "Acme") || !strings.Contains(bag["parties.claimants"], "Globex") {
t.Errorf("parties.claimants = %q, want both Acme and Globex", bag["parties.claimants"])
}
}
func TestIsProjectDerivedKey(t *testing.T) {
t.Parallel()
derived := []string{
"project.title", "project.proceeding.name",
"parties.claimants", "parties.claimant.0.name",
"deadline.due_date",
"procedural_event.name", "rule.name",
}
for _, k := range derived {
if !isProjectDerivedKey(k) {
t.Errorf("expected %q to be project-derived", k)
}
}
survives := []string{
"firm.name", "today", "today.long_de",
"user.email", "user.display_name",
}
for _, k := range survives {
if isProjectDerivedKey(k) {
t.Errorf("expected %q to survive Import-from-project (firm/today/user namespace)", k)
}
}
}

View File

@@ -66,8 +66,8 @@ func AgendaSystemView() SystemView {
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
Predicates: &Predicates{
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
},
},
Render: RenderSpec{
@@ -126,14 +126,14 @@ func InboxSystemView() SystemView {
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
Predicates: &Predicates{
ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"},
}},
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
},
ProjectEvent: &ProjectEventPredicates{
EventTypes: InboxProjectEventKinds,
}},
},
},
},
Render: RenderSpec{
@@ -159,10 +159,10 @@ func InboxRequesterSystemView() SystemView {
Sources: []DataSource{SourceApprovalRequest},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
Predicates: &Predicates{
ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "self_requested",
}},
},
},
},
Render: RenderSpec{

View File

@@ -82,11 +82,10 @@ func TestInboxSystemView_RowActionInbox(t *testing.T) {
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
sv := InboxSystemView()
preds := sv.Filter.Predicates[SourceProjectEvent]
if preds.ProjectEvent == nil {
if sv.Filter.Predicates == nil || sv.Filter.Predicates.ProjectEvent == nil {
t.Fatal("InboxSystemView must narrow project_event predicates")
}
got := preds.ProjectEvent.EventTypes
got := sv.Filter.Predicates.ProjectEvent.EventTypes
if len(got) != len(InboxProjectEventKinds) {
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
}

View File

@@ -234,8 +234,8 @@ func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec
uid := userID
df.CreatedBy = &uid
}
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
dp := preds.Deadline
if spec.Predicates != nil && spec.Predicates.Deadline != nil {
dp := spec.Predicates.Deadline
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
// If the spec asks for both pending+completed → no narrowing; if
// only pending → DeadlineFilterPending; only completed → Completed.
@@ -317,8 +317,8 @@ func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, sp
}
af.From = bounds.from
af.To = bounds.to
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
ap := preds.Appointment
if spec.Predicates != nil && spec.Predicates.Appointment != nil {
ap := spec.Predicates.Appointment
// AppointmentListFilter takes a single Type today; narrow to first
// listed value, fall back to all if multiple.
if len(ap.AppointmentTypes) == 1 {
@@ -482,21 +482,24 @@ func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, s
// ApprovalService inbox queries. ViewerRole picks which underlying
// query runs.
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
preds := spec.Predicates[SourceApprovalRequest]
var ap *ApprovalRequestPredicates
if spec.Predicates != nil {
ap = spec.Predicates.ApprovalRequest
}
role := "approver_eligible"
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
role = preds.ApprovalRequest.ViewerRole
if ap != nil && ap.ViewerRole != "" {
role = ap.ViewerRole
}
filter := InboxFilter{}
if preds.ApprovalRequest != nil {
if ap != nil {
// InboxFilter takes a single status today. If the spec says
// only one, narrow; if multiple, leave open.
if len(preds.ApprovalRequest.Status) == 1 {
filter.Status = preds.ApprovalRequest.Status[0]
if len(ap.Status) == 1 {
filter.Status = ap.Status[0]
}
if len(preds.ApprovalRequest.EntityTypes) == 1 {
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
if len(ap.EntityTypes) == 1 {
filter.EntityType = ap.EntityTypes[0]
}
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
@@ -665,19 +668,18 @@ func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
// approvalStatusMatches checks the entity-side approval_status filter.
// Returns true when the row passes (no filter set → always true).
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
preds, ok := spec.Predicates[src]
if !ok {
if spec.Predicates == nil {
return true
}
var allowed []string
switch src {
case SourceDeadline:
if preds.Deadline != nil {
allowed = preds.Deadline.ApprovalStatus
if spec.Predicates.Deadline != nil {
allowed = spec.Predicates.Deadline.ApprovalStatus
}
case SourceAppointment:
if preds.Appointment != nil {
allowed = preds.Appointment.ApprovalStatus
if spec.Predicates.Appointment != nil {
allowed = spec.Predicates.Appointment.ApprovalStatus
}
}
if len(allowed) == 0 {
@@ -689,15 +691,15 @@ func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bo
// allowedAppointmentTypes returns nil when the filter is open, otherwise
// a set of legal appointment_type values.
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceAppointment]
if !ok || preds.Appointment == nil {
if spec.Predicates == nil || spec.Predicates.Appointment == nil {
return nil
}
if len(preds.Appointment.AppointmentTypes) <= 1 {
ap := spec.Predicates.Appointment
if len(ap.AppointmentTypes) <= 1 {
return nil // single-value already pushed down via AppointmentListFilter.Type
}
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
for _, t := range preds.Appointment.AppointmentTypes {
out := make(map[string]bool, len(ap.AppointmentTypes))
for _, t := range ap.AppointmentTypes {
out[t] = true
}
return out
@@ -712,13 +714,16 @@ func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
// don't want both rows showing up side-by-side. The drop applies to
// both the explicit caller list and the implicit "all kinds" path.
func allowedProjectEventKinds(spec FilterSpec) []string {
preds, ok := spec.Predicates[SourceProjectEvent]
var pe *ProjectEventPredicates
if spec.Predicates != nil {
pe = spec.Predicates.ProjectEvent
}
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
var requested []string
switch {
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
requested = preds.ProjectEvent.EventTypes
case pe != nil && len(pe.EventTypes) > 0:
requested = pe.EventTypes
case dedupApprovals:
// No explicit narrowing, but ApprovalRequest is in sources —
// rebuild the implicit "all" list so we can subtract approvals.
@@ -750,30 +755,30 @@ func isApprovalAuditKind(kind string) bool {
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
// already pushed into InboxFilter.Status").
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.Status) <= 1 {
ap := spec.Predicates.ApprovalRequest
if len(ap.Status) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
for _, s := range preds.ApprovalRequest.Status {
out := make(map[string]bool, len(ap.Status))
for _, s := range ap.Status {
out[s] = true
}
return out
}
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
ap := spec.Predicates.ApprovalRequest
if len(ap.EntityTypes) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
for _, t := range preds.ApprovalRequest.EntityTypes {
out := make(map[string]bool, len(ap.EntityTypes))
for _, t := range ap.EntityTypes {
out[t] = true
}
return out

View File

@@ -13,8 +13,8 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
Predicates: map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
Predicates: &Predicates{
ProjectEvent: &ProjectEventPredicates{
EventTypes: []string{
"deadline_created",
"deadline_approval_requested",
@@ -22,7 +22,7 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
"approval_decided",
"note_created",
},
}},
},
},
}
got := allowedProjectEventKinds(spec)
@@ -47,13 +47,13 @@ func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
Predicates: map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
Predicates: &Predicates{
ProjectEvent: &ProjectEventPredicates{
EventTypes: []string{
"deadline_created",
"deadline_approval_requested",
},
}},
},
},
}
got := allowedProjectEventKinds(spec)

View File

@@ -0,0 +1,449 @@
// HL-firm skeleton submission template generator (t-paliad-275).
//
// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA
// macros and template-only artifacts, then emits a clean .docx that:
//
// 1. Preserves every HL paragraph + character style (HLpat-Heading-H1,
// HLpat-Body-B0, HLpat-Signature, HLpat-Table-Recitals-*, …) by
// keeping word/styles.xml, word/theme/*, word/numbering.xml,
// word/fontTable.xml, settings.xml, footnotes/endnotes from the
// source .dotm untouched.
// 2. Preserves the firm letterhead (logo header + firm-address footer)
// by keeping word/header[12].xml + word/footer[12].xml and the
// sectPr that references them.
// 3. Replaces word/document.xml with a Schriftsatz-shaped body that
// exercises every SubmissionVarsService placeholder (firm.*,
// today.*, user.*, project.*, parties.*, procedural_event.*, rule.*,
// deadline.*) — applying HL paragraph/character styles to each
// section so the rendered output reads as a real HL submission with
// variables substituted.
//
// Drop the output into HL/mWorkRepo at
//
// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
//
// so paliad's submission generator picks it up via the fallback chain.
// Lookup order after this CL: per-firm per-code → _firm-skeleton.docx
// (THIS file — HL formatting + placeholders) → universal _skeleton.docx
// (generic skeleton from t-paliad-259) → bare HL Patents Style .dotm
// (no placeholders). See internal/handlers/submission_drafts.go
// resolveSubmissionTemplate.
//
// Why this is firm-specific: the .dotm carries HL-licensed fonts,
// HL-branded logo media, and HLpat-prefixed style IDs. The output lives
// under the firm-namespaced directory in mWorkRepo so a future firm gets
// its own equivalent file generated against its own .dotm.
//
// Run:
//
// go run ./scripts/gen-hl-skeleton-template \
// -in /tmp/hl-patents-style.dotm \
// -out /tmp/_firm-skeleton.docx
//
// Output is byte-stable across runs for a given input (zip mtimes
// pinned).
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
)
func main() {
in := flag.String("in", "", "path to source HL Patents Style .dotm (required)")
out := flag.String("out", "_firm-skeleton.docx", "output .docx path")
flag.Parse()
if *in == "" {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)")
os.Exit(2)
}
srcBytes, err := os.ReadFile(*in)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err)
os.Exit(1)
}
docx, err := buildDocx(srcBytes)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
}
// fixedTime pins every zip entry's mtime so successive runs over the
// same .dotm produce byte-stable output. Useful for diffing the
// generated file in PR review.
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
// dropPaths lists zip entries removed during the .dotm → .docx
// conversion. VBA macros + their keymap binding + the template-only
// glossary parts and ribbon customizations are all dead weight (and
// some actively trigger Word's macro-security warning) — none of them
// add anything to a placeholder-rich Schriftsatz starter.
var dropPaths = map[string]bool{
"word/vbaProject.bin": true,
"word/vbaData.xml": true,
"word/customizations.xml": true,
"userCustomization/customUI.xml": true,
"customUI/customUI14.xml": true,
"word/glossary/document.xml": true,
"word/glossary/_rels/document.xml.rels": true,
"word/glossary/fontTable.xml": true,
"word/glossary/numbering.xml": true,
"word/glossary/settings.xml": true,
"word/glossary/styles.xml": true,
"word/glossary/webSettings.xml": true,
}
// rIdsToDrop names the document-rel ids whose targets are stripped
// from the package (vbaProject, customizations.xml, glossary). They
// must vanish from word/_rels/document.xml.rels so Word doesn't choke
// on a dangling reference.
var rIdsToDrop = map[string]bool{
"rId1": true, // vbaProject.bin
"rId2": true, // customizations.xml (keymap to VBA)
"rId21": true, // glossary/document.xml
}
func buildDocx(src []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return nil, fmt.Errorf("open source zip: %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range zr.File {
name := f.Name
if dropPaths[name] {
continue
}
body, err := readZipEntry(f)
if err != nil {
return nil, fmt.Errorf("read %s: %w", name, err)
}
switch name {
case "[Content_Types].xml":
body = []byte(patchContentTypes(string(body)))
case "_rels/.rels":
body = []byte(patchRootRels(string(body)))
case "word/_rels/document.xml.rels":
body = []byte(patchDocumentRels(string(body)))
case "word/document.xml":
body = []byte(buildDocumentXML())
}
hdr := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("write %s: %w", name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// patchContentTypes rewrites the macroEnabledTemplate part type to the
// regular wordprocessingml.document type (a .dotm carries the macro
// part type even on the body part), and removes Default/Override
// entries that target now-deleted parts (vba binary, customizations,
// glossary).
func patchContentTypes(in string) string {
out := in
out = strings.ReplaceAll(out,
`<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.template.macroEnabledTemplate.main+xml"/>`,
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
removals := []string{
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`,
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`,
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`,
`<Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/>`,
`<Override PartName="/word/glossary/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`,
`<Override PartName="/word/glossary/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`,
`<Override PartName="/word/glossary/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>`,
`<Override PartName="/word/glossary/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/>`,
`<Override PartName="/word/glossary/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>`,
}
for _, r := range removals {
out = strings.ReplaceAll(out, r, "")
}
return out
}
// patchRootRels drops the userCustomization (ribbon mini-tab) and the
// customUI14 extensibility relationships — both reference VBA-backed
// UI we don't ship.
func patchRootRels(in string) string {
out := in
out = stripRelByPrefix(out, `<Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2006/relationships/ui/userCustomization"`)
out = stripRelByPrefix(out, `<Relationship Id="Rf8f70ab1afd0469a" Type="http://schemas.microsoft.com/office/2007/relationships/ui/extensibility"`)
return out
}
// patchDocumentRels drops the document-level rels whose targets we
// stripped (vbaProject, customizations.xml, glossaryDocument).
func patchDocumentRels(in string) string {
out := in
for rid := range rIdsToDrop {
needle := `<Relationship Id="` + rid + `" `
out = stripRelByPrefix(out, needle)
}
return out
}
// stripRelByPrefix removes the full <Relationship .../> element whose
// open tag starts with the given prefix. Tolerates either a regular
// closing tag (</Relationship>) or the more common self-closing form.
func stripRelByPrefix(s, prefix string) string {
for {
start := strings.Index(s, prefix)
if start < 0 {
return s
}
// Find end of this element (next "/>"). The .dotm always uses the
// self-closing form for Relationship elements.
end := strings.Index(s[start:], "/>")
if end < 0 {
return s
}
s = s[:start] + s[start+end+2:]
}
}
// buildDocumentXML emits a Schriftsatz skeleton that exercises every
// SubmissionVarsService placeholder (the canonical 48-key v1 contract
// + the procedural_event.* canonical names + their rule.* legacy
// aliases). The structure mirrors a real DE/UPC submission — title
// block → court → rubrum → patent reference → submission title →
// legal grounds → Sachverhalt/Anträge/Rechtsausführungen/Beweis →
// signature → locale-variant verification footer.
//
// Each placeholder lives in its own <w:r> run so the renderer's pass-1
// (format-preserving single-run replace) catches every key. HL
// paragraph styles (HLpat-Heading-H1, HLpat-Header-Section, etc.) are
// applied via pStyle, character styles via rStyle.
//
// The sectPr at the bottom is copied verbatim from the source .dotm
// so the firm header/footer references (rId16=header1, rId17=footer1,
// rId18=header2 first-page, rId19=footer2 first-page) keep resolving
// after we replace the body. pgSz/pgMar/cols/docGrid match the .dotm
// exactly — a lawyer printing this gets the same A4 layout the .dotm
// produces.
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" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
b.WriteString(`<w:body>`)
skeletonBanner(&b)
heading(&b, "HLpat-Heading-H1", "{{firm.name}}")
body0(&b, "Bearbeiter: {{user.display_name}}")
body0(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
body0(&b, "Datum: {{today.long_de}} ({{today.iso}})")
body0(&b, "{{firm.signature_block}}")
headerSection(&b, "{{project.court}}")
body0(&b, "Aktenzeichen: {{project.case_number}}")
body0(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
body0(&b, "Instanz: {{project.instance_level}}")
headerSubsection(&b, "In der Sache")
recitalsParty(&b, "{{parties.claimant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}")
recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
recitalsSequencer(&b, "gegen")
recitalsParty(&b, "{{parties.defendant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}")
recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
recitalsSequencer(&b, "sowie")
recitalsParty(&b, "{{parties.other.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}")
recitalsRoles(&b, "— Weitere Beteiligte —")
headerSubsection(&b, "Betreff")
body0(&b, "Streitpatent: {{project.patent_number}} (UPC-Schreibweise: {{project.patent_number_upc}})")
body0(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
body0(&b, "Projekttitel: {{project.title}}")
body0(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
body0(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
body0(&b, "Internes Aktenzeichen: {{project.reference}}")
heading(&b, "HLpat-Heading-H1", "{{procedural_event.name}}")
body0(&b, "(Schriftsatz-Code: {{procedural_event.code}})")
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// for custom templates that want them, but the default HL skeleton
// no longer renders them in the submission body: the deadline is
// internal/admin context and has no place in a court-bound document.
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
heading(&b, "HLpat-Heading-H2", "II. Anträge")
requestsIntro(&b, "Es wird beantragt:")
requestsLevel1(&b, "[Antrag 1 — gemäß {{procedural_event.legal_source_pretty}}]")
requestsLevel1(&b, "[Antrag 2]")
heading(&b, "HLpat-Heading-H2", "III. Rechtsausführungen")
body0(&b, "[Hier folgen die Rechtsausführungen.]")
heading(&b, "HLpat-Heading-H2", "IV. Beweis")
evidenceOffering(&b, "Beweis: [Beweismittel — z. B. Anlage K1: {{project.patent_number}}]")
heading(&b, "HLpat-Heading-H2", "Schlussformel")
signature(&b, "{{today.long_de}}")
signature(&b, "")
signature(&b, "{{user.display_name}}")
signature(&b, "{{firm.name}}")
// Locale-aware verification block — exercises every EN/DE alias the
// variable bag carries plus the rule.* legacy aliases so a lawyer
// editing the template sees that both surfaces resolve. A real
// submission deletes this section after sanity-checking the render.
heading(&b, "HLpat-Heading-H3", "Locale-Varianten & Legacy-Aliase (SKELETON)")
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
body1(&b, "Rule legacy aliases — primary_party: {{rule.primary_party}}, event_type: {{rule.event_type}}")
// sectPr — copied verbatim from the source .dotm. Keeps the firm
// letterhead header (rId16=header1.xml, rId18=header2.xml first-page)
// and the firm-address footer (rId17, rId19) on every printed page.
b.WriteString(sectPrXML)
b.WriteString(`</w:body></w:document>`)
return b.String()
}
// sectPrXML matches the source .dotm's section properties exactly so
// the firm header/footer refs and A4 page geometry round-trip.
const sectPrXML = `<w:sectPr><w:headerReference w:type="default" r:id="rId16"/><w:footerReference w:type="default" r:id="rId17"/><w:headerReference w:type="first" r:id="rId18"/><w:footerReference w:type="first" r:id="rId19"/><w:pgSz w:w="11906" w:h="16838" w:code="9"/><w:pgMar w:top="567" w:right="1418" w:bottom="567" w:left="1418" w:header="284" w:footer="284" w:gutter="0"/><w:cols w:space="720"/><w:titlePg/><w:docGrid w:linePitch="286"/></w:sectPr>`
func skeletonBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="HLpat-Heading-H1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)</w:t></w:r></w:p>`)
}
func heading(b *strings.Builder, style, text string) { styledPara(b, style, "", text) }
func headerSection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Section", "", text) }
func headerSubsection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Subsection", "", text) }
func body0(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B0", "", text) }
func body1(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B1", "", text) }
func recitalsParty(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Party", "", text) }
func recitalsPartyDetails(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyDetails", "", text) }
func recitalsRoles(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyRoles", "", text) }
func recitalsSequencer(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Sequencers", "", text) }
func requestsIntro(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Intro", "", text) }
func requestsLevel1(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Level1", "", text) }
func evidenceOffering(b *strings.Builder, text string) { styledPara(b, "HLpat-EvidenceOffering", "", text) }
func signature(b *strings.Builder, text string) { styledPara(b, "HLpat-Signature", "", text) }
// styledPara writes one paragraph with the given pStyle (paragraph
// style id) and optional rStyle (character style applied to every run).
// Empty style ids drop the corresponding wrapper. Placeholders inside
// `text` are split into their own runs so the renderer's pass-1
// single-run replace catches each one independently.
func styledPara(b *strings.Builder, pStyle, rStyle, text string) {
b.WriteString(`<w:p>`)
if pStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(pStyle)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if rStyle != "" {
b.WriteString(`<w:rPr><w:rStyle w:val="`)
b.WriteString(rStyle)
b.WriteString(`"/></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

@@ -137,14 +137,19 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
</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.
// case caption + parties + submission heading + a single neutral body
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
// today.* / user.* / project.* / parties.* / rule.*) 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.
//
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
// default skeleton (t-paliad-287). The deadline is internal context for
// the lawyer, not text that belongs in a court-bound submission. The
// keys stay resolvable in the bag so a custom template can still
// reference them where it actually wants them.
//
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
// (format-preserving, single-run) substitution catches it. The
@@ -194,11 +199,12 @@ func buildDocumentXML() string {
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}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// (lawyer can still drop them into a custom paragraph) but the
// default skeleton no longer renders them in the submission body:
// the deadline is internal/admin context and has no place in a
// document going out to court.
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.]")
@@ -217,7 +223,7 @@ func buildDocumentXML() string {
// 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, "EN long date: {{today.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}}")