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
387 lines
18 KiB
Go
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")
|
|
)
|