Files
paliad/pkg/litigationplanner/types.go
mAi 4cc301b8ff
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
refactor(litigationplanner): extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (Slice A, t-paliad-298 / m/paliad#124)
Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.

Package contents (~1850 LoC):
- doc.go              package docstring + reuse manifesto
- types.go            Rule, ProceedingType, NullableJSON, AdjustmentReason,
                      HolidayDTO, CalcOptions, CalcRuleParams, Timeline,
                      TimelineEntry, RuleCalculation*, FristenrechnerType,
                      ProjectHint, sentinel errors
- catalog.go          Catalog interface (proceeding + rule lookups)
- holidays.go         HolidayCalendar interface
- courts.go           CourtRegistry interface + DefaultsForJurisdiction +
                      country/regime constants
- expr.go             EvalConditionExpr + HasConditionExpr +
                      ExtractFlagsFromExpr (jsonb gate evaluator)
- durations.go        ApplyDuration + AddWorkingDays (pure compute)
- subtrack.go         SubTrackRouting + LookupSubTrackRouting registry
- legal_source.go     FormatLegalSourceDisplay + BuildLegalSourceURL
- proceeding_mapping.go  MapLitigationToFristenrechner + code constants
                      (CodeUPCInfringement, CodeDEInfringementLG, ...)
- engine.go           Calculate + CalculateRule + the trigger-event
                      branch + applyRuleOverrides (the big move)

paliad side (~1900 LoC net deletion):
- internal/services/fristenrechner.go shrinks from 1505 → ~290 lines
  (thin paliad Catalog adapter + type aliases for back-compat).
- internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON
  become type aliases to litigationplanner.* — every sqlx scan and
  every projection_service caller compiles unchanged.
- internal/services/holidays.go: AdjustmentReason + HolidayDTO become
  aliases to lp.* (canonical definitions now in the package).
- internal/services/proceeding_mapping.go: rewritten as thin re-exports
  of lp constants + helpers.
- internal/services/deadline_search_service.go: FormatLegalSourceDisplay
  + BuildLegalSourceURL replaced with delegating wrappers to lp.

Catalog interface satisfaction:
- DeadlineRuleService → paliadCatalog adapter (wraps the existing
  service, replicates the original SELECT shapes).
- HolidayService → satisfies lp.HolidayCalendar directly (compile-
  time assertion at end of fristenrechner.go).
- CourtService → satisfies lp.CourtRegistry directly.

Wire shape is byte-identical. JSON tags on Rule / ProceedingType /
Timeline / TimelineEntry / RuleCalculation match the historical
UIResponse / UIDeadline shape; the frontend reads the same bytes.

Slice B (Catalog interface + paliad loader cleanup) is folded into
this commit since Slice A already needs the interfaces to call
Calculate across the boundary. Slice C (embedded UPC snapshot +
generator) is the next coder shift; the Berufung unification m
called out lands in Slice B/C per head's brief.

Refs: docs/design-litigation-planner-2026-05-26.md
2026-05-26 12:52:59 +02:00

387 lines
18 KiB
Go

package litigationplanner
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// 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"`
}
// 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"`
}
// 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
}
// 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"`
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"`
}
// 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")
)