Merge: t-paliad-292 — Slice B1: Berufung unification (one upc.apl + 5 appeal_target chips, mig 134 additive) (m/paliad#124 §18.1)
This commit is contained in:
@@ -1141,4 +1141,312 @@ Slice F is a youpc-side task; it needs a worker with youpc-go familiarity (a sep
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## §18 Slice B — Catalog Interface + Unifications (2026-05-26)
|
||||||
|
|
||||||
|
Slice A landed atomically at `d1d0cf9`. Before Slice B's coder shift begins, three additional decisions m confirmed today need to be folded into the package design:
|
||||||
|
|
||||||
|
- §18.1 — **Berufung unification**. Collapse the 3 active UPC appeal proceeding_types (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) into ONE `upc.apl` proceeding type + an `appeal_target` discriminator.
|
||||||
|
- §18.2 — **Multi-axis catalog query API**. New `Catalog.LookupEvents` method taking any subset of `{jurisdiction, proceeding_type_id, party, event_category_id}` axes + a depth control (`next` / `all-following`).
|
||||||
|
- §18.3 — **`primary_party` enum tightening**. Convert the free-text `paliad.deadline_rules.primary_party` column to a CHECK constraint matching the four-value vocabulary `claimant / defendant / court / both`.
|
||||||
|
|
||||||
|
Each subsection follows the same shape: motivation → schema impact → API shape → acceptance criteria.
|
||||||
|
|
||||||
|
### §18.0 Live state on main (audit summary)
|
||||||
|
|
||||||
|
Confirmed via Supabase before drafting (`mai/cronus/inventor-litigation-slice-b` branch off `main`):
|
||||||
|
|
||||||
|
- **9 active UPC proceeding_types**: `upc.inf.cfi` (25 rules), `upc.rev.cfi` (17), `upc.pi.cfi` (7), `upc.dmgs.cfi` (8), `upc.disc.cfi` (4), `upc.ccr.cfi` (0 — sub-track), `upc.apl.merits` (7), `upc.apl.cost` (2), `upc.apl.order` (7).
|
||||||
|
- **3 appeal-flavoured proceeding_types** = 16 rules across 3 codes. Schadensbemessung + Bucheinsicht are SEPARATE first-instance proceedings today (`upc.dmgs.cfi`, `upc.disc.cfi`), NOT appeal sub-tracks.
|
||||||
|
- **`paliad.deadline_rules.primary_party`** value distribution: `claimant=26`, `defendant=26`, `court=38`, `both=63`, `NULL=78`. The 78 NULL rows are ALL `proceeding_type_id IS NULL` orphans (cross-cutting concept seeds: Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung — 8 distinct concepts × N rules). Every proceeding-bound rule already has a four-value `primary_party`.
|
||||||
|
- **`paliad.event_categories.party`** column shape: `text[]` (array). Live distinct values: `{claimant}`, `{defendant}`, NULL. No `court` or `both` in event_categories.party today. The semantic is "from whose perspective is this event triggered?" — narrower than `primary_party` which is "who files this submission".
|
||||||
|
|
||||||
|
### §18.1 Berufung unification
|
||||||
|
|
||||||
|
#### Motivation
|
||||||
|
|
||||||
|
m's framing (2026-05-26 09:55, t-paliad-298 instructions): *"the Verfahrensablauf event picker has 4-5 separate proceeding_types … plus Berufung Schadensbemessung and Berufung Bucheinsicht variants. m doesn't like the pre-separation. He wants ONE 'Berufung' entry in the picker, and the user then picks what the appeal is directed AT … the system derives the correct frist sequence from that target."*
|
||||||
|
|
||||||
|
Today's 3 codes (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) are a leaky abstraction of "appeal" — the user has to know whether it's a merits/cost/order appeal BEFORE they enter the picker, even though that branching question is "what's being appealed?" not "what kind of appeal?". Schadensbemessung + Bucheinsicht aren't in the appeal taxonomy at all today; appeals against those decisions silently fall into `upc.apl.merits`, blurring the rule sequence (RoP.220 vs RoP.221 vs RoP.224 timing).
|
||||||
|
|
||||||
|
The five appeal-target kinds are:
|
||||||
|
|
||||||
|
| Target | Source decision | Typical RoP track | Current proceeding code |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Endentscheidung | Final merits decision (UPC.RoP.118.1 / 219) | 2-month notice + 4-month grounds (R.224.1.a / R.224.2.a) | `upc.apl.merits` |
|
||||||
|
| Kostenentscheidung | Cost decision (R.150 / R.221.1) | 15-day leave-to-appeal (R.221.1) | `upc.apl.cost` |
|
||||||
|
| Anordnung | Order during proceedings (R.220) | 15-day track (R.220.2 / R.220.3 / R.224.2.b) | `upc.apl.order` |
|
||||||
|
| Schadensbemessung | Damages-determination decision (R.118.4 + R.140.2.b damages award) | Same merits track (2/4 month), but conceptually distinct anchor | (today maps to `upc.apl.merits`, silently) |
|
||||||
|
| Bucheinsicht | Lay-open-books decision (R.142) | 15-day track (R.220.2 — order-flavoured) OR merits track depending on the underlying decision shape | (today maps to `upc.apl.merits`, silently) |
|
||||||
|
|
||||||
|
#### Schema impact
|
||||||
|
|
||||||
|
**Migration plan (single `134_berufung_unification.up.sql`)**:
|
||||||
|
|
||||||
|
1. **Add column** `paliad.proceeding_types.appeal_target text NULL` — discriminator on the unified `upc.apl` row.
|
||||||
|
2. **Add CHECK** on `appeal_target`: NULL OR one of `endentscheidung | kostenentscheidung | anordnung | schadensbemessung | bucheinsicht`. Slugged in English-lowercase to match the package's English-identifier rule; the user-facing label is i18n'd in frontend.
|
||||||
|
3. **Insert** a new unified row `upc.apl` (name="Berufungsverfahren", name_en="Appeal", jurisdiction="UPC", category="fristenrechner", `appeal_target=NULL`).
|
||||||
|
4. **Re-target rule rows** by `appeal_target`:
|
||||||
|
- Today's 7 `upc.apl.merits` rules → keep `proceeding_type_id` pointing at the new `upc.apl` row, set a NEW column on `paliad.deadline_rules` called `applies_to_target text NULL` (CHECK matching the five-value vocab) to `'endentscheidung'`.
|
||||||
|
- Today's 2 `upc.apl.cost` rules → `applies_to_target='kostenentscheidung'`.
|
||||||
|
- Today's 7 `upc.apl.order` rules → `applies_to_target='anordnung'`.
|
||||||
|
- The 7 merits rules ALSO carry the implicit "applies to Schadensbemessung" semantic (the merits track is shared) — explicit duplication or a multi-value applies_to_target array? See §18.1 "Open question" below.
|
||||||
|
5. **Archive** the 3 old proceeding_types — set `category='archived'`, `is_active=false`. Keep the rows for FK integrity (project_event_choices, etc. may reference them historically; the archive flag stops them surfacing in the picker).
|
||||||
|
6. **Add 5 stable proceeding-type alias rows** OR **just emit one chip per appeal_target in the frontend**. Recommended (see API shape below): emit chips from the package's catalog, no DB row per target.
|
||||||
|
|
||||||
|
**Two new columns added by this migration:**
|
||||||
|
- `paliad.proceeding_types.appeal_target text NULL` (CHECK on 5 slugs OR NULL — NULL means "not an appeal").
|
||||||
|
- `paliad.deadline_rules.applies_to_target text[] NULL` (CHECK each element ∈ the 5 slugs — array because the merits track applies to BOTH endentscheidung AND schadensbemessung today).
|
||||||
|
|
||||||
|
**Migration audit pass first**: before running step 4 the migration should `RAISE NOTICE` for any rule row whose `applies_to_target` derivation is ambiguous (e.g. an old `upc.apl.merits` rule that has a `condition_flag` that doesn't fit any target). In practice the 16 rules all map cleanly, but the audit pattern matches Phase 2 Step E discipline (see `docs/design-fristen-phase2-2026-05-15.md` §3.E).
|
||||||
|
|
||||||
|
**Down-migration**: re-insert the 3 archived proceeding_types, restore `proceeding_type_id` on rules from the saved `applies_to_target`, drop the two new columns. Standard down-symmetry per `docs/design-fristen-phase2-2026-05-15.md`.
|
||||||
|
|
||||||
|
#### API shape
|
||||||
|
|
||||||
|
The package's existing `Catalog.LoadProceeding(ctx, code, hint)` already returns a `ProceedingType` + `[]Rule`. The Berufung unification fits cleanly:
|
||||||
|
|
||||||
|
- `LoadProceeding(ctx, "upc.apl", hint)` returns the unified Berufung proceeding + ALL appeal rules across the 5 targets.
|
||||||
|
- A new optional field on the request narrows by target: extend `CalcOptions` with `AppealTarget string`. When non-empty, the engine filters the returned rule list to rules whose `applies_to_target` contains the requested target.
|
||||||
|
- The package exposes the 5 target slugs as constants:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
AppealTargetEndentscheidung = "endentscheidung"
|
||||||
|
AppealTargetKostenentscheidung = "kostenentscheidung"
|
||||||
|
AppealTargetAnordnung = "anordnung"
|
||||||
|
AppealTargetSchadensbemessung = "schadensbemessung"
|
||||||
|
AppealTargetBucheinsicht = "bucheinsicht"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppealTargets is the canonical ordered list for UI chip rendering.
|
||||||
|
var AppealTargets = []string{
|
||||||
|
AppealTargetEndentscheidung,
|
||||||
|
AppealTargetKostenentscheidung,
|
||||||
|
AppealTargetAnordnung,
|
||||||
|
AppealTargetSchadensbemessung,
|
||||||
|
AppealTargetBucheinsicht,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ProceedingType` gains a field: `AppealTarget *string ` db:"appeal_target" json:"appealTarget,omitempty"`` (per-row tag for clarity; redundant with the unified row's `code='upc.apl'` but useful for non-appeal proceedings that may carry NULL).
|
||||||
|
- `Rule` gains a field: `AppliesToTarget []string ` db:"applies_to_target" json:"appliesToTarget,omitempty"`` (per-row applies-to set).
|
||||||
|
|
||||||
|
Frontend logic:
|
||||||
|
- Verfahrensablauf picker shows one "Berufung" entry (the `upc.apl` proceeding).
|
||||||
|
- After picking Berufung, a chip group renders the 5 `AppealTargets` slugs (i18n labels in `frontend/src/client/i18n.ts`).
|
||||||
|
- Selecting a target sets `?target=<slug>` query param → backend includes `opts.AppealTarget=<slug>` in the request → engine filters.
|
||||||
|
|
||||||
|
#### Acceptance criteria (Slice B sub-tasks for this fold-in)
|
||||||
|
|
||||||
|
1. Migration `134_berufung_unification.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB.
|
||||||
|
2. After migration, `SELECT code FROM paliad.proceeding_types WHERE jurisdiction='UPC' AND is_active=true AND category='fristenrechner'` returns one less row (the 3 old appeal codes collapsed to 1 new code).
|
||||||
|
3. `Catalog.LoadProceeding(ctx, "upc.apl", hint)` returns the merged 16-rule set; with `opts.AppealTarget="endentscheidung"` it returns exactly 7 rules.
|
||||||
|
4. Verfahrensablauf renders one "Berufung" picker entry. The 5 target chips render below it post-pick; switching chips re-renders the timeline.
|
||||||
|
5. Existing project rows that referenced the old `upc.apl.merits` / `upc.apl.cost` / `upc.apl.order` codes still load (the FK integrity is preserved via the archived old rows).
|
||||||
|
6. The `paliad.proceeding_type_history` follow-up (not in scope here) can later migrate those project FKs to the new `upc.apl` + `appeal_target` field — that's a follow-up.
|
||||||
|
|
||||||
|
#### m's answer on Q18.1.1 (2026-05-26 13:40)
|
||||||
|
|
||||||
|
> Schadensbemessung-as-appeal is a NEW appeal target. Model it as a first-class entry in the appeal_target enum with its own rule set (no shared inheritance from upc.apl.merits). The rules don't exist yet in the catalog; for now the appeal_target value is defined and `CalcOptions.AppealTarget="schadensbemessung"` returns an empty sequence until rules are seeded.
|
||||||
|
|
||||||
|
→ **Option B wins (m overrode inventor's R=A).** Migration 134 still ships the schema + the 5 enum values + the chip group + the engine filter. Existing rules:
|
||||||
|
- 7 `upc.apl.merits` rules → `applies_to_target=['endentscheidung']` (Endentscheidung only — NOT also Schadensbemessung).
|
||||||
|
- 2 `upc.apl.cost` rules → `applies_to_target=['kostenentscheidung']`.
|
||||||
|
- 7 `upc.apl.order` rules → `applies_to_target=['anordnung']`.
|
||||||
|
- **Schadensbemessung + Bucheinsicht get NO rules in this migration.** `applies_to_target='schadensbemessung'` and `'bucheinsicht'` are valid enum values but no rule row carries them yet.
|
||||||
|
|
||||||
|
**Frontend behaviour with empty rule sets:**
|
||||||
|
- All 5 target chips still render (the picker promises the user a complete vocabulary).
|
||||||
|
- Picking Schadensbemessung or Bucheinsicht returns an empty timeline with a banner: "Frist-Sequenz für diesen Berufungstyp ist noch nicht hinterlegt — bitte über /admin/rules einpflegen oder Migrations-Follow-up abwarten."
|
||||||
|
- Picking the 3 populated targets renders normally.
|
||||||
|
|
||||||
|
**Rule-seeding follow-up (TODO, separate slice):**
|
||||||
|
- Schadensbemessung-appeal rules: anchor on R.118.4 (Folgeentscheidung Schadensbemessung) decision; conjecture 2/4-month track but distinct legal basis.
|
||||||
|
- Bucheinsicht-appeal rules: anchor on R.142 (Lay-open-books decision); conjecture 15-day track per R.220.2 + R.224.2.b.
|
||||||
|
- Can pair with `t-paliad-193` orphan-concept-seed if m wants a combined seeding pass.
|
||||||
|
- Either path: editorial via `/admin/rules` (rule-editor service, Slice 11a) so the lawyer team can author + audit.
|
||||||
|
|
||||||
|
**Q18.1.2 — User-facing label for "appeal_target": "Worauf richtet sich die Berufung?" (DE) / "Appeal against:" (EN)?**
|
||||||
|
|
||||||
|
Recommendation: yes, those exact strings; defer i18n decisions to the coder shift.
|
||||||
|
|
||||||
|
### §18.2 Multi-axis catalog query API
|
||||||
|
|
||||||
|
#### Motivation
|
||||||
|
|
||||||
|
The current `Catalog` interface (added in Slice A) supports proceeding-code lookups only. The new scenarios surface (Slice D) + the Determinator cascade (t-paliad-166) + a future "show me all next-step events when I'm in state X" need a generalised query that takes any subset of axes and returns matching events.
|
||||||
|
|
||||||
|
m's brief (2026-05-26 13:33): *"any subset of these axes (all optional): jurisdiction, proceeding_type_id, party, event_category_id. Returns matching events with the priority flag and a sequence-depth control: caller picks 'next' (1 hop downstream) or 'all-following' (full chain)."*
|
||||||
|
|
||||||
|
Today the cascade reconstructs this client-side via fanned-out calls to `/api/tools/fristenrechner` etc. — fragile + duplicated logic. The new method centralises the graph walk in the package.
|
||||||
|
|
||||||
|
#### Schema impact
|
||||||
|
|
||||||
|
**None new.** The query reads existing tables:
|
||||||
|
|
||||||
|
- `paliad.proceeding_types` (jurisdiction, id, code)
|
||||||
|
- `paliad.deadline_rules` (parent_id, sequence_order, primary_party, priority, event_category_id via concept_id → event_category_concepts)
|
||||||
|
- `paliad.event_categories` (id, party, parent_id for the cascade hierarchy)
|
||||||
|
- `paliad.event_category_concepts` (junction; concept_id → event_category_id)
|
||||||
|
|
||||||
|
The depth control is a runtime graph walk — `next` returns one hop from the matched parent, `all-following` walks `parent_id` recursively until leaves.
|
||||||
|
|
||||||
|
The audit found one schema gap worth flagging but NOT changing in Slice B:
|
||||||
|
- `paliad.deadline_rules` has no direct `event_category_id` column — it goes through `concept_id → deadline_concepts → event_category_concepts → event_categories`. The join is well-trodden but introduces an extra hop. v1 of the catalog API uses the join; a future denormalisation (`paliad.deadline_rules.event_category_id` cached column) is out of scope.
|
||||||
|
|
||||||
|
#### API shape
|
||||||
|
|
||||||
|
```go
|
||||||
|
// EventLookupAxes carries the optional filter axes for LookupEvents. All
|
||||||
|
// fields are optional; the empty value is "no filter on this axis". When
|
||||||
|
// multiple axes are set the engine applies them as AND (a rule must
|
||||||
|
// match ALL non-zero axes).
|
||||||
|
type EventLookupAxes struct {
|
||||||
|
Jurisdiction string // "UPC" | "DE" | "EPA" | "DPMA" — empty = any
|
||||||
|
ProceedingTypeID *int // narrow to one proceeding — nil = any
|
||||||
|
Party string // "claimant" | "defendant" | "court" | "both" — empty = any
|
||||||
|
EventCategoryID *uuid.UUID // narrow to one event_categories row — nil = any
|
||||||
|
AppealTarget string // §18.1 fold-in — empty = any
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventLookupDepth controls the sequence-depth of the returned events.
|
||||||
|
type EventLookupDepth string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// EventLookupDepthNext returns immediate children of the matched
|
||||||
|
// anchor (1 hop downstream). Default for "what comes next from
|
||||||
|
// this point?" queries.
|
||||||
|
EventLookupDepthNext EventLookupDepth = "next"
|
||||||
|
// EventLookupDepthAllFollowing returns the entire downstream
|
||||||
|
// chain (parent_id walk to leaves). Default for "show me the
|
||||||
|
// whole sequence from here onward" queries.
|
||||||
|
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventMatch is one result row from LookupEvents.
|
||||||
|
type EventMatch struct {
|
||||||
|
Rule Rule `json:"rule"` // full rule row
|
||||||
|
ProceedingType ProceedingType `json:"proceedingType"` // owning proceeding
|
||||||
|
Priority string `json:"priority"` // mandatory|recommended|optional|informational
|
||||||
|
DepthFromAnchor int `json:"depthFromAnchor"` // 1 = next, 2+ = deeper
|
||||||
|
// ParentRuleID populated when the match has a parent_id in the
|
||||||
|
// returned set (so the frontend can render a tree).
|
||||||
|
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupEvents on the Catalog interface returns events matching any
|
||||||
|
// subset of axes, at the requested sequence depth. Returns an empty
|
||||||
|
// slice (NOT an error) when no events match.
|
||||||
|
//
|
||||||
|
// Implementation must respect the catalog's "published + active" rule
|
||||||
|
// gate that LoadProceeding already enforces.
|
||||||
|
type Catalog interface {
|
||||||
|
// ... existing methods (LoadProceeding, LoadProceedingByID, ...)
|
||||||
|
|
||||||
|
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
paliad's `paliadCatalog` impl builds one SQL query with optional WHERE clauses + the existing `deadline_concept_event_types` JOIN for the event_category_id axis. youpc.org's embedded snapshot impl runs the same axis-filter pass on the in-memory rule slice.
|
||||||
|
|
||||||
|
#### Acceptance criteria (Slice B sub-tasks)
|
||||||
|
|
||||||
|
1. `Catalog.LookupEvents` exists on the interface + has both a paliad-side impl (SQL) and a stub for the future embedded/upc snapshot impl.
|
||||||
|
2. Round-trip test: `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all 77 UPC rules (matches the count from §0).
|
||||||
|
3. Combined-axis test: `EventLookupAxes{Jurisdiction:"UPC", Party:"claimant"}` returns the claimant-perspective subset.
|
||||||
|
4. Depth test: with a specific `ProceedingTypeID` + `Party:"defendant"`, `EventLookupDepthNext` returns only 1-hop children of the proceeding's root; `EventLookupDepthAllFollowing` returns the full chain.
|
||||||
|
5. New axis-driven endpoint at `GET /api/tools/lookup-events?…` proxies the call (separate slice — out of scope for the package-side acceptance, but listed for the coder).
|
||||||
|
|
||||||
|
### §18.3 `primary_party` enum tightening
|
||||||
|
|
||||||
|
#### Motivation
|
||||||
|
|
||||||
|
Today `paliad.deadline_rules.primary_party` is free-text. The live values confirm a stable four-value vocabulary (`claimant=26, defendant=26, court=38, both=63, NULL=78`) but nothing prevents a future rule editor from typing `clamant` or `Court` and silently breaking the appellant-context propagation in `engine.go`.
|
||||||
|
|
||||||
|
m's brief: *"Tighten to a check constraint matching event_categories.party's allowed values: claimant/defendant/court/both. Migration must audit + clean existing rows first; surface dirty rows to m if any don't fit the four-value vocabulary."*
|
||||||
|
|
||||||
|
Note: `event_categories.party` is a `text[]` (array) with current live values `{claimant}`, `{defendant}`, NULL. It does NOT carry `court` or `both` today. The brief's "matching event_categories.party's allowed values" is taken to mean the SEMANTIC vocabulary (claimant/defendant/court/both), not the literal current rows of event_categories.party. The package owns the canonical list.
|
||||||
|
|
||||||
|
#### Schema impact
|
||||||
|
|
||||||
|
**Migration `135_primary_party_check.up.sql`**:
|
||||||
|
|
||||||
|
1. **Audit pass** (DO $$ block): COUNT rules where `proceeding_type_id IS NOT NULL AND primary_party NOT IN ('claimant', 'defendant', 'court', 'both', NULL)`. RAISE NOTICE for each non-matching row's `(id, name, primary_party)`. If COUNT > 0, RAISE EXCEPTION 'dirty rows — see notice; manual cleanup required'.
|
||||||
|
2. **Add CHECK constraint**: `ALTER TABLE paliad.deadline_rules ADD CONSTRAINT deadline_rules_primary_party_chk CHECK (primary_party IS NULL OR primary_party IN ('claimant', 'defendant', 'court', 'both'))`.
|
||||||
|
3. **No data change** — every proceeding-bound rule already has a valid four-value value; the 78 NULL rows are orphan concept seeds and stay NULL.
|
||||||
|
4. **Down-migration**: `ALTER TABLE paliad.deadline_rules DROP CONSTRAINT deadline_rules_primary_party_chk`. No data revert needed.
|
||||||
|
|
||||||
|
**Why NULL stays valid:**
|
||||||
|
- The 78 NULL rows are cross-cutting concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) that have NO proceeding binding. They're not in the calculator's path; loosening the CHECK to `IS NULL OR IN (...)` keeps them valid without further schema gymnastics.
|
||||||
|
- A stricter "NOT NULL when proceeding_type_id is NOT NULL" partial constraint would be cleaner but adds a multi-column rule that's harder to maintain. The simpler form suffices given today's invariant.
|
||||||
|
|
||||||
|
**Should the same vocabulary be propagated to `paliad.event_categories.party`?**
|
||||||
|
|
||||||
|
Recommendation: **NO, not in this migration**. event_categories.party is array-shaped (a category can apply to multiple perspectives) and today carries only `{claimant}` / `{defendant}` per its narrower semantic ("from whose perspective is this category triggered?"). Tightening it to require court/both would force backfill of rows where neither perspective is the trigger. Out of scope for Slice B; flag as a follow-up.
|
||||||
|
|
||||||
|
#### API shape
|
||||||
|
|
||||||
|
The package's `Rule.PrimaryParty` field (already `*string`) stays as-is — the type doesn't change. A new package-level set of constants:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
PrimaryPartyClaimant = "claimant"
|
||||||
|
PrimaryPartyDefendant = "defendant"
|
||||||
|
PrimaryPartyCourt = "court"
|
||||||
|
PrimaryPartyBoth = "both"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrimaryParties is the canonical ordered list for validation +
|
||||||
|
// admin-UI rendering.
|
||||||
|
var PrimaryParties = []string{
|
||||||
|
PrimaryPartyClaimant,
|
||||||
|
PrimaryPartyDefendant,
|
||||||
|
PrimaryPartyCourt,
|
||||||
|
PrimaryPartyBoth,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidPrimaryParty returns true for empty (NULL-equivalent) or any
|
||||||
|
// of the four canonical values. Used by the rule-editor (Slice E2 if
|
||||||
|
// ever revisited) to validate writes before they hit the CHECK.
|
||||||
|
func IsValidPrimaryParty(s string) bool { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule-editor service (`internal/services/rule_editor_service.go`) gains a validation call against `lp.IsValidPrimaryParty` before any UPDATE — surfaces a user-friendly 400 before the DB CHECK fires with the less-pretty error.
|
||||||
|
|
||||||
|
#### Acceptance criteria (Slice B sub-tasks)
|
||||||
|
|
||||||
|
1. Migration `135_primary_party_check.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB.
|
||||||
|
2. Pre-migration audit pass surfaces zero dirty rows on the current live corpus (verified via Supabase audit before migration drafted).
|
||||||
|
3. Post-migration, attempting to UPDATE a rule's primary_party to `'foo'` raises a DB CHECK violation.
|
||||||
|
4. The package exposes the four constants + the `PrimaryParties` slice + the `IsValidPrimaryParty` predicate.
|
||||||
|
5. Rule-editor service surfaces 400 with a clear message when a write violates the constraint (instead of leaking the raw PG error).
|
||||||
|
|
||||||
|
### §18 Summary table
|
||||||
|
|
||||||
|
| § | Topic | Schema delta | API delta | Migration |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 18.1 | Berufung unification | +2 columns (proceeding_types.appeal_target + deadline_rules.applies_to_target[]); collapse 3→1 active appeal codes | `Catalog` returns merged proceeding + Rule.AppliesToTarget; CalcOptions.AppealTarget filter; AppealTargets[] constants | `134_berufung_unification.up.sql` |
|
||||||
|
| 18.2 | Multi-axis catalog query | None new (uses existing joins) | `Catalog.LookupEvents(axes, depth)` new method + EventLookupAxes / EventMatch types | None |
|
||||||
|
| 18.3 | `primary_party` enum | +CHECK constraint on deadline_rules.primary_party | `PrimaryParties[]` constants + `IsValidPrimaryParty()` predicate | `135_primary_party_check.up.sql` |
|
||||||
|
|
||||||
|
### §18.4 Slice plan refinement (revises §10 for Slice B)
|
||||||
|
|
||||||
|
The original §10 listed Slice B as "Catalog / HolidayCalendar / CourtRegistry interfaces + paliad's default loaders." Slice A already folded those interfaces in (the engine.Calculate signature accepts them). Slice B's revised scope:
|
||||||
|
|
||||||
|
1. **Slice B1 — Berufung unification** (§18.1): migration 134 + package constants + `appeal_target` field on ProceedingType + `applies_to_target[]` on Rule + `CalcOptions.AppealTarget` filter. Frontend updates (verfahrensablauf chip group) follow in the same PR.
|
||||||
|
2. **Slice B2 — Multi-axis catalog query API** (§18.2): `Catalog.LookupEvents` method + paliad impl + tests. New `GET /api/tools/lookup-events` endpoint optional (slice C may want it earlier).
|
||||||
|
3. **Slice B3 — primary_party enum tightening** (§18.3): migration 135 + package constants + rule-editor validation hook.
|
||||||
|
|
||||||
|
B1 / B2 / B3 are independently shippable and can land in any order. B1 has the most user-facing impact (the picker change is what m flagged); B3 is the smallest hardening; B2 is the largest API surface.
|
||||||
|
|
||||||
|
### §18.5 Open questions escalated to head
|
||||||
|
|
||||||
|
- §18.1 Q1 — Schadensbemessung-as-appeal: shared vs distinct vs deferred (R: shared, multi-valued `applies_to_target`).
|
||||||
|
- §18.1 Q2 — i18n label for "Worauf richtet sich die Berufung?" (R: yes, defer to coder).
|
||||||
|
- §18.3 — Should event_categories.party be tightened in the same migration? (R: no, separate follow-up.)
|
||||||
|
|
||||||
|
No `AskUserQuestion` per inventor protocol; head escalates to m if material.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*End of design doc.*
|
*End of design doc.*
|
||||||
|
|||||||
@@ -237,6 +237,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"deadlines.upc.disc.cfi": "Bucheinsicht",
|
"deadlines.upc.disc.cfi": "Bucheinsicht",
|
||||||
"deadlines.upc.apl.cost": "Berufung Kosten",
|
"deadlines.upc.apl.cost": "Berufung Kosten",
|
||||||
"deadlines.upc.apl.order": "Berufung Anordnungen",
|
"deadlines.upc.apl.order": "Berufung Anordnungen",
|
||||||
|
"deadlines.upc.apl": "Berufung",
|
||||||
|
"deadlines.appeal_target.label": "Worauf richtet sich die Berufung?",
|
||||||
|
"deadlines.appeal_target.endentscheidung": "Endentscheidung",
|
||||||
|
"deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung",
|
||||||
|
"deadlines.appeal_target.anordnung": "Anordnung",
|
||||||
|
"deadlines.appeal_target.schadensbemessung": "Schadensbemessung",
|
||||||
|
"deadlines.appeal_target.bucheinsicht": "Bucheinsicht",
|
||||||
"deadlines.de.group.inf": "Verletzungsverfahren",
|
"deadlines.de.group.inf": "Verletzungsverfahren",
|
||||||
"deadlines.de.group.null": "Nichtigkeitsverfahren",
|
"deadlines.de.group.null": "Nichtigkeitsverfahren",
|
||||||
"deadlines.de.inf.lg": "LG (1. Instanz)",
|
"deadlines.de.inf.lg": "LG (1. Instanz)",
|
||||||
@@ -3327,6 +3334,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"deadlines.upc.dmgs.cfi": "Damages Determination",
|
"deadlines.upc.dmgs.cfi": "Damages Determination",
|
||||||
"deadlines.upc.disc.cfi": "Lay-open Books",
|
"deadlines.upc.disc.cfi": "Lay-open Books",
|
||||||
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
|
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
|
||||||
|
"deadlines.upc.apl": "Appeal",
|
||||||
|
"deadlines.appeal_target.label": "Appeal against:",
|
||||||
|
"deadlines.appeal_target.endentscheidung": "Final Decision",
|
||||||
|
"deadlines.appeal_target.kostenentscheidung": "Cost Decision",
|
||||||
|
"deadlines.appeal_target.anordnung": "Order",
|
||||||
|
"deadlines.appeal_target.schadensbemessung": "Damages Determination",
|
||||||
|
"deadlines.appeal_target.bucheinsicht": "Lay-open Books",
|
||||||
"deadlines.upc.apl.order": "Order Appeal (15-day)",
|
"deadlines.upc.apl.order": "Order Appeal (15-day)",
|
||||||
"deadlines.de.group.inf": "Infringement proceedings",
|
"deadlines.de.group.inf": "Infringement proceedings",
|
||||||
"deadlines.de.group.null": "Nullity proceedings",
|
"deadlines.de.group.null": "Nullity proceedings",
|
||||||
|
|||||||
@@ -64,9 +64,7 @@ let sidePrefilledFromProject = false;
|
|||||||
// Conservative — false negatives just hide a control; false positives
|
// Conservative — false negatives just hide a control; false positives
|
||||||
// would show an irrelevant control.
|
// would show an irrelevant control.
|
||||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||||
"upc.apl.merits",
|
"upc.apl",
|
||||||
"upc.apl.cost",
|
|
||||||
"upc.apl.order",
|
|
||||||
"de.inf.olg",
|
"de.inf.olg",
|
||||||
"de.inf.bgh",
|
"de.inf.bgh",
|
||||||
"de.null.bgh",
|
"de.null.bgh",
|
||||||
@@ -75,6 +73,29 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
|||||||
"epa.opp.boa",
|
"epa.opp.boa",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||||
|
// Proceedings that surface the appeal-target chip group. Currently
|
||||||
|
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
|
||||||
|
// can opt in by adding the code here.
|
||||||
|
const APPEAL_TARGET_PROCEEDINGS = new Set([
|
||||||
|
"upc.apl",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
|
||||||
|
// in sync with pkg/litigationplanner/types.go AppealTargets).
|
||||||
|
const APPEAL_TARGETS = [
|
||||||
|
"endentscheidung",
|
||||||
|
"kostenentscheidung",
|
||||||
|
"anordnung",
|
||||||
|
"schadensbemessung",
|
||||||
|
"bucheinsicht",
|
||||||
|
] as const;
|
||||||
|
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||||
|
|
||||||
|
function hasAppealTarget(proceedingType: string): boolean {
|
||||||
|
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
||||||
|
}
|
||||||
|
|
||||||
function hasAppellantAxis(proceedingType: string): boolean {
|
function hasAppellantAxis(proceedingType: string): boolean {
|
||||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||||
}
|
}
|
||||||
@@ -103,6 +124,32 @@ function writeAppellantToURL(a: Side) {
|
|||||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice B1 — appeal-target URL state. Empty string = no target picked
|
||||||
|
// (the row is hidden because the proceeding isn't an appeal). Any
|
||||||
|
// other value must be one of APPEAL_TARGETS; unknown values are
|
||||||
|
// rejected by readAppealTargetFromURL so a stale link can't break
|
||||||
|
// the engine filter.
|
||||||
|
function readAppealTargetFromURL(): AppealTarget {
|
||||||
|
const raw = new URLSearchParams(window.location.search).get("target") || "";
|
||||||
|
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||||
|
return raw as AppealTarget;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAppealTargetToURL(t: AppealTarget) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (t === "") url.searchParams.delete("target");
|
||||||
|
else url.searchParams.set("target", t);
|
||||||
|
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default target on first picker entry into upc.apl. m: Endentscheidung
|
||||||
|
// is the most-common appeal target; the chip group also defaults
|
||||||
|
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
|
||||||
|
// sync so the URL-less default render hits the same code path.
|
||||||
|
let currentAppealTarget: AppealTarget = "";
|
||||||
|
|
||||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||||
@@ -268,6 +315,13 @@ async function doCalc() {
|
|||||||
const overrides: Record<string, string> = {};
|
const overrides: Record<string, string> = {};
|
||||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||||
|
|
||||||
|
// Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung,
|
||||||
|
// default to "endentscheidung" when no chip pick is stored in URL.
|
||||||
|
// For non-appeal proceedings the engine ignores opts.AppealTarget.
|
||||||
|
const appealTarget = hasAppealTarget(selectedType)
|
||||||
|
? (currentAppealTarget || "endentscheidung")
|
||||||
|
: "";
|
||||||
|
|
||||||
const data = await calculateDeadlines({
|
const data = await calculateDeadlines({
|
||||||
proceedingType: selectedType,
|
proceedingType: selectedType,
|
||||||
triggerDate,
|
triggerDate,
|
||||||
@@ -276,6 +330,7 @@ async function doCalc() {
|
|||||||
courtId,
|
courtId,
|
||||||
perCardChoices,
|
perCardChoices,
|
||||||
includeHidden: showHidden,
|
includeHidden: showHidden,
|
||||||
|
appealTarget,
|
||||||
});
|
});
|
||||||
if (seq !== calcSeq) return;
|
if (seq !== calcSeq) return;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -447,6 +502,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
|||||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||||
syncFlagRows();
|
syncFlagRows();
|
||||||
syncAppellantRowVisibility();
|
syncAppellantRowVisibility();
|
||||||
|
syncAppealTargetRowVisibility();
|
||||||
|
|
||||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||||
|
|
||||||
@@ -471,6 +527,23 @@ function syncAppellantRowVisibility() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||||
|
// syncAppealTargetRowVisibility shows the appeal-target chip group
|
||||||
|
// when the unified upc.apl Berufung tile is selected, hides it
|
||||||
|
// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears
|
||||||
|
// state + URL when hiding so a stale ?target= can't leak.
|
||||||
|
function syncAppealTargetRowVisibility() {
|
||||||
|
const row = document.getElementById("appeal-target-row");
|
||||||
|
if (!row) return;
|
||||||
|
const visible = hasAppealTarget(selectedType);
|
||||||
|
row.style.display = visible ? "" : "none";
|
||||||
|
if (!visible && currentAppealTarget !== "") {
|
||||||
|
currentAppealTarget = "";
|
||||||
|
writeAppealTargetToURL("");
|
||||||
|
syncRadioGroup("appeal-target", "endentscheidung");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncRadioGroup(name: string, value: string) {
|
function syncRadioGroup(name: string, value: string) {
|
||||||
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
||||||
input.checked = input.value === value;
|
input.checked = input.value === value;
|
||||||
@@ -655,8 +728,10 @@ function initViewToggle() {
|
|||||||
function initPerspectiveControls() {
|
function initPerspectiveControls() {
|
||||||
currentSide = readSideFromURL();
|
currentSide = readSideFromURL();
|
||||||
currentAppellant = readAppellantFromURL();
|
currentAppellant = readAppellantFromURL();
|
||||||
|
currentAppealTarget = readAppealTargetFromURL();
|
||||||
syncRadioGroup("side", currentSide ?? "");
|
syncRadioGroup("side", currentSide ?? "");
|
||||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||||
|
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
||||||
syncSideHintVisibility();
|
syncSideHintVisibility();
|
||||||
|
|
||||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||||
@@ -679,6 +754,23 @@ function initPerspectiveControls() {
|
|||||||
if (lastResponse) renderResults(lastResponse);
|
if (lastResponse) renderResults(lastResponse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
|
||||||
|
// Each chip change re-fetches with the new target slug so the
|
||||||
|
// timeline re-renders against the matching rule subset.
|
||||||
|
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appeal-target]").forEach((input) => {
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
if (!input.checked) return;
|
||||||
|
const v = input.value;
|
||||||
|
if ((APPEAL_TARGETS as readonly string[]).includes(v)) {
|
||||||
|
currentAppealTarget = v as AppealTarget;
|
||||||
|
} else {
|
||||||
|
currentAppealTarget = "";
|
||||||
|
}
|
||||||
|
writeAppealTargetToURL(currentAppealTarget);
|
||||||
|
scheduleCalc(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ export interface CalcParams {
|
|||||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||||
// ON.
|
// ON.
|
||||||
includeHidden?: boolean;
|
includeHidden?: boolean;
|
||||||
|
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
|
||||||
|
// (upc.apl) timeline to the rule subset whose applies_to_target
|
||||||
|
// contains the requested slug. Empty = no filter. Valid values:
|
||||||
|
// endentscheidung | kostenentscheidung | anordnung |
|
||||||
|
// schadensbemessung | bucheinsicht.
|
||||||
|
appealTarget?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PARTY_CLASS: Record<string, string> = {
|
const PARTY_CLASS: Record<string, string> = {
|
||||||
@@ -811,6 +817,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
|||||||
? params.perCardChoices
|
? params.perCardChoices
|
||||||
: undefined,
|
: undefined,
|
||||||
includeHidden: params.includeHidden ? true : undefined,
|
includeHidden: params.includeHidden ? true : undefined,
|
||||||
|
appealTarget: params.appealTarget || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|||||||
@@ -1184,6 +1184,12 @@ export type I18nKey =
|
|||||||
| "deadlines.adjusted.weekend"
|
| "deadlines.adjusted.weekend"
|
||||||
| "deadlines.adjusted.weekend.saturday"
|
| "deadlines.adjusted.weekend.saturday"
|
||||||
| "deadlines.adjusted.weekend.sunday"
|
| "deadlines.adjusted.weekend.sunday"
|
||||||
|
| "deadlines.appeal_target.anordnung"
|
||||||
|
| "deadlines.appeal_target.bucheinsicht"
|
||||||
|
| "deadlines.appeal_target.endentscheidung"
|
||||||
|
| "deadlines.appeal_target.kostenentscheidung"
|
||||||
|
| "deadlines.appeal_target.label"
|
||||||
|
| "deadlines.appeal_target.schadensbemessung"
|
||||||
| "deadlines.appellant.claimant"
|
| "deadlines.appellant.claimant"
|
||||||
| "deadlines.appellant.defendant"
|
| "deadlines.appellant.defendant"
|
||||||
| "deadlines.appellant.label"
|
| "deadlines.appellant.label"
|
||||||
@@ -1511,6 +1517,7 @@ export type I18nKey =
|
|||||||
| "deadlines.trigger.label"
|
| "deadlines.trigger.label"
|
||||||
| "deadlines.unavailable"
|
| "deadlines.unavailable"
|
||||||
| "deadlines.upc"
|
| "deadlines.upc"
|
||||||
|
| "deadlines.upc.apl"
|
||||||
| "deadlines.upc.apl.cost"
|
| "deadlines.upc.apl.cost"
|
||||||
| "deadlines.upc.apl.merits"
|
| "deadlines.upc.apl.merits"
|
||||||
| "deadlines.upc.apl.order"
|
| "deadlines.upc.apl.order"
|
||||||
|
|||||||
@@ -28,16 +28,20 @@ function proceedingBtn(p: ProceedingDef): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
|
||||||
|
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
|
||||||
|
// unified "Berufung" tile (upc.apl). After picking it, the user
|
||||||
|
// selects which decision the appeal is directed AT via the
|
||||||
|
// .appeal-target-row chip group below — the engine then filters
|
||||||
|
// rules whose applies_to_target contains the picked slug.
|
||||||
const UPC_TYPES: ProceedingDef[] = [
|
const UPC_TYPES: ProceedingDef[] = [
|
||||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
{ code: "upc.apl", i18nKey: "deadlines.upc.apl", name: "Berufung" },
|
||||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
|
||||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||||
@@ -216,6 +220,36 @@ export function renderVerfahrensablauf(): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
|
||||||
|
Shown only when the unified upc.apl Berufung tile is
|
||||||
|
selected; lets the user narrow the timeline to the
|
||||||
|
rules whose applies_to_target contains the picked
|
||||||
|
decision kind. URL state ?target=<slug>. */}
|
||||||
|
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||||
|
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||||
|
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||||
|
<label className="fristen-view-option">
|
||||||
|
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||||
|
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||||
|
</label>
|
||||||
|
<label className="fristen-view-option">
|
||||||
|
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||||
|
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||||
|
</label>
|
||||||
|
<label className="fristen-view-option">
|
||||||
|
<input type="radio" name="appeal-target" value="anordnung" />
|
||||||
|
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||||
|
</label>
|
||||||
|
<label className="fristen-view-option">
|
||||||
|
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||||
|
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||||
|
</label>
|
||||||
|
<label className="fristen-view-option">
|
||||||
|
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||||
|
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||||
|
|||||||
63
internal/db/migrations/134_berufung_unification.down.sql
Normal file
63
internal/db/migrations/134_berufung_unification.down.sql
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
-- 134_berufung_unification — DOWN
|
||||||
|
--
|
||||||
|
-- Reverses the Berufung unification: un-archives the 3 old appeal
|
||||||
|
-- proceeding_types, points the 16 rules back at their original
|
||||||
|
-- proceeding by their applies_to_target stamp, drops the new
|
||||||
|
-- upc.apl row, drops the two columns + their CHECK constraints.
|
||||||
|
--
|
||||||
|
-- The 3 old proceeding_types are recovered by code (we archived them,
|
||||||
|
-- never deleted them — that's what makes this down-migration safe).
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 1. Un-archive the 3 old appeal proceeding_types.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE paliad.proceeding_types
|
||||||
|
SET is_active = true,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 2. Point rules back at their original proceeding_type by stamp.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE paliad.deadline_rules dr
|
||||||
|
SET proceeding_type_id = (
|
||||||
|
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.merits'
|
||||||
|
)
|
||||||
|
WHERE dr.applies_to_target = ARRAY['endentscheidung']::text[];
|
||||||
|
|
||||||
|
UPDATE paliad.deadline_rules dr
|
||||||
|
SET proceeding_type_id = (
|
||||||
|
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.cost'
|
||||||
|
)
|
||||||
|
WHERE dr.applies_to_target = ARRAY['kostenentscheidung']::text[];
|
||||||
|
|
||||||
|
UPDATE paliad.deadline_rules dr
|
||||||
|
SET proceeding_type_id = (
|
||||||
|
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.order'
|
||||||
|
)
|
||||||
|
WHERE dr.applies_to_target = ARRAY['anordnung']::text[];
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 3. Drop the unified upc.apl row (now orphaned).
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 4. Drop the new columns + their CHECK constraints.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE paliad.deadline_rules
|
||||||
|
DROP CONSTRAINT IF EXISTS deadline_rules_applies_to_target_chk;
|
||||||
|
|
||||||
|
ALTER TABLE paliad.deadline_rules
|
||||||
|
DROP COLUMN IF EXISTS applies_to_target;
|
||||||
|
|
||||||
|
ALTER TABLE paliad.proceeding_types
|
||||||
|
DROP CONSTRAINT IF EXISTS proceeding_types_appeal_target_chk;
|
||||||
|
|
||||||
|
ALTER TABLE paliad.proceeding_types
|
||||||
|
DROP COLUMN IF EXISTS appeal_target;
|
||||||
263
internal/db/migrations/134_berufung_unification.up.sql
Normal file
263
internal/db/migrations/134_berufung_unification.up.sql
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
-- 134_berufung_unification — Slice B1, m/paliad#124, t-paliad-298+
|
||||||
|
--
|
||||||
|
-- Collapses the 3 active UPC appeal proceeding_types (upc.apl.merits,
|
||||||
|
-- upc.apl.cost, upc.apl.order — 16 rules across 3 codes) into ONE
|
||||||
|
-- unified upc.apl proceeding type + an `appeal_target` discriminator on
|
||||||
|
-- both proceeding_types (top-level marker) and deadline_rules
|
||||||
|
-- (per-row applies-to set, text[] for multi-target rules).
|
||||||
|
--
|
||||||
|
-- ADDITIVE ONLY. The migration:
|
||||||
|
-- 1. Adds the two columns + check constraints.
|
||||||
|
-- 2. Inserts the new upc.apl proceeding type.
|
||||||
|
-- 3. Audit-first: NOTICES every row about to be touched.
|
||||||
|
-- 4. Reassigns rule rows from the 3 old types to upc.apl, stamping
|
||||||
|
-- applies_to_target by source proceeding code.
|
||||||
|
-- 5. Archives (is_active=false) the 3 old proceeding_types — NEVER
|
||||||
|
-- deletes them, so any historical project_event_choices / FK
|
||||||
|
-- references stay intact.
|
||||||
|
--
|
||||||
|
-- Schadensbemessung + Bucheinsicht get NO rule rows in this migration
|
||||||
|
-- (m's 2026-05-26 decision: distinct rule sets, not shared with
|
||||||
|
-- merits). Their appeal_target enum values are defined and addressable
|
||||||
|
-- by CalcOptions.AppealTarget; the engine returns an empty timeline
|
||||||
|
-- until rules are seeded in a follow-up slice (likely via
|
||||||
|
-- /admin/rules, pairing with t-paliad-193 orphan-concept-seed).
|
||||||
|
--
|
||||||
|
-- See docs/design-litigation-planner-2026-05-26.md §18.1.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 1. Schema additions
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE paliad.proceeding_types
|
||||||
|
ADD COLUMN appeal_target text NULL;
|
||||||
|
|
||||||
|
ALTER TABLE paliad.proceeding_types
|
||||||
|
ADD CONSTRAINT proceeding_types_appeal_target_chk
|
||||||
|
CHECK (appeal_target IS NULL OR appeal_target IN (
|
||||||
|
'endentscheidung',
|
||||||
|
'kostenentscheidung',
|
||||||
|
'anordnung',
|
||||||
|
'schadensbemessung',
|
||||||
|
'bucheinsicht'
|
||||||
|
));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN paliad.proceeding_types.appeal_target IS
|
||||||
|
'Top-level appeal-target marker. NULL on non-appeal proceedings. '
|
||||||
|
'Reserved for future variants — today only the unified upc.apl row '
|
||||||
|
'has this NULL (the actual per-rule target set lives on '
|
||||||
|
'paliad.deadline_rules.applies_to_target).';
|
||||||
|
|
||||||
|
ALTER TABLE paliad.deadline_rules
|
||||||
|
ADD COLUMN applies_to_target text[] NULL;
|
||||||
|
|
||||||
|
ALTER TABLE paliad.deadline_rules
|
||||||
|
ADD CONSTRAINT deadline_rules_applies_to_target_chk
|
||||||
|
CHECK (
|
||||||
|
applies_to_target IS NULL
|
||||||
|
OR applies_to_target <@ ARRAY[
|
||||||
|
'endentscheidung',
|
||||||
|
'kostenentscheidung',
|
||||||
|
'anordnung',
|
||||||
|
'schadensbemessung',
|
||||||
|
'bucheinsicht'
|
||||||
|
]::text[]
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN paliad.deadline_rules.applies_to_target IS
|
||||||
|
'Set of appeal_target slugs this rule applies to. NULL on rules '
|
||||||
|
'that don''t belong to an appeal proceeding. The engine filters '
|
||||||
|
'by CalcOptions.AppealTarget — rules whose applies_to_target '
|
||||||
|
'contains the requested slug are emitted; others are suppressed.';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 2. Insert the unified upc.apl row.
|
||||||
|
--
|
||||||
|
-- Inherits default_color from the merits row (the most-used appeal
|
||||||
|
-- track today). sort_order follows the cluster of UPC proceedings;
|
||||||
|
-- placed just before upc.apl.merits's old slot so the chip-grouped
|
||||||
|
-- picker UI lands Berufung in a sensible position. Tweakable later
|
||||||
|
-- without a migration.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO paliad.proceeding_types (
|
||||||
|
code, name, name_en, description, jurisdiction, category,
|
||||||
|
default_color, sort_order, is_active, display_order,
|
||||||
|
appeal_target
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'upc.apl',
|
||||||
|
'Berufungsverfahren',
|
||||||
|
'Appeal',
|
||||||
|
'Vereinheitlichtes Berufungsverfahren — wählen Sie anschließend, '
|
||||||
|
'worauf die Berufung sich richtet (Endentscheidung, '
|
||||||
|
'Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht).',
|
||||||
|
'UPC',
|
||||||
|
'fristenrechner',
|
||||||
|
default_color,
|
||||||
|
sort_order,
|
||||||
|
true,
|
||||||
|
display_order,
|
||||||
|
NULL
|
||||||
|
FROM paliad.proceeding_types
|
||||||
|
WHERE code = 'upc.apl.merits';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 3. Audit-first RAISE NOTICE pass.
|
||||||
|
--
|
||||||
|
-- Lists every rule row that will be reassigned + every proceeding_type
|
||||||
|
-- row that will be archived. The migration runs to completion either
|
||||||
|
-- way; the operator reads the notices to confirm scope before the
|
||||||
|
-- next migration in the chain.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rec record;
|
||||||
|
upc_apl_id int;
|
||||||
|
rules_touched int := 0;
|
||||||
|
procs_archived int := 0;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO upc_apl_id
|
||||||
|
FROM paliad.proceeding_types
|
||||||
|
WHERE code = 'upc.apl';
|
||||||
|
RAISE NOTICE '[mig 134] new upc.apl proceeding_type_id = %', upc_apl_id;
|
||||||
|
|
||||||
|
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl with applies_to_target:';
|
||||||
|
FOR rec IN
|
||||||
|
SELECT dr.id AS rule_id,
|
||||||
|
pt.code AS old_proceeding,
|
||||||
|
dr.submission_code,
|
||||||
|
dr.name
|
||||||
|
FROM paliad.deadline_rules dr
|
||||||
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||||
|
WHERE pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||||
|
AND dr.is_active = true
|
||||||
|
ORDER BY pt.code, dr.sequence_order
|
||||||
|
LOOP
|
||||||
|
RAISE NOTICE '[mig 134] % % % (%)',
|
||||||
|
rec.old_proceeding, rec.submission_code, rec.name, rec.rule_id;
|
||||||
|
rules_touched := rules_touched + 1;
|
||||||
|
END LOOP;
|
||||||
|
RAISE NOTICE '[mig 134] Total rules to reassign: %', rules_touched;
|
||||||
|
|
||||||
|
RAISE NOTICE '[mig 134] Proceeding_types to archive (is_active=false):';
|
||||||
|
FOR rec IN
|
||||||
|
SELECT id, code, name
|
||||||
|
FROM paliad.proceeding_types
|
||||||
|
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||||
|
ORDER BY sort_order
|
||||||
|
LOOP
|
||||||
|
RAISE NOTICE '[mig 134] % % (id=%)', rec.code, rec.name, rec.id;
|
||||||
|
procs_archived := procs_archived + 1;
|
||||||
|
END LOOP;
|
||||||
|
RAISE NOTICE '[mig 134] Total proceeding_types to archive: %', procs_archived;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 4. Reassign rule rows.
|
||||||
|
--
|
||||||
|
-- Stamp applies_to_target by source proceeding code, then point all
|
||||||
|
-- 16 rules at the new upc.apl row.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 4a. upc.apl.merits → applies_to_target = {endentscheidung}
|
||||||
|
UPDATE paliad.deadline_rules dr
|
||||||
|
SET applies_to_target = ARRAY['endentscheidung']::text[]
|
||||||
|
FROM paliad.proceeding_types pt
|
||||||
|
WHERE pt.id = dr.proceeding_type_id
|
||||||
|
AND pt.code = 'upc.apl.merits'
|
||||||
|
AND dr.is_active = true;
|
||||||
|
|
||||||
|
-- 4b. upc.apl.cost → applies_to_target = {kostenentscheidung}
|
||||||
|
UPDATE paliad.deadline_rules dr
|
||||||
|
SET applies_to_target = ARRAY['kostenentscheidung']::text[]
|
||||||
|
FROM paliad.proceeding_types pt
|
||||||
|
WHERE pt.id = dr.proceeding_type_id
|
||||||
|
AND pt.code = 'upc.apl.cost'
|
||||||
|
AND dr.is_active = true;
|
||||||
|
|
||||||
|
-- 4c. upc.apl.order → applies_to_target = {anordnung}
|
||||||
|
UPDATE paliad.deadline_rules dr
|
||||||
|
SET applies_to_target = ARRAY['anordnung']::text[]
|
||||||
|
FROM paliad.proceeding_types pt
|
||||||
|
WHERE pt.id = dr.proceeding_type_id
|
||||||
|
AND pt.code = 'upc.apl.order'
|
||||||
|
AND dr.is_active = true;
|
||||||
|
|
||||||
|
-- 4d. Reassign all 16 rules to the new upc.apl proceeding_type row.
|
||||||
|
UPDATE paliad.deadline_rules dr
|
||||||
|
SET proceeding_type_id = (
|
||||||
|
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl'
|
||||||
|
)
|
||||||
|
FROM paliad.proceeding_types pt
|
||||||
|
WHERE pt.id = dr.proceeding_type_id
|
||||||
|
AND pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 5. Archive the 3 old proceeding_types.
|
||||||
|
--
|
||||||
|
-- NEVER DELETE — historical project_event_choices and project FKs
|
||||||
|
-- (paliad.projects.proceeding_type_id) may still reference these IDs.
|
||||||
|
-- The is_active=false flag stops them appearing in the picker but
|
||||||
|
-- preserves FK integrity for historical reads.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE paliad.proceeding_types
|
||||||
|
SET is_active = false,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- 6. Post-migration sanity check.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
unified_count int;
|
||||||
|
archived_count int;
|
||||||
|
target_distribution record;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO unified_count
|
||||||
|
FROM paliad.deadline_rules dr
|
||||||
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||||
|
WHERE pt.code = 'upc.apl' AND dr.is_active = true;
|
||||||
|
RAISE NOTICE '[mig 134] post: rules on unified upc.apl = % (expected 16)', unified_count;
|
||||||
|
IF unified_count <> 16 THEN
|
||||||
|
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl, got %', unified_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO archived_count
|
||||||
|
FROM paliad.proceeding_types
|
||||||
|
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||||
|
AND is_active = false;
|
||||||
|
RAISE NOTICE '[mig 134] post: archived old appeal proceeding_types = % (expected 3)', archived_count;
|
||||||
|
IF archived_count <> 3 THEN
|
||||||
|
RAISE EXCEPTION '[mig 134] FAILED — expected 3 archived types, got %', archived_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOR target_distribution IN
|
||||||
|
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
|
||||||
|
FROM paliad.deadline_rules dr
|
||||||
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||||
|
WHERE pt.code = 'upc.apl' AND dr.is_active = true
|
||||||
|
GROUP BY unnest(applies_to_target)
|
||||||
|
ORDER BY 1
|
||||||
|
LOOP
|
||||||
|
RAISE NOTICE '[mig 134] post: applies_to_target=% count=%',
|
||||||
|
target_distribution.target, target_distribution.n;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- TODO (follow-up slice, not in 134):
|
||||||
|
--
|
||||||
|
-- Seed rules for Schadensbemessung-as-appeal + Bucheinsicht-as-appeal.
|
||||||
|
-- m's 2026-05-26 decision: distinct rule sets, NOT shared with merits.
|
||||||
|
-- - Schadensbemessung: anchor on R.118.4 decision; conjecture 2/4-month
|
||||||
|
-- merits-style track but distinct legal basis.
|
||||||
|
-- - Bucheinsicht: anchor on R.142 (Lay-open-books decision); conjecture
|
||||||
|
-- 15-day track per R.220.2 + R.224.2.b.
|
||||||
|
-- Can pair with t-paliad-193 orphan-concept-seed if m wants a combined
|
||||||
|
-- editorial pass via /admin/rules.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
@@ -69,6 +69,14 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
// stay in the result list. Default false preserves the legacy
|
// stay in the result list. Default false preserves the legacy
|
||||||
// suppression. HiddenCount on the response is independent.
|
// suppression. HiddenCount on the response is independent.
|
||||||
IncludeHidden bool `json:"includeHidden,omitempty"`
|
IncludeHidden bool `json:"includeHidden,omitempty"`
|
||||||
|
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC
|
||||||
|
// Berufung (upc.apl) timeline to the rule subset whose
|
||||||
|
// applies_to_target contains the requested slug. Empty = no
|
||||||
|
// filter. Valid values: endentscheidung | kostenentscheidung
|
||||||
|
// | anordnung | schadensbemessung | bucheinsicht. Unknown
|
||||||
|
// slugs are silently dropped (no filter) so a stale frontend
|
||||||
|
// chip doesn't 400 the request.
|
||||||
|
AppealTarget string `json:"appealTarget,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||||
@@ -116,6 +124,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
SkipRules: addendum.SkipRules,
|
SkipRules: addendum.SkipRules,
|
||||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||||
IncludeHidden: req.IncludeHidden,
|
IncludeHidden: req.IncludeHidden,
|
||||||
|
AppealTarget: req.AppealTarget,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
|||||||
created_at, updated_at,
|
created_at, updated_at,
|
||||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||||
choices_offered`
|
choices_offered, applies_to_target`
|
||||||
|
|
||||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||||
category, default_color, sort_order, is_active,
|
category, default_color, sort_order, is_active,
|
||||||
trigger_event_label_de, trigger_event_label_en`
|
trigger_event_label_de, trigger_event_label_en,
|
||||||
|
appeal_target`
|
||||||
|
|
||||||
// List returns active rules, optionally filtered by proceeding type.
|
// List returns active rules, optionally filtered by proceeding type.
|
||||||
// Each row has ConceptDefaultEventTypeID hydrated from
|
// Each row has ConceptDefaultEventTypeID hydrated from
|
||||||
|
|||||||
@@ -126,6 +126,25 @@ func Calculate(
|
|||||||
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppealTarget filter (Slice B1, m/paliad#124 §18.1). When set,
|
||||||
|
// keep only rules whose AppliesToTarget contains the requested
|
||||||
|
// slug. Unknown slugs short-circuit to no-op (defensive: a stale
|
||||||
|
// frontend chip shouldn't break the render). Empty AppliesToTarget
|
||||||
|
// on a rule means "doesn't belong to an appeal target" — such a
|
||||||
|
// rule is suppressed under any non-empty AppealTarget filter.
|
||||||
|
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||||
|
filtered := make([]Rule, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
for _, t := range r.AppliesToTarget {
|
||||||
|
if t == opts.AppealTarget {
|
||||||
|
filtered = append(filtered, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules = filtered
|
||||||
|
}
|
||||||
|
|
||||||
// ruleByID lets the conditional-rendering branches resolve a parent
|
// ruleByID lets the conditional-rendering branches resolve a parent
|
||||||
// rule's display fields (submission_code, name, name_en) for the
|
// rule's display fields (submission_code, name, name_en) for the
|
||||||
// "abhängig von <ParentRuleName>" chip without re-scanning the rules
|
// "abhängig von <ParentRuleName>" chip without re-scanning the rules
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||||
@@ -149,6 +150,13 @@ type Rule struct {
|
|||||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||||
// t-paliad-265). NULL = no caret affordance (default).
|
// t-paliad-265). NULL = no caret affordance (default).
|
||||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||||
|
|
||||||
|
// AppliesToTarget is the per-rule applies-to set for the unified
|
||||||
|
// UPC Berufung proceeding type (Slice B1, mig 134, m/paliad#124
|
||||||
|
// §18.1). Each element ∈ AppealTargets. NULL on rules outside
|
||||||
|
// the appeal proceeding. The engine filters by this when
|
||||||
|
// CalcOptions.AppealTarget is set.
|
||||||
|
AppliesToTarget pq.StringArray `db:"applies_to_target" json:"appliesToTarget,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
|
// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
|
||||||
@@ -171,6 +179,12 @@ type ProceedingType struct {
|
|||||||
// that fires when no rule has IsRootEvent=true.
|
// that fires when no rule has IsRootEvent=true.
|
||||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||||
|
|
||||||
|
// AppealTarget is the top-level appeal-target marker (Slice B1, mig
|
||||||
|
// 134). NULL on non-appeal proceedings. Reserved for future variants
|
||||||
|
// — today the unified upc.apl row has this NULL (per-rule targets
|
||||||
|
// live on Rule.AppliesToTarget).
|
||||||
|
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdjustmentReason describes why a date was rolled forward / backward
|
// AdjustmentReason describes why a date was rolled forward / backward
|
||||||
@@ -253,6 +267,14 @@ type CalcOptions struct {
|
|||||||
IncludeHidden bool
|
IncludeHidden bool
|
||||||
|
|
||||||
ProjectHint ProjectHint
|
ProjectHint ProjectHint
|
||||||
|
|
||||||
|
// AppealTarget narrows the timeline to rules whose AppliesToTarget
|
||||||
|
// contains the requested slug. Empty = no filter. Set to one of
|
||||||
|
// AppealTargets for the unified UPC Berufung picker (Slice B1,
|
||||||
|
// m/paliad#124 §18.1). Unknown slugs are silently dropped (no
|
||||||
|
// filter applied) so a stale frontend chip doesn't break the
|
||||||
|
// timeline render — see IsValidAppealTarget.
|
||||||
|
AppealTarget string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||||
@@ -426,3 +448,52 @@ var (
|
|||||||
ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
||||||
ErrUnknownRule = errors.New("unknown rule")
|
ErrUnknownRule = errors.New("unknown rule")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AppealTarget* are the canonical slugs for the unified UPC Berufung
|
||||||
|
// proceeding type's appeal-target discriminator (Slice B1, m/paliad#124
|
||||||
|
// §18.1). The verfahrensablauf picker renders one "Berufung" entry;
|
||||||
|
// the user then picks one of these five targets and the engine filters
|
||||||
|
// rules whose AppliesToTarget contains the requested slug.
|
||||||
|
//
|
||||||
|
// Schadensbemessung + Bucheinsicht have no rule rows in migration 134;
|
||||||
|
// per m's 2026-05-26 decision they are distinct from the merits track
|
||||||
|
// and their rule sets will be seeded in a follow-up slice (paired with
|
||||||
|
// t-paliad-193 orphan-concept-seed or editorial via /admin/rules).
|
||||||
|
// CalcOptions.AppealTarget="schadensbemessung" or "bucheinsicht"
|
||||||
|
// currently returns an empty timeline.
|
||||||
|
const (
|
||||||
|
AppealTargetEndentscheidung = "endentscheidung"
|
||||||
|
AppealTargetKostenentscheidung = "kostenentscheidung"
|
||||||
|
AppealTargetAnordnung = "anordnung"
|
||||||
|
AppealTargetSchadensbemessung = "schadensbemessung"
|
||||||
|
AppealTargetBucheinsicht = "bucheinsicht"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppealTargets is the canonical ordered list for UI chip rendering +
|
||||||
|
// validation. Order matches the design doc + the frontend's i18n key
|
||||||
|
// ordering — do not reorder without coordinating with the chip-group
|
||||||
|
// renderer.
|
||||||
|
var AppealTargets = []string{
|
||||||
|
AppealTargetEndentscheidung,
|
||||||
|
AppealTargetKostenentscheidung,
|
||||||
|
AppealTargetAnordnung,
|
||||||
|
AppealTargetSchadensbemessung,
|
||||||
|
AppealTargetBucheinsicht,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidAppealTarget returns true for empty (no filter requested) or
|
||||||
|
// any of the five canonical slugs. The engine uses this to gate the
|
||||||
|
// CalcOptions.AppealTarget filter — an unknown slug is silently
|
||||||
|
// dropped (no filter applied) rather than producing an error, so a
|
||||||
|
// stale frontend chip doesn't break the timeline render.
|
||||||
|
func IsValidAppealTarget(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, t := range AppealTargets {
|
||||||
|
if t == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user