Compare commits
11 Commits
mai/curie/
...
mai/ritchi
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d688459e3 | |||
| 058a36976b | |||
| 3219bff4d4 | |||
| 081b66ebc8 | |||
| 9ab8dd8e0f | |||
| 4218d9cb52 | |||
| 7ea415145f | |||
| 109946edff | |||
| 528fe35540 | |||
| 9c2788ed8c | |||
| c56859058d |
@@ -117,9 +117,13 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
|
||||
return fmt.Errorf("mkdir output: %w", err)
|
||||
}
|
||||
|
||||
// 1. Proceeding types — UPC + active only. The unified upc.apl row
|
||||
// 1. Proceeding types — UPC primaries only. The unified upc.apl row
|
||||
// from B1 mig 134 is included; the 3 archived old appeal codes
|
||||
// (is_active=false) are filtered out by the WHERE.
|
||||
// (is_active=false) are filtered out by the is_active predicate.
|
||||
// The kind='proceeding' predicate (mig 153, t-paliad-325) belts the
|
||||
// is_active filter so phase/side_action/meta rows can't slip into
|
||||
// the embedded catalog even if some future deploy re-activates one
|
||||
// for an admin task.
|
||||
var procs []litigationplanner.ProceedingType
|
||||
if err := pool.SelectContext(ctx, &procs, `
|
||||
SELECT id, code, name, name_en, description, jurisdiction,
|
||||
@@ -127,7 +131,9 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target
|
||||
FROM paliad.proceeding_types
|
||||
WHERE jurisdiction = 'UPC' AND is_active = true
|
||||
WHERE jurisdiction = 'UPC'
|
||||
AND is_active = true
|
||||
AND kind = 'proceeding'
|
||||
ORDER BY sort_order, id`); err != nil {
|
||||
return fmt.Errorf("select proceeding_types: %w", err)
|
||||
}
|
||||
|
||||
553
docs/design-fristenrechner-overhaul-2026-05-26.md
Normal file
553
docs/design-fristenrechner-overhaul-2026-05-26.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# Design — Fristenrechner complete UX overhaul (dual-mode + project write-back)
|
||||
|
||||
**Task:** t-paliad-322
|
||||
**Gitea:** m/paliad#146
|
||||
**Inventor:** cronus (shift-1)
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Draft for m's ratification — coder gate held
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Verified against the live youpc Postgres (port 11833, paliad schema) and the live source tree on `mai/cronus/inventor-fristenrechner` @ HEAD.
|
||||
|
||||
### 0.1 Rule-and-event corpus today
|
||||
|
||||
| Table | Active+published rows | Notes |
|
||||
|---|---|---|
|
||||
| `paliad.procedural_events` | 222 (236 total) | The events that anchor a deadline. 4 `event_kind` buckets: `filing`, `hearing`, `decision`, `order`, plus `NULL` for legacy/dpma stragglers. |
|
||||
| `paliad.sequencing_rules` | 231 | The deadlines themselves, anchored on `procedural_event_id` and (sometimes) `trigger_event_id`. 80 carry a `trigger_event_id`, 4 are `is_spawn=true`, 45 are `is_court_set=true`, 18 carry a `condition_expr`. |
|
||||
| `paliad.deadline_concepts` | 57 | Hub layer above events (Klageerhebung, Wiedereinsetzung, …). |
|
||||
| `paliad.proceeding_types` | 46 fristenrechner | 4 jurisdictions: UPC (35), DE (5), EPA (3), DPMA (3). |
|
||||
| `paliad.event_categories` | 125 (103 leaves) | The current cascade tree — 5 user-bucket roots (`cms-eingang`, `muendl-verhandlung`, `beschluss-entscheidung`, `frist-verpasst`, `ich-moechte-einreichen`) + `sonstiges` leaf. UI hides the forward-workflow root (`HIDDEN_CASCADE_ROOTS` in `client/fristenrechner.ts:2605`). |
|
||||
| `paliad.deadlines` | 10 (8 with `sequencing_rule_id`) | Demand-side still tiny. The 2 without `sequencing_rule_id` are manual entries. |
|
||||
|
||||
Live `primary_party` vocabulary on `sequencing_rules`: `claimant`, `defendant`, `both`, `court`, `NULL`. Live `priority` vocabulary: `mandatory`, `recommended`, `optional` (no `informational` rows yet — Phase 2 reserved the slot but seeding is deferred).
|
||||
|
||||
### 0.2 The legacy `deadline_rules` reader is a view
|
||||
|
||||
`paliad.deadline_rules_unified` (mig 139, Slice B.3) is a **view** over `sequencing_rules ⋈ procedural_events ⋈ legal_sources`. All Go calculator paths read through it (see `deadline_rule_service.go:70`). The physical `paliad.deadline_rules` table was dropped in mig 140; the view is the canonical legacy-shape reader. Important for this design: there is no "trigger event" table parallel to events — the rule rows themselves are the things the wizard must land on. `trigger_events` (110 rows) is the youpc-parity legacy table used by `/api/tools/event-deadlines` only.
|
||||
|
||||
### 0.3 The frontend today (`/tools/fristenrechner`)
|
||||
|
||||
Two server-rendered surfaces share the same page (`frontend/src/fristenrechner.tsx`, 657 lines) — the legacy "Procedure mode" (R1 step-list, proceeding picker, trigger date, flag checkboxes) and the **Pathway-B row stack** (`buildRowStack` at `client/fristenrechner.ts:2848`, 4009 lines total). Row stack composes three row kinds via a single `.fristen-row` primitive:
|
||||
|
||||
| Row | Source | Filter or qualifier today |
|
||||
|---|---|---|
|
||||
| R1 Perspective (Beide / Klägerseite / Beklagtenseite) | `currentPerspective`, prefilled from `project.our_side` | hybrid — narrows party-tagged cascade chips AND is used as a column-bucket hint in the result view |
|
||||
| R2 Inbox channel (CMS / beA / Postal / Alle) | `currentInboxChannel` | filter — narrows cascade by forum (CMS → upc, beA → de, …) |
|
||||
| R3..Rn Cascade chain | `event_categories` tree | each step narrows children by `inboxFilterAllowsForums` + `perspectiveAllowsParty` + `cascadeChildAllowsProject` |
|
||||
|
||||
The cascade auto-walks single-child branches under a project context and stops at the first branching point. The user picks a leaf; the leaf's slug feeds `/api/tools/fristenrechner/search?event_category_slug=…` which returns concept cards. Each card expands inline to a calc panel (trigger-date input + flags + computed deadline + "in Akte" CTA).
|
||||
|
||||
### 0.4 What is broken in this UI (m's verdict, 2026-05-26 21:21)
|
||||
|
||||
m's brief in m/paliad#146 enumerates four visible bugs:
|
||||
|
||||
1. **"Beide" as default perspective** is incoherent for the headline use case ("file a deadline because something happened" — you ARE one side).
|
||||
2. **R2 (inbox) does not constrain R3 (cascade)** the way a user expects — picking CMS still leaves "Mündliche Verhandlung" / "Frist verpasst" on the next step. (Cause: those roots have `forums=NULL` in the seed → neutral → visible from every inbox.)
|
||||
3. **Mixed axes** — the form layers filters (forum, inbox channel) on top of qualifiers (event-kind, perspective, proceeding_type) without making the difference visible. The user can't tell which picks narrow and which define.
|
||||
4. **Trigger vs follow-up confusion** — the wizard's purpose is to identify the *trigger event*, then surface the *follow-up deadlines*. Today that split is not reflected in the form. After landing on a leaf, the user gets a flat list of concept cards and has to figure out which one is "the thing that happened" vs "the thing I have to file next".
|
||||
|
||||
m's verdict: "complete overhaul. Should be easy to use."
|
||||
|
||||
### 0.5 Anchor files for the eventual coder
|
||||
|
||||
- `frontend/src/client/fristenrechner.ts` (4009 LoC) — page brain. `buildRowStack` @ L2848, `renderRowStack` @ L3112, `runB1Search` (concept-card render) downstream, `expandCardCalc` @ L1337 (inline calc panel), `openSaveModal` @ L290 + `submitSave` @ L374 (project write-back).
|
||||
- `frontend/src/fristenrechner.tsx` (657 LoC) — server-rendered shell. Contains both the Procedure-mode form **and** the Pathway-B row-stack scaffold. The new design replaces the row-stack scaffold; the procedure-mode form survives.
|
||||
- `internal/handlers/fristenrechner.go` + `_search.go` + `_event_categories.go` — three handler files. `POST /api/tools/fristenrechner` (procedure-mode calc), `GET /search` (concept cards), `GET /event-categories` (cascade tree).
|
||||
- `internal/services/fristenrechner.go` (661 LoC) — calculator adapter to `pkg/litigationplanner`. The calculator is **not** touched by this design.
|
||||
- `internal/handlers/deadlines.go:167` + `services/deadline_service.go:411` (`CreateBulk`) — the project write-back endpoint (`POST /api/projects/{id}/deadlines/bulk`). This survives; the design extends its caller.
|
||||
|
||||
### 0.6 Adjacent design docs to read alongside
|
||||
|
||||
- `docs/design-determinator-row-cascade-2026-05-13.md` — the row-cascade pillars (Project-driven narrowing / Visual hierarchy overhaul / Persistent row stack). This overhaul **keeps** Pillars 2 and 3 and reworks Pillar 1's contract.
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` — the unified `sequencing_rules` model the calculator already runs on.
|
||||
- `docs/audit-fristen-logic-2026-05-13.md` — the trigger-event / Pipeline-A-vs-C distinction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**One page, two complementary entry paths, one result surface, one write-back.**
|
||||
|
||||
```text
|
||||
┌───────────────────────── /tools/fristenrechner ─────────────────────────┐
|
||||
│ │
|
||||
│ ╭──────── Akte / kontextfrei ────────╮ (Step 0 — unchanged today) │
|
||||
│ │ HL-2024-001 ▼ | ohne Akte │ │
|
||||
│ ╰─────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ╭────── Entry mode tabs ──────╮ │
|
||||
│ │ [⚡ Direkt suchen] │ ◀── A: power user, search + chips │
|
||||
│ │ [🧭 Geführt] │ ◀── B: 3-5 question wizard │
|
||||
│ ╰─────────────────────────────╯ │
|
||||
│ │
|
||||
│ ┌── Mode A: Suche ──────────────┐ ┌── Mode B: Wizard ────────────────┐│
|
||||
│ │ search-box ▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢ │ │ R1 Was ist passiert? ✓ filing ││
|
||||
│ │ filter chips: │ │ R2 Forum? ✓ UPC ││
|
||||
│ │ Forum · Proceeding · Event- │ │ R3 Verfahren? ✓ INF ││
|
||||
│ │ Kind · Partei │ │ R4 Welcher Schritt? [active] ││
|
||||
│ │ ┌ Ergebnis-Liste ────────────┐│ │ R5 Welche Seite? ✓ Kläger ││
|
||||
│ │ │ procedural_event hits ││ │ ││
|
||||
│ │ │ [Trigger einrasten →] ││ │ (Direkt-suchen ←) ││
|
||||
│ │ └────────────────────────────┘│ └───────────────────────────────────┘│
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ════ shared from here ═══════════════════════════════════════════════ │
|
||||
│ │
|
||||
│ ┌── Trigger event (locked) ──────────────────────────────────────────┐ │
|
||||
│ │ 📥 Klageschrift wurde eingereicht │ │
|
||||
│ │ upc.inf.cfi · Verletzungsverfahren · Klägerseite │ │
|
||||
│ │ Trigger-Datum: [📅 2026-05-20] (heute) │ │
|
||||
│ │ ändern ↩ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── Folge-Fristen ────────────────────────────────────────────────────┐ │
|
||||
│ │ ◉ MANDATORY (auto-checked) │ │
|
||||
│ │ ☑ Klageerwiderung (3 Monate) — 20.08.2026 — RoP 23 ✏ Datum │ │
|
||||
│ │ ☑ ... │ │
|
||||
│ │ ◇ OPTIONAL │ │
|
||||
│ │ □ Wiedereinsetzungsantrag (R.320) — bei Versäumnis │ │
|
||||
│ │ ◊ CONDITIONAL │ │
|
||||
│ │ □ Erwiderung auf Nichtigkeitswiderklage nur wenn CCR │ │
|
||||
│ │ ⇲ SPAWNED │ │
|
||||
│ │ ☑ Berufung gegen Endurteil (kein Datum) │ │
|
||||
│ │ ╭────────────────────────────╮ │ │
|
||||
│ │ │ 4 ausgewählt → in Akte ▶ │ │ │
|
||||
│ │ ╰────────────────────────────╯ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The two modes never compete: they're two front doors into the **same** locked-trigger-event → follow-up-list → write-back flow.
|
||||
|
||||
---
|
||||
|
||||
## 2. Axis taxonomy — ratified (filters vs qualifiers)
|
||||
|
||||
The headline source of today's UX confusion is the unmarked mixing of *filters* (narrowing the question space without committing to an answer) and *qualifiers* (parts of the eventual deadline definition).
|
||||
|
||||
| Axis | Role | Source | Constrains | Visual in new UI |
|
||||
|---|---|---|---|---|
|
||||
| `forum` | **filter** | derived from `proceeding_types.jurisdiction` (UPC/DE/EPA/DPMA), or from `project.proceeding_type_id`, or user pick | which `proceeding_types` are reachable; which `event_categories` are visible | Mode A: filter chip strip. Mode B: explicit wizard row (R2). Pre-filled + collapsed when there's a project. |
|
||||
| `proceeding_type` | **qualifier** | `project.proceeding_type_id` (binds via mig 096 codes) OR user pick during wizard | the set of `sequencing_rules` rows that can apply | Mode A: filter chip strip. Mode B: explicit wizard row (R3). Pre-filled + collapsed when there's a project. |
|
||||
| `event_kind` | **filter** | `procedural_events.event_kind` (filing / hearing / decision / order) | which `procedural_events` are reachable as triggers | Mode A: filter chip strip. Mode B: explicit wizard row (R1 — the headline question). |
|
||||
| `inbox channel` | **filter** (today) → **out of scope row** (new) | user pick | nothing the user can see (the rule corpus has no "inbox" column; it was only used to recolour the cascade) | Removed from the primary wizard. Pushed into Mode A's secondary chips (off by default). See §3.3. |
|
||||
| `perspective (our_side)` | **qualifier in file-mode**, **filter in explore-mode** | `project.our_side` OR user pick OR implicit-via-event-kind | `sequencing_rules.primary_party`; result-view column bucketing | Wizard tail (R5) **only when** the trigger event's follow-ups actually differ by side. Pre-filled when project has `our_side`. |
|
||||
| `instance_level` (first / appeal / cassation) | qualifier | `project.instance_level` (mig 084) — sparse | rare — used to disambiguate APP+DE | Surfaced only when the wizard hits APP+DE-style ambiguity. |
|
||||
|
||||
**Rule:** a **filter** narrows the visible options without locking in a deadline answer; it can be cleared and re-applied. A **qualifier** is part of the resulting deadline calculation and is locked the moment it's picked. Filters must propagate forward (Mode A's forum-chip narrows the proceeding-chip's options). Qualifiers are picked once and the answer view reads them.
|
||||
|
||||
The "Beide" perspective default (today's bug) is wrong because perspective is a *qualifier* in the headline use case ("file a deadline because something happened — you are one side"), not a *filter*. New default in Mode B: derive from the project's `our_side`, otherwise force a R5 pick (no "Beide"). See Q8 for the explore-mode exception.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mode taxonomy
|
||||
|
||||
### 3.1 Mode A — "⚡ Direkt suchen" (power user)
|
||||
|
||||
Two visually distinct strips (per m §11.Q3):
|
||||
|
||||
```text
|
||||
┌── Filter (eingrenzen) ─────────────────────────────────────────────────┐
|
||||
│ Forum: [UPC] [DE] [EPA] [DPMA] [Alle] │
|
||||
│ Verfahren: [upc.inf.cfi] [...] [Alle] │
|
||||
│ Was passierte: [📥 Eingereicht] [🏛️ Termin] [⚖️ Entscheidung] [📜 Verfügung] [Alle] │
|
||||
│ Partei: [Klägerseite] [Beklagtenseite] [Beide] │
|
||||
├── Suchen ──────────────────────────────────────────────────────────────┤
|
||||
│ 🔎 [_______________________________________________________________] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌── Ergebnisse (klicken = als Trigger einrasten) ────────────────────────┐
|
||||
│ 📥 Klageerhebung · upc.inf.cfi · UPC · 3 Folge-Fristen → │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Single text input, four filter chip strips above it (Forum · Proceeding · Event-Kind · Partei), and a ranked result list of `procedural_events` underneath. The "Filter" strip is visibly grouped (e.g. light background + "Filter (eingrenzen)" header) so users see at a glance that those picks narrow but don't commit; clicking a result row IS the commit (the qualifier action).
|
||||
|
||||
- Search hits `/api/tools/fristenrechner/search` (extended to return events, not just concepts — see §6.1).
|
||||
- Filter chips compose with the text query (`?forum=upc&pt=upc.inf.cfi&kind=filing&party=defendant&q=Klageerwiderung`).
|
||||
- Result rows are individual `procedural_events` (not aggregated concept-cards). Each row shows: name (DE/EN), proceeding_type code, jurisdiction badge, event_kind icon, the rule-count it triggers ("3 Folge-Fristen").
|
||||
- Click a row → "lock as trigger event" → page transitions to the §4 result view.
|
||||
- Power affordance: a row with multiple linked rules can be locked in **per-rule** ("nur diese Frist") via a kebab menu on the row. (Sane default: lock the whole event; the kebab is for the lawyer who only wants one specific reactive deadline.)
|
||||
|
||||
### 3.2 Mode B — "🧭 Geführt" (the wizard)
|
||||
|
||||
A 3-5 question row stack that lands on one `procedural_events` row.
|
||||
|
||||
**Question order (strawman; m to ratify in Q5):**
|
||||
|
||||
1. **R1 — Was ist passiert?** Chips: 📥 Eingereicht (`filing`) · 🏛️ Termin (`hearing`) · ⚖️ Entscheidung (`decision`) · 📜 Verfügung (`order`) · 🕒 Frist versäumt (special bucket, routes to Wiedereinsetzung). One chip = one `event_kind` (or the special). Always asked. ~6 chips, fits one row.
|
||||
2. **R2 — Vor welchem Gericht / bei welchem Amt?** Chips: UPC · LG/OLG/BGH · EPA · DPMA. Pre-filled from `project.proceeding_type → jurisdiction` (or `project.court` substring). **Skipped if R1 narrows to a single forum** (e.g. "Termin" + project has UPC → R2 is implied).
|
||||
3. **R3 — In welchem Verfahren?** Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind. Pre-filled from `project.proceeding_type_id`. **Auto-skipped** when the narrowed scope has only one candidate.
|
||||
4. **R4 — Welches Schriftstück / Welcher Termin?** This is the wizard's landing question. Chips = `procedural_events` filtered by (R2 forum, R3 proceeding_type, R1 event_kind). Typical scope: 1-12 events. If the user types into this row, the chip layout flips to a search list (same widget as Mode A's result list, narrowed to the wizard's filters).
|
||||
5. **R5 — Vertreten Sie Kläger- oder Beklagtenseite?** Asked **only when** the selected event's `sequencing_rules` have follow-ups that differ by `primary_party` (a quick "are there both claimant- and defendant-tagged rules among the follow-ups?" check on the catalog). Pre-filled from `project.our_side`. **Skipped otherwise.**
|
||||
|
||||
**Row badges** (per m §11.Q3): each wizard row carries a small "Filter" or "Qualifier" tag next to its row-number badge. R1 (event_kind), R2 (forum) → "Filter". R3 (proceeding_type), R4 (procedural_event), R5 (perspective) → "Qualifier". A user can tell at a glance which picks lock in vs which narrow.
|
||||
|
||||
Branching policy (locked):
|
||||
|
||||
- Pre-fill + collapse a row when the answer is implied by the project (Determinator §4 pattern, unchanged).
|
||||
- Auto-skip a row when the narrowed scope has exactly one option (the user has effectively no choice). Show the skipped row as a compact `.fristen-row.is-prefilled` line with "(aus Akte)" or "(implizit aus R1)" annotation. *Don't hide the row* — m's "see your selections" pillar from the row-cascade design demands every decision stays visible.
|
||||
- A user-edited upstream answer **preserves compatible downstream picks** (m §11.Q10): if a re-picked R2 (forum) keeps the existing R1 (event_kind) legal, R1 stays; if it makes R3 (proceeding_type) illegal, R3 resets to active. Rows whose pick was carried across an upstream change render with a one-render "erhalten" annotation so the user notices.
|
||||
- "Welches Schriftstück?" (R4) is the landing question. Once R4 is answered, the wizard exits and the §4 result view takes over.
|
||||
|
||||
### 3.3 The dropped `inbox channel` row
|
||||
|
||||
R2-inbox in today's row stack is removed from the primary surface for both modes. Rationale:
|
||||
|
||||
- The rule corpus has no `inbox` column. The cascade's `forums=['cms']` etc. tags were a presentation-layer reflection of which forum naturally arrives on which channel — but the rule itself doesn't change based on whether a UPC document arrived via CMS or by post (it can't; only CMS is legal). So the only honest role for "inbox" is to nudge the forum filter on Mode A.
|
||||
- Mode A keeps inbox as a *secondary* chip strip ("Erweitert" toggle, off by default). Picking CMS auto-sets the forum chip to UPC; picking beA auto-sets it to DE. The user can override.
|
||||
- Mode B never asks. The wizard derives forum from project context or from R2.
|
||||
|
||||
This collapses one bug class entirely (R2-not-constraining-R3) by retiring R2 from the headline path.
|
||||
|
||||
---
|
||||
|
||||
## 4. Shared result view — "follow-up deadlines"
|
||||
|
||||
Once a trigger event is locked (via Mode A click or Mode B R4 pick), the same result view renders.
|
||||
|
||||
### 4.1 Trigger card (sticky header)
|
||||
|
||||
```text
|
||||
┌─ Trigger-Ereignis ─────────────────────────────────────────────────────┐
|
||||
│ 📥 Klageerhebung │
|
||||
│ upc.inf.cfi · Verletzungsverfahren · UPC │
|
||||
│ ⓘ "Einreichung der Klageschrift gemäß R.13 RoP" │
|
||||
│ Trigger-Datum: 📅 2026-05-20 [ändern ↩] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`Trigger-Datum` defaults to today. The user can change it inline (date picker). Changing it re-renders the follow-ups with new computed dates.
|
||||
|
||||
The "ändern" link drops back to whichever mode the user came from with R1-R4 still answered. (Per Q4: the wizard preserves compatible upstream picks rather than rebooting.)
|
||||
|
||||
### 4.2 Follow-up groups
|
||||
|
||||
Group `sequencing_rules` rows that have the trigger event as **anchor** (i.e. `sr.procedural_event_id = trigger.id`) into 4 visible groups:
|
||||
|
||||
1. **MANDATORY** (`priority='mandatory'`) — pre-checked. The bread-and-butter follow-ups.
|
||||
2. **RECOMMENDED** (`priority='recommended'`) — pre-checked. Best-practice fillings (R.19 EPÜ Einspruch, replication briefs).
|
||||
3. **OPTIONAL** (`priority='optional'`) — unchecked. Discretionary actions (R.320 Wiedereinsetzung).
|
||||
4. **CONDITIONAL** (`condition_expr IS NOT NULL`) — unchecked, with the condition rendered ("nur wenn CCR im Verfahren"). Lawyer ticks if applicable.
|
||||
|
||||
Plus a fifth implicit bucket:
|
||||
|
||||
5. **SPAWNED / CROSS-PROCEEDING** (`is_spawn=true`, `spawn_proceeding_type_id IS NOT NULL`) — surfaced as a separate sub-section with a clear "leitet ein neues Verfahren ein" annotation. Pre-checked when mandatory.
|
||||
|
||||
Recommendation (Q6): **4 visible groups, with SPAWNED inlined into whichever priority bucket it belongs to but tagged with a "⇲ neues Verfahren" badge.** Five groups is too many for a one-page result; folding SPAWNED into its priority keeps the math right (mandatory spawned = mandatory) while still flagging the cross-proceeding implication.
|
||||
|
||||
### 4.3 Per-rule row
|
||||
|
||||
```text
|
||||
☑ Klageerwiderung ✏ Datum
|
||||
3 Monate nach Klageerhebung 20.08.2026
|
||||
RoP 23 · Beklagtenseite
|
||||
ⓘ Schriftlich, mit Vollmacht. Erstmaliges Bestreiten der Patentverletzung.
|
||||
```
|
||||
|
||||
Columns: checkbox · title (DE/EN) · duration phrase · computed due date · rule citation · party stance · expandable notes.
|
||||
|
||||
Inline date editor (✏ Datum) lets the lawyer override the computed date for this rule (same affordance as today's `wireDateEditClicks`). The override flows into the write-back payload.
|
||||
|
||||
`is_court_set=true` rules render with the "wird vom Gericht bestimmt" placeholder instead of a date and are unchecked-by-default (matches the current `openSaveModal` behaviour).
|
||||
|
||||
### 4.4 Result-view footer (write-back CTA)
|
||||
|
||||
```text
|
||||
┌─ Auswahl ──────────────────────────────────────────────────────────────┐
|
||||
│ 4 Fristen ausgewählt → In Akte HL-2024-001 eintragen ▶ │
|
||||
│ (oder: 2 mit eigenem Datum, 2 mit Standardberechnung) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The CTA opens a **confirm-and-edit-dates modal** (per m §11.Q6) where the lawyer can revise each selected deadline's due date one last time, then commits via `POST /api/projects/{id}/deadlines/bulk` (today's endpoint).
|
||||
|
||||
**Kontextfrei mode (no Akte)** — per m §11.Q7, the entire write-back footer **does not render** when `project == null`. The result view stays informational. In its place, an inline nudge appears above the deadline groups:
|
||||
|
||||
```text
|
||||
ⓘ Tipp: Wähle oben eine Akte, um diese Fristen einzutragen.
|
||||
```
|
||||
|
||||
The "oben" link focuses the Akte picker. Once a project is picked, the nudge collapses and the footer materialises; no page reload, no result-view rebuild (the trigger event and date persist across the project pick).
|
||||
|
||||
Modal payload per deadline (extends today's `CreateDeadlineInput`):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Klageerwiderung",
|
||||
"rule_code": "RoP 23",
|
||||
"due_date": "2026-08-20",
|
||||
"original_due_date": "2026-08-20",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "<sequencing_rules.id>", /* maps to deadlines.sequencing_rule_id */
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**audit_reason wording (per Q12):** every row inserted via this flow carries an audit-log breadcrumb on the project (matches the deadline `Verlauf` pattern). Default reason string:
|
||||
|
||||
> `Aus Fristenrechner — Trigger: {trigger_event_name} ({trigger_date_iso})`
|
||||
|
||||
e.g. `Aus Fristenrechner — Trigger: Klageerhebung (2026-05-20)`. Falls into `paliad.project_events` with `kind='deadline_created'` via the existing `DeadlineService.CreateBulk` audit hook; no schema change needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. URL / state representation
|
||||
|
||||
The new flow keeps Pathway-B's URL-as-state contract, simplified:
|
||||
|
||||
| Param | Owner | Meaning |
|
||||
|---|---|---|
|
||||
| `project` | Step 0 | Active project UUID. Drives the prefills. |
|
||||
| `mode` | mode tab | `wizard` (default) or `search`. |
|
||||
| `q` | Mode A | Free text query. |
|
||||
| `forum` | Mode A | Comma-separated forum codes (`upc,de`). Mode B writes this only when the wizard derives it. |
|
||||
| `pt` | Mode A | Selected proceeding_type code. |
|
||||
| `kind` | Mode A | event_kind chip pick. |
|
||||
| `party` | both | Perspective. Mode A's chip; Mode B's R5. |
|
||||
| `wizard` | Mode B | Dotted state cursor encoding which row is active and the picks made: `wizard=kind:filing,forum:upc,pt:upc.inf.cfi,active:event`. |
|
||||
| `event` | both | The locked trigger `procedural_events.code`. Once set, the result view renders. |
|
||||
| `trigger_date` | result | ISO date. Default = today; URL only carries it when overridden. |
|
||||
| `selected` | result | Comma-separated `sequencing_rules.id` checkbox state. Only carried when it differs from the priority default. |
|
||||
|
||||
Deep links work end-to-end: `?project=…&event=upc.inf.cfi.soc&trigger_date=2026-05-20&selected=…` jumps a colleague straight to the result view with the same picks. (Per Q11 — query string, not pathname.)
|
||||
|
||||
`popstate` rebuilds the page from the params alone (same pattern as today). The wizard state cursor lets browser back/forward step the wizard rows instead of dropping back to the page root.
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend contract changes
|
||||
|
||||
### 6.1 Extend `/api/tools/fristenrechner/search`
|
||||
|
||||
Today returns concept-cards. Add an alternate response shape (or a `?kind=events` flag) that returns `procedural_events` rows directly:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Klageerhebung",
|
||||
"filters": { "forum": "upc", "pt": null, "kind": "filing", "party": null },
|
||||
"events": [
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"code": "upc.inf.cfi.soc",
|
||||
"name_de": "Klageerhebung",
|
||||
"name_en": "Statement of Claim",
|
||||
"event_kind": "filing",
|
||||
"proceeding_type": { "code": "upc.inf.cfi", "jurisdiction": "UPC", "name": "..." },
|
||||
"follow_up_count": 3,
|
||||
"concept_id": "<uuid>",
|
||||
"score": 0.92
|
||||
}
|
||||
],
|
||||
"total": 12
|
||||
}
|
||||
```
|
||||
|
||||
The concept-card shape stays available for the legacy Pathway-B-filter route (kept as a deep-link compat surface, not user-facing).
|
||||
|
||||
### 6.2 New `/api/tools/fristenrechner/follow-ups`
|
||||
|
||||
Given a trigger event id + trigger date + optional party qualifier, return the follow-up `sequencing_rules` rows, grouped + with computed dates. Wire shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": { "id": "...", "code": "upc.inf.cfi.soc", "name_de": "Klageerhebung", "event_kind": "filing", "proceeding_type": { "code": "upc.inf.cfi", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC" } },
|
||||
"trigger_date": "2026-05-20",
|
||||
"party": "claimant",
|
||||
"follow_ups": [
|
||||
{
|
||||
"rule_id": "<uuid>",
|
||||
"title_de": "Klageerwiderung",
|
||||
"title_en": "Defence",
|
||||
"priority": "mandatory",
|
||||
"primary_party": "defendant",
|
||||
"duration_phrase": "3 Monate",
|
||||
"due_date": "2026-08-20",
|
||||
"is_court_set": false,
|
||||
"is_spawn": false,
|
||||
"condition_expr": null,
|
||||
"rule_code": "RoP 23",
|
||||
"notes_de": "...",
|
||||
"spawn_label": null,
|
||||
"spawn_proceeding_type": null,
|
||||
"appeal_target": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: `FristenrechnerService.LookupFollowUps(ctx, eventID, triggerDate, party)` — wraps `catalog.LookupEvents(axes={EventID:…, Depth:Next})` (already implemented for the Litigation Planner per `services/fristenrechner.go:251`) and runs the result through `pkg/litigationplanner.Calculate` to fill the dates. The calculator is unchanged.
|
||||
|
||||
### 6.3 No schema changes
|
||||
|
||||
This design is pure UX + handler shape. The unified `sequencing_rules` model already has every column needed (priority, condition_expr, spawn_*, is_court_set, primary_party, applies_to_target). No migration accompanies this design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration plan — from current row stack to the overhaul
|
||||
|
||||
Drop nothing on day one; co-exist for one release. The cutover is by URL flag.
|
||||
|
||||
| Phase | What changes | What survives | Branch |
|
||||
|---|---|---|---|
|
||||
| **S1 — Backend** | Add `GET /search?kind=events`. Add `GET /follow-ups`. Both feature-flagged behind a request header (off by default). | Existing endpoints. | one PR |
|
||||
| **S2 — Result view** | New `frontend/src/client/fristenrechner-result.ts` module — given a trigger event + date, render the §4 result view. Mount under a `?overhaul=1` query flag on /tools/fristenrechner. The legacy `renderProcedureResults` stays. | All today's UI. | one PR |
|
||||
| **S3 — Mode A** | New search-with-filter-chips UI. Mount alongside the row stack under `?overhaul=1`. | Row stack still primary. | one PR |
|
||||
| **S4 — Mode B (wizard)** | New `frontend/src/client/fristenrechner-wizard.ts` — the 3-5 row stack. Replaces today's `buildRowStack` only when `?overhaul=1`. Project prefill logic from `buildRowStack` ports 1:1. | The legacy row stack stays in place under no flag. | one PR |
|
||||
| **S5 — Flip the flag** | `?overhaul=1` becomes the default. Legacy row stack and `event_categories`-based cascade rendered with a hard-coded `?legacy=1` for two weeks. | Procedure mode (the upper half of `fristenrechner.tsx`) is unchanged throughout. | one PR |
|
||||
| **S6 — Cleanup** | Drop the `buildRowStack` function tree and the `event_categories`-served cascade endpoint (the table can stay — it's still semantically a useful taxonomy for future tools, just not the Fristenrechner's UI). Drop the `HIDDEN_CASCADE_ROOTS` constant and the cascade-segment bridge. | None of today's row-stack code. | one PR |
|
||||
|
||||
Single project per slice; each PR rebases off main; no shared branches.
|
||||
|
||||
The `event_categories` table itself **stays** — `audit-fristen-logic-2026-05-13.md` §2.4 already calls it "a config layer" useful for taxonomy work. The Fristenrechner just no longer reads it. Future tools (the "Ich möchte einreichen" forward-workflow surface m hid in `HIDDEN_CASCADE_ROOTS`) can resurrect it without DB migration.
|
||||
|
||||
---
|
||||
|
||||
## 8. Worked example — "PA at LG Düsseldorf bekommt einen Hinweisbeschluss via CMS in einer aktiven Akte"
|
||||
|
||||
Project: `HL-2024-001`, proceeding_type=`de.inf.lg` (Verletzungsverfahren LG), `our_side='defendant'`, `court='LG Düsseldorf'`.
|
||||
|
||||
### 8.1 Wizard path (Mode B, default)
|
||||
|
||||
User opens /tools/fristenrechner with that project in Step 0. Mode tab defaults to "🧭 Geführt".
|
||||
|
||||
Wizard rows render top-to-bottom, pre-filled where the project implies:
|
||||
|
||||
```text
|
||||
[1] Was ist passiert? [ active — chips for filing/hearing/decision/order/missed ]
|
||||
[2] Vor welchem Gericht? ✓ LG (aus Akte: HL-2024-001) ← prefilled+collapsed
|
||||
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled+collapsed
|
||||
```
|
||||
|
||||
User clicks ⚖️ Entscheidung in R1.
|
||||
|
||||
Row stack updates:
|
||||
```text
|
||||
[1] Was ist passiert? ✓ Entscheidung ← answered
|
||||
[2] Vor welchem Gericht? ✓ LG (aus Akte) ← prefilled
|
||||
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled
|
||||
[4] Welche Entscheidung konkret? [ active — chips: Urteil, Beschluss, Hinweisbeschluss, ... ]
|
||||
```
|
||||
|
||||
R4 chip set is the `procedural_events` whose `proceeding_type_id` = de.inf.lg AND `event_kind` = 'decision'. (Hinweisbeschluss is in this set — `de.inf.lg.hinweisbeschluss` or similar.)
|
||||
|
||||
User clicks Hinweisbeschluss. The wizard checks: do the follow-up rules differ by `primary_party`? In this case yes (the Hinweis triggers a reply window for the defendant only). So R5 fires:
|
||||
|
||||
```text
|
||||
[5] Welche Seite vertreten Sie? ✓ Beklagtenseite (aus Akte) ← prefilled
|
||||
```
|
||||
|
||||
R5 is pre-filled from `project.our_side='defendant'`. The user could click ändern to override, but doesn't.
|
||||
|
||||
Wizard transitions to the §4 result view. Trigger card: "📜 Hinweisbeschluss · de.inf.lg · LG · Beklagtenseite". Trigger date defaults to today.
|
||||
|
||||
### 8.2 Result view
|
||||
|
||||
Three follow-ups in scope (illustrative):
|
||||
|
||||
```text
|
||||
MANDATORY
|
||||
☑ Stellungnahme zum Hinweisbeschluss (Frist 4 Wochen) — 24.06.2026 — ZPO §139
|
||||
RECOMMENDED
|
||||
☑ Anpassung der Klageerwiderung — 24.06.2026 — best practice
|
||||
OPTIONAL
|
||||
□ Antrag auf Fristverlängerung (begründet) — auf Antrag
|
||||
```
|
||||
|
||||
User unchecks "Anpassung", changes the Stellungnahme date inline to 2026-06-20 (one weekday earlier), clicks "In Akte HL-2024-001 eintragen ▶".
|
||||
|
||||
Modal opens with the 1 selected deadline + the user's date override. User confirms.
|
||||
|
||||
### 8.3 Write-back
|
||||
|
||||
Server-side: `POST /api/projects/HL-2024-001/deadlines/bulk` with one `CreateDeadlineInput`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Stellungnahme zum Hinweisbeschluss",
|
||||
"rule_code": "ZPO §139",
|
||||
"due_date": "2026-06-20",
|
||||
"original_due_date": "2026-06-24",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "<sr-uuid>",
|
||||
"notes": null
|
||||
}
|
||||
```
|
||||
|
||||
`DeadlineService.CreateBulk` inserts the row into `paliad.deadlines` (with `sequencing_rule_id` populated from `rule_id`), creates the audit event with the wording "Aus Fristenrechner — Trigger: Hinweisbeschluss (2026-05-26)", and the user is redirected to `/deadlines?project_id=…` with a green success toast.
|
||||
|
||||
### 8.4 Mode A path for the same user
|
||||
|
||||
User flips the mode tab to "⚡ Direkt suchen". Filter chips auto-load to Forum=DE + Proceeding=de.inf.lg (from project context). User types "Hinweis" — the result list shows `de.inf.lg.hinweisbeschluss` (and maybe `upc.inf.cfi.hinweis` filtered out because Forum=DE narrows it). User clicks. Same result view appears.
|
||||
|
||||
Total clicks Mode A: 2 (type + click). Mode B: 2 (R1 chip + R4 chip; R2/R3/R5 prefilled). The wizard wins for trainees who don't know vocabulary; search wins for power users who know "Hinweisbeschluss" and can type 4 chars.
|
||||
|
||||
---
|
||||
|
||||
## 9. What's NOT in scope
|
||||
|
||||
- **Replacing the `sequencing_rules` model.** Phase 3 schema is already what the calculator runs on.
|
||||
- **Paliadin (LLM) integration into the wizard.** A "Frist-Extraktion aus Dokument" path is filed elsewhere (memory `b6a11b55…`) and stays out of this design. The wizard could later call out to Paliadin for "the user typed something we don't know" — Phase 2 of *this* overhaul, not Phase 1.
|
||||
- **Calendar / Outlook sync** of created deadlines. Separate t-paliad ticket per project-status.md long-term goals.
|
||||
- **Editing `sequencing_rules`** from the result view. Read-only here. The admin surface at `/admin/procedural-events` handles editing.
|
||||
- **The Procedure-mode surface** (upper half of `fristenrechner.tsx`). The proceeding-picker + trigger-date + flag-checkbox UI stays exactly as it is today. That surface answers a different question ("show me the full procedural ablauf for upc.inf.cfi") and is the right tool for that question; the overhaul targets only the Pathway-B / row-stack half of the page.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions for m (12 questions, batched for `AskUserQuestion`)
|
||||
|
||||
All 12 questions tracked in m/paliad#146 § "Open design questions". Each gets a recommended option (listed first in the AskUserQuestion call). Bundled into 3 batches of 4.
|
||||
|
||||
| # | Topic | Recommended pick |
|
||||
|---|---|---|
|
||||
| Q1 | Single page or stepper? | Single page with mode-tabs + collapsible rows. |
|
||||
| Q2 | Mode switcher placement | Tab pair under Step-0 ("Akte / kontextfrei"). |
|
||||
| Q3 | Filter-vs-qualifier UX | Qualifiers carry a small "(Pflichtangabe)" tag; filters render in a slimmer pill. |
|
||||
| Q4 | Cascade tree (keep/replace) | Replace with the 5-question wizard. Drop `event_categories` from the Fristenrechner UI (table stays). |
|
||||
| Q5 | Result grouping | 4 visible groups (Mandatory / Recommended / Optional / Conditional), SPAWNED folded with badge. |
|
||||
| Q6 | Project write-back UX | Confirm-and-edit-dates modal (revise each date once before commit). |
|
||||
| Q7 | No-project mode | CTA disabled with hint ("Wähle eine Akte oben"). Match today's pattern. |
|
||||
| Q8 | Perspective semantics by mode | Mode B (file): qualifier — required pick. Mode A (search): filter — optional. |
|
||||
| Q9 | Trigger-date input timing | In the result-view trigger card; default today; inline editable. |
|
||||
| Q10 | Backward navigation | Preserve compatible downstream picks; reset only those invalidated. |
|
||||
| Q11 | Deep-link encoding | Query string (`?event=…&trigger_date=…`). |
|
||||
| Q12 | Audit reason wording | `Aus Fristenrechner — Trigger: {name} ({date})`. |
|
||||
|
||||
(Recommendations land as the "first option" in each AskUserQuestion call per the inventor SKILL contract.)
|
||||
|
||||
---
|
||||
|
||||
## 11. m's decisions (2026-05-26)
|
||||
|
||||
All 12 questions answered via `AskUserQuestion` on 2026-05-26 21:30. Recording each pick + the reasoning where it diverges from the inventor's recommendation. Sections of the design that are now load-bearing on these answers carry a "(m §11.Q{n})" cross-reference.
|
||||
|
||||
- **Q1 (Page layout): Single page, mode-tabs.** [= recommendation] Both modes share /tools/fristenrechner; the mode-tabs swap the question surface in place. Result view is shared. **Locks §3, §4, §5.**
|
||||
- **Q2 (Mode switcher): Tab pair under Step-0.** [= recommendation] "⚡ Direkt suchen" / "🧭 Geführt" tabs render directly below the Akte picker. Project context survives the tab flip; compatible filter picks (forum, proceeding) carry across.
|
||||
- **Q3 (Filter-vs-qualifier UX): Section split — Filter above, Qualifier below.** [≠ recommendation; m picked option 2.] Mode A's filter chips render in a "Filter (eingrenzen)" strip on top; below it, the result list is the qualifier surface (clicking a row locks). Mode B wizard rows carry a small "Filter" / "Qualifier" badge in the row badge area (e.g. R1/R2 = Filter, R3/R5 = Qualifier). The "(Pflichtangabe)" tag from the original recommendation is replaced by this section-level visual hierarchy. **Updates §3.1 (Mode A layout) and §3.2 (wizard row badges).**
|
||||
- **Q4 (Cascade tree): Replace with wizard, keep table.** [= recommendation] The Fristenrechner UI stops reading `paliad.event_categories`. The table stays for future tools (the hidden "Ich möchte einreichen" forward-workflow). **Locks §3.2 and the cleanup in §7 S6.**
|
||||
- **Q5 (Result grouping): 4 groups + SPAWNED badge.** [= recommendation] Mandatory / Recommended / Optional / Conditional are the four sub-sections; spawned rules fold into their priority bucket with a `⇲ neues Verfahren` badge. **Locks §4.2.**
|
||||
- **Q6 (Write-back UX): Confirm-and-edit-dates modal.** [= recommendation] Inline checkbox selection in the result view → "In Akte eintragen ▶" → modal with editable due-date fields per row + Akte picker. **Locks §4.4.**
|
||||
- **Q7 (No-project mode): Hide the CTA entirely.** [≠ recommendation; m picked option 3.] In kontextfrei mode the result view renders without the write-back footer at all — no disabled-with-hint button. Rationale (inferred from m's pick): the result view is informational by design in explore mode, and a permanently-disabled CTA is visual noise. **Updates §4.4** — the CTA is conditional on `project != null`, not on `disabled`. The hint message moves into the Step-0 picker: when a user is in kontextfrei mode and reaches a result view, a one-line nudge appears above the result groups ("Tipp: Wähle oben eine Akte, um diese Fristen einzutragen") with a link to focus the Akte picker. This preserves the affordance discovery without polluting the footer.
|
||||
- **Q8 (Perspective semantics): Mode B qualifier, Mode A filter.** [= recommendation] Wizard Mode B's R5 is required and Klagerseite/Beklagtenseite only (no "Beide"); Mode A's perspective chip is a filter with a "Beide" option, off by default. **Locks §2 axis table and §3.2 R5 description.**
|
||||
- **Q9 (Trigger-date input): In the result-view trigger card.** [= recommendation] The sticky header card on the result view shows the date; default today; inline editable. Changing it re-renders follow-up dates live. **Locks §4.1.**
|
||||
- **Q10 (Backward navigation): Preserve compatible picks.** [= recommendation] Re-opening any wizard row keeps downstream picks that are still legal under the new upstream value; resets only the picks the new value invalidates. A small chip-strip annotation ("erhalten") appears for one render-cycle on rows whose pick was carried so the user notices. **Updates §3.2 branching policy.**
|
||||
- **Q11 (Deep-link encoding): Query string.** [= recommendation] `?project=…&mode=…&event=…&trigger_date=…&selected=…&forum=…&pt=…&kind=…&party=…` — every state piece is a query param. `popstate` rebuilds the page from params. **Locks §5.**
|
||||
- **Q12 (Audit reason wording): `Aus Fristenrechner — Trigger: {name} ({date})`.** [= recommendation] German-locale, includes the trigger event name and its ISO date. Stored as `paliad.project_events.metadata->>'audit_reason'` via the existing `DeadlineService.CreateBulk` audit hook. **Locks §4.4.**
|
||||
|
||||
### 11.1 What changed from the strawman as a result
|
||||
|
||||
Two follow-on edits flow from m's picks:
|
||||
|
||||
1. **§3.1 Mode A layout** — top strip is "Filter (eingrenzen)" with the four filter chip groups (Forum · Proceeding · Event-Kind · Partei); the result list directly below carries the implicit "click here to lock" qualifier action. No "(Pflichtangabe)" tag.
|
||||
2. **§4.4 Write-back footer** — the footer is rendered conditionally on `project != null`. The kontextfrei-mode informational nudge moves into the result view body above the deadline groups.
|
||||
|
||||
These edits don't change the §7 migration plan or the §6 backend contracts.
|
||||
|
||||
---
|
||||
|
||||
## 12. Synthesis links
|
||||
|
||||
- mBrian topic: `topic-fristenrechner` (existing) — file this design as a `[synthesis]` node linked `triggered_by` t-paliad-322 and `related_to` the row-cascade + Phase 2 designs.
|
||||
- Related memories: row-cascade design `0fbd2c1a-…`, Phase 2 design `a454dc86-…`, audit logic `f6c0c3a2-…`.
|
||||
580
docs/design-proceeding-types-taxonomy-2026-05-26.md
Normal file
580
docs/design-proceeding-types-taxonomy-2026-05-26.md
Normal file
@@ -0,0 +1,580 @@
|
||||
# Design — `paliad.proceeding_types` taxonomy cleanup: primary proceedings vs phases vs side-actions vs meta
|
||||
|
||||
**Task:** t-paliad-324
|
||||
**Gitea:** m/paliad#147
|
||||
**Inventor:** atlas (shift-1)
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Draft — coder gate held until m ratifies the 10 design questions in §9
|
||||
**Branch:** `mai/atlas/inventor-proceeding`
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Verified against live youpc Postgres (port 11833, `paliad` schema) on 2026-05-26 22:05. Findings supersede the audit grouping in m/paliad#147 wherever they diverge — the issue body was correct on shape but conservative on counts.
|
||||
|
||||
### 0.1 The 46-row table, fully classified by usage
|
||||
|
||||
`paliad.proceeding_types` has 49 rows total; 46 active, 3 inactive (`upc.apl.merits/cost/order` — superseded by `upc.apl.unified`, id 160) plus 1 archive bucket (`_archived_litigation`, id 32). Cross-references against the four downstream consumers:
|
||||
|
||||
| Consumer | Column | Active rows that point at the 46 active types |
|
||||
|---|---|---|
|
||||
| `paliad.sequencing_rules.proceeding_type_id` | rule's anchor proceeding | **18 distinct rows used** — the primaries with corpus. 28 rows have 0 rules. |
|
||||
| `paliad.sequencing_rules.spawn_proceeding_type_id` | cross-proceeding spawn target | **1 distinct row used** — `upc.apl.merits` (id=11, **inactive!**). 0 active types are spawn targets. |
|
||||
| `paliad.projects.proceeding_type_id` | project's primary type | **6 distinct rows used** (across 18 projects). All 6 are in the 18 primaries. |
|
||||
| `paliad.event_category_concepts.proceeding_type_code` | concept's owning proceeding | **18 distinct codes used.** 3 of those codes (`upc.apl.merits`, `upc.apl.order`, `upc.apl.cost`) point at **inactive** rows — pre-existing data drift from the `upc.apl.unified` merger (flagged §8, out of scope here). |
|
||||
|
||||
The audit answer in one sentence: **of the 46 active rows, only 18 have any downstream consumer pointing at them today** (the 18 primaries with corpus). The remaining 28 rows are decorative — they exist in the table but nothing references them.
|
||||
|
||||
This makes reparenting **trivially safe**: no FK invariant breaks, no SQL update touches existing data, no migration risk.
|
||||
|
||||
### 0.2 The 18 primaries with corpus (rules + concepts)
|
||||
|
||||
Ordered by `paliad.sequencing_rules` count (descending), with `event_category_concepts` count alongside:
|
||||
|
||||
| id | code | jurisdiction | rules | concepts | projects |
|
||||
|---:|---|---|---:|---:|---:|
|
||||
| 8 | `upc.inf.cfi` | UPC | 25 | 14 | 1 |
|
||||
| 9 | `upc.rev.cfi` | UPC | 17 | 10 | 0 |
|
||||
| 160 | `upc.apl.unified` | UPC | 16 | 0 *(see drift note)* | 0 |
|
||||
| 12 | `de.inf.lg` | DE | 11 | 4 | 1 |
|
||||
| 13 | `de.null.bpatg` | DE | 10 | 4 | 1 |
|
||||
| 14 | `epa.opp.opd` | EPA | 8 | 7 | 1 |
|
||||
| 15 | `epa.opp.boa` | EPA | 8 | 12 | 0 |
|
||||
| 16 | `epa.grant.exa` | EPA | 8 | 0 | 0 |
|
||||
| 17 | `upc.dmgs.cfi` | UPC | 8 | 1 | 0 |
|
||||
| 26 | `de.inf.bgh` | DE | 8 | 17 | 0 |
|
||||
| 25 | `de.inf.olg` | DE | 7 | 8 | 0 |
|
||||
| 10 | `upc.pi.cfi` | UPC | 7 | 3 | 0 |
|
||||
| 27 | `de.null.bgh` | DE | 6 | 10 | 0 |
|
||||
| 29 | `dpma.appeal.bpatg` | DPMA | 5 | 6 | 0 |
|
||||
| 30 | `dpma.appeal.bgh` | DPMA | 4 | 8 | 0 |
|
||||
| 28 | `dpma.opp.dpma` | DPMA | 4 | 3 | 1 |
|
||||
| 18 | `upc.disc.cfi` | UPC | 4 | 1 | 0 |
|
||||
| 35 | `upc.ccr.cfi` | UPC | 1 | 0 | 1 |
|
||||
|
||||
These 18 are unambiguously **primary proceedings** in the m/paliad#147 sense — self-contained matters, own filing, own deadline cascade, own ablauf. They survive every model.
|
||||
|
||||
### 0.3 The 4 unloaded primaries (Group A continued)
|
||||
|
||||
Four more active rows are conceptually primaries but carry **zero rules and zero concepts today** — seeded for catalog completeness, waiting for corpus:
|
||||
|
||||
| id | code | jurisdiction | what it is |
|
||||
|---:|---|---|---|
|
||||
| 171 | `upc.dni.cfi` | UPC | Negative Feststellungsklage — standalone declaratory action |
|
||||
| 172 | `upc.epo.review` | UPC | Überprüfung von EPA-Entscheidungen — standalone review action |
|
||||
| 179 | `upc.bsv.cfi` | UPC | Beweissicherung / saisie — standalone evidence-preservation order |
|
||||
| 188 | `upc.pl.cfi` | UPC | Schutzschrift — pre-litigation defensive filing |
|
||||
|
||||
These are **primary** by character (each has its own RoP-defined filing pathway and its own deadline tree once rules get seeded) but **unloaded** today. Decision: keep them as `kind='proceeding'` so Mode B R3 surfaces them for future rule attachment and `pkg/litigationplanner` accepts them as valid catalog codes.
|
||||
|
||||
§9 Q3.b discusses `upc.pl.cfi` (it's the only borderline — Schutzschrift is technically a pre-action filing, not a proceeding at the time of filing). m's call.
|
||||
|
||||
### 0.4 The 28 non-primary rows
|
||||
|
||||
The 28 active rows that have **zero rules + zero concepts + zero projects pointing at them** group cleanly into three categories:
|
||||
|
||||
#### Group B — Phases of a primary CFI proceeding (5 rows)
|
||||
|
||||
These describe stages *within* an existing CFI proceeding, not standalone matters. A `upc.inf.cfi` action passes through interim → oral → decision phases; the phase isn't a separately-elected proceeding type.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 173 | `upc.cfi.interim` | CFI - Zwischenverfahren |
|
||||
| 174 | `upc.cfi.oral` | CFI - Mündliche Verhandlung |
|
||||
| 175 | `upc.cfi.decision` | CFI - Endentscheidung |
|
||||
| 176 | `upc.costs.cfi` | Separate Kostenentscheidung *(post-decision sub-phase)* |
|
||||
| 185 | `upc.default.cfi` | Versäumnisentscheidung *(alt. decision outcome)* |
|
||||
|
||||
The "phase" concept already has a natural home in the data model: `paliad.procedural_events.event_kind` (filing/hearing/decision/order). What `upc.cfi.interim` actually represents is "all events with kind=filing under upc.inf.cfi/upc.rev.cfi/upc.pi.cfi/etc."; `upc.cfi.oral` is "all events with kind=hearing"; `upc.cfi.decision` is "all events with kind=decision". The proceeding-type row buys nothing the event_kind already carries.
|
||||
|
||||
#### Group C — Side-actions inside a proceeding (10 rows)
|
||||
|
||||
Applications and court orders that arise *inside* a primary proceeding. They could each become a `condition_expr`-gated rule on the parent proceeding when corpus arrives; they don't need their own proceeding row.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 178 | `upc.evidence.cfi` | Beweisanordnungen (allgemein) |
|
||||
| 182 | `upc.experiments.cfi` | Gerichtlich angeordnete Versuche |
|
||||
| 177 | `upc.security.cfi` | Sicherheitsleistung |
|
||||
| 184 | `upc.intervention.rop` | Streitbeitritt |
|
||||
| 165 | `upc.parties.change` | Parteiwechsel / Patentübergang |
|
||||
| 170 | `upc.optout.cfi` | Antrag auf Opt-out |
|
||||
| 180 | `upc.inspection.cfi` | Besichtigungsantrag |
|
||||
| 181 | `upc.freezing.cfi` | Anordnung zur Vermögenssperre |
|
||||
| 187 | `upc.withdrawal.rop` | Klagerücknahme |
|
||||
| 183 | `upc.rehearing.coa` | Wiederaufnahmeantrag |
|
||||
|
||||
A subtle distinction: `upc.bsv.cfi` (Beweissicherung) IS a standalone primary (its own RoP filing) whereas `upc.evidence.cfi` (Beweisanordnungen allgemein) is a side-action class (orders the court makes inside any proceeding). The two are not duplicates; the categorisation is structural, not nominal.
|
||||
|
||||
#### Group D — Cross-cutting administrative / meta (8 rows)
|
||||
|
||||
These describe rules-of-procedure mechanics, not matters a lawyer takes on. None of them is a "Verfahren" in any user-facing sense.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 162 | `upc.case.mgmt` | Verfahrensverwaltung |
|
||||
| 161 | `upc.general.rop` | Allgemeine Bestimmungen |
|
||||
| 163 | `upc.service.rop` | Zustellung von Schriftsätzen |
|
||||
| 168 | `upc.language.rop` | Verfahrenssprache |
|
||||
| 164 | `upc.representation.rop` | Vertretung / Anwaltsprivileg |
|
||||
| 166 | `upc.fees.court` | Gerichtsgebühren |
|
||||
| 167 | `upc.legalaid.cfi` | Prozesskostenhilfe |
|
||||
| 186 | `upc.special.cfi` | Besondere Verfahrenslagen |
|
||||
| 169 | `upc.reestablishment.rop` | Wiedereinsetzung in den vorigen Stand *(cross-cutting; applies to every proceeding)* |
|
||||
|
||||
`upc.reestablishment.rop` lands in Group D because **every** proceeding has a Wiedereinsetzung path — it isn't a kind-of-proceeding, it's a cross-cutting remedy. Today's rules already model it correctly (it's a `condition_expr`-gated rule on each primary, not a separately-elected proceeding type).
|
||||
|
||||
### 0.5 Counts reconciled
|
||||
|
||||
| Group | Count | Total of 46 |
|
||||
|---|---:|---:|
|
||||
| A.1 Primary with corpus (18 rows) | 18 | |
|
||||
| A.2 Primary, unloaded (4 rows) | 4 | |
|
||||
| B Phases (5 rows) | 5 | |
|
||||
| C Side-actions (10 rows) | 10 | |
|
||||
| D Meta / cross-cutting (9 rows) | 9 | |
|
||||
| **Total** | | **46 ✓** |
|
||||
|
||||
m/paliad#147's audit listed 8 Group-D rows; live data shows 9 once `upc.reestablishment.rop` is moved into the meta bucket (it appeared as ambiguous "cross-cutting admin / meta" — confirming this design's read).
|
||||
|
||||
---
|
||||
|
||||
## 1. Categorization — ratified
|
||||
|
||||
The taxonomy proposal: a row in `paliad.proceeding_types` has exactly one of four **structural kinds**.
|
||||
|
||||
| `kind` | What it is | Visible in Mode B R3 wizard? | In `pkg/litigationplanner` catalog? | Eligible for `projects.proceeding_type_id`? |
|
||||
|---|---|---|---|---|
|
||||
| `proceeding` | A self-contained matter with its own filing pathway and its own deadline tree | **Yes** | **Yes** (filtered by `kind='proceeding' AND is_active=true`) | **Yes** |
|
||||
| `phase` | A stage *within* a primary proceeding | No | No | No |
|
||||
| `side_action` | An application/order that arises inside a primary proceeding | No | No | No |
|
||||
| `meta` | RoP mechanics, cross-cutting rules, court administration | No | No | No |
|
||||
|
||||
This is **Model 1 from m/paliad#147** (kind discriminator on `proceeding_types`). §2 explains why it beats Models 2-4 for the actual data.
|
||||
|
||||
The 46 active rows map to the 4 kinds as follows:
|
||||
|
||||
- **`proceeding` (22 rows):** all 18 primaries-with-corpus + the 4 unloaded primaries from §0.3. Specifically the union of §0.2 + §0.3.
|
||||
- **`phase` (5 rows):** the §0.4 Group B list.
|
||||
- **`side_action` (10 rows):** the §0.4 Group C list.
|
||||
- **`meta` (9 rows):** the §0.4 Group D list (incl. `upc.reestablishment.rop`).
|
||||
|
||||
### 1.1 Edge calls
|
||||
|
||||
- **`upc.ccr.cfi` (id 35)** — stays `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1 (the determinator surfaces it, the mapping returns inf.cfi's id with `with_ccr=true`). Rationale: the routing layer is already built and m ratified it 2026-05-18. This design does not re-open that decision. §9 Q7 lets m revisit.
|
||||
- **`upc.pl.cfi` (Schutzschrift, id 188)** — borderline. Schutzschrift is filed *before* a proceeding exists; it's a defensive pre-litigation filing. Recommendation: keep as `kind='proceeding'` (it has its own RoP path + its own deadlines once seeded). The alternative — calling it `side_action` of a not-yet-existing inf.cfi — is semantically backwards. §9 Q3.b lets m revisit.
|
||||
- **`upc.bsv.cfi` (saisie, id 179)** vs **`upc.evidence.cfi` (id 178)** — bsv stays `kind='proceeding'` (own RoP filing under R.192-198), evidence stays `kind='side_action'` (the orders a court makes inside any proceeding under R.190). The codes are not duplicates.
|
||||
|
||||
### 1.2 What the categorisation buys
|
||||
|
||||
- **Mode B R3 (Fristenrechner overhaul, t-paliad-322)** queries `proceeding_types WHERE is_active AND kind='proceeding'` and gets a clean 22-row pick list — no phase/side-action/meta noise.
|
||||
- **`projects.proceeding_type_id` integrity** is enforceable: an FK + CHECK (or a triggered constraint, see §3.3) blocks setting a project's type to anything except `kind='proceeding'`.
|
||||
- **`pkg/litigationplanner` snapshot generator** filters identically; youpc.org's catalog stays UPC-primary-only with no leakage of phase/admin rows.
|
||||
- **Determinator + dropdowns** get a forward-compatible filter; future feature work (e.g. "show me all side-actions available in this proceeding") becomes a different query against the same table.
|
||||
- **Forward-compatibility for new rows** — when corpus for a side-action arrives (e.g. `upc.evidence.cfi` gains 4 sequencing_rules with `condition_expr='evidence_order_issued'`), the rules anchor on the *parent* primary, not on the side-action row. The kind classification stays correct; the side-action row remains a taxonomic label.
|
||||
|
||||
---
|
||||
|
||||
## 2. Model choice — Model 1 (kind discriminator)
|
||||
|
||||
### 2.1 The four candidate models, scored
|
||||
|
||||
| Model | Schema churn | Models phase parentage? | Mode B R3 filter | Migration risk | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **1. `kind` discriminator on `proceeding_types`** | One column + CHECK constraint | No, but doesn't need to | `WHERE kind='proceeding'` | Trivial — UPDATE only | **Recommended** |
|
||||
| 2. Self-referencing `parent_id` | One column + FK + CHECK | Yes, but parentage is wrong shape (phases are phase-of-EVERY-CFI, not of one) | `WHERE parent_id IS NULL` | Trivial | Over-modelled |
|
||||
| 3. Separate tables | Three new tables + view/JOINs | Yes, fully | Just query `proceeding_types` | Migration churn + every consumer query learns a new shape | Overkill for 28 unused rows |
|
||||
| 4. Move phases into `procedural_events` | One mass row-move + DELETE | n/a (phases vanish from `proceeding_types`) | Trivial | Highest — would touch event_kind taxonomy and Fristenrechner result-view structure | Wrong shape (phases ≠ events) |
|
||||
|
||||
### 2.2 Why Model 1 wins
|
||||
|
||||
The fundamental observation: **the 28 non-primary rows have zero downstream pressure**. No rule, no project, no concept, no spawn FK references them. They exist in the table as taxonomic placeholders — names someone wrote down so future corpus could attach. We don't need to physically restructure the table; we just need to label what's what so consumers can filter correctly.
|
||||
|
||||
Model 1 gives us exactly that with one column. The other models pay schema/migration cost to model a parent-child relationship that **no consumer queries**. Mode B R3 doesn't ask "what are the phases of upc.inf.cfi?" — it asks "what are the proceedings I can pick?". The Fristenrechner result view doesn't ask the proceeding-types table about phases — phases live inside `procedural_events.event_kind` and the priority-bucket sub-sections in the §4.2 of the Fristenrechner overhaul doc.
|
||||
|
||||
Model 2's `parent_id` is wrong in shape: `upc.cfi.interim` doesn't have ONE parent (`upc.inf.cfi`), it has SEVEN parents (every CFI proceeding). Modelling that as a self-reference would force either (a) duplicating the phase rows per primary, or (b) using NULL parent_id for "applies to all". Both options are uglier than just dropping parent_id and trusting `kind='phase'`.
|
||||
|
||||
Model 3's separate tables would create rich relations that no consumer reads. Premature relational normalisation.
|
||||
|
||||
Model 4 would force phases into `procedural_events`, but phases aren't events. A phase is a *bucket of events*. The bucket is already implicit in the `event_kind` column (filing → interim, hearing → oral, decision → decision). If anything, Model 4 is *backwards* — phases should disappear into `event_kind`, not become event rows. The way to "delete" the phase rows from proceeding_types is just to deactivate them (or mark them `kind='phase'`); we don't need to re-locate them into another table to claim that conceptual move.
|
||||
|
||||
### 2.3 What we don't do — physical deletion
|
||||
|
||||
The 28 non-primary rows are NOT dropped from the table. They:
|
||||
|
||||
- Get tagged with the right `kind` value.
|
||||
- Optionally get `is_active=false` flipped (m's call, §9 Q9).
|
||||
- Stay in the table so consumers that historically referenced them by id (admin tools, audit logs, future schema-rescue scripts) keep working.
|
||||
|
||||
`DROP` is a one-way door we don't need to walk through. The CHECK constraint + kind tagging gives us the same logical cleanliness with none of the irreversibility risk.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema sketch + migration plan
|
||||
|
||||
### 3.1 DDL — the new column
|
||||
|
||||
```sql
|
||||
-- Migration NNN_proceeding_types_kind.up.sql
|
||||
-- (NNN = whatever MAX(version) + 1 is at write time; see project-status.md
|
||||
-- for the live numbering. As of 2026-05-26 the head is mig 152 per the
|
||||
-- recent dedupe of identical sequencing_rule clones.)
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
|
||||
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.kind IS
|
||||
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
|
||||
'proceeding = self-contained matter (own filing + deadline tree); '
|
||||
'phase = stage inside a primary CFI proceeding; '
|
||||
'side_action = application/order inside a proceeding; '
|
||||
'meta = RoP mechanics, court admin, cross-cutting remedies.';
|
||||
|
||||
CREATE INDEX proceeding_types_kind_active_idx
|
||||
ON paliad.proceeding_types(kind, is_active)
|
||||
WHERE is_active = true;
|
||||
```
|
||||
|
||||
The DEFAULT keeps existing inserts (admin tooling, snapshot tests) safe: any new row defaults to `proceeding`. The CHECK enforces the vocabulary at write time.
|
||||
|
||||
### 3.2 Data move — UPDATE statements, no INSERT/DELETE
|
||||
|
||||
```sql
|
||||
-- Phases (per m's Q2 carve-out: upc.costs.cfi (176) is NOT a phase, it stays primary)
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'phase'
|
||||
WHERE id IN (173, 174, 175, 185); -- §0.4 Group B minus 176
|
||||
|
||||
-- Side-actions
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'side_action'
|
||||
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183); -- §0.4 Group C
|
||||
|
||||
-- Meta / cross-cutting
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'meta'
|
||||
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169); -- §0.4 Group D
|
||||
|
||||
-- Primaries (incl. m's Q2 carve-out for upc.costs.cfi) stay on the DEFAULT
|
||||
-- 'proceeding' value — no UPDATE needed.
|
||||
|
||||
-- Per m's Q9: deactivate the non-primary rows so the admin list surfaces only
|
||||
-- primaries. The kind column carries the semantic info; is_active controls UI
|
||||
-- visibility. Reversible — flip is_active back on if a row gains corpus.
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
```
|
||||
|
||||
Per m's Q9, the `is_active=false` flip is mandatory in this mig. After it: 23 active rows (all `kind='proceeding'`), 23 inactive rows (the phase/side_action/meta set), in addition to the pre-existing inactive appeal-triplet + archived bucket. The `kind` column tells consumers what each row IS; `is_active` tells consumers whether to show it.
|
||||
|
||||
### 3.3 Optional integrity constraints
|
||||
|
||||
If m wants stronger guarantees that `projects.proceeding_type_id` can only point at primaries, add a deferrable FK validator. Cleanest pattern in Postgres:
|
||||
|
||||
```sql
|
||||
-- Option A: trigger-based check (works for any kind set, deferred-friendly).
|
||||
CREATE OR REPLACE FUNCTION paliad.assert_project_type_is_proceeding()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.proceeding_type_id IS NOT NULL THEN
|
||||
PERFORM 1 FROM paliad.proceeding_types
|
||||
WHERE id = NEW.proceeding_type_id AND kind = 'proceeding';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'projects.proceeding_type_id must reference a kind=proceeding row, got id=%', NEW.proceeding_type_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER projects_proceeding_type_kind_check
|
||||
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.assert_project_type_is_proceeding();
|
||||
```
|
||||
|
||||
Per m's Q8: **trigger on `projects` only**, no symmetric enforcement on `sequencing_rules`. Projects are written via the public app (the surface most exposed to operator error); rules are edited via the admin `/admin/procedural-events` surface which already validates against active+published lifecycle. The single trigger is enough.
|
||||
|
||||
### 3.4 Migration sequencing — single self-contained mig
|
||||
|
||||
One migration file:
|
||||
|
||||
```
|
||||
internal/db/migrations/153_proceeding_types_kind.up.sql
|
||||
internal/db/migrations/153_proceeding_types_kind.down.sql
|
||||
```
|
||||
|
||||
Up does ALTER + UPDATE + (optional) trigger creation. Down does DROP COLUMN (cascading the trigger if present). No data loss on either direction — the kind column is purely additive.
|
||||
|
||||
Mig number depends on what knuth lands first; the coder reads `MAX(version)` at write time per the project's mig conventions.
|
||||
|
||||
---
|
||||
|
||||
## 4. FK reparenting tables
|
||||
|
||||
There is no reparenting to do. Below for completeness:
|
||||
|
||||
| Source table.column | Pointing at non-primary rows? | Action |
|
||||
|---|---|---|
|
||||
| `sequencing_rules.proceeding_type_id` | **0 active rules** (verified §0.1) | None |
|
||||
| `sequencing_rules.spawn_proceeding_type_id` | **0 active rules** point at non-primaries; 4 active rules point at id=11 (inactive `upc.apl.merits`) | Pre-existing drift, out of scope (§8) |
|
||||
| `projects.proceeding_type_id` | **0 projects** (all 6 distinct values are primaries) | None |
|
||||
| `event_category_concepts.proceeding_type_code` | **0 concepts** point at non-primary codes; 30 concepts point at `upc.apl.merits/order/cost` codes (which are inactive but conceptually primaries) | Pre-existing drift, out of scope (§8) |
|
||||
|
||||
The "FK reparent" section of the acceptance criteria in m/paliad#147 is a no-op for this design: the 28 rows being re-classified have **no incoming references** to reparent. The migration is pure relabelling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Worked example — `upc.cfi.interim` after the mig
|
||||
|
||||
### 5.1 Today (broken)
|
||||
|
||||
Someone created the row `upc.cfi.interim` (id 173, name "CFI - Zwischenverfahren") in `paliad.proceeding_types` with `category='fristenrechner'`. The intent was probably "we'll attach interim-phase rules here later". Result:
|
||||
|
||||
- The row appears in the Mode B R3 wizard chip strip (if R3 queries `WHERE is_active=true AND jurisdiction='UPC'`) — confusing to the user, because "Zwischenverfahren" is not a proceeding they pick; it's a stage their proceeding passes through.
|
||||
- The row could be set as `projects.proceeding_type_id` (no FK constraint forbids it today) — corrupting the SmartTimeline's lane logic, which assumes the project's type is a primary.
|
||||
- The row appears in admin /admin/proceeding-types lists, polluting the primary-proceedings overview.
|
||||
|
||||
### 5.2 After mig 153
|
||||
|
||||
The migration runs:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.proceeding_types SET kind = 'phase' WHERE id = 173;
|
||||
-- Optionally: UPDATE paliad.proceeding_types SET is_active = false WHERE id = 173;
|
||||
```
|
||||
|
||||
Now:
|
||||
|
||||
- Mode B R3 query becomes `WHERE is_active=true AND jurisdiction = $1 AND kind='proceeding'`. `upc.cfi.interim` is filtered out — it is not a "Verfahren" the user can pick.
|
||||
- A future admin who tries to set a project's `proceeding_type_id = 173` either fails the optional trigger from §3.3 (with a clear error) or gets a code-level rejection from `ProjectService.SetProceedingType` (which the coder will harden to filter by `kind='proceeding'`).
|
||||
- The `pkg/litigationplanner` snapshot generator filter becomes `WHERE is_active=true AND category='fristenrechner' AND kind='proceeding' AND jurisdiction IN ('UPC')`. The row never makes it into the youpc.org catalog.
|
||||
|
||||
The row itself stays in the database. Its id is stable. Future work that wants to *use* the phase row as a taxonomic label (e.g. "show me which event_kinds map to which UPC phases") gets a clean shape: query `WHERE kind='phase' AND code LIKE 'upc.cfi.%'`.
|
||||
|
||||
### 5.3 Where interim-phase deadlines actually live
|
||||
|
||||
The user-facing concept "interim phase" is already modelled correctly, just elsewhere:
|
||||
|
||||
- A `procedural_events` row like `upc.inf.cfi.soc` (Statement of Claim) has `event_kind='filing'`. The Fristenrechner overhaul (t-paliad-322 §4.2) groups follow-ups by priority + presents them under the trigger card. There is no UI element that needs a "Zwischenverfahren" proceeding-type label to operate.
|
||||
- A future "show me the full ablauf of UPC inf, broken down by phase" feature can derive phases from `procedural_events.event_kind` ordering + the rule sequence_order. The `proceeding_types` table doesn't need to carry the phase labels.
|
||||
|
||||
---
|
||||
|
||||
## 6. Consumer impact
|
||||
|
||||
### 6.1 `projects.proceeding_type_id`
|
||||
|
||||
| Concern | Before | After mig 153 |
|
||||
|---|---|---|
|
||||
| Valid values | Any active proceeding_types row | Any `kind='proceeding'` active row (22 rows) |
|
||||
| Enforcement | None at DB level | Optional trigger (§3.3 / §9 Q8) |
|
||||
| Code-level filter in ProjectService | No filter on kind | Filter to `kind='proceeding'` when listing pickable types |
|
||||
| Existing data | 6 distinct values (all in 22) | No change — all 6 are kind='proceeding' |
|
||||
| SmartTimeline lane logic | Assumes primary-proceeding shape | Assumption now FK-enforceable |
|
||||
|
||||
**No data migration on existing projects.** The 6 currently-used proceeding types are all in the primary set.
|
||||
|
||||
### 6.2 `sequencing_rules.proceeding_type_id` + `spawn_proceeding_type_id`
|
||||
|
||||
| Concern | Before | After mig 153 |
|
||||
|---|---|---|
|
||||
| `proceeding_type_id` valid values | Any active row | Any active row (no enforcement change; admin curation suffices) |
|
||||
| `spawn_proceeding_type_id` valid values | Any active row | Same — spawns conceptually must point at a primary, but enforcement stays in admin tooling |
|
||||
| Existing data | 157 rules anchored on 18 primaries | No change — all 157 already on `kind='proceeding'` rows |
|
||||
| `id=11 spawn pressure` (`upc.apl.merits`, inactive) | 4 active spawn rules point here | Pre-existing drift, out of scope (§8) |
|
||||
|
||||
No `sequencing_rules` table changes accompany this mig. The post-mig invariant **"every active rule's `proceeding_type_id` is a `kind='proceeding'` row"** holds without any UPDATE.
|
||||
|
||||
### 6.3 Fristenrechner Mode B R3 (t-paliad-322, knuth's S3+)
|
||||
|
||||
§3.2 R3 of the Fristenrechner overhaul says:
|
||||
|
||||
> Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind.
|
||||
|
||||
After mig 153, the R3 query gains one more AND-clause:
|
||||
|
||||
```sql
|
||||
SELECT pt.id, pt.code, pt.name, pt.name_en, pt.sort_order
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.is_active = true
|
||||
AND pt.kind = 'proceeding' -- NEW
|
||||
AND pt.jurisdiction = $1 -- from R2
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.proceeding_type_id = pt.id
|
||||
AND pe.event_kind = $2 -- from R1
|
||||
AND sr.is_active = true
|
||||
)
|
||||
ORDER BY pt.sort_order, pt.code;
|
||||
```
|
||||
|
||||
The `kind='proceeding'` filter is the only line that changes. Knuth's S3 implementation reads from this query; the chip pool shrinks from "all 35 active UPC types" to "the 14 primary UPC types that have rules" (still narrowed further by R1's event_kind via the EXISTS subquery).
|
||||
|
||||
No coder churn beyond adding the AND-clause. The mig 153 lands either alongside knuth's S3 work or independently (§7 sequencing decision).
|
||||
|
||||
### 6.4 Litigation Planner suite (t-paliad-292)
|
||||
|
||||
The package's catalog snapshot generator (`pkg/litigationplanner/scripts/snapshot/main.go`) currently filters:
|
||||
|
||||
```go
|
||||
// scripts/snapshot/main.go
|
||||
const proceedingTypesQuery = `
|
||||
SELECT id, code, name, name_en, jurisdiction, default_color, sort_order, display_order,
|
||||
trigger_event_label_de, trigger_event_label_en
|
||||
FROM paliad.proceeding_types
|
||||
WHERE is_active = true
|
||||
AND category = 'fristenrechner'
|
||||
AND jurisdiction = $1
|
||||
`
|
||||
```
|
||||
|
||||
After mig 153, this query gains the same `AND kind = 'proceeding'` line. The UPC snapshot shrinks from "potentially 35 rows" to a clean primary-only set. Today's snapshot probably already includes the phase/side-action/meta rows (since `is_active=true` is true for all of them) — depending on whether a snapshot has been regenerated since the 161-188 rows landed, the embedded JSON may be carrying decorative rows that the youpc.org catalog never resolves to rules. Mig 153 + a snapshot regen cleans this up.
|
||||
|
||||
The package's `Catalog.Proceeding(ctx, code, hint)` interface stays unchanged. A youpc-side call asking for `code='upc.cfi.interim'` previously returned the row + zero rules (technically valid but useless); after mig 153 the snapshot doesn't include it and the call returns `ErrUnknownProceedingType`. That's the correct shape — youpc users never had a reason to ask for a phase row.
|
||||
|
||||
The scenarios design (`paliad.scenarios.spec.proceedings[].code`) gains an integrity check at write time: the validator already asserts every code resolves to an active proceeding; now it additionally asserts `kind='proceeding'`. A user trying to compose a scenario with `code='upc.cfi.interim'` gets a clear error. (The validator is paliad-side, not library-side — see Litigation Planner doc §5 "Validatable at write time".)
|
||||
|
||||
### 6.5 Admin /admin/procedural-events list (recently shipped, t-paliad-321)
|
||||
|
||||
The proceeding-type column in the admin list (m/paliad#144 follow-up, just landed) renders one of the 46 active codes per row. Post-mig 153, the admin filter dropdown can:
|
||||
|
||||
- Default to showing only `kind='proceeding'` rows (clean primary view).
|
||||
- Offer a "show all kinds" toggle for admins triaging the non-primary rows.
|
||||
|
||||
This is presentation-only — the underlying admin queries don't need to change immediately. The kind column is a forward-compat hook.
|
||||
|
||||
### 6.6 Knowledge-platform pages (Gerichtsverzeichnis, Patentglossar)
|
||||
|
||||
Untouched. None of those pages query `proceeding_types` directly.
|
||||
|
||||
### 6.7 Fristen export / paliad data export (t-paliad-279)
|
||||
|
||||
Untouched. The exporter dumps `proceeding_types` as a whole (no kind-filter); after mig 153 it dumps the same rows with the new kind column. Forward-compat by default.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration sequencing decision vs m/paliad#146
|
||||
|
||||
m/paliad#146 (Fristenrechner overhaul, t-paliad-322 / 323) is on the S1-S6 train under knuth. m's directive at task brief time: **knuth pauses at the S1+S2 seam waiting for this taxonomy decision**.
|
||||
|
||||
Three options were on the table:
|
||||
|
||||
(a) **Pause #146 until taxonomy clean** — knuth blocked, this design lands first, then knuth resumes S3+.
|
||||
(b) **Land #146 against current shape, migrate later** — knuth ships S3-S6 against the current 46-row table, taxonomy mig follows.
|
||||
(c) **Land taxonomy in parallel, knuth re-targets if needed** — both run, knuth's S3 picks up the new filter when mig 153 is ready.
|
||||
|
||||
**Recommendation: (c) parallel-land** with the following caveats:
|
||||
|
||||
- The taxonomy mig is **additive** (ADD COLUMN with safe DEFAULT, no DROP, no data move beyond UPDATEs that touch unreferenced rows). Knuth's S3 implementation can be written with or without the `kind='proceeding'` filter — adding the filter is a one-line patch the moment mig 153 lands.
|
||||
- The R3 chip-pool query in knuth's S3 PR should be **future-proofed by also adding the `kind='proceeding'` filter behind a feature flag or an env-time SQL constant**, defaulting to "no filter" pre-mig and "filter" post-mig. (Or simpler: knuth writes the filter unconditionally; the migration lands first; ordering is mechanical.)
|
||||
- The mig 153 PR should land **before** knuth's S3 PR ships to main, so the filter is never false-positive (chipping phase rows users can't actually pick). Both PRs can be drafted in parallel; the squeeze happens at merge time.
|
||||
- Sequence on main: mig 153 → knuth S3 (with filter) → knuth S4-S6.
|
||||
|
||||
Option (c) keeps knuth productive (S3 work can start immediately after this design ratifies; doesn't have to wait for the mig to merge) and avoids the option (a) idle cost.
|
||||
|
||||
Option (b) was rejected because it leaves the Mode B R3 wizard chipping 35 UPC rows on initial release — exactly the bug m flagged in m/paliad#147 ("half of the 46 active proceeding_types are not primary proceedings"). The user would see phase rows in R3 day one of the Fristenrechner overhaul shipping; we'd be shipping the bug.
|
||||
|
||||
Option (a) was rejected as the safest but slowest path. The taxonomy mig is trivial enough (one ALTER + four UPDATE statements + optional trigger) that parallel-running has no real risk.
|
||||
|
||||
§9 Q10 gives m the chance to pick differently.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope (flagged for separate work)
|
||||
|
||||
- **`upc.apl.*` data drift.** 30 rows in `paliad.event_category_concepts` reference the inactive `upc.apl.merits` / `upc.apl.order` / `upc.apl.cost` codes (the pre-`upc.apl.unified` triplet). 4 active sequencing_rules reference `spawn_proceeding_type_id=11` (the inactive `upc.apl.merits` row). This is a pre-existing inconsistency from the appeal unification mig — needs its own follow-up ticket. Not blocking this design; can be cleaned up in a separate migration that retargets concepts + spawn FKs to `upc.apl.unified` (id=160).
|
||||
- **Renaming or relabelling primary proceedings.** Out per m/paliad#147 acceptance — editorial work, not structural.
|
||||
- **Adding new proceeding types beyond the existing corpus.** Out per m/paliad#147 acceptance.
|
||||
- **The Fristenrechner UI overhaul itself (m/paliad#146).** Separate track; this design only tells knuth's S3 what set to chip.
|
||||
- **The scenarios design (m/paliad#124).** Already ratified in `docs/design-litigation-planner-2026-05-26.md` §5; this design only refines the spec validator's "every code resolves to a primary" check.
|
||||
- **DROPing the non-primary rows physically.** Reversible deactivation via `kind=...` + optional `is_active=false` is enough; physical deletion adds irreversibility risk for no functional gain.
|
||||
- **Migration of `event_category_concepts.proceeding_type_code` to a real FK.** It's text today, joined softly; converting to FK is a separate hardening task.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m (10 decision questions)
|
||||
|
||||
Sent via `AskUserQuestion` in 3 batches per inventor SKILL contract (4+3+3). m's picks land in §10 below after the round-trip.
|
||||
|
||||
| # | Topic | Recommended pick |
|
||||
|---|---|---|
|
||||
| Q1 | Model choice | Model 1 (kind discriminator) |
|
||||
| Q2 | Phases — linear sub-phases of every CFI, or separately-elected? | Implicit: phases live in `procedural_events.event_kind`, not as proceeding_types |
|
||||
| Q3.a | Side-actions — triggered by parent event, or initiated out-of-band? | Mixed; today's data has no rules, future rules anchor on the parent primary with `condition_expr` |
|
||||
| Q3.b | `upc.pl.cfi` (Schutzschrift) — primary or side-action? | Primary (own RoP filing pathway) |
|
||||
| Q4 | Collapse `de.inf.lg`/`olg`/`bgh` into one `de.inf` with instance_level qualifier? | No — keep discrete |
|
||||
| Q5 | Collapse `de.null.bpatg`/`bgh` into one `de.null` with instance_level qualifier? | No — keep discrete |
|
||||
| Q6 | Should DE follow the `upc.apl.unified` pattern? | No (= keep discrete, locks Q4+Q5) |
|
||||
| Q7 | `upc.ccr.cfi` — proceeding row with routing (status quo), or `with_ccr` flag on `upc.inf.cfi`? | Keep as proceeding (status quo per t-paliad-204 S1) |
|
||||
| Q8 | Enforce `projects.proceeding_type_id` → `kind='proceeding'` at the DB level? | Yes, via trigger (§3.3) |
|
||||
| Q9 | Set `is_active=false` on the 28 non-primary rows after mig 153? | Yes (cleanest admin UX) |
|
||||
| Q10 | Sequencing vs m/paliad#146 — pause / parallel / re-target? | (c) parallel-land — mig first, then knuth S3 with filter |
|
||||
|
||||
Q11 in the issue body ("how many rules need new condition_expr disambiguation?") is **empirically answered, no decision needed**: 0 rules need new condition_expr — every active rule is already correctly anchored to a primary. Surfaced in §4 + §6.2.
|
||||
|
||||
---
|
||||
|
||||
## 10. m's decisions (2026-05-27)
|
||||
|
||||
All 11 questions answered via `AskUserQuestion` on 2026-05-27 09:52 (3 batches of 4+4+3). 10 of 11 picks = recommendation; Q9 diverged at the chip-picker but m's follow-up instruction ("I follow your recommendation") flips Q9 to the recommendation as well. Q2 carries a precise carve-out captured verbatim below.
|
||||
|
||||
- **Q1 (Model): Model 1 — kind discriminator.** [= recommendation] One column + CHECK constraint + UPDATE statements. **Locks §1, §2, §3.1, §3.2.**
|
||||
- **Q2 (Phases): Generally option 1 (implicit via `procedural_events.event_kind`), with carve-outs.** [≈ option 1 with carve-out] m's verbatim call:
|
||||
> Generally 1, but I agree with costs which are not only a phase but also "standalone" side proceedings. But default decision application is not.
|
||||
Concretely:
|
||||
- `upc.cfi.interim` (173) → `kind='phase'`
|
||||
- `upc.cfi.oral` (174) → `kind='phase'`
|
||||
- `upc.cfi.decision` (175) → `kind='phase'`
|
||||
- `upc.default.cfi` (185) → `kind='phase'` (m: "default decision application is not [a standalone side proceeding]")
|
||||
- **`upc.costs.cfi` (176) → `kind='proceeding'`** (m: "costs are not only a phase but also standalone side proceedings"). The Separate Kostenentscheidung can be filed as its own application under R.151 RoP independently of the parent decision; m's read is that the standalone-application character outweighs the phase-of-CFI character.
|
||||
Net: 4 phase rows (not 5 as in the strawman), 23 primary-proceeding rows (not 22). **Updates §0.4 Group B count, §0.5 totals row, §1 categorisation, §3.2 UPDATE statement IDs (drop 176 from the phase UPDATE).**
|
||||
- **Q3.a (Side-actions): kind='side_action', rules anchor on parent primary.** [= recommendation] All 10 §0.4 Group C rows get `kind='side_action'`. When corpus arrives, rules attach to the parent primary with a `condition_expr` flag. **Locks §1.1, §3.2 side-action UPDATE.**
|
||||
- **Q3.b (Schutzschrift): kind='proceeding'.** [= recommendation] `upc.pl.cfi` (188) stays in the primary set on the strength of its own RoP filing pathway. **Locks §0.3 unloaded-primary list.**
|
||||
- **Q4 (DE inf collapse): Keep discrete.** [= recommendation] `de.inf.lg/olg/bgh` stay as 3 separate primaries. No collapse, no instance_level qualifier introduction. **Locks §0.2 + §1 DE-side categorisation.**
|
||||
- **Q5 (DE null collapse): Keep discrete.** [= recommendation] `de.null.bpatg/bgh` stay separate. Symmetric with Q4. **Locks §0.2 + §1 DE-side categorisation.**
|
||||
- **Q6 (DE follow upc.apl pattern): No — keep DE discrete.** [= recommendation] Locks Q4+Q5. The `upc.apl.unified` consolidation was about same-court appeal variants; DE appeals are different-court-instance appeals — different problem. **No code-rename work falls out of this design.**
|
||||
- **Q7 (CCR shape): Keep status quo.** [= recommendation] `upc.ccr.cfi` stays as `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1. **Locks §1.1.**
|
||||
- **Q8 (DB trigger): Trigger on `projects` only.** [= recommendation] BEFORE INSERT/UPDATE trigger on `paliad.projects` enforces `proceeding_type_id → kind='proceeding'`. No trigger on `sequencing_rules` (admin tooling already gates). **Locks §3.3 — keep the `projects` trigger DDL, drop the optional `sequencing_rules` variant.**
|
||||
- **Q9 (Deactivate non-primaries): Yes — deactivate.** [m's chip-pick was "keep active"; flipped to recommendation per m's "I follow your recommendation" instruction] All `kind IN ('phase', 'side_action', 'meta')` rows get `is_active=false` in mig 153. The admin `/admin/proceeding-types` list shows only the 23 active primaries. Rows stay in the table with their `kind` tag so future tooling that wants to surface them can flip `is_active` back on. **Updates §3.2 — uncomment the optional `UPDATE … SET is_active=false` block.**
|
||||
- **Q10 (Sequencing vs #146): Parallel-land.** [= recommendation] Mig 153 + knuth's S3 PR drafted in parallel; mig merges first; knuth's S3 includes the `kind='proceeding'` filter in R3's chip query from day one. No idle cost; no bug shipped. **Locks §7.**
|
||||
|
||||
### 10.1 What changed from the strawman as a result
|
||||
|
||||
Two material edits flow from m's picks:
|
||||
|
||||
1. **§0.4 Group B (Phases) drops `upc.costs.cfi` (id 176)** — moved into the primary set. Phase count: 5 → 4. Primary count: 22 → 23. §0.2 picks up id 176 as an unloaded primary (zero rules today; future corpus will attach).
|
||||
2. **§3.2 migration includes the `is_active=false` UPDATE** (was optional in the strawman, now mandatory):
|
||||
|
||||
```sql
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
```
|
||||
|
||||
This is what the post-mig 153 cleanup looks like: 23 active rows (all `kind='proceeding'`), 23 inactive rows (4 phase + 10 side_action + 9 meta + the pre-existing 3 inactive appeal-triplet + 1 archived bucket = 27 inactive total, but 23 of those are the freshly-deactivated taxonomy rows).
|
||||
|
||||
These edits don't change the §7 sequencing decision or the §6 consumer-impact analysis. They tighten the mig file and shift one row's classification.
|
||||
|
||||
### 10.2 Final categorisation (post-decisions)
|
||||
|
||||
| `kind` | Count | Codes |
|
||||
|---|---:|---|
|
||||
| `proceeding` | **23** | upc.inf.cfi, upc.rev.cfi, upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, upc.ccr.cfi, upc.apl.unified, upc.dni.cfi, upc.epo.review, upc.bsv.cfi, upc.pl.cfi, **upc.costs.cfi** (m's Q2 carve-out), de.inf.lg, de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh, epa.opp.opd, epa.opp.boa, epa.grant.exa, dpma.opp.dpma, dpma.appeal.bpatg, dpma.appeal.bgh |
|
||||
| `phase` | **4** | upc.cfi.interim, upc.cfi.oral, upc.cfi.decision, upc.default.cfi |
|
||||
| `side_action` | **10** | upc.evidence.cfi, upc.experiments.cfi, upc.security.cfi, upc.intervention.rop, upc.parties.change, upc.optout.cfi, upc.inspection.cfi, upc.freezing.cfi, upc.withdrawal.rop, upc.rehearing.coa |
|
||||
| `meta` | **9** | upc.case.mgmt, upc.general.rop, upc.service.rop, upc.language.rop, upc.representation.rop, upc.fees.court, upc.legalaid.cfi, upc.special.cfi, upc.reestablishment.rop |
|
||||
| **Total** | **46** | ✓ |
|
||||
|
||||
Post-mig 153: 23 active (all `kind='proceeding'`), 23 deactivated (the phase/side_action/meta set).
|
||||
|
||||
---
|
||||
|
||||
## 11. Synthesis links
|
||||
|
||||
- mBrian topic: `topic-fristenrechner` — file this design as a `[synthesis]` node, link `related_to` the proceeding-code-taxonomy doc (2026-05-18) and the Fristenrechner overhaul (2026-05-26), `triggered_by` t-paliad-324.
|
||||
- Related design docs: `docs/design-proceeding-code-taxonomy-2026-05-18.md` (the code-shape doc), `docs/design-fristenrechner-overhaul-2026-05-26.md` (knuth's parent design), `docs/design-litigation-planner-2026-05-26.md` §5 (scenarios spec validator).
|
||||
- Related migrations: 095 (fristen gap-fill, spawn FK invariant), 096 (proceeding code rename), 152 (sequencing_rule dedupe + admin column).
|
||||
72
frontend/src/client/fristenrechner-result.test.ts
Normal file
72
frontend/src/client/fristenrechner-result.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
defaultChecked,
|
||||
groupFollowUps,
|
||||
type FollowUpRule,
|
||||
} from "./fristenrechner-result";
|
||||
|
||||
// Pure helpers exercised here; the DOM-driven render path is covered
|
||||
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
|
||||
// entry-mode UIs in later slices).
|
||||
|
||||
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
|
||||
return {
|
||||
rule_id: "r" + Math.random().toString(36).slice(2, 8),
|
||||
event_code: "evt",
|
||||
title_de: "Frist",
|
||||
title_en: "Deadline",
|
||||
priority: "mandatory",
|
||||
is_court_set: false,
|
||||
is_spawn: false,
|
||||
is_bilateral: false,
|
||||
has_condition: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
|
||||
test("groups by priority; conditional takes precedence over priority", () => {
|
||||
const rows = [
|
||||
mk({ priority: "mandatory" }),
|
||||
mk({ priority: "recommended" }),
|
||||
mk({ priority: "optional" }),
|
||||
mk({ priority: "mandatory", has_condition: true }), // → conditional
|
||||
mk({ priority: "optional", has_condition: true }), // → conditional
|
||||
];
|
||||
const g = groupFollowUps(rows);
|
||||
expect(g.mandatory.length).toBe(1);
|
||||
expect(g.recommended.length).toBe(1);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.conditional.length).toBe(2);
|
||||
});
|
||||
|
||||
test("unknown priority falls through to optional", () => {
|
||||
const g = groupFollowUps([mk({ priority: "informational" })]);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.mandatory.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
|
||||
test("mandatory rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
|
||||
});
|
||||
test("recommended rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
|
||||
});
|
||||
test("optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
|
||||
});
|
||||
test("conditional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
|
||||
});
|
||||
test("court-set rules unchecked even when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
|
||||
});
|
||||
test("spawned rules pre-checked when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
|
||||
});
|
||||
test("spawned optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
611
frontend/src/client/fristenrechner-result.ts
Normal file
611
frontend/src/client/fristenrechner-result.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
// Fristenrechner overhaul — shared result view (design §4).
|
||||
//
|
||||
// Given a locked trigger event + a trigger date, this module renders
|
||||
// the result surface: a sticky trigger card on top, then four priority
|
||||
// groups (mandatory / recommended / optional / conditional) of follow-up
|
||||
// rules with computed dates, then a write-back footer that calls the
|
||||
// existing POST /api/projects/{id}/deadlines/bulk.
|
||||
//
|
||||
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
|
||||
// wizard in S4) both land here once they've identified a trigger
|
||||
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
|
||||
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
|
||||
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
|
||||
// services.FollowUpsResponse server-side.
|
||||
export interface FollowUpRule {
|
||||
rule_id: string;
|
||||
event_code: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
priority: string;
|
||||
primary_party?: string;
|
||||
duration_value?: number;
|
||||
duration_unit?: string;
|
||||
timing?: string;
|
||||
due_date?: string;
|
||||
original_due_date?: string;
|
||||
was_adjusted?: boolean;
|
||||
is_court_set: boolean;
|
||||
is_spawn: boolean;
|
||||
is_bilateral: boolean;
|
||||
has_condition: boolean;
|
||||
rule_code?: string;
|
||||
legal_source?: string;
|
||||
legal_source_display?: string;
|
||||
legal_source_url?: string;
|
||||
notes_de?: string;
|
||||
notes_en?: string;
|
||||
spawn_label?: string;
|
||||
spawn_proceeding_code?: string;
|
||||
concept_id?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpsResponse {
|
||||
trigger: {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
anchor_rule_id: string;
|
||||
};
|
||||
trigger_date: string;
|
||||
party?: string;
|
||||
follow_ups: FollowUpRule[];
|
||||
}
|
||||
|
||||
// Per-rule UI state — checkbox, optional date override.
|
||||
interface RuleSelection {
|
||||
checked: boolean;
|
||||
override?: string;
|
||||
}
|
||||
|
||||
// Module-local state. Single result view at a time; the surface
|
||||
// re-renders in place when the user changes the trigger date or
|
||||
// re-locks a different event.
|
||||
let currentResponse: FollowUpsResponse | null = null;
|
||||
const selections = new Map<string, RuleSelection>();
|
||||
let currentProjectId: string | null = null;
|
||||
|
||||
// Public API ----------------------------------------------------------
|
||||
|
||||
// isOverhaulMode reports whether the page is in overhaul mode (S2+).
|
||||
// True when `?overhaul=1` is present. Once S5 flips the flag, the
|
||||
// reverse check (?legacy=1) replaces this.
|
||||
export function isOverhaulMode(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("overhaul") === "1";
|
||||
}
|
||||
|
||||
// resolveProjectId reads the active Akte from the URL query string.
|
||||
// Returns null when in kontextfrei mode (no project picked).
|
||||
function resolveProjectId(): string | null {
|
||||
const p = new URLSearchParams(window.location.search).get("project");
|
||||
return p && p.length > 0 ? p : null;
|
||||
}
|
||||
|
||||
// MountOptions configures the surface entry. Both entry-mode paths
|
||||
// (Mode A in S3, Mode B in S4) call mount() with the event reference
|
||||
// that the user committed.
|
||||
export interface MountOptions {
|
||||
// eventRef is the procedural_event code OR its uuid OR the anchor
|
||||
// sequencing_rule id. Resolved server-side; the wire returns the
|
||||
// canonical code so the URL bookmark is stable.
|
||||
eventRef: string;
|
||||
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
|
||||
triggerDate?: string;
|
||||
// party is "claimant" | "defendant"; mode A may pass "both" or
|
||||
// "court". When omitted, follow-ups are returned without party
|
||||
// narrowing.
|
||||
party?: string;
|
||||
// courtId selects the holiday calendar for the per-rule date
|
||||
// adjustment. Optional.
|
||||
courtId?: string;
|
||||
}
|
||||
|
||||
// mountResultView fetches /follow-ups and renders the result surface
|
||||
// into the host container. Re-callable: replaces previous state.
|
||||
export async function mountResultView(opts: MountOptions): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
|
||||
const triggerDate = opts.triggerDate || todayIso();
|
||||
currentProjectId = resolveProjectId();
|
||||
|
||||
// Show a quick "loading…" placeholder so the user sees something
|
||||
// immediately, even on a cold fetch.
|
||||
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
|
||||
|
||||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||||
url.searchParams.set("event", opts.eventRef);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
if (opts.party) url.searchParams.set("party", opts.party);
|
||||
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
|
||||
|
||||
let data: FollowUpsResponse;
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
data = (await resp.json()) as FollowUpsResponse;
|
||||
} catch (err) {
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
currentResponse = data;
|
||||
selections.clear();
|
||||
for (const r of data.follow_ups) {
|
||||
selections.set(r.rule_id, { checked: defaultChecked(r) });
|
||||
}
|
||||
|
||||
renderSurface();
|
||||
// Reflect the canonical event code + trigger date in the URL so the
|
||||
// deep-link survives a reload.
|
||||
syncUrlState(data.trigger.code, data.trigger_date);
|
||||
}
|
||||
|
||||
// Render --------------------------------------------------------------
|
||||
|
||||
function renderSurface(): void {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root || !currentResponse) return;
|
||||
|
||||
const lang = getLang();
|
||||
const trig = currentResponse.trigger;
|
||||
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
|
||||
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
|
||||
const juris = trig.proceeding_type.jurisdiction || "";
|
||||
const kindIcon = eventKindIcon(trig.event_kind);
|
||||
|
||||
const triggerCard = `
|
||||
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
|
||||
<header class="fristen-overhaul-trigger-header">
|
||||
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
|
||||
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
|
||||
</header>
|
||||
<div class="fristen-overhaul-trigger-meta">
|
||||
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
|
||||
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
|
||||
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
|
||||
</div>
|
||||
<div class="fristen-overhaul-trigger-date">
|
||||
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
|
||||
${escHtml(t("deadlines.overhaul.trigger.date"))}
|
||||
</label>
|
||||
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
|
||||
value="${escAttr(currentResponse.trigger_date)}" />
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const groups = groupFollowUps(currentResponse.follow_ups);
|
||||
const groupHtml = renderGroups(groups, lang);
|
||||
|
||||
const nudge = currentProjectId
|
||||
? ""
|
||||
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
|
||||
|
||||
const footer = currentProjectId
|
||||
? renderFooter()
|
||||
: "";
|
||||
|
||||
root.innerHTML = `
|
||||
${triggerCard}
|
||||
${nudge}
|
||||
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
|
||||
${groupHtml}
|
||||
</section>
|
||||
${footer}
|
||||
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
|
||||
`;
|
||||
|
||||
wireSurfaceEvents();
|
||||
}
|
||||
|
||||
export interface GroupedFollowUps {
|
||||
mandatory: FollowUpRule[];
|
||||
recommended: FollowUpRule[];
|
||||
optional: FollowUpRule[];
|
||||
conditional: FollowUpRule[];
|
||||
}
|
||||
|
||||
// groupFollowUps splits the wire list into the four visible groups per
|
||||
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
|
||||
// precedence over the priority bucket so a "nur wenn CCR" mandatory
|
||||
// rule renders under Conditional with the gating language visible.
|
||||
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
|
||||
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
|
||||
for (const r of rows) {
|
||||
if (r.has_condition) {
|
||||
out.conditional.push(r);
|
||||
continue;
|
||||
}
|
||||
switch (r.priority) {
|
||||
case "mandatory":
|
||||
out.mandatory.push(r);
|
||||
break;
|
||||
case "recommended":
|
||||
out.recommended.push(r);
|
||||
break;
|
||||
case "optional":
|
||||
out.optional.push(r);
|
||||
break;
|
||||
default:
|
||||
// unknown / informational — fold into optional so the row is at
|
||||
// least visible. Future Phase 2 'informational' tier gets a
|
||||
// dedicated bucket once seeded.
|
||||
out.optional.push(r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
|
||||
const blocks: string[] = [];
|
||||
if (groups.mandatory.length > 0) {
|
||||
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
|
||||
}
|
||||
if (groups.recommended.length > 0) {
|
||||
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
|
||||
}
|
||||
if (groups.optional.length > 0) {
|
||||
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
|
||||
}
|
||||
if (groups.conditional.length > 0) {
|
||||
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
|
||||
}
|
||||
if (blocks.length === 0) {
|
||||
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
|
||||
}
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
|
||||
const items = rows.map((r) => renderRule(r, lang)).join("");
|
||||
return `
|
||||
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
|
||||
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
|
||||
<ul class="fristen-overhaul-rule-list">
|
||||
${items}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
const sel = selections.get(r.rule_id);
|
||||
const checked = sel ? sel.checked : defaultChecked(r);
|
||||
const dateOverride = sel?.override;
|
||||
const computedDate = r.due_date || "";
|
||||
const effectiveDate = dateOverride || computedDate;
|
||||
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
|
||||
|
||||
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
|
||||
const durationPhrase = formatDurationPhrase(r, lang);
|
||||
const dateCell = r.is_court_set
|
||||
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
|
||||
: effectiveDate
|
||||
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
|
||||
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">—</span>`;
|
||||
|
||||
const partyBadge = r.primary_party
|
||||
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
|
||||
: "";
|
||||
|
||||
const sourceBadge = r.legal_source_display
|
||||
? r.legal_source_url
|
||||
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
|
||||
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
|
||||
: r.rule_code
|
||||
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
|
||||
: "";
|
||||
|
||||
const spawnBadge = r.is_spawn
|
||||
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
|
||||
: "";
|
||||
|
||||
const condBadge = r.has_condition
|
||||
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
|
||||
: "";
|
||||
|
||||
const notesHtml = notes
|
||||
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
|
||||
: "";
|
||||
|
||||
const editBtn = r.is_court_set || r.is_spawn || !computedDate
|
||||
? ""
|
||||
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
|
||||
|
||||
return `
|
||||
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}" data-rule-id="${escAttr(r.rule_id)}">
|
||||
<label class="fristen-overhaul-rule-check">
|
||||
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
|
||||
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
|
||||
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
|
||||
</label>
|
||||
<div class="fristen-overhaul-rule-body">
|
||||
<div class="fristen-overhaul-rule-title-row">
|
||||
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
|
||||
${spawnBadge}
|
||||
${condBadge}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-meta-row">
|
||||
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
|
||||
${partyBadge}
|
||||
${sourceBadge}
|
||||
</div>
|
||||
${notesHtml}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-date-cell">
|
||||
${dateCell}
|
||||
${editBtn}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFooter(): string {
|
||||
const selectedCount = countSelected();
|
||||
return `
|
||||
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
|
||||
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
|
||||
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
|
||||
</span>
|
||||
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
|
||||
id="fristen-overhaul-write-back"
|
||||
${selectedCount === 0 ? "disabled" : ""}>
|
||||
${escHtml(t("deadlines.overhaul.footer.cta"))}
|
||||
</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event wiring --------------------------------------------------------
|
||||
|
||||
function wireSurfaceEvents(): void {
|
||||
// Trigger-date change → re-fetch with new date.
|
||||
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput && currentResponse) {
|
||||
dateInput.addEventListener("change", () => {
|
||||
if (!currentResponse) return;
|
||||
const newDate = dateInput.value;
|
||||
if (!newDate) return;
|
||||
void mountResultView({
|
||||
eventRef: currentResponse.trigger.code,
|
||||
triggerDate: newDate,
|
||||
party: currentResponse.party,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Checkbox toggles → update selections + footer count.
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (root) {
|
||||
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.ruleId || "";
|
||||
const sel = selections.get(id) ?? { checked: cb.checked };
|
||||
sel.checked = cb.checked;
|
||||
selections.set(id, sel);
|
||||
refreshFooterCount();
|
||||
});
|
||||
});
|
||||
|
||||
// Per-rule date override.
|
||||
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
|
||||
btn.addEventListener("click", () => editRuleDate(btn));
|
||||
});
|
||||
}
|
||||
|
||||
// Write-back CTA.
|
||||
const cta = document.getElementById("fristen-overhaul-write-back");
|
||||
if (cta) cta.addEventListener("click", () => void submitWriteBack());
|
||||
}
|
||||
|
||||
function editRuleDate(btn: HTMLButtonElement): void {
|
||||
const ruleId = btn.dataset.ruleId || "";
|
||||
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
|
||||
if (!rule) return;
|
||||
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
|
||||
const current = sel.override || rule.due_date || todayIso();
|
||||
|
||||
const dateCell = btn.parentElement;
|
||||
if (!dateCell) return;
|
||||
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
|
||||
if (!dateSpan) return;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "date";
|
||||
input.value = current;
|
||||
input.className = "fristen-overhaul-rule-date-input";
|
||||
dateSpan.replaceWith(input);
|
||||
btn.disabled = true;
|
||||
input.focus();
|
||||
|
||||
const commit = () => {
|
||||
const newDate = input.value;
|
||||
if (newDate && newDate !== current) {
|
||||
sel.override = newDate;
|
||||
selections.set(ruleId, sel);
|
||||
}
|
||||
renderSurface();
|
||||
};
|
||||
input.addEventListener("blur", commit, { once: true });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
} else if ((e as KeyboardEvent).key === "Escape") {
|
||||
renderSurface();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshFooterCount(): void {
|
||||
const countEl = document.getElementById("fristen-overhaul-footer-count");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const n = countSelected();
|
||||
if (countEl) {
|
||||
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
|
||||
}
|
||||
if (cta) cta.disabled = n === 0;
|
||||
}
|
||||
|
||||
function countSelected(): number {
|
||||
let n = 0;
|
||||
if (!currentResponse) return 0;
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
if (r.is_court_set) continue;
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (sel?.checked) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Write-back ----------------------------------------------------------
|
||||
|
||||
async function submitWriteBack(): Promise<void> {
|
||||
if (!currentResponse) return;
|
||||
if (!currentProjectId) return;
|
||||
const msg = document.getElementById("fristen-overhaul-msg");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const lang = getLang();
|
||||
|
||||
const deadlines: Array<Record<string, unknown>> = [];
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (!sel?.checked) continue;
|
||||
if (r.is_court_set) continue;
|
||||
const dueDate = sel.override || r.due_date;
|
||||
if (!dueDate) continue;
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
deadlines.push({
|
||||
title,
|
||||
rule_code: r.rule_code || undefined,
|
||||
due_date: dueDate,
|
||||
original_due_date: r.original_due_date || r.due_date || undefined,
|
||||
source: "fristenrechner",
|
||||
rule_id: r.rule_id,
|
||||
notes: notes || undefined,
|
||||
audit_reason: auditReason(),
|
||||
});
|
||||
}
|
||||
|
||||
if (deadlines.length === 0 || !msg || !cta) return;
|
||||
cta.disabled = true;
|
||||
msg.textContent = "";
|
||||
msg.className = "fristen-overhaul-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deadlines }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = body.error || t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
return;
|
||||
}
|
||||
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
|
||||
msg.className = "fristen-overhaul-msg form-msg-ok";
|
||||
setTimeout(() => {
|
||||
if (cta) cta.disabled = false;
|
||||
}, 1500);
|
||||
} catch {
|
||||
msg.textContent = t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
|
||||
function auditReason(): string {
|
||||
if (!currentResponse) return "";
|
||||
const name = currentResponse.trigger.name_de;
|
||||
const date = currentResponse.trigger_date;
|
||||
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
|
||||
}
|
||||
|
||||
// Helpers -------------------------------------------------------------
|
||||
|
||||
export function defaultChecked(r: FollowUpRule): boolean {
|
||||
if (r.is_court_set) return false;
|
||||
if (r.is_spawn) return r.priority === "mandatory";
|
||||
if (r.has_condition) return false;
|
||||
return r.priority === "mandatory" || r.priority === "recommended";
|
||||
}
|
||||
|
||||
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
if (!r.duration_value || !r.duration_unit) return "";
|
||||
const unitDE: Record<string, string> = {
|
||||
days: "Tage",
|
||||
months: "Monate",
|
||||
weeks: "Wochen",
|
||||
years: "Jahre",
|
||||
};
|
||||
const unitEN: Record<string, string> = {
|
||||
days: "days",
|
||||
months: "months",
|
||||
weeks: "weeks",
|
||||
years: "years",
|
||||
};
|
||||
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
|
||||
return `${r.duration_value} ${u}`;
|
||||
}
|
||||
|
||||
function formatDateForLang(iso: string, lang: "de" | "en"): string {
|
||||
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
|
||||
if (!iso || iso.length < 10) return iso;
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return iso;
|
||||
if (lang === "en") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const idx = parseInt(m, 10) - 1;
|
||||
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
|
||||
return `${parseInt(d, 10)} ${mn} ${y}`;
|
||||
}
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
function eventKindIcon(kind?: string): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥"; // inbox/letter
|
||||
case "hearing": return "🏛️"; // courthouse
|
||||
case "decision": return "⚖️"; // scales
|
||||
case "order": return "📜"; // page
|
||||
default: return "📅"; // calendar
|
||||
}
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function syncUrlState(eventCode: string, triggerDate: string): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", eventCode);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type EventChoice,
|
||||
type ChoiceKind,
|
||||
} from "./views/event-card-choices";
|
||||
import { isOverhaulMode, mountResultView } from "./fristenrechner-result";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
@@ -113,6 +114,49 @@ onLangChange(() => {
|
||||
|
||||
let selectedType = "";
|
||||
|
||||
// t-paliad-323 Slice S2 — Fristenrechner overhaul boot. Hides the
|
||||
// legacy step / pathway shells and mounts the result view. S3+S4 will
|
||||
// hook entry-mode UIs into this; S2 is deep-link only.
|
||||
function bootOverhaulMode(): void {
|
||||
// Hide every legacy section so only the overhaul root is visible.
|
||||
// The page wrapper (`<main>`, `<section class="tool-page">`, the
|
||||
// tool-header) stays so the sidebar + title carry through.
|
||||
const hideIds = [
|
||||
"fristen-step1",
|
||||
"fristen-step1-summary",
|
||||
"fristen-step2",
|
||||
"fristen-pathway-b",
|
||||
"fristen-step3a",
|
||||
"fristen-pathway-a",
|
||||
];
|
||||
for (const id of hideIds) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.hidden = true;
|
||||
el.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// S2 deep-link contract: ?overhaul=1&event=<code>&trigger_date=…
|
||||
// When event is missing, leave the surface empty — S3/S4 will mount
|
||||
// entry-mode UIs onto this surface in later slices.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const eventRef = params.get("event") || "";
|
||||
const triggerDate = params.get("trigger_date") || undefined;
|
||||
const party = params.get("party") || undefined;
|
||||
const courtId = params.get("court_id") || undefined;
|
||||
|
||||
if (!eventRef) {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (root) {
|
||||
root.hidden = false;
|
||||
root.innerHTML = `<div class="fristen-overhaul-nudge">${t("deadlines.overhaul.empty")}</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
void mountResultView({ eventRef, triggerDate, party, courtId });
|
||||
}
|
||||
|
||||
function showStep(n: number) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const el = document.getElementById(`step-${i}`);
|
||||
@@ -656,6 +700,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
// t-paliad-323 Slice S2 — Fristenrechner overhaul boot.
|
||||
// When ?overhaul=1 is set, hide the legacy three-step wizard /
|
||||
// Pathway A+B shells and mount the new result view in their place.
|
||||
// Deep-linkable via ?overhaul=1&event=<code>&trigger_date=…&project=…
|
||||
// (the trigger date defaults to today when omitted). S3 (Mode A
|
||||
// search) and S4 (Mode B wizard) will land users here once they
|
||||
// identify a trigger event — for now the surface is reached only
|
||||
// via deep link, but ?overhaul=1 alone shows the empty shell so
|
||||
// the path is exercisable end-to-end.
|
||||
if (isOverhaulMode()) {
|
||||
bootOverhaulMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceeding type selection
|
||||
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => selectProceeding(btn));
|
||||
|
||||
@@ -1010,6 +1010,32 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.save.error": "\u00dcbernahme fehlgeschlagen.",
|
||||
"deadlines.save.skip_court_set": "Gerichtsbestimmte Termine ohne Datum werden \u00fcbersprungen.",
|
||||
|
||||
// Fristenrechner overhaul \u2014 shared result view (S2, design \u00a74).
|
||||
"deadlines.overhaul.loading": "Folge-Fristen werden geladen\u2026",
|
||||
"deadlines.overhaul.load_error": "Folge-Fristen konnten nicht geladen werden.",
|
||||
"deadlines.overhaul.empty": "Keine Folge-Fristen f\u00fcr dieses Ereignis hinterlegt.",
|
||||
"deadlines.overhaul.trigger.label": "Trigger-Ereignis",
|
||||
"deadlines.overhaul.trigger.date": "Trigger-Datum:",
|
||||
"deadlines.overhaul.followups.label": "Folge-Fristen",
|
||||
"deadlines.overhaul.group.mandatory": "Pflicht",
|
||||
"deadlines.overhaul.group.recommended": "Empfohlen",
|
||||
"deadlines.overhaul.group.optional": "Kann (auf Antrag)",
|
||||
"deadlines.overhaul.group.conditional": "Bedingt",
|
||||
"deadlines.overhaul.spawn.badge": "\u21f2 neues Verfahren",
|
||||
"deadlines.overhaul.spawn.tooltip": "Diese Regel leitet ein neues Verfahren ein.",
|
||||
"deadlines.overhaul.condition.badge": "Nur unter Bedingung",
|
||||
"deadlines.overhaul.notes.summary": "Hinweis",
|
||||
"deadlines.overhaul.edit_date.label": "\u270f Datum",
|
||||
"deadlines.overhaul.edit_date.title": "Datum manuell anpassen",
|
||||
"deadlines.overhaul.select_rule": "Frist ausw\u00e4hlen",
|
||||
"deadlines.overhaul.footer.count": "{n} Fristen ausgew\u00e4hlt",
|
||||
"deadlines.overhaul.footer.cta": "In Akte eintragen",
|
||||
"deadlines.overhaul.nudge.no_project": "Tipp: W\u00e4hle oben eine Akte, um diese Fristen einzutragen.",
|
||||
"deadlines.party.claimant": "Kl\u00e4gerseite",
|
||||
"deadlines.party.defendant": "Beklagtenseite",
|
||||
"deadlines.party.both": "Beide Seiten",
|
||||
"deadlines.party.court": "Gericht",
|
||||
|
||||
// Office labels (shared)
|
||||
"office.munich": "M\u00fcnchen",
|
||||
"office.duesseldorf": "D\u00fcsseldorf",
|
||||
@@ -4122,6 +4148,32 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.save.error": "Import failed.",
|
||||
"deadlines.save.skip_court_set": "Court-set entries with no date will be skipped.",
|
||||
|
||||
// Fristenrechner overhaul — shared result view (S2, design §4).
|
||||
"deadlines.overhaul.loading": "Loading follow-up deadlines…",
|
||||
"deadlines.overhaul.load_error": "Could not load follow-up deadlines.",
|
||||
"deadlines.overhaul.empty": "No follow-up deadlines configured for this event.",
|
||||
"deadlines.overhaul.trigger.label": "Trigger event",
|
||||
"deadlines.overhaul.trigger.date": "Trigger date:",
|
||||
"deadlines.overhaul.followups.label": "Follow-up deadlines",
|
||||
"deadlines.overhaul.group.mandatory": "Mandatory",
|
||||
"deadlines.overhaul.group.recommended": "Recommended",
|
||||
"deadlines.overhaul.group.optional": "Optional",
|
||||
"deadlines.overhaul.group.conditional": "Conditional",
|
||||
"deadlines.overhaul.spawn.badge": "⇲ new proceeding",
|
||||
"deadlines.overhaul.spawn.tooltip": "This rule initiates a new proceeding.",
|
||||
"deadlines.overhaul.condition.badge": "Conditional",
|
||||
"deadlines.overhaul.notes.summary": "Note",
|
||||
"deadlines.overhaul.edit_date.label": "✏ Date",
|
||||
"deadlines.overhaul.edit_date.title": "Edit date manually",
|
||||
"deadlines.overhaul.select_rule": "Select deadline",
|
||||
"deadlines.overhaul.footer.count": "{n} deadlines selected",
|
||||
"deadlines.overhaul.footer.cta": "Add to project",
|
||||
"deadlines.overhaul.nudge.no_project": "Tip: pick a project above to import these deadlines.",
|
||||
"deadlines.party.claimant": "Claimant",
|
||||
"deadlines.party.defendant": "Defendant",
|
||||
"deadlines.party.both": "Both parties",
|
||||
"deadlines.party.court": "Court",
|
||||
|
||||
// Office labels (shared)
|
||||
"office.munich": "Munich",
|
||||
"office.duesseldorf": "D\u00fcsseldorf",
|
||||
|
||||
@@ -123,6 +123,15 @@ export function renderFristenrechner(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-323 Slice S2 — overhaul result view mount root.
|
||||
Hidden by default; the client module shows this and hides
|
||||
the legacy panels when `?overhaul=1` is present in the
|
||||
URL. Deep-linkable on its own via
|
||||
`?overhaul=1&event=<code>&trigger_date=…`. Mode A (S3)
|
||||
and Mode B wizard (S4) will land users on this surface
|
||||
once they identify a trigger procedural_event. */}
|
||||
<div className="fristen-overhaul-root" id="fristen-overhaul-root" hidden></div>
|
||||
|
||||
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
|
||||
Akte (project) that scopes the rest of the flow. Filtered
|
||||
list of visible projects + "Neue Akte anlegen" link +
|
||||
|
||||
@@ -1377,6 +1377,26 @@ export type I18nKey =
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.notes.show"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.overhaul.condition.badge"
|
||||
| "deadlines.overhaul.edit_date.label"
|
||||
| "deadlines.overhaul.edit_date.title"
|
||||
| "deadlines.overhaul.empty"
|
||||
| "deadlines.overhaul.followups.label"
|
||||
| "deadlines.overhaul.footer.count"
|
||||
| "deadlines.overhaul.footer.cta"
|
||||
| "deadlines.overhaul.group.conditional"
|
||||
| "deadlines.overhaul.group.mandatory"
|
||||
| "deadlines.overhaul.group.optional"
|
||||
| "deadlines.overhaul.group.recommended"
|
||||
| "deadlines.overhaul.load_error"
|
||||
| "deadlines.overhaul.loading"
|
||||
| "deadlines.overhaul.notes.summary"
|
||||
| "deadlines.overhaul.nudge.no_project"
|
||||
| "deadlines.overhaul.select_rule"
|
||||
| "deadlines.overhaul.spawn.badge"
|
||||
| "deadlines.overhaul.spawn.tooltip"
|
||||
| "deadlines.overhaul.trigger.date"
|
||||
| "deadlines.overhaul.trigger.label"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
| "deadlines.party.claimant"
|
||||
|
||||
@@ -18886,3 +18886,343 @@ dialog.quick-add-sheet::backdrop {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Fristenrechner overhaul (t-paliad-323 Slice S2) =================
|
||||
*
|
||||
* Result-view surface mounted under `?overhaul=1`. Sticky trigger card
|
||||
* on top, four priority groups of follow-up rules, write-back footer
|
||||
* conditional on `?project=<uuid>`. See
|
||||
* docs/design-fristenrechner-overhaul-2026-05-26.md §4.
|
||||
* ==================================================================== */
|
||||
|
||||
.fristen-overhaul-root {
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-loading,
|
||||
.fristen-overhaul-error,
|
||||
.fristen-overhaul-empty,
|
||||
.fristen-overhaul-nudge {
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: 0.6rem;
|
||||
margin: 0.5rem 0;
|
||||
background: #f4f4f0;
|
||||
border: 1px solid #e3e3da;
|
||||
color: #444;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-error {
|
||||
background: #fde9e7;
|
||||
border-color: #f0b8b1;
|
||||
color: #732f25;
|
||||
}
|
||||
|
||||
.fristen-overhaul-nudge {
|
||||
background: #f8fbe8;
|
||||
border-color: #d2e08b;
|
||||
color: #4d5a2a;
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger {
|
||||
background: #fff;
|
||||
border: 1px solid #d8d8cf;
|
||||
border-radius: 0.8rem;
|
||||
padding: 1rem 1.2rem;
|
||||
margin-bottom: 1.2rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-kind-icon {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger-code,
|
||||
.fristen-overhaul-trigger-pt,
|
||||
.fristen-overhaul-trigger-juris {
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 0.4rem;
|
||||
background: #f1f1eb;
|
||||
color: #555;
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger-juris {
|
||||
background: #d3edb7;
|
||||
color: #38531a;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger-date-label {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.fristen-overhaul-trigger-date-input {
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid #c8c8be;
|
||||
border-radius: 0.4rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.fristen-overhaul-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-group {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e2d6;
|
||||
border-radius: 0.7rem;
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-group--mandatory { border-left: 4px solid #c6f41c; }
|
||||
.fristen-overhaul-group--recommended { border-left: 4px solid #99c4e3; }
|
||||
.fristen-overhaul-group--optional { border-left: 4px solid #d4d4cc; }
|
||||
.fristen-overhaul-group--conditional { border-left: 4px solid #f5b66a; }
|
||||
|
||||
.fristen-overhaul-group-title {
|
||||
margin: 0 0 0.6rem 0;
|
||||
font-size: 1rem;
|
||||
color: #2a2a2a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.7rem;
|
||||
align-items: start;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: #fafaf6;
|
||||
border: 1px solid #ececde;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule.is-disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-title {
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-spawn,
|
||||
.fristen-overhaul-rule-cond {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 0.35rem;
|
||||
background: #f3e5cf;
|
||||
color: #6e4a1d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-cond {
|
||||
background: #fff2d6;
|
||||
color: #7a570e;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-duration {
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-party {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
background: #eef2e3;
|
||||
color: #4a5d2a;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-party--claimant { background: #d2e9ff; color: #1c4567; }
|
||||
.fristen-overhaul-rule-party--defendant { background: #ffe2d7; color: #6e2c14; }
|
||||
.fristen-overhaul-rule-party--court { background: #f0e2f7; color: #4f2c66; }
|
||||
|
||||
.fristen-overhaul-rule-source {
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
a.fristen-overhaul-rule-source {
|
||||
color: #2d4f1a;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-notes {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-notes summary {
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-date-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
min-width: 6.5rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-date {
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-date--unknown {
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-court-set {
|
||||
color: #6e4a1d;
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-date-input {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid #c8c8be;
|
||||
border-radius: 0.3rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-edit-date {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #4a6f1f;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-edit-date:hover {
|
||||
background: #eef4dd;
|
||||
}
|
||||
|
||||
.fristen-overhaul-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1.2rem;
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: #f7fbe6;
|
||||
border: 1px solid #d3e08b;
|
||||
border-radius: 0.7rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-footer-count {
|
||||
font-size: 0.95rem;
|
||||
color: #3d501c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fristen-overhaul-footer-cta {
|
||||
/* leans on btn-primary / btn-cta-lime classes from global */
|
||||
}
|
||||
|
||||
.fristen-overhaul-msg {
|
||||
margin-top: 0.8rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.fristen-overhaul-msg.form-msg-ok { background: #e7f4d6; color: #3a5113; }
|
||||
.fristen-overhaul-msg.form-msg-error { background: #fde9e7; color: #732f25; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.fristen-overhaul-rule {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
.fristen-overhaul-rule-date-cell {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
53
internal/db/migrations/153_proceeding_types_kind.down.sql
Normal file
53
internal/db/migrations/153_proceeding_types_kind.down.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- 153_proceeding_types_kind.down — t-paliad-325 / m/paliad#147
|
||||
--
|
||||
-- Best-effort rollback of mig 153. Restores the pre-mig state of
|
||||
-- paliad.proceeding_types from the same-TX snapshot, drops the kind
|
||||
-- column, drops the backstop trigger.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 153 down: revert proceeding_types kind discriminator',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Drop the backstop trigger + function.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
|
||||
ON paliad.projects;
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_kind_check();
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Restore is_active flags from the snapshot. We only touch rows
|
||||
-- whose is_active value diverged from the snapshot — i.e. the 23
|
||||
-- rows that mig 153 §4 deactivated.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types pt
|
||||
SET is_active = pre.is_active
|
||||
FROM paliad.proceeding_types_pre_153 pre
|
||||
WHERE pt.id = pre.id
|
||||
AND pt.is_active IS DISTINCT FROM pre.is_active;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Drop the kind column (cascades the index).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DROP INDEX IF EXISTS paliad.proceeding_types_kind_active_idx;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS kind;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Drop the snapshot table.
|
||||
-- (The CHECK constraint on the kind column is dropped implicitly
|
||||
-- when the column is dropped.)
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DROP TABLE IF EXISTS paliad.proceeding_types_pre_153;
|
||||
|
||||
COMMIT;
|
||||
201
internal/db/migrations/153_proceeding_types_kind.up.sql
Normal file
201
internal/db/migrations/153_proceeding_types_kind.up.sql
Normal file
@@ -0,0 +1,201 @@
|
||||
-- 153_proceeding_types_kind — t-paliad-325 / m/paliad#147
|
||||
--
|
||||
-- Purpose: tag every paliad.proceeding_types row with a structural
|
||||
-- classification so the Mode B R3 wizard (Fristenrechner overhaul,
|
||||
-- m/paliad#146), the projects.proceeding_type_id binding, and the
|
||||
-- pkg/litigationplanner snapshot can filter to primary proceedings
|
||||
-- only — separating self-contained matters from CFI phases,
|
||||
-- in-proceeding side-actions, and cross-cutting RoP/admin rows.
|
||||
--
|
||||
-- Design: docs/design-proceeding-types-taxonomy-2026-05-26.md
|
||||
-- §0–§10 (m ratified 2026-05-27 09:52 via 11-question AskUserQuestion
|
||||
-- batch; "proceed, sure" greenlight at 09:57).
|
||||
--
|
||||
-- This mig is purely additive: ALTER TABLE adds the kind column with
|
||||
-- a safe DEFAULT, UPDATEs reclassify the 23 non-primary rows, and a
|
||||
-- BEFORE INSERT/UPDATE trigger backstops the new
|
||||
-- "projects.proceeding_type_id must point at kind='proceeding'"
|
||||
-- invariant. The 23 rows being reclassified have zero downstream
|
||||
-- consumers today (0 active sequencing_rules anchor, 0 spawn, 0
|
||||
-- projects bind, 0 event_category_concepts reference) so no FK
|
||||
-- reparenting is needed — verified via Supabase MCP 2026-05-27
|
||||
-- before write.
|
||||
--
|
||||
-- Hard constraints honoured (mirrors precedent migs 091/093/095/098/
|
||||
-- 140/151/152):
|
||||
-- * No deletions. Non-primary rows flip is_active=false but stay in
|
||||
-- the table for audit + future re-activation.
|
||||
-- * Snapshot the affected proceeding_types into
|
||||
-- paliad.proceeding_types_pre_153 in the same TX.
|
||||
-- * set_config('paliad.audit_reason') is defensively called even
|
||||
-- though no audit trigger fires on proceeding_types today; a
|
||||
-- future audit trigger would inherit the reason automatically.
|
||||
-- * Idempotent on re-apply — the ADD COLUMN uses IF NOT EXISTS
|
||||
-- semantics through golang-migrate's tracker (mig only fires
|
||||
-- once); the UPDATEs only touch rows that match the explicit ID
|
||||
-- list from the ratified design §3.2 / §10.2.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 153: proceeding_types kind discriminator (m/paliad#147)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Snapshot the pre-mig state for audit + rollback safety.
|
||||
-- Mirrors precedent: sequencing_rules_pre_151/_pre_152,
|
||||
-- procedural_events_pre_151.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.proceeding_types_pre_153 AS
|
||||
SELECT * FROM paliad.proceeding_types;
|
||||
|
||||
COMMENT ON TABLE paliad.proceeding_types_pre_153 IS
|
||||
'Snapshot of paliad.proceeding_types taken in the same TX as '
|
||||
'mig 153 (kind discriminator). Audit + rollback safety per the '
|
||||
'precedent set by migs 091/093/095/098/140/151/152. Drop only '
|
||||
'when the kind taxonomy has held in prod for at least one '
|
||||
'release cycle and no rollback is anticipated.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Add the kind column.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
|
||||
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.kind IS
|
||||
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
|
||||
'proceeding = self-contained matter (own filing + deadline tree); '
|
||||
'phase = stage inside a primary CFI proceeding; '
|
||||
'side_action = application/order inside a proceeding; '
|
||||
'meta = RoP mechanics, court admin, cross-cutting remedies.';
|
||||
|
||||
CREATE INDEX proceeding_types_kind_active_idx
|
||||
ON paliad.proceeding_types(kind, is_active)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Reclassify the 23 non-primary rows.
|
||||
-- IDs per ratified design §3.2 / §10.2. m's Q2 carve-out keeps
|
||||
-- upc.costs.cfi (176) as kind='proceeding' (defaults to that);
|
||||
-- Q3.b keeps upc.pl.cfi (188) as kind='proceeding' (defaults).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
-- 3.1 Phases: 4 rows (Q2 carve-out drops upc.costs.cfi from the original 5).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'phase'
|
||||
WHERE id IN (173, 174, 175, 185);
|
||||
|
||||
-- 3.2 Side-actions: 10 rows (§0.4 Group C).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'side_action'
|
||||
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183);
|
||||
|
||||
-- 3.3 Meta / cross-cutting: 9 rows (§0.4 Group D incl. upc.reestablishment.rop).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'meta'
|
||||
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169);
|
||||
|
||||
-- 3.4 Defensive integrity check — every reclassified ID must have been
|
||||
-- reached. If the live table drifted between design (2026-05-26)
|
||||
-- and apply, this raises before the trigger ships.
|
||||
DO $$
|
||||
DECLARE
|
||||
expected int := 23;
|
||||
actual int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO actual
|
||||
FROM paliad.proceeding_types
|
||||
WHERE kind <> 'proceeding';
|
||||
IF actual <> expected THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 153] expected % rows reclassified to non-proceeding kind, found % — '
|
||||
'live IDs drifted from the design. Abort.',
|
||||
expected, actual;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 153] reclassified % rows: 4 phase + 10 side_action + 9 meta', actual;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Per m's Q9: deactivate the non-primary rows so the admin list
|
||||
-- surfaces only primaries. The kind column carries the semantic
|
||||
-- info; is_active controls UI visibility. Reversible — flip
|
||||
-- is_active back on if a row gains corpus.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. Backstop trigger on projects.proceeding_type_id (§3.3 + Q8).
|
||||
-- Complements mig 088's category check; rejects any
|
||||
-- INSERT/UPDATE that would bind a project to a non-proceeding
|
||||
-- kind. Independent from the category trigger so each invariant
|
||||
-- can be dropped in isolation.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_kind_check()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_kind text;
|
||||
BEGIN
|
||||
IF NEW.proceeding_type_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT kind INTO v_kind
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = NEW.proceeding_type_id;
|
||||
|
||||
IF v_kind IS NULL THEN
|
||||
-- FK should have caught this; defensive for any future FK relax.
|
||||
RAISE EXCEPTION
|
||||
'paliad.projects.proceeding_type_id = % does not resolve to a '
|
||||
'proceeding_types row — FK constraint should have caught this.',
|
||||
NEW.proceeding_type_id;
|
||||
END IF;
|
||||
|
||||
IF v_kind <> 'proceeding' THEN
|
||||
RAISE EXCEPTION
|
||||
'paliad.projects.proceeding_type_id must reference a kind=''proceeding'' '
|
||||
'proceeding_types row (got kind=''%''). '
|
||||
'Verfahrenstyp muss ein primäres Verfahren sein (kind=''%''). '
|
||||
'Phasen, Nebenanträge und RoP-Querschnittsregeln sind keine '
|
||||
'wählbaren Projekt-Verfahrenstypen.',
|
||||
v_kind, v_kind
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.projects_proceeding_type_kind_check() IS
|
||||
'BEFORE INSERT/UPDATE trigger function enforcing the mig 153 '
|
||||
'invariant: paliad.projects.proceeding_type_id may only '
|
||||
'reference kind=''proceeding'' proceeding_types rows. NULL is '
|
||||
'allowed. Complements mig 088''s category check.';
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
|
||||
ON paliad.projects;
|
||||
|
||||
CREATE TRIGGER projects_proceeding_type_kind_check
|
||||
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.projects_proceeding_type_kind_check();
|
||||
|
||||
COMMENT ON TRIGGER projects_proceeding_type_kind_check ON paliad.projects IS
|
||||
'mig 153 (t-paliad-325 / m/paliad#147) runtime guard — rejects '
|
||||
'any INSERT/UPDATE that would bind a project to a phase/'
|
||||
'side_action/meta proceeding_types row. The Go service layer '
|
||||
'also enforces this with a typed error; this trigger is the '
|
||||
'defence-in-depth backstop.';
|
||||
|
||||
COMMIT;
|
||||
65
internal/handlers/fristenrechner_followups.go
Normal file
65
internal/handlers/fristenrechner_followups.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/tools/fristenrechner/follow-ups — given a trigger event and
|
||||
// a trigger date, return the immediate follow-up sequencing rules with
|
||||
// their computed due dates (Fristenrechner overhaul S1, design §6.2).
|
||||
//
|
||||
// Query params:
|
||||
// event - procedural_events.code OR procedural_events.id
|
||||
// (uuid) OR sequencing_rules.id (uuid). Required.
|
||||
// trigger_date - YYYY-MM-DD. Defaults to today when omitted, so the
|
||||
// frontend can show a result preview before the user
|
||||
// commits a date.
|
||||
// party - "claimant" | "defendant" | "court" | "both".
|
||||
// Optional; narrows follow-ups by primary_party
|
||||
// (claimant/defendant filters keep "both" rules
|
||||
// visible — they're bilateral procedural moves).
|
||||
// court_id - paliad.courts.id (uuid); selects the holiday
|
||||
// calendar for date adjustment. Optional.
|
||||
func handleFristenrechnerFollowUps(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.fristenrechner == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
eventRef := q.Get("event")
|
||||
if eventRef == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "event ist erforderlich (procedural_events.code oder id)",
|
||||
})
|
||||
return
|
||||
}
|
||||
triggerDate := q.Get("trigger_date")
|
||||
if triggerDate == "" {
|
||||
triggerDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.LookupFollowUps(
|
||||
r.Context(),
|
||||
eventRef,
|
||||
triggerDate,
|
||||
q.Get("party"),
|
||||
q.Get("court_id"),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceduralEvent) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": "Unbekanntes Ereignis: " + eventRef,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -32,6 +32,10 @@ import (
|
||||
// dpma). Trigger pills bypass this filter.
|
||||
// limit - max cards (default 12, max 30; in browse
|
||||
// modes default 200, max 500)
|
||||
// kind - "events" switches to the events-shape
|
||||
// response (Fristenrechner overhaul S1,
|
||||
// design §6.1). The default concept-card
|
||||
// shape is unchanged when kind is empty.
|
||||
//
|
||||
// Returns an empty cards array (not 400) when q is empty — that lets
|
||||
// the frontend boot the search input without a server round-trip.
|
||||
@@ -42,6 +46,10 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("kind") == "events" {
|
||||
handleFristenrechnerSearchEvents(w, r)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
opts := services.SearchOptions{
|
||||
Party: r.URL.Query().Get("party"),
|
||||
@@ -60,6 +68,35 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleFristenrechnerSearchEvents serves the ?kind=events shape of
|
||||
// /api/tools/fristenrechner/search (overhaul S1, design §6.1). Returns
|
||||
// one hit per (procedural_event × proceeding_type) tuple, with a
|
||||
// follow-up count and a trigram similarity score.
|
||||
//
|
||||
// Query params (additive to the legacy search params):
|
||||
// q - free-text search against name / name_en / code
|
||||
// jurisdiction - "UPC" | "DE" | "EPA" | "DPMA"
|
||||
// proc - proceeding_type code
|
||||
// event_kind - "filing" | "hearing" | "decision" | "order"
|
||||
// party - primary_party of the anchor rule
|
||||
// limit - max hits (default 50, max 200)
|
||||
func handleFristenrechnerSearchEvents(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
opts := services.EventSearchOptions{
|
||||
Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))),
|
||||
ProceedingTypeCode: r.URL.Query().Get("proc"),
|
||||
EventKind: r.URL.Query().Get("event_kind"),
|
||||
PrimaryParty: r.URL.Query().Get("party"),
|
||||
Limit: parseLimit(r.URL.Query().Get("limit")),
|
||||
}
|
||||
resp, err := dbSvc.deadlineSearch.SearchEvents(r.Context(), q, opts)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Ereignis-Suche fehlgeschlagen: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// parseCSV splits a comma-separated query-string value into a slice of
|
||||
// trimmed non-empty entries. Empty input → nil.
|
||||
func parseCSV(raw string) []string {
|
||||
|
||||
@@ -307,6 +307,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/tools/event-trigger", handleEventTriggerCalculate)
|
||||
protected.HandleFunc("GET /api/tools/courts", handleCourtsList)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/follow-ups", handleFristenrechnerFollowUps)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories)
|
||||
protected.HandleFunc("GET /downloads", handleDownloadsPage)
|
||||
protected.HandleFunc("GET /glossary", handleGlossaryPage)
|
||||
|
||||
404
internal/services/fristenrechner_followups.go
Normal file
404
internal/services/fristenrechner_followups.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// ErrUnknownProceduralEvent is returned by LookupFollowUps when the
|
||||
// requested procedural_event cannot be resolved (unknown id / unknown
|
||||
// code / not active+published). Distinct from ErrUnknownTriggerEvent
|
||||
// (which lives on the legacy Pipeline C / paliad.trigger_events path).
|
||||
var ErrUnknownProceduralEvent = errors.New("unknown procedural event")
|
||||
|
||||
// FollowUpsResponse is the wire shape for GET
|
||||
// /api/tools/fristenrechner/follow-ups (Fristenrechner overhaul S1,
|
||||
// design §6.2). Captures the locked trigger event + every immediate
|
||||
// follow-up rule with its computed due date.
|
||||
type FollowUpsResponse struct {
|
||||
Trigger FollowUpTrigger `json:"trigger"`
|
||||
TriggerDate string `json:"trigger_date"`
|
||||
Party *string `json:"party,omitempty"`
|
||||
FollowUps []FollowUpRule `json:"follow_ups"`
|
||||
}
|
||||
|
||||
// FollowUpTrigger is the locked trigger event identity returned by
|
||||
// LookupFollowUps.
|
||||
type FollowUpTrigger struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
ProceedingType EventSearchPT `json:"proceeding_type"`
|
||||
AnchorRuleID uuid.UUID `json:"anchor_rule_id"`
|
||||
}
|
||||
|
||||
// FollowUpRule is one follow-up deadline returned by LookupFollowUps.
|
||||
// Carries the rule metadata + the computed due date (or the
|
||||
// "wird vom Gericht bestimmt" / "abhängig von …" marker for rules whose
|
||||
// date is undefined).
|
||||
type FollowUpRule struct {
|
||||
RuleID uuid.UUID `json:"rule_id"`
|
||||
EventCode string `json:"event_code"`
|
||||
TitleDE string `json:"title_de"`
|
||||
TitleEN string `json:"title_en"`
|
||||
Priority string `json:"priority"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
DurationValue *int `json:"duration_value,omitempty"`
|
||||
DurationUnit *string `json:"duration_unit,omitempty"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
OriginalDueDate string `json:"original_due_date,omitempty"`
|
||||
WasAdjusted bool `json:"was_adjusted,omitempty"`
|
||||
IsCourtSet bool `json:"is_court_set"`
|
||||
IsSpawn bool `json:"is_spawn"`
|
||||
IsBilateral bool `json:"is_bilateral"`
|
||||
HasCondition bool `json:"has_condition"`
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
LegalSource *string `json:"legal_source,omitempty"`
|
||||
LegalSourceDisplay *string `json:"legal_source_display,omitempty"`
|
||||
LegalSourceURL *string `json:"legal_source_url,omitempty"`
|
||||
NotesDE *string `json:"notes_de,omitempty"`
|
||||
NotesEN *string `json:"notes_en,omitempty"`
|
||||
SpawnLabel *string `json:"spawn_label,omitempty"`
|
||||
SpawnProceedingCode *string `json:"spawn_proceeding_code,omitempty"`
|
||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||
}
|
||||
|
||||
// LookupFollowUps returns the follow-up rules anchored on a single
|
||||
// procedural_event, with computed dates run through the holiday-aware
|
||||
// litigationplanner.CalculateRule. Identifies the anchor by either the
|
||||
// procedural_event.id (uuid) or its code; resolves the anchor rule
|
||||
// (the sequencing_rule with procedural_event_id matching), then walks
|
||||
// one hop down via parent_id to collect immediate follow-ups.
|
||||
//
|
||||
// When party is non-empty, follow-ups are filtered to rules whose
|
||||
// primary_party matches OR is "both" (so a defendant filter still
|
||||
// returns bilateral procedural moves like Vertraulichkeitsantrag-
|
||||
// Erwiderung).
|
||||
func (s *FristenrechnerService) LookupFollowUps(
|
||||
ctx context.Context,
|
||||
eventRef string,
|
||||
triggerDateStr string,
|
||||
party string,
|
||||
courtID string,
|
||||
) (*FollowUpsResponse, error) {
|
||||
if eventRef == "" {
|
||||
return nil, fmt.Errorf("eventRef required")
|
||||
}
|
||||
if triggerDateStr == "" {
|
||||
return nil, fmt.Errorf("triggerDate required")
|
||||
}
|
||||
|
||||
anchor, err := s.resolveTriggerEvent(ctx, eventRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &FollowUpsResponse{
|
||||
Trigger: anchor.Trigger,
|
||||
TriggerDate: triggerDateStr,
|
||||
FollowUps: []FollowUpRule{},
|
||||
}
|
||||
if party != "" {
|
||||
p := party
|
||||
resp.Party = &p
|
||||
}
|
||||
|
||||
// Pull the proceeding_type metadata once so we can pass it
|
||||
// downstream to populate the trigger card and to seed the
|
||||
// CalculateRule lookup (which uses RuleID anyway).
|
||||
rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID, party)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
fr := FollowUpRule{
|
||||
RuleID: r.RuleID,
|
||||
EventCode: r.EventCode,
|
||||
TitleDE: r.NameDE,
|
||||
TitleEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
IsCourtSet: r.IsCourtSet,
|
||||
IsSpawn: r.IsSpawn,
|
||||
IsBilateral: r.IsBilateral,
|
||||
HasCondition: r.HasCondition,
|
||||
}
|
||||
if r.PrimaryParty.Valid {
|
||||
v := r.PrimaryParty.String
|
||||
fr.PrimaryParty = &v
|
||||
}
|
||||
if r.DurationValue.Valid {
|
||||
v := int(r.DurationValue.Int32)
|
||||
fr.DurationValue = &v
|
||||
}
|
||||
if r.DurationUnit.Valid {
|
||||
v := r.DurationUnit.String
|
||||
fr.DurationUnit = &v
|
||||
}
|
||||
if r.Timing.Valid {
|
||||
v := r.Timing.String
|
||||
fr.Timing = &v
|
||||
}
|
||||
if r.RuleCode.Valid {
|
||||
v := r.RuleCode.String
|
||||
fr.RuleCode = &v
|
||||
}
|
||||
if r.LegalSource.Valid {
|
||||
v := r.LegalSource.String
|
||||
fr.LegalSource = &v
|
||||
display := lp.FormatLegalSourceDisplay(v)
|
||||
if display != "" {
|
||||
fr.LegalSourceDisplay = &display
|
||||
}
|
||||
url := lp.BuildLegalSourceURL(v)
|
||||
if url != "" {
|
||||
fr.LegalSourceURL = &url
|
||||
}
|
||||
}
|
||||
if r.NotesDE.Valid {
|
||||
v := r.NotesDE.String
|
||||
fr.NotesDE = &v
|
||||
}
|
||||
if r.NotesEN.Valid {
|
||||
v := r.NotesEN.String
|
||||
fr.NotesEN = &v
|
||||
}
|
||||
if r.SpawnLabel.Valid {
|
||||
v := r.SpawnLabel.String
|
||||
fr.SpawnLabel = &v
|
||||
}
|
||||
if r.SpawnProceedingCode.Valid {
|
||||
v := r.SpawnProceedingCode.String
|
||||
fr.SpawnProceedingCode = &v
|
||||
}
|
||||
if r.ConceptID != nil {
|
||||
fr.ConceptID = r.ConceptID
|
||||
}
|
||||
|
||||
// Skip date computation for court-set / spawn rules — they don't
|
||||
// project a calendar date here.
|
||||
if !r.IsCourtSet && !r.IsSpawn {
|
||||
calc, err := s.CalculateRule(ctx, lp.CalcRuleParams{
|
||||
RuleID: r.RuleID.String(),
|
||||
TriggerDate: triggerDateStr,
|
||||
CourtID: courtID,
|
||||
})
|
||||
if err == nil {
|
||||
fr.DueDate = calc.DueDate
|
||||
fr.OriginalDueDate = calc.OriginalDate
|
||||
fr.WasAdjusted = calc.WasAdjusted
|
||||
}
|
||||
// On error: leave the date fields empty — the frontend
|
||||
// already handles missing dates as "abhängig von ..." style
|
||||
// markers and a single bad rule shouldn't 500 the whole
|
||||
// follow-up list.
|
||||
}
|
||||
|
||||
resp.FollowUps = append(resp.FollowUps, fr)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// anchorResolution carries the resolver output: the trigger card metadata
|
||||
// plus the anchor rule id (the sequencing_rule.id whose
|
||||
// procedural_event_id equals the trigger event).
|
||||
type anchorResolution struct {
|
||||
Trigger FollowUpTrigger
|
||||
AnchorRuleID uuid.UUID
|
||||
}
|
||||
|
||||
// resolveTriggerEvent looks up the trigger event by either uuid or code.
|
||||
// Returns ErrUnknownTriggerEvent when no published+active anchor row
|
||||
// matches.
|
||||
func (s *FristenrechnerService) resolveTriggerEvent(ctx context.Context, ref string) (*anchorResolution, error) {
|
||||
// Try uuid first; fall back to code lookup.
|
||||
type row struct {
|
||||
EventID uuid.UUID `db:"event_id"`
|
||||
Code string `db:"code"`
|
||||
NameDE string `db:"name_de"`
|
||||
NameEN string `db:"name_en"`
|
||||
EventKind sql.NullString `db:"event_kind"`
|
||||
AnchorRuleID uuid.UUID `db:"anchor_rule_id"`
|
||||
PTID int `db:"pt_id"`
|
||||
PTCode string `db:"pt_code"`
|
||||
PTNameDE string `db:"pt_name_de"`
|
||||
PTNameEN string `db:"pt_name_en"`
|
||||
PTJurisdiction sql.NullString `db:"pt_jurisdiction"`
|
||||
}
|
||||
|
||||
var r row
|
||||
queryBase := `
|
||||
SELECT pe.id AS event_id,
|
||||
pe.code,
|
||||
pe.name AS name_de,
|
||||
pe.name_en,
|
||||
pe.event_kind,
|
||||
sr.id AS anchor_rule_id,
|
||||
pt.id AS pt_id,
|
||||
pt.code AS pt_code,
|
||||
pt.name AS pt_name_de,
|
||||
pt.name_en AS pt_name_en,
|
||||
pt.jurisdiction AS pt_jurisdiction
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
WHERE sr.is_active = true
|
||||
AND sr.lifecycle_state = 'published'
|
||||
AND pe.is_active = true
|
||||
AND pe.lifecycle_state = 'published'
|
||||
AND pt.is_active = true
|
||||
AND %s
|
||||
ORDER BY pt.sort_order
|
||||
LIMIT 1`
|
||||
|
||||
if id, err := uuid.Parse(ref); err == nil {
|
||||
// Treat as a procedural_event id OR a sequencing_rule id (the
|
||||
// frontend may pass either — search returns event id but a
|
||||
// concept-card-derived flow may pass the rule id).
|
||||
err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "(pe.id = $1 OR sr.id = $1)"), id)
|
||||
if err == nil {
|
||||
goto found
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("resolve trigger event by id: %w", err)
|
||||
}
|
||||
// fall through to code lookup
|
||||
}
|
||||
{
|
||||
err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "pe.code = $1"), ref)
|
||||
if err == nil {
|
||||
goto found
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUnknownProceduralEvent
|
||||
}
|
||||
return nil, fmt.Errorf("resolve trigger event by code: %w", err)
|
||||
}
|
||||
|
||||
found:
|
||||
res := &anchorResolution{
|
||||
AnchorRuleID: r.AnchorRuleID,
|
||||
Trigger: FollowUpTrigger{
|
||||
ID: r.EventID,
|
||||
Code: r.Code,
|
||||
NameDE: r.NameDE,
|
||||
NameEN: r.NameEN,
|
||||
AnchorRuleID: r.AnchorRuleID,
|
||||
ProceedingType: EventSearchPT{
|
||||
ID: r.PTID,
|
||||
Code: r.PTCode,
|
||||
NameDE: r.PTNameDE,
|
||||
NameEN: r.PTNameEN,
|
||||
},
|
||||
},
|
||||
}
|
||||
if r.EventKind.Valid {
|
||||
v := r.EventKind.String
|
||||
res.Trigger.EventKind = &v
|
||||
}
|
||||
if r.PTJurisdiction.Valid {
|
||||
v := r.PTJurisdiction.String
|
||||
res.Trigger.ProceedingType.Jurisdiction = &v
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// followUpRow is the joined SELECT shape for follow-up rules.
|
||||
type followUpRow struct {
|
||||
RuleID uuid.UUID `db:"rule_id"`
|
||||
EventCode string `db:"event_code"`
|
||||
NameDE string `db:"name_de"`
|
||||
NameEN string `db:"name_en"`
|
||||
Priority string `db:"priority"`
|
||||
PrimaryParty sql.NullString `db:"primary_party"`
|
||||
DurationValue sql.NullInt32 `db:"duration_value"`
|
||||
DurationUnit sql.NullString `db:"duration_unit"`
|
||||
Timing sql.NullString `db:"timing"`
|
||||
IsCourtSet bool `db:"is_court_set"`
|
||||
IsSpawn bool `db:"is_spawn"`
|
||||
IsBilateral bool `db:"is_bilateral"`
|
||||
HasCondition bool `db:"has_condition"`
|
||||
RuleCode sql.NullString `db:"rule_code"`
|
||||
LegalSource sql.NullString `db:"legal_source"`
|
||||
NotesDE sql.NullString `db:"notes_de"`
|
||||
NotesEN sql.NullString `db:"notes_en"`
|
||||
SpawnLabel sql.NullString `db:"spawn_label"`
|
||||
SpawnProceedingCode sql.NullString `db:"spawn_proceeding_code"`
|
||||
ConceptID *uuid.UUID `db:"concept_id"`
|
||||
SequenceOrder int `db:"sequence_order"`
|
||||
}
|
||||
|
||||
// queryFollowUpRows pulls the immediate-children rules of an anchor.
|
||||
// Party filter is inclusive of "both" so bilateral moves stay visible
|
||||
// when the user picks claimant or defendant.
|
||||
func (s *FristenrechnerService) queryFollowUpRows(
|
||||
ctx context.Context,
|
||||
anchorRuleID uuid.UUID,
|
||||
party string,
|
||||
) ([]followUpRow, error) {
|
||||
where := []string{
|
||||
"sr.parent_id = $1",
|
||||
"sr.is_active = true",
|
||||
"sr.lifecycle_state = 'published'",
|
||||
"pe.is_active = true",
|
||||
"pe.lifecycle_state = 'published'",
|
||||
}
|
||||
args := []any{anchorRuleID}
|
||||
if party == "claimant" || party == "defendant" {
|
||||
args = append(args, party)
|
||||
where = append(where, fmt.Sprintf(
|
||||
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
|
||||
len(args)))
|
||||
} else if party != "" {
|
||||
// "court" / "both" — exact match
|
||||
args = append(args, party)
|
||||
where = append(where, fmt.Sprintf("sr.primary_party = $%d", len(args)))
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT sr.id AS rule_id,
|
||||
pe.code AS event_code,
|
||||
pe.name AS name_de,
|
||||
pe.name_en,
|
||||
sr.priority,
|
||||
sr.primary_party,
|
||||
sr.duration_value,
|
||||
sr.duration_unit,
|
||||
sr.timing,
|
||||
sr.is_court_set,
|
||||
sr.is_spawn,
|
||||
sr.is_bilateral,
|
||||
(sr.condition_expr IS NOT NULL) AS has_condition,
|
||||
sr.rule_code,
|
||||
ls.citation AS legal_source,
|
||||
sr.deadline_notes AS notes_de,
|
||||
sr.deadline_notes_en AS notes_en,
|
||||
sr.spawn_label,
|
||||
spt.code AS spawn_proceeding_code,
|
||||
pe.concept_id,
|
||||
sr.sequence_order
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
|
||||
LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id
|
||||
WHERE ` + strings.Join(where, "\n AND ") + `
|
||||
ORDER BY sr.sequence_order, pe.code`
|
||||
|
||||
var rows []followUpRow
|
||||
if err := s.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("load follow-up rows: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
205
internal/services/fristenrechner_followups_test.go
Normal file
205
internal/services/fristenrechner_followups_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestSearchEvents covers the ?kind=events response shape for the
|
||||
// Fristenrechner overhaul S1 (design §6.1). Verified against live data:
|
||||
// "Klageerhebung" must return upc.inf.cfi.soc (the canonical SoC
|
||||
// procedural event) as the top hit, with the proceeding metadata
|
||||
// populated and a non-zero follow_up_count.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB
|
||||
// tests in this package.
|
||||
func TestSearchEvents(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()
|
||||
|
||||
svc := NewDeadlineSearchService(pool)
|
||||
|
||||
t.Run("Klageerhebung returns upc.inf.cfi.soc with follow-ups", func(t *testing.T) {
|
||||
resp, err := svc.SearchEvents(ctx, "Klageerhebung", EventSearchOptions{Limit: 30})
|
||||
if err != nil {
|
||||
t.Fatalf("search events: %v", err)
|
||||
}
|
||||
if len(resp.Events) == 0 {
|
||||
t.Fatalf("no events returned for Klageerhebung")
|
||||
}
|
||||
var soc *EventSearchHit
|
||||
for i := range resp.Events {
|
||||
if resp.Events[i].Code == "upc.inf.cfi.soc" {
|
||||
soc = &resp.Events[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if soc == nil {
|
||||
t.Fatalf("upc.inf.cfi.soc not in event hits (got %d hits)", len(resp.Events))
|
||||
}
|
||||
if soc.NameDE == "" {
|
||||
t.Errorf("expected name_de populated, got empty")
|
||||
}
|
||||
if soc.ProceedingType.Code != "upc.inf.cfi" {
|
||||
t.Errorf("expected proceeding upc.inf.cfi, got %q", soc.ProceedingType.Code)
|
||||
}
|
||||
if soc.FollowUpCount <= 0 {
|
||||
t.Errorf("expected follow_up_count > 0 for SoC, got %d", soc.FollowUpCount)
|
||||
}
|
||||
if soc.EventKind == nil || *soc.EventKind != "filing" {
|
||||
gotKind := "<nil>"
|
||||
if soc.EventKind != nil {
|
||||
gotKind = *soc.EventKind
|
||||
}
|
||||
t.Errorf("expected event_kind=filing, got %q", gotKind)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jurisdiction filter narrows to UPC", func(t *testing.T) {
|
||||
resp, err := svc.SearchEvents(ctx, "", EventSearchOptions{
|
||||
Jurisdiction: "UPC",
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search events UPC: %v", err)
|
||||
}
|
||||
if len(resp.Events) == 0 {
|
||||
t.Fatalf("expected UPC events, got 0")
|
||||
}
|
||||
for _, e := range resp.Events {
|
||||
if e.ProceedingType.Jurisdiction == nil || *e.ProceedingType.Jurisdiction != "UPC" {
|
||||
gotJ := "<nil>"
|
||||
if e.ProceedingType.Jurisdiction != nil {
|
||||
gotJ = *e.ProceedingType.Jurisdiction
|
||||
}
|
||||
t.Errorf("non-UPC event leaked: %s (jurisdiction=%q)", e.Code, gotJ)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("event_kind=filing narrows by kind", func(t *testing.T) {
|
||||
resp, err := svc.SearchEvents(ctx, "", EventSearchOptions{
|
||||
EventKind: "filing",
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search events filing: %v", err)
|
||||
}
|
||||
if len(resp.Events) == 0 {
|
||||
t.Fatalf("expected filing events, got 0")
|
||||
}
|
||||
for _, e := range resp.Events {
|
||||
if e.EventKind == nil || *e.EventKind != "filing" {
|
||||
gotKind := "<nil>"
|
||||
if e.EventKind != nil {
|
||||
gotKind = *e.EventKind
|
||||
}
|
||||
t.Errorf("non-filing event leaked: %s (event_kind=%q)", e.Code, gotKind)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLookupFollowUps covers the GET /api/tools/fristenrechner/follow-ups
|
||||
// endpoint contract (overhaul S1, design §6.2). Verified against live
|
||||
// data: looking up upc.inf.cfi.soc returns the four canonical follow-up
|
||||
// rules (Klageerwiderung, CCR, Einspruch, Vertraulichkeits-Erwiderung),
|
||||
// each with a computed due date or court-set marker.
|
||||
func TestLookupFollowUps(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)
|
||||
courts := NewCourtService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
fr := NewFristenrechnerService(rules, holidays, courts)
|
||||
|
||||
t.Run("SoC returns follow-ups with computed dates", func(t *testing.T) {
|
||||
resp, err := fr.LookupFollowUps(ctx, "upc.inf.cfi.soc", "2026-05-20", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup follow-ups: %v", err)
|
||||
}
|
||||
if resp.Trigger.Code != "upc.inf.cfi.soc" {
|
||||
t.Errorf("trigger code = %q, want upc.inf.cfi.soc", resp.Trigger.Code)
|
||||
}
|
||||
if len(resp.FollowUps) == 0 {
|
||||
t.Fatalf("expected follow-ups, got 0")
|
||||
}
|
||||
// At least the Klageerwiderung (sod) should be present and have a date.
|
||||
var sod *FollowUpRule
|
||||
for i := range resp.FollowUps {
|
||||
if resp.FollowUps[i].EventCode == "upc.inf.cfi.sod" {
|
||||
sod = &resp.FollowUps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if sod == nil {
|
||||
t.Fatalf("Klageerwiderung (upc.inf.cfi.sod) not in follow-ups")
|
||||
}
|
||||
if sod.DueDate == "" {
|
||||
t.Errorf("expected due_date populated for sod, got empty")
|
||||
}
|
||||
if sod.Priority != "mandatory" {
|
||||
t.Errorf("expected priority=mandatory for sod, got %q", sod.Priority)
|
||||
}
|
||||
// 3 months after 2026-05-20 (then weekend-adjusted) — sanity check
|
||||
// only that something resembling 2026-08 came back.
|
||||
if len(sod.DueDate) < 7 || sod.DueDate[:7] != "2026-08" {
|
||||
t.Errorf("expected due_date in 2026-08, got %q", sod.DueDate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("party=defendant narrows but keeps bilateral rules", func(t *testing.T) {
|
||||
resp, err := fr.LookupFollowUps(ctx, "upc.inf.cfi.soc", "2026-05-20", "defendant", "")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup follow-ups (defendant): %v", err)
|
||||
}
|
||||
if len(resp.FollowUps) == 0 {
|
||||
t.Fatalf("expected defendant follow-ups, got 0")
|
||||
}
|
||||
for _, r := range resp.FollowUps {
|
||||
if r.PrimaryParty == nil {
|
||||
continue
|
||||
}
|
||||
p := *r.PrimaryParty
|
||||
if p == "claimant" {
|
||||
t.Errorf("claimant-only rule leaked under defendant filter: %s", r.EventCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown event returns ErrUnknownProceduralEvent", func(t *testing.T) {
|
||||
_, err := fr.LookupFollowUps(ctx, "no.such.event", "2026-05-20", "", "")
|
||||
if err != ErrUnknownProceduralEvent {
|
||||
t.Errorf("expected ErrUnknownProceduralEvent, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
257
internal/services/fristenrechner_search_events.go
Normal file
257
internal/services/fristenrechner_search_events.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// EventSearchHit is one ranked hit in the events-shape search response.
|
||||
// Returned by FristenrechnerService.SearchEvents.
|
||||
//
|
||||
// One hit per (procedural_event, proceeding_type) tuple: a single event
|
||||
// can appear in multiple proceedings (the data carries handful of
|
||||
// procedural_event rows whose code is null.* and that are anchored by
|
||||
// rules in different proceedings — those legacy stragglers surface as
|
||||
// multiple hits, one per proceeding context).
|
||||
type EventSearchHit struct {
|
||||
EventID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
ProceedingType EventSearchPT `json:"proceeding_type"`
|
||||
AnchorRuleID uuid.UUID `json:"anchor_rule_id"`
|
||||
FollowUpCount int `json:"follow_up_count"`
|
||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// EventSearchPT is the proceeding-type slice embedded in an EventSearchHit.
|
||||
type EventSearchPT struct {
|
||||
ID int `json:"id"`
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
Jurisdiction *string `json:"jurisdiction,omitempty"`
|
||||
}
|
||||
|
||||
// EventSearchOptions is the filter set for SearchEvents. Empty values
|
||||
// mean "no narrowing on this axis".
|
||||
type EventSearchOptions struct {
|
||||
// Jurisdiction filters by proceeding_types.jurisdiction
|
||||
// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
|
||||
Jurisdiction string
|
||||
// ProceedingTypeCode narrows to one proceeding. Empty = any.
|
||||
ProceedingTypeCode string
|
||||
// EventKind filters by procedural_events.event_kind
|
||||
// ("filing" | "hearing" | "decision" | "order"). Empty = any.
|
||||
EventKind string
|
||||
// PrimaryParty narrows by the anchor rule's primary_party
|
||||
// ("claimant" | "defendant" | "court" | "both"). Empty = any.
|
||||
PrimaryParty string
|
||||
// Limit caps the result set; defaults to 50, max 200.
|
||||
Limit int
|
||||
}
|
||||
|
||||
// EventSearchResponse is the wire shape for ?kind=events on the
|
||||
// /api/tools/fristenrechner/search endpoint (design §6.1).
|
||||
type EventSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Filters EventSearchFilters `json:"filters"`
|
||||
Events []EventSearchHit `json:"events"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// EventSearchFilters is the filter echo returned to the client.
|
||||
type EventSearchFilters struct {
|
||||
Jurisdiction *string `json:"jurisdiction"`
|
||||
ProceedingTypeCode *string `json:"proceeding_type_code"`
|
||||
EventKind *string `json:"event_kind"`
|
||||
PrimaryParty *string `json:"primary_party"`
|
||||
}
|
||||
|
||||
// SearchEvents implements the ?kind=events response shape (Fristenrechner
|
||||
// overhaul S1, design §6.1). Returns one hit per (procedural_event ×
|
||||
// proceeding_type) tuple, ranked by trigram similarity against name /
|
||||
// name_en / code. Empty q returns the unranked catalog filtered by the
|
||||
// supplied facets.
|
||||
func (s *DeadlineSearchService) SearchEvents(ctx context.Context, q string, opts EventSearchOptions) (*EventSearchResponse, error) {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
qNorm := normalizeQuery(q)
|
||||
|
||||
resp := &EventSearchResponse{
|
||||
Query: q,
|
||||
Filters: buildEventFilters(opts),
|
||||
Events: []EventSearchHit{},
|
||||
}
|
||||
|
||||
where := []string{
|
||||
"sr.is_active = true",
|
||||
"sr.lifecycle_state = 'published'",
|
||||
"pe.is_active = true",
|
||||
"pe.lifecycle_state = 'published'",
|
||||
"pt.is_active = true",
|
||||
}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if opts.Jurisdiction != "" {
|
||||
add("pt.jurisdiction = $%d", opts.Jurisdiction)
|
||||
}
|
||||
if opts.ProceedingTypeCode != "" {
|
||||
add("pt.code = $%d", opts.ProceedingTypeCode)
|
||||
}
|
||||
if opts.EventKind != "" {
|
||||
add("pe.event_kind = $%d", opts.EventKind)
|
||||
}
|
||||
if opts.PrimaryParty != "" {
|
||||
add("sr.primary_party = $%d", opts.PrimaryParty)
|
||||
}
|
||||
|
||||
// Trigram score over (name || name_en || code). Empty query collapses
|
||||
// the score to 0 — keeps the SQL identical regardless of input mode.
|
||||
scoreExpr := "0::float8"
|
||||
if qNorm != "" {
|
||||
args = append(args, qNorm)
|
||||
scoreExpr = fmt.Sprintf(
|
||||
`GREATEST(similarity(pe.name, $%[1]d), similarity(pe.name_en, $%[1]d), similarity(pe.code, $%[1]d))`,
|
||||
len(args))
|
||||
// Drop hits with zero similarity so a typo doesn't return the
|
||||
// whole catalog ranked at 0.
|
||||
where = append(where, fmt.Sprintf(
|
||||
`(pe.name %% $%[1]d OR pe.name_en %% $%[1]d OR pe.code %% $%[1]d)`,
|
||||
len(args)))
|
||||
}
|
||||
|
||||
// follow_up_count: rules whose parent_id points at this anchor rule.
|
||||
// Computed via correlated subquery; cheap at the 231-row scale.
|
||||
query := `
|
||||
SELECT pe.id AS event_id,
|
||||
pe.code,
|
||||
pe.name AS name_de,
|
||||
pe.name_en,
|
||||
pe.event_kind,
|
||||
pe.description,
|
||||
sr.primary_party,
|
||||
pe.concept_id,
|
||||
sr.id AS anchor_rule_id,
|
||||
pt.id AS pt_id,
|
||||
pt.code AS pt_code,
|
||||
pt.name AS pt_name_de,
|
||||
pt.name_en AS pt_name_en,
|
||||
pt.jurisdiction AS pt_jurisdiction,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM paliad.sequencing_rules child
|
||||
WHERE child.parent_id = sr.id
|
||||
AND child.is_active = true
|
||||
AND child.lifecycle_state = 'published') AS follow_up_count,
|
||||
` + scoreExpr + ` AS score
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
WHERE ` + strings.Join(where, "\n AND ") + `
|
||||
ORDER BY score DESC, pt.sort_order, pe.code
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args)+1)
|
||||
|
||||
args = append(args, limit)
|
||||
|
||||
type row struct {
|
||||
EventID uuid.UUID `db:"event_id"`
|
||||
Code string `db:"code"`
|
||||
NameDE string `db:"name_de"`
|
||||
NameEN string `db:"name_en"`
|
||||
EventKind sql.NullString `db:"event_kind"`
|
||||
Description sql.NullString `db:"description"`
|
||||
PrimaryParty sql.NullString `db:"primary_party"`
|
||||
ConceptID *uuid.UUID `db:"concept_id"`
|
||||
AnchorRuleID uuid.UUID `db:"anchor_rule_id"`
|
||||
PTID int `db:"pt_id"`
|
||||
PTCode string `db:"pt_code"`
|
||||
PTNameDE string `db:"pt_name_de"`
|
||||
PTNameEN string `db:"pt_name_en"`
|
||||
PTJurisdiction sql.NullString `db:"pt_jurisdiction"`
|
||||
FollowUpCount int `db:"follow_up_count"`
|
||||
Score float64 `db:"score"`
|
||||
}
|
||||
|
||||
var rows []row
|
||||
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("search events: %w", err)
|
||||
}
|
||||
|
||||
hits := make([]EventSearchHit, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
hit := EventSearchHit{
|
||||
EventID: r.EventID,
|
||||
Code: r.Code,
|
||||
NameDE: r.NameDE,
|
||||
NameEN: r.NameEN,
|
||||
AnchorRuleID: r.AnchorRuleID,
|
||||
FollowUpCount: r.FollowUpCount,
|
||||
ConceptID: r.ConceptID,
|
||||
Score: r.Score,
|
||||
ProceedingType: EventSearchPT{
|
||||
ID: r.PTID,
|
||||
Code: r.PTCode,
|
||||
NameDE: r.PTNameDE,
|
||||
NameEN: r.PTNameEN,
|
||||
},
|
||||
}
|
||||
if r.EventKind.Valid {
|
||||
v := r.EventKind.String
|
||||
hit.EventKind = &v
|
||||
}
|
||||
if r.Description.Valid {
|
||||
v := r.Description.String
|
||||
hit.Description = &v
|
||||
}
|
||||
if r.PrimaryParty.Valid {
|
||||
v := r.PrimaryParty.String
|
||||
hit.PrimaryParty = &v
|
||||
}
|
||||
if r.PTJurisdiction.Valid {
|
||||
v := r.PTJurisdiction.String
|
||||
hit.ProceedingType.Jurisdiction = &v
|
||||
}
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
resp.Events = hits
|
||||
resp.Total = len(hits)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildEventFilters(opts EventSearchOptions) EventSearchFilters {
|
||||
f := EventSearchFilters{}
|
||||
if opts.Jurisdiction != "" {
|
||||
v := opts.Jurisdiction
|
||||
f.Jurisdiction = &v
|
||||
}
|
||||
if opts.ProceedingTypeCode != "" {
|
||||
v := opts.ProceedingTypeCode
|
||||
f.ProceedingTypeCode = &v
|
||||
}
|
||||
if opts.EventKind != "" {
|
||||
v := opts.EventKind
|
||||
f.EventKind = &v
|
||||
}
|
||||
if opts.PrimaryParty != "" {
|
||||
v := opts.PrimaryParty
|
||||
f.PrimaryParty = &v
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -58,6 +58,14 @@ var (
|
||||
// surface this as a 400 with a bilingual friendly message; the
|
||||
// matching DB trigger (mig 088) is the defence-in-depth backstop.
|
||||
ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
|
||||
// ErrInvalidProceedingTypeKind signals that the caller supplied a
|
||||
// proceeding_type_id pointing at a non-primary row — i.e. a
|
||||
// phase/side_action/meta row, or an inactive row. Mig 153
|
||||
// (t-paliad-325, design §1) carved the taxonomy so only
|
||||
// kind='proceeding' AND is_active=true rows may bind to a
|
||||
// project. Handlers surface this as a 400; the matching DB
|
||||
// trigger (mig 153) is the defence-in-depth backstop.
|
||||
ErrInvalidProceedingTypeKind = errors.New("proceeding_type_id must reference an active kind='proceeding' proceeding_types row")
|
||||
)
|
||||
|
||||
// ProjectType values enumerated on the projects.type CHECK constraint.
|
||||
@@ -1165,29 +1173,47 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant
|
||||
// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind
|
||||
// to a fristenrechner-category proceeding_types row. NULL passes
|
||||
// through; the matching DB trigger (mig 088) is the defence-in-depth
|
||||
// backstop should this slip somehow.
|
||||
// validateProceedingTypeCategory enforces the project-binding invariants
|
||||
// on paliad.projects.proceeding_type_id:
|
||||
//
|
||||
// Surfaces ErrInvalidProceedingTypeCategory so handlers can map to a
|
||||
// 400 with a bilingual user-facing message.
|
||||
// 1. Phase 3 Slice 5 (t-paliad-186, design §3.F): row must be
|
||||
// category='fristenrechner'. DB-side backstop: mig 088 trigger.
|
||||
// Surfaces ErrInvalidProceedingTypeCategory.
|
||||
//
|
||||
// 2. Mig 153 (t-paliad-325, design §1 + m's Q8): row must be
|
||||
// kind='proceeding' AND is_active=true. DB-side backstop: mig 153
|
||||
// trigger. Surfaces ErrInvalidProceedingTypeKind. Rejects phase /
|
||||
// side_action / meta rows and any deactivated row.
|
||||
//
|
||||
// NULL passes through. The Go layer fires first so handlers get typed
|
||||
// errors; the DB triggers catch any writer that bypasses the service.
|
||||
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
|
||||
if ptID == nil {
|
||||
return nil
|
||||
}
|
||||
var category sql.NullString
|
||||
if err := s.db.GetContext(ctx, &category,
|
||||
`SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
|
||||
var row struct {
|
||||
Category sql.NullString `db:"category"`
|
||||
Kind sql.NullString `db:"kind"`
|
||||
IsActive bool `db:"is_active"`
|
||||
}
|
||||
if err := s.db.GetContext(ctx, &row,
|
||||
`SELECT category, kind, is_active FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
|
||||
}
|
||||
return fmt.Errorf("lookup proceeding_type category: %w", err)
|
||||
return fmt.Errorf("lookup proceeding_type: %w", err)
|
||||
}
|
||||
if !category.Valid || category.String != "fristenrechner" {
|
||||
if !row.Category.Valid || row.Category.String != "fristenrechner" {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
|
||||
ErrInvalidProceedingTypeCategory, *ptID, category.String)
|
||||
ErrInvalidProceedingTypeCategory, *ptID, row.Category.String)
|
||||
}
|
||||
if !row.Kind.Valid || row.Kind.String != "proceeding" {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d has kind=%q",
|
||||
ErrInvalidProceedingTypeKind, *ptID, row.Kind.String)
|
||||
}
|
||||
if !row.IsActive {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d is inactive",
|
||||
ErrInvalidProceedingTypeKind, *ptID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,6 +163,162 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectService_ProceedingTypeKindGuard exercises the mig 153
|
||||
// (t-paliad-325 / m/paliad#147) "kind='proceeding' only" invariant on
|
||||
// paliad.projects.proceeding_type_id from three angles:
|
||||
//
|
||||
// 1. ProjectService.Create returns ErrInvalidProceedingTypeKind when
|
||||
// handed an id pointing at a kind='phase' / 'side_action' / 'meta'
|
||||
// row (the Go service guard fires before the DB trigger).
|
||||
//
|
||||
// 2. ProjectService.Create returns ErrInvalidProceedingTypeKind when
|
||||
// handed an id pointing at a row with is_active=false (mig 153 §4
|
||||
// deactivated all non-primary rows so this is the same set of IDs;
|
||||
// the test still independently asserts the is_active branch by
|
||||
// re-activating a phase row inside the test and confirming the kind
|
||||
// check still fires).
|
||||
//
|
||||
// 3. The mig 153 backstop trigger rejects a raw INSERT that bypasses
|
||||
// the Go service layer (defence-in-depth). Bypasses mig 088's
|
||||
// category trigger by also picking a fristenrechner-category row.
|
||||
//
|
||||
// 4. Passing a kind='proceeding' active id (upc.inf.cfi) still
|
||||
// succeeds — proves the new guard doesn't break the happy path.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring the rest of this
|
||||
// file.
|
||||
func TestProjectService_ProceedingTypeKindGuard(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()
|
||||
|
||||
// A row that is fristenrechner-category but kind != 'proceeding'.
|
||||
// Picks the first phase row by id (deterministic). Falls back to any
|
||||
// non-proceeding kind if no phase rows are present (post-data-drift
|
||||
// hardening).
|
||||
var phaseID int
|
||||
if err := pool.GetContext(ctx, &phaseID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner' AND kind <> 'proceeding'
|
||||
ORDER BY (kind = 'phase') DESC, id
|
||||
LIMIT 1`); err != nil {
|
||||
t.Fatalf("look up non-proceeding kind id: %v", err)
|
||||
}
|
||||
|
||||
// A primary id for the happy-path case + raw-INSERT control.
|
||||
var proceedingID int
|
||||
if err := pool.GetContext(ctx, &proceedingID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner' AND kind = 'proceeding'
|
||||
AND is_active = true AND code = $1`,
|
||||
CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up %s id: %v", CodeUPCInfringement, err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
svc := NewProjectService(pool, users)
|
||||
|
||||
userID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'mig153-guard-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
||||
VALUES ($1, 'mig153-guard-test@hlc.com', 'Mig153 Guard', 'munich', 'associate', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
// 1. Non-proceeding kind id → ErrInvalidProceedingTypeKind from the
|
||||
// service guard. (The row is also is_active=false post-mig-153,
|
||||
// but the kind check fires first.)
|
||||
_, err = svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Mig 153 — non-proceeding-kind reject",
|
||||
ProceedingTypeID: &phaseID,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Create with kind!=proceeding proceeding_type_id should fail, but succeeded")
|
||||
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
|
||||
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
|
||||
}
|
||||
|
||||
// 2. Re-activate the phase row in a savepoint so the kind check
|
||||
// still fires (proves the kind branch isn't shadowed by the
|
||||
// is_active branch).
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.proceeding_types SET is_active = true WHERE id = $1`, phaseID); err != nil {
|
||||
t.Fatalf("re-activate phase row: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pool.ExecContext(ctx,
|
||||
`UPDATE paliad.proceeding_types SET is_active = false WHERE id = $1`, phaseID)
|
||||
})
|
||||
|
||||
_, err = svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Mig 153 — active phase row still rejects on kind",
|
||||
ProceedingTypeID: &phaseID,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Create with active kind=phase row should still fail on kind check; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
|
||||
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
|
||||
}
|
||||
|
||||
// 3. mig 153 trigger — raw INSERT bypassing Go service must raise.
|
||||
// We use the active phase row (still re-activated from step 2)
|
||||
// so we don't trip mig 088's category check first. Both triggers
|
||||
// are independent; mig 153's must fire on a category=fristenrechner
|
||||
// kind!=proceeding row.
|
||||
rawID := uuid.New()
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, rawID)
|
||||
_, err = pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, status, created_by,
|
||||
proceeding_type_id, metadata, created_at, updated_at)
|
||||
VALUES ($1, 'project', NULL, $1::text, 'Mig 153 — trigger bypass', 'active', $2,
|
||||
$3, '{}'::jsonb, now(), now())`,
|
||||
rawID, userID, phaseID)
|
||||
if err == nil {
|
||||
t.Error("raw INSERT with kind!=proceeding proceeding_type_id should have raised; got nil")
|
||||
}
|
||||
|
||||
// 4. Happy path: kind='proceeding' active id → success.
|
||||
created, err := svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Mig 153 — primary proceeding accept",
|
||||
ProceedingTypeID: &proceedingID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create with kind=proceeding proceeding_type_id: %v", err)
|
||||
}
|
||||
if created.ProceedingTypeID == nil || *created.ProceedingTypeID != proceedingID {
|
||||
t.Errorf("created proceeding_type_id = %v, want %d", created.ProceedingTypeID, proceedingID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectService_InstanceLevel_Roundtrip covers the Phase 3 Slice 8
|
||||
// (t-paliad-189) instance_level data path: Create + Update both accept
|
||||
// the four allowed shapes (first / appeal / cassation / NULL) and reject
|
||||
|
||||
Reference in New Issue
Block a user