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 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": ""} // {"op":"and"|"or", "args":[, ...]} // {"op":"not", "args":[]} // 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"` } // 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 '" 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 " 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 " 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 " 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, } // 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 }