Two bugs from the Slice B1 Berufung rollout, one fix surface:
Bug A — duplicate side selectors collapse into ONE proactive-side
picker with per-proceeding role labels. The Verfahrensablauf used to
show both ?side= (Klägerseite/Beklagtenseite) AND ?appellant= (same
labels in case-form) on the Berufung tile. Now: one side picker, with
labels that swap to Berufungskläger/Berufungsbeklagter on the unified
upc.apl.unified tile (and Antragsteller/Antragsgegner Nichtigkeit on
upc.rev.cfi, Einsprechende(r)/Patentinhaber(in) on epa.opp.*).
Bug B — 'Auslösendes Ereignis' label derives from appeal_target on
the unified Berufung tile (5 target-specific strings) instead of the
proceeding's own trigger_event_label. Endentscheidung (R.118) /
Kostenentscheidung / Anordnung / Entscheidung im
Schadensbemessungsverfahren / Anordnung der Bucheinsicht.
Migration 137 (additive, no triggers on proceeding_types — verified
via mcp__supabase__execute_sql before drafting; no updated_at on the
table — lesson from mig 134 HOTFIX 3; no audit_reason setup needed):
- ADD COLUMN role_proactive_label_de (text NULL)
- ADD COLUMN role_proactive_label_en (text NULL)
- ADD COLUMN role_reactive_label_de (text NULL)
- ADD COLUMN role_reactive_label_en (text NULL)
- Audit-first DO block lists the rows the UPDATE will touch.
- Backfill 4 proceedings (upc.apl.unified + upc.rev.cfi +
epa.opp.opd + epa.opp.boa); every other proceeding stays NULL
and the renderer falls back to default labels.
- Down drops the 4 columns.
Package additions (pkg/litigationplanner):
- ProceedingType gains 4 *string fields (RoleProactive/Reactive
LabelDE/EN) — db tags match the new columns; existing scans pick
them up via the proceedingTypeColumns extension.
- TriggerEventLabelForAppealTarget(target, lang) — Go-side map of
the 5 appeal-target slugs to their DE/EN trigger-event labels.
Empty result on unknown target signals "fall back to proceeding's
own trigger_event_label".
- Engine override: when CalcOptions.AppealTarget is set, the
resulting Timeline.TriggerEventLabel/EN are replaced from the
per-target map.
Frontend:
- Removed #appellant-row div (was a separate 3-radio selector
duplicating side).
- Dropped ?appellant= URL state + the change handler + the init
readback. The engine still consumes "appellant" — sourced from
currentSide for role-swap proceedings; null otherwise.
- applyRoleLabels(proceedingType) swaps the side-row radio labels
from a hardcoded ROLE_LABELS map mirroring mig 137's backfill.
Falls back to deadlines.side.claimant/defendant i18n keys for
proceedings without overrides.
- syncTriggerEventLabel reads data.triggerEventLabel from the calc
response — which the engine override now sets per appeal_target,
so no client-side mapping needed.
- i18n cleanup: removed orphan deadlines.appellant.* keys (label /
claimant / defendant / none) in both DE + EN.
Tests:
- pkg/litigationplanner/appeal_target_label_test.go pins the 5×2
label matrix + a coverage test that fails if a new entry in
AppealTargets is added without populating the label switch.
Acceptance:
- go build + go test all green (incl. new lp test).
- bun run build clean (i18n codegen drops 4 keys, regenerates).
- Live-DB audit before drafting confirmed: 4 target columns don't
exist on proceeding_types, zero triggers on the table, exact
column inventory matches the design.
673 lines
30 KiB
Go
673 lines
30 KiB
Go
package litigationplanner
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
|
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
|
// from Postgres breaks the row scan with "unsupported Scan, storing
|
|
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
|
// error that hid every approval_request from the inbox when m's first
|
|
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
|
|
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
|
|
// fixes the scan and preserves inline JSON output (no base64 cast).
|
|
type NullableJSON []byte
|
|
|
|
// Scan implements sql.Scanner.
|
|
func (n *NullableJSON) Scan(value any) error {
|
|
if value == nil {
|
|
*n = nil
|
|
return nil
|
|
}
|
|
switch v := value.(type) {
|
|
case []byte:
|
|
*n = append((*n)[:0], v...)
|
|
return nil
|
|
case string:
|
|
*n = []byte(v)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
|
|
}
|
|
|
|
// Value implements driver.Valuer.
|
|
func (n NullableJSON) Value() (driver.Value, error) {
|
|
if len(n) == 0 {
|
|
return nil, nil
|
|
}
|
|
return []byte(n), nil
|
|
}
|
|
|
|
// MarshalJSON emits the raw JSON bytes (or "null").
|
|
func (n NullableJSON) MarshalJSON() ([]byte, error) {
|
|
if len(n) == 0 {
|
|
return []byte("null"), nil
|
|
}
|
|
return []byte(n), nil
|
|
}
|
|
|
|
// UnmarshalJSON consumes raw JSON bytes (literal "null" maps to nil).
|
|
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
|
|
if string(data) == "null" {
|
|
*n = nil
|
|
return nil
|
|
}
|
|
*n = append((*n)[:0], data...)
|
|
return nil
|
|
}
|
|
|
|
// Rule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
|
//
|
|
// JSON + db tags are intentionally identical to the historical
|
|
// paliad.deadline_rules row shape — sqlx scans onto Rule directly and
|
|
// the wire bytes the frontend reads are unchanged from the pre-extract
|
|
// shape.
|
|
type Rule struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
|
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
|
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
|
|
Name string `db:"name" json:"name"`
|
|
NameEN string `db:"name_en" json:"name_en"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
|
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
|
DurationValue int `db:"duration_value" json:"duration_value"`
|
|
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
|
Timing *string `db:"timing" json:"timing,omitempty"`
|
|
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
|
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
|
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
|
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
|
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
|
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
|
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
|
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
|
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
|
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
|
// this rule's concept (joined via paliad.deadline_concept_event_types
|
|
// where is_default = true). Lets the deadline create form auto-populate
|
|
// the Typ chip when the user picks this rule. Hydrated by the service
|
|
// layer; not a column. NULL when the concept has no mapped event_type.
|
|
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
|
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
|
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
|
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
|
IsActive bool `db:"is_active" json:"is_active"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
|
|
// TriggerEventID points at paliad.trigger_events when this rule is
|
|
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
|
// proceeding-rooted rules.
|
|
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
|
|
|
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
|
// when is_spawn=true and this is non-NULL, the calculator follows
|
|
// the FK and emits the target proceeding's root rule chain.
|
|
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
|
|
|
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
|
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
|
// NULL = single-anchor arithmetic.
|
|
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
|
|
|
// ConditionExpr is the jsonb gating expression. Grammar:
|
|
// {"flag": "<name>"}
|
|
// {"op":"and"|"or", "args":[<node>, ...]}
|
|
// {"op":"not", "args":[<node>]}
|
|
// NULL or {} = unconditional.
|
|
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
|
|
|
// Priority is the 4-way unified enum: 'mandatory' (default),
|
|
// 'recommended', 'optional', 'informational'.
|
|
Priority string `db:"priority" json:"priority"`
|
|
|
|
// IsCourtSet replaces the runtime heuristic (primary_party='court'
|
|
// OR event_type IN ('hearing','decision','order')).
|
|
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
|
|
|
// LifecycleState drives the rule-editor flow:
|
|
// 'draft' | 'published' | 'archived'.
|
|
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
|
|
|
// DraftOf points at the published rule this draft will replace on
|
|
// publish. NULL on published / archived rows.
|
|
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
|
|
|
// PublishedAt records when the row entered LifecycleState='published'.
|
|
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
|
|
|
// ChoicesOffered declares which per-event-card choice-kinds this
|
|
// rule offers on the Verfahrensablauf timeline (mig 129,
|
|
// t-paliad-265). NULL = no caret affordance (default).
|
|
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
|
|
// /APM/APP/AMD/ZPO_CIVIL — matter management) or the lowercase dot-
|
|
// separated fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) —
|
|
// see docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
|
type ProceedingType struct {
|
|
ID int `db:"id" json:"id"`
|
|
Code string `db:"code" json:"code"`
|
|
Name string `db:"name" json:"name"`
|
|
NameEN string `db:"name_en" json:"name_en"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
|
Category *string `db:"category" json:"category,omitempty"`
|
|
DefaultColor string `db:"default_color" json:"default_color"`
|
|
SortOrder int `db:"sort_order" json:"sort_order"`
|
|
IsActive bool `db:"is_active" json:"is_active"`
|
|
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
|
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
|
// that fires when no rule has IsRootEvent=true.
|
|
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"`
|
|
|
|
// 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"`
|
|
|
|
// Role label overrides (t-paliad-301 / m/paliad#132, mig 137).
|
|
// NULL = renderer falls back to the language-default labels
|
|
// ("Klägerseite" / "Beklagtenseite" / "Claimant side" / "Defendant side").
|
|
// Set on proceedings where the role-naming diverges from the
|
|
// claimant/defendant default (Appeal → Berufungskläger /
|
|
// Berufungsbeklagter; Revocation → Antragsteller /
|
|
// Antragsgegner Nichtigkeit; EPA Opposition → Einsprechende(r) /
|
|
// Patentinhaber(in)).
|
|
RoleProactiveLabelDE *string `db:"role_proactive_label_de" json:"role_proactive_label_de,omitempty"`
|
|
RoleProactiveLabelEN *string `db:"role_proactive_label_en" json:"role_proactive_label_en,omitempty"`
|
|
RoleReactiveLabelDE *string `db:"role_reactive_label_de" json:"role_reactive_label_de,omitempty"`
|
|
RoleReactiveLabelEN *string `db:"role_reactive_label_en" json:"role_reactive_label_en,omitempty"`
|
|
}
|
|
|
|
// TriggerEventLabelForAppealTarget returns the per-target
|
|
// "Auslösendes Ereignis" label for the unified UPC Berufung
|
|
// proceeding (t-paliad-301 / m/paliad#132 Bug B). The trigger event
|
|
// for an appeal is the underlying decision, not the appeal
|
|
// proceeding itself — these labels override the proceeding's own
|
|
// trigger_event_label when appeal_target is set.
|
|
//
|
|
// lang ∈ {"de", "en"}; any other value falls through to "de" so the
|
|
// caller never gets an empty string.
|
|
//
|
|
// Returns empty when target is empty / unknown (caller must fall
|
|
// back to the proceeding's own trigger_event_label).
|
|
func TriggerEventLabelForAppealTarget(target, lang string) string {
|
|
if lang != "en" {
|
|
lang = "de"
|
|
}
|
|
switch target {
|
|
case AppealTargetEndentscheidung:
|
|
if lang == "en" {
|
|
return "Final decision (R.118)"
|
|
}
|
|
return "Endentscheidung (R.118)"
|
|
case AppealTargetKostenentscheidung:
|
|
if lang == "en" {
|
|
return "Cost decision"
|
|
}
|
|
return "Kostenentscheidung"
|
|
case AppealTargetAnordnung:
|
|
if lang == "en" {
|
|
return "Order"
|
|
}
|
|
return "Anordnung"
|
|
case AppealTargetSchadensbemessung:
|
|
if lang == "en" {
|
|
return "Damages-assessment decision"
|
|
}
|
|
return "Entscheidung im Schadensbemessungsverfahren"
|
|
case AppealTargetBucheinsicht:
|
|
if lang == "en" {
|
|
return "Book-inspection order"
|
|
}
|
|
return "Anordnung der Bucheinsicht"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AdjustmentReason describes why a date was rolled forward / backward
|
|
// off a non-working day. Populated by HolidayCalendar implementations
|
|
// when AdjustForNonWorkingDaysWithReason moves the date.
|
|
//
|
|
// Date fields are JSON-serialised as YYYY-MM-DD strings (matching
|
|
// TimelineEntry.DueDate / OriginalDate) so the frontend doesn't need a
|
|
// separate RFC3339 parser.
|
|
type AdjustmentReason struct {
|
|
// Kind is the dominant cause; longest cause wins when several apply
|
|
// (vacation > public_holiday > weekend).
|
|
Kind string `json:"kind"`
|
|
// Holidays collects every named holiday encountered while walking
|
|
// past the non-working run, deduped by (date, name). May be empty
|
|
// when the only cause is a weekend.
|
|
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
|
// VacationName, VacationStart and VacationEnd describe the
|
|
// contiguous vacation block the original date sits in. Populated
|
|
// only when Kind == "vacation". Span boundaries are the first/last
|
|
// vacation day in the block (excludes the weekends that pad it).
|
|
VacationName string `json:"vacationName,omitempty"`
|
|
VacationStart string `json:"vacationStart,omitempty"`
|
|
VacationEnd string `json:"vacationEnd,omitempty"`
|
|
// OriginalWeekday is the English weekday name of the original date —
|
|
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
|
// can localise it.
|
|
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
|
}
|
|
|
|
// HolidayDTO is the JSON shape for a holiday emitted in
|
|
// AdjustmentReason — distinct from a DB-level Holiday row so dates
|
|
// serialise as YYYY-MM-DD strings.
|
|
type HolidayDTO struct {
|
|
Date string `json:"date"`
|
|
Name string `json:"name"`
|
|
IsVacation bool `json:"isVacation,omitempty"`
|
|
IsClosure bool `json:"isClosure,omitempty"`
|
|
}
|
|
|
|
// CalcOptions carries optional inputs for Calculate. Callers can leave
|
|
// fields empty/nil for the legacy behaviour.
|
|
//
|
|
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with
|
|
// anchor_alt='priority_date' (e.g. epa.grant.exa.ep_grant.publish
|
|
// per Art. 93 EPÜ) use this date as their base instead of the
|
|
// parent's adjusted date / the trigger date.
|
|
// - Flags: lowercase string flags from the UI (e.g. "with_ccr",
|
|
// "with_amend"). Drive condition_expr evaluation + flag-keyed
|
|
// alt-swap.
|
|
// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides
|
|
// of the computed deadline date. When a child rule chains off a
|
|
// parent whose code is in AnchorOverrides, the override date is
|
|
// used as the anchor instead of the parent's calculated date.
|
|
// - CourtID picks the forum the proceeding is filed in (e.g.
|
|
// "upc-ld-paris", "de-bgh"). The calculator resolves it to
|
|
// (country, regime) for non-working-day computation.
|
|
// - TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
|
|
// rules: when non-nil, the proceedingCode argument is ignored and
|
|
// the engine selects rules WHERE trigger_event_id = *filter.
|
|
// - RuleOverrides substitutes specific rules in the calculator's
|
|
// rule list with caller-supplied in-memory rows. Used by the
|
|
// rule-editor preview.
|
|
// - PerCardAppellant / SkipRules / IncludeCCRFor / IncludeHidden
|
|
// drive per-event-card choice overlays (t-paliad-265, t-paliad-290).
|
|
// - ProjectHint scopes the catalog lookup to a project context
|
|
// (paliad's catalog uses this to merge in project-scoped rules
|
|
// in future slices; v1 catalogs may ignore it).
|
|
type CalcOptions struct {
|
|
PriorityDateStr string
|
|
Flags []string
|
|
AnchorOverrides map[string]string
|
|
CourtID string
|
|
TriggerEventIDFilter *int64
|
|
RuleOverrides []Rule
|
|
|
|
PerCardAppellant map[string]string
|
|
SkipRules map[string]struct{}
|
|
IncludeCCRFor map[string]struct{}
|
|
IncludeHidden bool
|
|
|
|
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
|
|
// catalog uses ProjectID to merge in project-scoped rules in a future
|
|
// slice (m/paliad#124 §6 — currently dropped per m's 2026-05-26
|
|
// decision; the field stays for forward-compat). Other catalogs (the
|
|
// embedded UPC snapshot used by youpc.org) ignore the hint.
|
|
//
|
|
// Zero value = no project context (the abstract Verfahrensablauf /
|
|
// public Fristenrechner case).
|
|
type ProjectHint struct {
|
|
ProjectID uuid.UUID
|
|
}
|
|
|
|
// CalcRuleParams identifies a single rule and the inputs needed to
|
|
// compute one deadline from it. Caller supplies either RuleID OR the
|
|
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
|
|
// hand from the concept-card pill it just received a click on.
|
|
type CalcRuleParams struct {
|
|
RuleID string // optional — UUID
|
|
ProceedingCode string // optional — used with RuleLocalCode
|
|
RuleLocalCode string // optional — paliad.deadline_rules.submission_code
|
|
TriggerDate string // required — YYYY-MM-DD
|
|
Flags []string // optional — condition_flag inputs
|
|
CourtID string // optional — selects holiday calendar
|
|
}
|
|
|
|
// Timeline is the package's structured return for Calculate. JSON tags
|
|
// are aligned with paliad's historical UIResponse so handlers can serve
|
|
// it directly — the wire bytes the frontend reads are unchanged.
|
|
type Timeline struct {
|
|
ProceedingType string `json:"proceedingType"`
|
|
ProceedingName string `json:"proceedingName"`
|
|
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
|
|
TriggerDate string `json:"triggerDate"`
|
|
Deadlines []TimelineEntry `json:"deadlines"`
|
|
ContextualNote string `json:"contextualNote,omitempty"`
|
|
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
|
|
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
|
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
|
HiddenCount int `json:"hiddenCount"`
|
|
}
|
|
|
|
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
|
// interface (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
|
type TimelineEntry struct {
|
|
RuleID string `json:"ruleId,omitempty"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
NameEN string `json:"nameEN"`
|
|
Party string `json:"party"`
|
|
Priority string `json:"priority"`
|
|
RuleRef string `json:"ruleRef"`
|
|
LegalSource string `json:"legalSource,omitempty"`
|
|
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
|
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
NotesEN string `json:"notesEN,omitempty"`
|
|
DueDate string `json:"dueDate"`
|
|
OriginalDate string `json:"originalDate"`
|
|
WasAdjusted bool `json:"wasAdjusted"`
|
|
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
|
IsRootEvent bool `json:"isRootEvent"`
|
|
IsCourtSet bool `json:"isCourtSet"`
|
|
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
|
|
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
|
// IsConditional signals the rule's anchor is uncertain — no
|
|
// concrete date can be projected. Set when the rule depends on:
|
|
// - a court-set ancestor whose date isn't anchored (overlaps
|
|
// with IsCourtSetIndirect; the two are kept distinct because
|
|
// IsCourtSet wraps a specific UX message "wird vom Gericht
|
|
// bestimmt", whereas IsConditional is the broader "render as
|
|
// 'abhängig von <parent>'" signal)
|
|
// - timing='before' rules whose forward anchor isn't set
|
|
// - optional opposing-side rules whose true triggering event
|
|
// hasn't been recorded for this project (e.g. R.262(2)
|
|
// Erwiderung auf Vertraulichkeitsantrag)
|
|
// When true, DueDate and OriginalDate are empty and the frontend
|
|
// renders an "abhängig von <ParentRuleName>" chip in place of a
|
|
// date. Suppressed by an explicit user anchor. (t-paliad-289)
|
|
IsConditional bool `json:"isConditional,omitempty"`
|
|
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
|
|
// parent's identity so the frontend can render
|
|
// "abhängig von <ParentRuleName>" when IsConditional=true.
|
|
// Populated whenever the rule has a parent_id, not only when
|
|
// conditional — keeps the wire shape stable. Empty for root rules.
|
|
// When a rule has a real trigger_event_id, these fields are
|
|
// overridden to point at the trigger_events catalog row instead of
|
|
// the parent_id chain (t-paliad-294 / m/paliad#126).
|
|
ParentRuleCode string `json:"parentRuleCode,omitempty"`
|
|
ParentRuleName string `json:"parentRuleName,omitempty"`
|
|
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
|
|
IsOverridden bool `json:"isOverridden,omitempty"`
|
|
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
|
|
AppellantContext string `json:"appellantContext,omitempty"`
|
|
IsHidden bool `json:"isHidden,omitempty"`
|
|
}
|
|
|
|
// RuleCalculation is the single-rule calc response that backs the
|
|
// result-card click → calc-panel flow. Distinct from TimelineEntry
|
|
// (which represents one rendered row inside a full-proceeding
|
|
// response): RuleCalculation is self-contained.
|
|
type RuleCalculation struct {
|
|
Rule RuleCalculationRule `json:"rule"`
|
|
Proceeding RuleCalculationProceeding `json:"proceeding"`
|
|
TriggerDate string `json:"triggerDate"`
|
|
OriginalDate string `json:"originalDate"`
|
|
DueDate string `json:"dueDate"`
|
|
WasAdjusted bool `json:"wasAdjusted"`
|
|
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
|
IsCourtSet bool `json:"isCourtSet"`
|
|
FlagsApplied []string `json:"flagsApplied,omitempty"`
|
|
FlagsRequired []string `json:"flagsRequired,omitempty"`
|
|
}
|
|
|
|
// RuleCalculationRule mirrors the small subset of Rule the
|
|
// frontend needs to render the calc panel.
|
|
type RuleCalculationRule struct {
|
|
ID string `json:"id"`
|
|
LocalCode string `json:"localCode,omitempty"`
|
|
NameDE string `json:"nameDE"`
|
|
NameEN string `json:"nameEN"`
|
|
RuleRef string `json:"ruleRef,omitempty"`
|
|
LegalSource string `json:"legalSource,omitempty"`
|
|
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
|
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
|
DurationValue int `json:"durationValue"`
|
|
DurationUnit string `json:"durationUnit"`
|
|
Party string `json:"party,omitempty"`
|
|
IsMandatory bool `json:"isMandatory"`
|
|
NotesDE string `json:"notesDE,omitempty"`
|
|
NotesEN string `json:"notesEN,omitempty"`
|
|
}
|
|
|
|
// RuleCalculationProceeding identifies the proceeding context for the
|
|
// rule. Used by the frontend for display + by the add-to-project flow.
|
|
type RuleCalculationProceeding struct {
|
|
Code string `json:"code"`
|
|
NameDE string `json:"nameDE"`
|
|
NameEN string `json:"nameEN"`
|
|
}
|
|
|
|
// FristenrechnerType mirrors the /api/tools/proceeding-types response
|
|
// metadata.
|
|
type FristenrechnerType struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
NameEN string `json:"nameEN"`
|
|
Group string `json:"group"`
|
|
}
|
|
|
|
// EventLookupAxes carries the optional filter axes for
|
|
// Catalog.LookupEvents (Slice B2, m/paliad#124 §18.2). All fields are
|
|
// optional; the empty value (or nil pointer) is "no filter on this
|
|
// axis". When multiple axes are set the catalog applies them as AND —
|
|
// a rule must match every non-zero axis to be returned. An axis set
|
|
// to an unknown value (jurisdiction="XX", party="foo") is treated the
|
|
// same as "no filter on this axis" so a stale frontend doesn't
|
|
// silently drop the entire result set.
|
|
//
|
|
// AppealTarget narrows to rules whose AppliesToTarget contains the
|
|
// requested slug (same semantic as CalcOptions.AppealTarget). Useful
|
|
// for the unified UPC Berufung lookup.
|
|
type EventLookupAxes struct {
|
|
// Jurisdiction filters by paliad.proceeding_types.jurisdiction
|
|
// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
|
|
Jurisdiction string
|
|
// ProceedingTypeID narrows to one proceeding. nil = any.
|
|
ProceedingTypeID *int
|
|
// Party filters by paliad.deadline_rules.primary_party
|
|
// ("claimant" | "defendant" | "court" | "both"). Empty = any.
|
|
// Validated against PrimaryParties before the SQL pass; unknown
|
|
// values fall through as "no filter".
|
|
Party string
|
|
// EventCategoryID narrows to rules associated with one
|
|
// event_categories row via the
|
|
// deadline_concept_event_types junction. nil = any.
|
|
EventCategoryID *uuid.UUID
|
|
// AppealTarget filters by Rule.AppliesToTarget containing the
|
|
// requested slug (e.g. "endentscheidung"). Empty = any.
|
|
// Validated against AppealTargets before the SQL pass.
|
|
AppealTarget string
|
|
}
|
|
|
|
// EventLookupDepth controls the sequence-depth of the returned events.
|
|
type EventLookupDepth string
|
|
|
|
const (
|
|
// EventLookupDepthNext returns immediate children of the matched
|
|
// anchor (1 hop downstream via parent_id). Useful for "what comes
|
|
// next from this point?" queries.
|
|
EventLookupDepthNext EventLookupDepth = "next"
|
|
// EventLookupDepthAllFollowing returns the entire downstream
|
|
// chain (parent_id walk to leaves). Useful for "show me the
|
|
// whole sequence from here onward" queries.
|
|
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
|
|
)
|
|
|
|
// EventMatch is one result row from Catalog.LookupEvents.
|
|
type EventMatch struct {
|
|
// Rule carries the full deadline-rule row including parent_id,
|
|
// duration_value/_unit, condition_expr, applies_to_target, etc.
|
|
Rule Rule `json:"rule"`
|
|
// ProceedingType is the owning proceeding metadata. Lets the
|
|
// frontend render the "from <proceeding>" badge without a second
|
|
// roundtrip.
|
|
ProceedingType ProceedingType `json:"proceedingType"`
|
|
// Priority surfaces Rule.Priority at the top level for
|
|
// convenience — the four-value vocab (mandatory / recommended /
|
|
// optional / informational).
|
|
Priority string `json:"priority"`
|
|
// DepthFromAnchor is 1 for the immediate match, 2+ for deeper
|
|
// descendants returned under EventLookupDepthAllFollowing.
|
|
// Always >= 1 for any returned row.
|
|
DepthFromAnchor int `json:"depthFromAnchor"`
|
|
// ParentRuleID is the parent rule's UUID when that parent is
|
|
// itself in the returned result set (so the frontend can render
|
|
// a tree). nil when the parent is outside the returned set.
|
|
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
|
|
}
|
|
|
|
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
|
// whose semantic anchor is an event rather than a parent rule (the
|
|
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
|
|
// triggered by the opposing party's confidentiality application, not
|
|
// by the SoC parent rule). The conditional-rendering branch reads
|
|
// this when stamping ParentRule* on the wire.
|
|
type TriggerEvent struct {
|
|
ID int64 `db:"id" json:"id"`
|
|
Code string `db:"code" json:"code"`
|
|
Name string `db:"name" json:"name"`
|
|
NameDE string `db:"name_de" json:"name_de"`
|
|
Description string `db:"description" json:"description"`
|
|
IsActive bool `db:"is_active" json:"is_active"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
}
|
|
|
|
// Sentinel errors surfaced by Calculate / CalculateRule / Catalog
|
|
// implementations. Handlers map these to HTTP statuses.
|
|
var (
|
|
ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
|
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,
|
|
}
|
|
|
|
// PrimaryParty* are the canonical four-value vocabulary for
|
|
// paliad.deadline_rules.primary_party (Slice B3, m/paliad#124 §18.3,
|
|
// mig 135). The DB CHECK constraint enforces the same set; the
|
|
// application-layer helper IsValidPrimaryParty lets the rule editor
|
|
// surface a friendly 400 before the DB error fires.
|
|
//
|
|
// NULL is also valid in the DB (for the 78 orphan cross-cutting
|
|
// concept seeds — Wiedereinsetzung, Versäumnisurteil-Einspruch,
|
|
// Schriftsatznachreichung, Weiterbehandlung). The helper treats the
|
|
// empty string as "no value supplied" = valid; non-empty strings must
|
|
// match one of the four canonical values.
|
|
const (
|
|
PrimaryPartyClaimant = "claimant"
|
|
PrimaryPartyDefendant = "defendant"
|
|
PrimaryPartyCourt = "court"
|
|
PrimaryPartyBoth = "both"
|
|
)
|
|
|
|
// PrimaryParties is the canonical ordered list for validation +
|
|
// admin-UI rendering. Order matches the rule-editor select; do not
|
|
// reorder without coordinating with the frontend.
|
|
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 to validate
|
|
// writes before they hit the DB CHECK — produces a user-friendly 400
|
|
// instead of a raw constraint-violation error.
|
|
func IsValidPrimaryParty(s string) bool {
|
|
if s == "" {
|
|
return true
|
|
}
|
|
for _, p := range PrimaryParties {
|
|
if p == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
}
|