package services import ( "context" "database/sql" "encoding/json" "errors" "fmt" "sort" "time" "github.com/google/uuid" "mgit.msbls.de/m/paliad/internal/models" ) // FristenrechnerService renders the Paliad public Fristenrechner's response // shape from DB-stored rules. It sits on top of DeadlineRuleService and // HolidayService and produces the bilingual, rule-code + notes-rich payload // that /tools/fristenrechner's client expects. // // The UI-facing response is distinct from the plain calculator in // DeadlineCalculator: it adds IsRootEvent, IsCourtSet, RuleRef, Notes, // party color classes, and keeps the result ordered by sequence_order // within each proceeding type. type FristenrechnerService struct { rules *DeadlineRuleService holidays *HolidayService courts *CourtService } // NewFristenrechnerService wires the service to its dependencies. func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService { return &FristenrechnerService{rules: rules, holidays: holidays, courts: courts} } // UIDeadline matches the frontend's CalculatedDeadline TypeScript interface // (camelCase JSON to keep /tools/fristenrechner byte-identical). // // Phase 3 Slice 9 (t-paliad-195) dropped the legacy IsMandatory + // IsOptional fields — Priority is the canonical wire signal. The // frontend reads priorityRendering(d) which since Slice 8 has // priority as the primary input; Slice 9 removes the legacy fallback // branch from the frontend too. type UIDeadline struct { RuleID string `json:"ruleId,omitempty"` Code string `json:"code"` Name string `json:"name"` NameEN string `json:"nameEN"` Party string `json:"party"` // Priority is the 4-way enum the rule-editor + save-modal logic // reads: 'mandatory' | 'recommended' | 'optional' | 'informational'. // Informational rules render as notice cards (no save button, no // checkbox) — the visible UX win of Phase 3 on today's F/F rules. Priority string `json:"priority"` RuleRef string `json:"ruleRef"` LegalSource string `json:"legalSource,omitempty"` // LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)") // of LegalSource, produced by FormatLegalSourceDisplay. Frontend // renders this in the deadline card meta line; falls back to // RuleRef when LegalSource is empty. LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` // LegalSourceURL is the youpc.org/laws permalink when the cited // body is hosted there (UPCRoP / UPCA / UPCS today). Empty for // DE/EPA/EU bodies — the renderer shows display text without a link. 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 is the jsonb gate predicate (design §2.4 long // form) emitted verbatim so the rule editor (Slice 11) + admin // surfaces can show the rule's gating shape. NULL / empty when // the rule is unconditional. Frontend reads this to render the // "Mit Nichtigkeitswiderklage" hint chips. ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"` // IsCourtSetIndirect is true when IsCourtSet is true because the // rule chains off a court-determined parent (e.g. RoP.151 // Kostenentscheidung is "1 Monat ab Hauptentscheidung", and the // Hauptentscheidung itself is the court-set anchor). Direct // court-determined rules (Urteil / Beschluss / Anordnung // themselves) keep IsCourtSet=true with IsCourtSetIndirect=false. // The frontend uses this to render "unbestimmt" for indirect // cases instead of "wird vom Gericht bestimmt", which is only // strictly correct for the direct ones — the indirect deadline // is computed off a parent date that the COURT sets, not by the // court itself. 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 // (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month // before the oral hearing — without the hearing date, the // backward arithmetic against the trigger date is meaningless) // - optional opposing-side rules whose true triggering event // hasn't been recorded for this project (e.g. R.262(2) // Erwiderung auf Vertraulichkeitsantrag — the data-model // parent is the SoC, but the real trigger is the opposing // party's confidentiality motion which may never happen) // 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 (IsOverridden wins). // (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. ParentRuleCode string `json:"parentRuleCode,omitempty"` ParentRuleName string `json:"parentRuleName,omitempty"` ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"` IsOverridden bool `json:"isOverridden,omitempty"` // ChoicesOffered surfaces paliad.deadline_rules.choices_offered for // the rule so the frontend knows whether to render the per-event-card // caret affordance, and which choice-kinds to populate the popover // with. NULL / empty for rules with no choices. (t-paliad-265) ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"` // AppellantContext is the per-decision appellant pick that applies // to descendants of the closest ancestor decision card with a // PerCardAppellant set. Empty when no per-card override is in // effect (page-level ?appellant= still applies in that case). // Frontend bucketer prefers this over the page-level appellant when // non-empty. (t-paliad-265) AppellantContext string `json:"appellantContext,omitempty"` // IsHidden marks a card the user has previously hidden via a // skip choice. Only ever true when CalcOptions.IncludeHidden is // set — the toggle re-surfaces these rows so the user can either // keep them faded for context or un-hide them via the inline // "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122) IsHidden bool `json:"isHidden,omitempty"` } // UIResponse matches the frontend's DeadlineResponse TypeScript interface. type UIResponse struct { ProceedingType string `json:"proceedingType"` ProceedingName string `json:"proceedingName"` // ProceedingNameEN carries the English label of the proceeding so // the frontend can switch on lang. Empty when the proceeding has no // English label populated; the frontend falls back to ProceedingName. // Added 2026-05-20 (m/paliad#58) — previously the verfahrensablauf // "Trigger event" label fell back to the DE proceedingName whenever // the timeline had no root rule (e.g. for sub-track proceedings like // upc.ccr.cfi that have no native rules). ProceedingNameEN string `json:"proceedingNameEN,omitempty"` TriggerDate string `json:"triggerDate"` Deadlines []UIDeadline `json:"deadlines"` // ContextualNote / ContextualNoteEN surface a banner above the // timeline. Populated by sub-track routing (m/paliad#58): when the // user picks a proceeding that is normally a sub-track of another // proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with // with_ccr), the renderer routes to the parent's rules but keeps // the user-picked code/name as the response identity and surfaces a // note explaining the framing. ContextualNote string `json:"contextualNote,omitempty"` ContextualNoteEN string `json:"contextualNoteEN,omitempty"` // TriggerEventLabel / TriggerEventLabelEN: optional caption for the // /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated // from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121). // The frontend prefers this over the proceedingName fallback that // fires when no rule has IsRootEvent=true — UPC Appeal needed it // because all its rules carry a non-zero duration off the trigger // date so no rule is the "anchor". The trigger event for UPC Appeal // is the appealable first-instance decision (m/paliad#81). TriggerEventLabel string `json:"triggerEventLabel,omitempty"` TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"` // HiddenCount is the number of rules whose submission_code is in // CalcOptions.SkipRules AND whose condition_expr gate passes — // i.e. how many rows the user has hidden in this projection // regardless of the IncludeHidden toggle state. The frontend uses // this to render the "Ausgeblendete (N)" badge on the toggle even // when the toggle is OFF (so users know there's something to // re-surface). (t-paliad-290 / m/paliad#122) HiddenCount int `json:"hiddenCount"` } // ErrUnknownProceedingType is returned when the UI sends an unrecognised code. var ErrUnknownProceedingType = errors.New("unknown proceeding type") // 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", "with_cci"). A rule with a non-empty condition_flag // array renders iff EVERY element of that array is in Flags. When all // are present AND alt_duration_value is non-NULL, the calculator // swaps to alt_*; when set + flags not satisfied, the rule is // suppressed entirely (skipped from the result list). // - 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. Lets // the user set a real court-extended deadline, or a court-set // decision date once known, and have downstream rules re-flow. type CalcOptions struct { PriorityDateStr string Flags []string AnchorOverrides map[string]string // 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. Empty falls back to UPC München (DE/UPC) for // UPC-flavoured proceedings, DE for everything else — preserves legacy // behaviour for callers that don't yet send a court. CourtID string // TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C // rules: when non-nil, the proceedingCode argument is ignored and the // service selects rules WHERE trigger_event_id = *TriggerEventIDFilter // instead of WHERE proceeding_type_id = .... Set by // EventDeadlineService.Calculate so the unified backend can serve the // "Was kommt nach…" surface after Phase 3 Slice 3. The pointer width // matches paliad.trigger_events.id (bigint, mig 028). See design // §3.D (calculator unification). TriggerEventIDFilter *int64 // RuleOverrides substitutes specific rules in the calculator's // rule list with caller-supplied in-memory rows. Used by the // rule-editor preview (Slice 11a, t-paliad-191): the admin's // draft replaces its published peer (matched by rule.ID) so the // editor sees "what would this rule do?" without writing to the // DB. Net-new drafts (no draft_of peer) get appended to the rule // list so their effect lights up on a fresh evaluation. // // Empty / nil = no override (default). Overrides apply equally to // the proceeding-tree and trigger-event branches. RuleOverrides []models.DeadlineRule // Per-event-card choice overlays (t-paliad-265 / m/paliad#96). // Keyed by paliad.deadline_rules.submission_code — same key // AnchorOverrides uses. // // - PerCardAppellant: maps a decision-card's submission_code to the // user-picked appellant ("claimant"|"defendant"|"both"|"none"). // The engine walks the parent chain of each rule and stamps the // resulting UIDeadline.AppellantContext from the closest ancestor // decision with a pick. The frontend bucketer then prefers the // per-rule context over the page-level appellant. // - SkipRules: set of submission_code values whose rules (and any // descendants) the user has opted out of for this projection. // Same suppression path as a failed condition_expr gate. // - IncludeCCRFor: set of submission_code values for rules where // the user opted in to the include-CCR choice (Klageerwiderung // cards). v1 simplification (design §4.2 #2): if non-empty, // "with_ccr" is appended to the flag set before gate // evaluation. Correct for single-CCR-entry-point proceedings // (UPC INF + DE LG today). Multi-CCR scope is a future expansion. PerCardAppellant map[string]string SkipRules map[string]struct{} IncludeCCRFor map[string]struct{} // IncludeHidden re-surfaces rules whose submission_code is in // SkipRules (t-paliad-290 / m/paliad#122). When true: // - Skipped rules are NOT dropped from the result; they render // with UIDeadline.IsHidden=true so the frontend can fade them. // - Descendant suppression is bypassed (the skipped parent is // present in the result, so children compute their dates off // it as if the user had never hidden it). // Default false preserves the original skip semantic (drop rule + // suppress descendants). HiddenCount on UIResponse is independent // of this flag — it always reflects the number of hide-eligible // rows so the toggle's count badge stays accurate. IncludeHidden bool } // Calculate renders the full UI timeline for a proceeding type + trigger date. // Preserves the pre-Phase-C in-memory calculator's classification: // // - Rules with duration_value = 0 and no parent_id → IsRootEvent // (due date = trigger date) // - Rules with duration_value = 0 and a parent_id → IsCourtSet // (due date empty, UI shows "court-set" placeholder) // - All other rules → calculate from either the trigger date (no parent) // or the previously-computed date for their parent rule. // // Audit-driven extensions: // // - opts.Flags can flip flag-conditioned rules onto their alt_* values // (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr"). When a // rule's condition_flag array is non-empty, the rule renders iff // EVERY element is in opts.Flags; rules that fail this gate are // suppressed entirely (used by Phase B1 cross-flow rules that should // only appear with their flag). // - opts.PriorityDateStr overrides the anchor for rules with anchor_alt // set (e.g. epa.grant.exa publication date is 18mo from priority, not filing). // - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the // caller redirect a downstream rule's parent anchor to a user-set // date. Used for court-extended deadlines and for entering // court-set decision dates post-hoc. func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) { // Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven // branch (Pipeline-C unified rules; mig 085 moved 77 rows out of // paliad.event_deadlines into paliad.deadline_rules carrying a // non-NULL trigger_event_id). proceedingCode is ignored on this // path. EventDeadlineService.Calculate is the sole caller today; // future "event-trigger" surfaces (design §5) plug in here too. if opts.TriggerEventIDFilter != nil { return s.calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts) } triggerDate, err := time.Parse("2006-01-02", triggerDateStr) if err != nil { return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) } var priorityDate *time.Time if opts.PriorityDateStr != "" { pd, err := time.Parse("2006-01-02", opts.PriorityDateStr) if err != nil { return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err) } priorityDate = &pd } flagSet := make(map[string]struct{}, len(opts.Flags)) for _, f := range opts.Flags { flagSet[f] = struct{}{} } // v1 simplification (design §4.2 #2, t-paliad-265): when any // IncludeCCRFor entry exists, we treat with_ccr as set in the flag // context. Correct for single-CCR-entry-point proceedings (UPC INF + // DE LG today). Multi-CCR scope is a future expansion that would // thread the include set through the gate evaluator per-rule. if len(opts.IncludeCCRFor) > 0 { flagSet["with_ccr"] = struct{}{} } // Parse anchor overrides up-front so a malformed date errors out // before we start walking rules. overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides)) for code, dateStr := range opts.AnchorOverrides { od, err := time.Parse("2006-01-02", dateStr) if err != nil { return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err) } overrideDates[code] = od } // Look up proceeding type metadata. var pt struct { ID int `db:"id"` Code string `db:"code"` Name string `db:"name"` NameEN string `db:"name_en"` Jurisdiction *string `db:"jurisdiction"` TriggerEventLabelDE *string `db:"trigger_event_label_de"` TriggerEventLabelEN *string `db:"trigger_event_label_en"` } err = s.rules.db.GetContext(ctx, &pt, `SELECT id, code, name, name_en, jurisdiction, trigger_event_label_de, trigger_event_label_en FROM paliad.proceeding_types WHERE code = $1 AND is_active = true`, proceedingCode) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUnknownProceedingType } if err != nil { return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err) } // Sub-track routing (m/paliad#58). When the user picks a proceeding // that has no native rules and is normally a sub-track of another // proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route // rule lookup to the parent and merge the default flags into the // user's flag set. The response identity (Code/Name/NameEN) stays // on the user-picked proceeding so the page header still reads // "Counterclaim for Revocation", but the timeline body is the // parent's full flow with the sub-track flag enabled. A note // surfaces the framing. var pickedProceeding = pt var subTrackNote SubTrackRouting var hasSubTrackNote bool if route, ok := LookupSubTrackRouting(proceedingCode); ok { subTrackNote = route hasSubTrackNote = true // Re-resolve to the parent proceeding for rule lookup. err = s.rules.db.GetContext(ctx, &pt, `SELECT id, code, name, name_en, jurisdiction, trigger_event_label_de, trigger_event_label_en FROM paliad.proceeding_types WHERE code = $1 AND is_active = true`, route.ParentCode) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, ErrUnknownProceedingType) } if err != nil { return nil, fmt.Errorf("resolve sub-track parent %q: %w", route.ParentCode, err) } // Merge default flags into the user's flag set so the gated // rules render. User-supplied flags win on conflict (they're // already in flagSet); default flags only add what's missing. for _, f := range route.DefaultFlags { if _, exists := flagSet[f]; !exists { flagSet[f] = struct{}{} } } } // Resolve (country, regime) for non-working-day adjustment. Court wins // when supplied; otherwise default by proceeding regime. UPC proceedings // default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA / // DE proceedings default to DE (no supranational regime). defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) country, regime, err := s.courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime) if err != nil { return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) } rules, err := s.rules.List(ctx, &pt.ID) if err != nil { return nil, err } if len(opts.RuleOverrides) > 0 { rules = applyRuleOverrides(rules, opts.RuleOverrides) } // Walk the rule list in sequence_order (already sorted by the query) and // compute each entry, keeping a code→date map so RelativeTo / parent_id // references resolve to the adjusted predecessor date. computed := make(map[string]time.Time, len(rules)) courtSet := make(map[uuid.UUID]bool, len(rules)) deadlines := make([]UIDeadline, 0, len(rules)) // Pre-pass: identify rules flagged is_court_set=true in the data so // order-of-evaluation in sequence_order doesn't matter for the // parent-court-set check below. Without this, a rule processed // earlier than its court-set parent (e.g. R.109(1) Antrag auf // Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung // sequence_order=50 in upc.inf.cfi) misses the court-set propagation // and computes a meaningless date — for timing='before' rules, that // produces a backward offset from the trigger date, which has no // semantic relationship to the rule. (t-paliad-289) for _, r := range rules { if r.IsCourtSet { courtSet[r.ID] = true } } // ruleByID lets the conditional-rendering branches resolve a parent // rule's display fields (submission_code, name, name_en) for the // "abhängig von " chip without re-scanning the // rules slice on every iteration. ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules)) for _, r := range rules { ruleByID[r.ID] = r } // Per-event-card overlays (t-paliad-265). Empty/nil maps are safe // for membership tests; the engine reads them but doesn't mutate. skipRules := opts.SkipRules perCardAppellant := opts.PerCardAppellant // skippedIDs accumulates the set of rule UUIDs whose timeline entry // the user has opted out of. Walking in sequence_order means a // child rule's parent has already been classified — so descendant // suppression is a one-pass parent_id lookup. skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules)) // hiddenCount counts rows whose submission_code is in skipRules // AND that pass the condition_expr gate — i.e. rows the user has // hidden in this projection. Surfaced on UIResponse.HiddenCount so // the frontend's "Ausgeblendete (N)" badge stays accurate even when // IncludeHidden is off and the rows aren't in the result list. // (t-paliad-290 / m/paliad#122) hiddenCount := 0 // appellantContext maps a rule UUID to the appellant value that // applies to its descendants. A rule that has its own PerCardAppellant // pick stamps itself with that value; a rule whose parent has a // context inherits it. appellantContext := make(map[uuid.UUID]string, len(rules)) for _, r := range rules { // Phase-3 unified gate: evaluate condition_expr (jsonb). // Suppression semantic preserved: when the gate fires false AND // no alt_* values exist, the rule is dropped from the timeline // entirely (purely conditional). When alt_* values exist, the // gate-false branch still renders, just without the alt-swap // (legacy "swap-on-flag" pattern, e.g. with_ccr). gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet) if !gateMet && r.AltDurationValue == nil { continue } // SkipRules suppression (t-paliad-265): the user has marked // this rule (or one of its ancestors) as "don't consider for // this case". Drop the row entirely AND record the rule ID so // descendants suppress too. // // t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set, // we re-surface the directly-skipped row (faded via IsHidden) // instead of dropping it. Descendants are NOT cascade-suppressed // in that mode either — the un-suppressed parent computes its // date normally, so children compute off it as usual. Either // way we count the hide for the toggle's badge. var isHidden bool if r.SubmissionCode != nil { if _, skipped := skipRules[*r.SubmissionCode]; skipped { hiddenCount++ if !opts.IncludeHidden { skippedIDs[r.ID] = struct{}{} continue } isHidden = true } } if r.ParentID != nil { if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped { skippedIDs[r.ID] = struct{}{} continue } } // AppellantContext propagation. A rule with its own PerCardAppellant // pick stamps its UUID with that value. Otherwise inherit from // parent if the parent had a context. var ctxVal string if r.SubmissionCode != nil { if v, ok := perCardAppellant[*r.SubmissionCode]; ok { ctxVal = v } } if ctxVal == "" && r.ParentID != nil { if v, ok := appellantContext[*r.ParentID]; ok { ctxVal = v } } if ctxVal != "" { appellantContext[r.ID] = ctxVal } d := UIDeadline{ RuleID: r.ID.String(), Name: r.Name, NameEN: r.NameEN, Priority: r.Priority, ConditionExpr: json.RawMessage(r.ConditionExpr), AppellantContext: ctxVal, ChoicesOffered: json.RawMessage(r.ChoicesOffered), IsHidden: isHidden, } if r.SubmissionCode != nil { d.Code = *r.SubmissionCode } if r.PrimaryParty != nil { d.Party = *r.PrimaryParty } if r.RuleCode != nil { d.RuleRef = *r.RuleCode } if r.LegalSource != nil { d.LegalSource = *r.LegalSource d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) } if r.DeadlineNotes != nil { d.Notes = *r.DeadlineNotes } if r.DeadlineNotesEn != nil { d.NotesEN = *r.DeadlineNotesEn } // Resolve the parent rule once so every conditional-rendering // branch (incl. the optional-not-recorded path below) can stamp // ParentRule* on the wire without re-scanning. Populated even // for non-conditional rows — the frontend dependency-footer // ("Folgt aus …") already consumes this on regular projected // rows. (t-paliad-289) var parentRule *models.DeadlineRule if r.ParentID != nil { if pr, ok := ruleByID[*r.ParentID]; ok { parentRule = &pr if pr.SubmissionCode != nil { d.ParentRuleCode = *pr.SubmissionCode } d.ParentRuleName = pr.Name d.ParentRuleNameEN = pr.NameEN } } // Propagate court-set status from a parent rule whose date the // court determines: if the anchor itself has no real date, // nothing downstream can be computed either — UNLESS the user // has supplied an override date for the parent (which they can // once they know the real decision date). parentOverridden := false if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil { if parentRule.SubmissionCode != nil { if _, ok := overrideDates[*parentRule.SubmissionCode]; ok { parentOverridden = true } } } parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden // Zero-duration rules fall into one of four buckets: // 1. parent=nil, not court-determined → IsRootEvent (trigger anchor) // 2. parent=nil, court-determined → IsCourtSet (Zwischenverfahren / // Mündliche Verhandlung / Entscheidung etc.) // 3. parent set, court-determined → IsCourtSet (waypoint) // 4. parent set, NOT court-determined → "filed-with-parent" // semantic: rule is filed AT THE SAME TIME as its parent // (e.g. upc.rev.cfi.rev.app_to_amend, rev.cc_inf — R.49(2) says // Application to amend / Counterclaim for infringement are // INCLUDED in the Defence to revocation). Use the parent's // computed date. // // AnchorOverrides: when the user has set a date for any // zero-duration rule, that override wins over both the // court-set placeholder and the parent-inheritance. if r.DurationValue == 0 { // User override always wins. if r.SubmissionCode != nil { if ov, ok := overrideDates[*r.SubmissionCode]; ok { d.DueDate = ov.Format("2006-01-02") d.OriginalDate = d.DueDate d.IsOverridden = true computed[*r.SubmissionCode] = ov deadlines = append(deadlines, d) continue } } if r.ParentID == nil && !r.IsCourtSet { // Bucket 1: timeline anchor. d.IsRootEvent = true d.DueDate = triggerDateStr d.OriginalDate = triggerDateStr if r.SubmissionCode != nil { computed[*r.SubmissionCode] = triggerDate } } else if r.ParentID != nil && !r.IsCourtSet { // Bucket 4: filed-with-parent. Inherit parent's date. // If parent is court-set, we have nothing to inherit — // fall through to court-set marking. if parentIsCourtSet { // Indirect: this rule isn't itself court-determined, // it's blocked because its parent is. UI should say // "unbestimmt", not "wird vom Gericht bestimmt". d.IsCourtSet = true d.IsCourtSetIndirect = true d.IsConditional = true d.DueDate = "" d.OriginalDate = "" courtSet[r.ID] = true } else { var parentDate time.Time var haveParentDate bool for _, prev := range rules { if prev.ID == *r.ParentID { if prev.SubmissionCode != nil { if ov, ok := overrideDates[*prev.SubmissionCode]; ok { parentDate = ov haveParentDate = true } else if ref, ok := computed[*prev.SubmissionCode]; ok { parentDate = ref haveParentDate = true } } break } } if haveParentDate { d.DueDate = parentDate.Format("2006-01-02") d.OriginalDate = d.DueDate if r.SubmissionCode != nil { computed[*r.SubmissionCode] = parentDate } } else { // Parent not yet computed (defensive — shouldn't // happen given sequence_order). Treat as indirect // court-set: the date is unknown but the rule // itself isn't a court action. d.IsCourtSet = true d.IsCourtSetIndirect = true d.IsConditional = true d.DueDate = "" d.OriginalDate = "" courtSet[r.ID] = true } } } else { // Buckets 2 + 3: court-determined directly (the rule // itself is a hearing / decision / order or has // primary_party='court'). The label "wird vom Gericht // bestimmt" is strictly correct here — keep // IsCourtSetIndirect=false. d.IsCourtSet = true d.DueDate = "" d.OriginalDate = "" courtSet[r.ID] = true } deadlines = append(deadlines, d) continue } // If the parent is court-determined and not overridden we have no // real anchor date; surface this rule as court-set too rather // than fabricating one off the trigger date. The user can re-run // with the actual decision date once the court issues it (or // supplied via AnchorOverrides). // // This is the RoP.151 case (Antrag auf Kostenentscheidung is // "1 Monat ab Hauptentscheidung") — the rule has a real // duration but its anchor is the court-set parent. The UI // should say "unbestimmt", not "wird vom Gericht bestimmt": // the date isn't directly determined by the court, it's // derived from a date the court sets. // // timing='before' rules end up here too — a rule with // "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the // oral hearing as its parent; if the hearing date isn't set, // the backward arithmetic against the trigger date is // meaningless. The pre-pass above ensures courtSet[oral.ID] // is true even when the oral hearing rule is processed later // in sequence_order. IsConditional surfaces the "abhängig // von " UX. (t-paliad-289) if parentIsCourtSet { d.IsCourtSet = true d.IsCourtSetIndirect = true d.IsConditional = true d.DueDate = "" d.OriginalDate = "" courtSet[r.ID] = true deadlines = append(deadlines, d) continue } // Anchor: prefer alt-anchor (e.g. priority_date for epa.grant.exa publish) // when supplied, then parent's computed date (or user override), // then trigger date. baseDate := triggerDate if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil { baseDate = *priorityDate } else if r.ParentID != nil { // Linear scan is fine — rule trees are < 20 entries. for _, prev := range rules { if prev.ID == *r.ParentID { if prev.SubmissionCode != nil { // User override on the parent rule wins over // the calculated date — lets the user redirect // downstream from a real (court-extended, // court-set) date. if ov, ok := overrideDates[*prev.SubmissionCode]; ok { baseDate = ov } else if ref, ok := computed[*prev.SubmissionCode]; ok { baseDate = ref } } break } } } // Flag-conditioned alt-swap (legacy with_ccr pattern): when the // gate fires AND alt_* values exist, swap the primary duration // to the alt values. This is distinct from combine_op below — // alt-swap is a one-or-the-other choice keyed on flags, whereas // combine_op computes both legs and picks max/min. Mutually // exclusive in the live corpus today (no rule sets both). durationValue := r.DurationValue durationUnit := r.DurationUnit timing := "" if r.Timing != nil { timing = *r.Timing } if r.CombineOp == nil && gateMet && hasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil { durationValue = *r.AltDurationValue if r.AltDurationUnit != nil { durationUnit = *r.AltDurationUnit } if r.AltRuleCode != nil { d.RuleRef = *r.AltRuleCode } } // User override on this rule: replace the calculated date with // the user's date. Skip holiday rollover — the user's date is // authoritative. Downstream rules that chain off this rule will // see the override via the parent-anchor lookup above. if r.SubmissionCode != nil { if ov, ok := overrideDates[*r.SubmissionCode]; ok { d.OriginalDate = ov.Format("2006-01-02") d.DueDate = ov.Format("2006-01-02") d.WasAdjusted = false d.AdjustmentReason = nil d.IsOverridden = true computed[*r.SubmissionCode] = ov deadlines = append(deadlines, d) continue } } origDate, adjusted, wasAdj, reason := applyDuration( baseDate, durationValue, durationUnit, timing, country, regime, s.holidays, ) // combine_op composite: compute the alt leg too, apply max/min. // No proceeding-tree rules carry combine_op today (it's a // future-friendly column the rule editor will surface). When // present, the gate-met / alt-swap branch above has been // skipped, so the comparison is between the unmodified base // (durationValue/Unit) and the alt (AltDurationValue/Unit). if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { altOrig, altAdj, altWasAdj, altReason := applyDuration( baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays, ) switch *r.CombineOp { case "max": if altAdj.After(adjusted) { origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason } case "min": if altAdj.Before(adjusted) { origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason } } } d.OriginalDate = origDate.Format("2006-01-02") d.DueDate = adjusted.Format("2006-01-02") d.WasAdjusted = wasAdj d.AdjustmentReason = reason // Optional-on-the-other-side detection (t-paliad-289 Symptom B). // Rules with priority='optional' AND primary_party='both' whose // data-model parent is the proceeding's trigger anchor (parent // has parent_id=NULL and is not court-set, i.e. the SoC root // rule) represent a rule whose REAL triggering event sits // outside the rule data — e.g. R.262(2) Erwiderung auf // Vertraulichkeitsantrag anchors on SoC in the data, but the // real trigger is the opposing party's confidentiality motion // which may never happen. Without an explicit anchor on the // rule itself (user clicks "Datum setzen" after the motion // arrives), the projection must NOT claim a concrete date. // // In the live corpus this catches confidentiality_response; // every other optional+both rule has a court-set ancestor and // is already caught by the parentIsCourtSet branches above. // Suppressed when IsOverridden (the user has anchored the rule // — the date is real) or when the rule has already been marked // IsConditional by an earlier branch. if !d.IsOverridden && !d.IsConditional && r.Priority == "optional" && r.PrimaryParty != nil && *r.PrimaryParty == "both" && parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet { d.IsConditional = true d.DueDate = "" d.OriginalDate = "" d.WasAdjusted = false d.AdjustmentReason = nil // Mark this rule's ID as having an uncertain anchor so // rules chaining off it also surface conditional via the // parentIsCourtSet path (no rule currently chains off // confidentiality_response in the live corpus, but the // extension keeps the propagation semantics consistent). courtSet[r.ID] = true } if r.SubmissionCode != nil { computed[*r.SubmissionCode] = adjusted } deadlines = append(deadlines, d) } // t-paliad-296: within consecutive runs of rules sharing the same // trigger group (parent_id + trigger_event_id), reorder by duration // ascending so optional events following the same anchor render in // their likely-sequence order (a 1-month rule before a 2-month rule // chained off the same decision). Different trigger groups keep // their proceeding-sequence position — the chunk walk only sorts // adjacent same-group rows. Court-set / conditional rows whose // date isn't in the duration ladder sort LAST within their group. sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) resp := &UIResponse{ ProceedingType: pickedProceeding.Code, ProceedingName: pickedProceeding.Name, ProceedingNameEN: pickedProceeding.NameEN, TriggerDate: triggerDateStr, Deadlines: deadlines, HiddenCount: hiddenCount, } // Sub-track routing keeps the user-picked proceeding's identity, // so the trigger-event label rides on `pickedProceeding` (e.g. // upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not // upc.ccr.cfi's own — which is fine: the sub-track note already // explains the framing). if pickedProceeding.TriggerEventLabelDE != nil { resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE } if pickedProceeding.TriggerEventLabelEN != nil { resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN } if hasSubTrackNote { resp.ContextualNote = subTrackNote.NoteDE resp.ContextualNoteEN = subTrackNote.NoteEN } return resp, nil } // sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of // deadlines whose underlying rule shares the same trigger group // (parent_id + trigger_event_id) and reorders each run in place by // duration ascending. Different trigger groups keep their original // proceeding-sequence position — the walk only ever permutes adjacent // same-group rows. // // Sort key (within a run): // 1. Conditional / court-set rows (no concrete date in the duration // ladder) sort LAST, tiebroken by submission_code. // 2. duration_unit weight ASC: days/working_days < weeks < months < years // 3. duration_value ASC // 4. submission_code ASC (deterministic tiebreak) // // Issue: m/paliad#128 — post-decision optional events (R.151/R.353 // 1-month before R.118.4/R.220.1 2-month) were rendering in catalog // order instead of likely-sequence order. (t-paliad-296) func sortDeadlinesByDurationWithinTriggerGroup( deadlines []UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule, ) { if len(deadlines) < 2 { return } n := len(deadlines) i := 0 for i < n { gid := triggerGroupKey(deadlines[i], ruleByID) j := i + 1 for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid { j++ } // Root rules (no parent and no trigger_event) get gid="root" // and would otherwise collapse into one big run. Skip the sort // for the "root" pseudo-group — each root rule represents its // own anchor (SoC, oral hearing, decision …) and the // proceeding-sequence order between them must be preserved. if j-i > 1 && gid != "" { chunk := deadlines[i:j] sort.SliceStable(chunk, func(a, b int) bool { return durationLessForSort(chunk[a], chunk[b], ruleByID) }) } i = j } } // triggerGroupKey returns a string key identifying which trigger group // a deadline belongs to. Same key = same group = candidates for sort. // Empty string means "root" (no parent, no trigger_event) — used as a // sentinel by the caller to skip sorting roots against each other. func triggerGroupKey(d UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule) string { rid, err := uuid.Parse(d.RuleID) if err != nil { return "" } r, ok := ruleByID[rid] if !ok { return "" } if r.ParentID != nil { return "p:" + r.ParentID.String() } if r.TriggerEventID != nil { return fmt.Sprintf("t:%d", *r.TriggerEventID) } return "" } // durationLessForSort compares two deadlines for the duration-ascending // sort. Court-set / conditional rows (no concrete date) sort LAST // regardless of duration — they don't fit the duration ladder. func durationLessForSort( a, b UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule, ) bool { aLast := a.IsCourtSet || a.IsConditional bLast := b.IsCourtSet || b.IsConditional if aLast != bLast { return !aLast } if aLast && bLast { return a.Code < b.Code } ra := lookupRuleFromDeadline(a, ruleByID) rb := lookupRuleFromDeadline(b, ruleByID) wa := durationUnitWeight(ra.DurationUnit) wb := durationUnitWeight(rb.DurationUnit) if wa != wb { return wa < wb } if ra.DurationValue != rb.DurationValue { return ra.DurationValue < rb.DurationValue } return a.Code < b.Code } func lookupRuleFromDeadline( d UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule, ) models.DeadlineRule { if d.RuleID == "" { return models.DeadlineRule{} } rid, err := uuid.Parse(d.RuleID) if err != nil { return models.DeadlineRule{} } return ruleByID[rid] } // durationUnitWeight maps a duration unit to its sort weight so the // trigger-group sort can order shorter durations first. days and // working_days share weight 0 (both are sub-week granularities); // unknown units sort to the end so they're visible as a tail rather // than silently winning. func durationUnitWeight(unit string) int { switch unit { case "days", "working_days": return 0 case "weeks": return 1 case "months": return 2 case "years": return 3 } return 4 } // ErrUnknownRule is returned when CalculateRule can't resolve the // (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule. var ErrUnknownRule = errors.New("unknown rule") // 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.code TriggerDate string // required — YYYY-MM-DD Flags []string // optional — condition_flag inputs CourtID string // optional — selects holiday calendar; defaults via proceeding's jurisdiction } // RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc // response that backs the result-card click → calc-panel flow. Distinct // from UIDeadline (which represents one rendered timeline row inside a // full-proceeding response): RuleCalculation is self-contained — caller // gets the rule metadata + the computed date in one payload, no separate // proceeding-types lookup needed. // // Trigger semantics: TriggerDate is the immediate parent event's // effective date — i.e. when the user clicks "Duplik" in the card and // types "2026-05-05", they mean "I received the Replik on 2026-05-05". // We do NOT walk the parent chain; callers wanting the full timeline // for a proceeding still go through Calculate. 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 lists the condition_flag values from the rule that // the caller's Flags satisfied. Empty when the rule has no // condition_flag, OR when the caller didn't satisfy the gate. Lets // the frontend show "Mit Nichtigkeitswiderklage angewandt" hints. FlagsApplied []string `json:"flagsApplied,omitempty"` // FlagsRequired is the rule's condition_flag in canonical order so // the frontend can render checkboxes for each flag the rule gates on. FlagsRequired []string `json:"flagsRequired,omitempty"` } // RuleCalculationRule mirrors the small subset of DeadlineRule 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"` } // CalculateRule computes a single deadline from a rule + trigger date. // Used by the v4 result-card click flow. Distinct from Calculate: no // parent-chain walk, no full-timeline rendering — just one date out. // // When the rule is court-determined (primary_party='court' or event_type // ∈ {hearing, decision, order}), DueDate is empty and IsCourtSet=true; // the caller should disable the "Add to project" CTA in that case. // // When the rule has condition_flag and the caller's Flags satisfy every // element AND alt_duration_value is non-NULL, the calc swaps to alt_* // (matches the existing flag-conditional semantics in Calculate). // // When the rule has condition_flag and the caller's Flags do NOT satisfy // every element, the calc still proceeds with the base duration_value // and surfaces FlagsRequired so the frontend can render the gating // checkboxes. The result IS the date the rule would be due if the user // confirmed the flag — letting the user toggle the checkbox and see the // duration change live. func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) { triggerDate, err := time.Parse("2006-01-02", params.TriggerDate) if err != nil { return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err) } rule, pt, err := s.resolveRule(ctx, params) if err != nil { return nil, err } mandWire, _ := wireFlagsFromPriority(rule.Priority) out := &RuleCalculation{ Rule: RuleCalculationRule{ ID: rule.ID.String(), NameDE: rule.Name, NameEN: rule.NameEN, DurationValue: rule.DurationValue, DurationUnit: rule.DurationUnit, IsMandatory: mandWire, }, Proceeding: RuleCalculationProceeding{ Code: pt.Code, NameDE: pt.Name, NameEN: pt.NameEN, }, TriggerDate: params.TriggerDate, } if rule.SubmissionCode != nil { out.Rule.LocalCode = *rule.SubmissionCode } if rule.RuleCode != nil { out.Rule.RuleRef = *rule.RuleCode } if rule.LegalSource != nil { out.Rule.LegalSource = *rule.LegalSource out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource) out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource) } if rule.PrimaryParty != nil { out.Rule.Party = *rule.PrimaryParty } if rule.DeadlineNotes != nil { out.Rule.NotesDE = *rule.DeadlineNotes } if rule.DeadlineNotesEn != nil { out.Rule.NotesEN = *rule.DeadlineNotesEn } // Slice 9 (t-paliad-195) replacement for the dropped condition_flag // text[] enumeration: walk the jsonb gate to pull out flag-leaf // names. Returns nil on an unconditional rule. out.FlagsRequired = extractFlagsFromExpr(rule.ConditionExpr) // Court-determined: no calculable date. if rule.IsCourtSet { out.IsCourtSet = true return out, nil } // Resolve flag-conditional duration via the unified condition_expr // evaluator (Slice 4). Same semantics as Calculate: gate met + alt // values present → swap to alt; otherwise use base values. flagSet := make(map[string]struct{}, len(params.Flags)) for _, f := range params.Flags { flagSet[f] = struct{}{} } durationValue := rule.DurationValue durationUnit := rule.DurationUnit gateMet := evalConditionExpr([]byte(rule.ConditionExpr), flagSet) if gateMet && hasConditionExpr(rule.ConditionExpr) { out.FlagsApplied = out.FlagsRequired if rule.AltDurationValue != nil { durationValue = *rule.AltDurationValue } if rule.AltDurationUnit != nil { durationUnit = *rule.AltDurationUnit } if rule.AltRuleCode != nil { out.Rule.RuleRef = *rule.AltRuleCode } } // Zero-duration non-court-determined rules are "filed at the same // time as parent" markers (upc.rev.cfi.app_to_amend, upc.rev.cfi.cc_inf): // effectively mean "due on the trigger date itself". The card-click // flow doesn't need to surface those as a calc panel — but if it // does, returning the trigger date is the right answer. if durationValue == 0 { out.OriginalDate = params.TriggerDate out.DueDate = params.TriggerDate return out, nil } defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) country, regime, err := s.courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime) if err != nil { return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err) } timing := "" if rule.Timing != nil { timing = *rule.Timing } endDate, adjusted, wasAdj, reason := applyDuration( triggerDate, durationValue, durationUnit, timing, country, regime, s.holidays, ) out.OriginalDate = endDate.Format("2006-01-02") out.DueDate = adjusted.Format("2006-01-02") out.WasAdjusted = wasAdj out.AdjustmentReason = reason return out, nil } // resolveRule resolves CalcRuleParams to a rule + its proceeding type. // Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The // frontend uses the latter form (it has the pill context) and the // programmatic / test caller can use the former. func (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRuleParams) (*models.DeadlineRule, *models.ProceedingType, error) { if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") { return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required") } const ptCols = `id, code, name, name_en, description, jurisdiction, category, default_color, sort_order, is_active` var rule models.DeadlineRule var pt models.ProceedingType if params.RuleID != "" { err := s.rules.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1 AND is_active = true`, params.RuleID) if errors.Is(err, sql.ErrNoRows) { return nil, nil, ErrUnknownRule } if err != nil { return nil, nil, fmt.Errorf("resolve rule by id %q: %w", params.RuleID, err) } if rule.ProceedingTypeID == nil { return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID) } err = s.rules.db.GetContext(ctx, &pt, `SELECT `+ptCols+` FROM paliad.proceeding_types WHERE id = $1`, *rule.ProceedingTypeID) if err != nil { return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err) } return &rule, &pt, nil } err := s.rules.db.GetContext(ctx, &pt, `SELECT `+ptCols+` FROM paliad.proceeding_types WHERE code = $1 AND is_active = true`, params.ProceedingCode) if errors.Is(err, sql.ErrNoRows) { return nil, nil, ErrUnknownProceedingType } if err != nil { return nil, nil, fmt.Errorf("resolve proceeding %q: %w", params.ProceedingCode, err) } err = s.rules.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`, pt.ID, params.RuleLocalCode) if errors.Is(err, sql.ErrNoRows) { return nil, nil, ErrUnknownRule } if err != nil { return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", params.RuleLocalCode, params.ProceedingCode, err) } return &rule, &pt, nil } // ListFristenrechnerTypes returns the proceeding types that populate the // Fristenrechner UI (category = 'fristenrechner'), ordered by sort_order. func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]FristenrechnerType, error) { rows, err := s.rules.db.QueryxContext(ctx, ` SELECT code, name, name_en, jurisdiction FROM paliad.proceeding_types WHERE category = 'fristenrechner' AND is_active = true ORDER BY sort_order`) if err != nil { return nil, fmt.Errorf("list fristenrechner types: %w", err) } defer rows.Close() var out []FristenrechnerType for rows.Next() { var t FristenrechnerType var juris sql.NullString if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil { return nil, err } if juris.Valid { t.Group = juris.String } out = append(out, t) } return out, rows.Err() } // 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"` } // allFlagsSet returns true when every element of `required` is present in // `set`. Empty `required` returns true (no condition). Retained as the // fallback predicate used by evalConditionExpr when condition_expr is // NULL but the legacy condition_flag text[] is set — preserves // transition-window behaviour for any row Slice 2 missed (it shouldn't, // but defensive). func allFlagsSet(required []string, set map[string]struct{}) bool { for _, f := range required { if _, ok := set[f]; !ok { return false } } return true } // evalConditionExpr returns true iff the rule's gate predicate is // satisfied for the caller's flag set. Drives flag-conditional rendering // + flag-conditional alt-swap throughout the calculator. // // Grammar (design §2.4 long form, mig 084 backfill): // // {"flag": ""} — leaf: true iff ∈ flags // {"op": "and", "args": [...]} — true iff every arg evaluates true // {"op": "or", "args": [...]} — true iff any arg evaluates true // {"op": "not", "args": []} — true iff the single arg is false // // NULL / empty / "null" expression → true (unconditional). Malformed // JSON → true (defensive: the rule still renders, the lawyer sees // it even if the gate is broken). // // Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag // text[] column; the fallback that AND'd over it is gone. Any future // row needing array-of-flags semantics writes the equivalent // {"op":"and","args":[{"flag":""},...]} jsonb directly. func evalConditionExpr(expr []byte, flags map[string]struct{}) bool { if len(expr) == 0 || string(expr) == "null" { return true } return evalConditionExprNode(expr, flags) } // evalConditionExprNode walks one node of the condition_expr jsonb // tree. Recursion depth is bounded by the editor (Slice 11 caps tree // depth + arg count); pre-Slice-11 backfilled rows have at most a // 2-arg AND (mig 084). func evalConditionExprNode(raw []byte, flags map[string]struct{}) bool { var node struct { Flag string `json:"flag"` Op string `json:"op"` Args []json.RawMessage `json:"args"` } if err := json.Unmarshal(raw, &node); err != nil { // Malformed → unconditional. The Slice 11 editor's validation // will block such writes; in the live corpus today mig 084's // jsonb_build_object output is well-formed by construction. return true } if node.Flag != "" { _, ok := flags[node.Flag] return ok } switch node.Op { case "and": for _, a := range node.Args { if !evalConditionExprNode(a, flags) { return false } } return true case "or": for _, a := range node.Args { if evalConditionExprNode(a, flags) { return true } } return false case "not": if len(node.Args) != 1 { // Malformed NOT — fall through to unconditional rather // than risk suppressing a rule the lawyer expects to see. return true } return !evalConditionExprNode(node.Args[0], flags) } // Unknown op (forward-compat with editor extensions): treat as // unconditional so the rule still renders. return true } // hasConditionExpr returns true when the rule carries a non-empty, // non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the // pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the // flag-keyed alt-swap branch. Same intent: "this rule has a gate; // when the gate flips to met, swap to alt". func hasConditionExpr(expr models.NullableJSON) bool { if len(expr) == 0 { return false } s := string(expr) return s != "null" && s != "{}" } // extractFlagsFromExpr walks the jsonb gate and returns the unique // flag names referenced as {"flag":""} leaves. Used by // CalculateRule's response (FlagsRequired) so the result-card calc // panel can render flag checkboxes for each gate input. Replaces the // dropped condition_flag text[] enumeration. Returns nil on a NULL // expression or one that contains no flag leaves. func extractFlagsFromExpr(expr models.NullableJSON) []string { if !hasConditionExpr(expr) { return nil } seen := make(map[string]struct{}) walkFlagLeaves([]byte(expr), seen) if len(seen) == 0 { return nil } out := make([]string, 0, len(seen)) for f := range seen { out = append(out, f) } return out } func walkFlagLeaves(raw []byte, into map[string]struct{}) { var node struct { Flag string `json:"flag"` Op string `json:"op"` Args []json.RawMessage `json:"args"` } if err := json.Unmarshal(raw, &node); err != nil { return } if node.Flag != "" { into[node.Flag] = struct{}{} return } for _, a := range node.Args { walkFlagLeaves(a, into) } } // wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional) // pair from the unified priority enum so the wire shape stays // pixel-identical through Slice 4. Slice 8 will swap the wire to // emit priority directly. Mapping is the exact reverse of mig 083's // backfill (per design §2.3): // // 'mandatory' → (true, false) — statutory must, ☑ pre-checked // 'optional' → (true, true) — RoP.151 case: strict but opt-in, // ☐ pre-unchecked save modal // 'recommended' → (false, false) — situational filing, save by default // with override (legacy F/F semantic) // 'informational' → (false, false) — never saves; today no live rows // carry it. Future: surfaces as a // notice card in the timeline. // (unknown) → (true, false) — safe default; treat as mandatory // so we never silently drop a rule. func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { switch priority { case "mandatory": return true, false case "optional": return true, true case "recommended": return false, false case "informational": return false, false default: return true, false } } // applyRuleOverrides replaces rules whose ID appears in `overrides` // with the override row, and appends any override whose ID isn't in // the source list (net-new drafts the rule editor wants to preview). // // Used by the Slice 11a (t-paliad-191) preview endpoint: the editor // passes the draft as an override so Calculate runs against the // proposed shape without writing to the DB. Empty overrides slice = // pass-through (Calculate's existing behaviour for non-preview // callers). The override slice is small (1 row in practice — the // draft being previewed) so the linear scan is fine. func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule { if len(overrides) == 0 { return src } byID := make(map[uuid.UUID]models.DeadlineRule, len(overrides)) for _, o := range overrides { byID[o.ID] = o } out := make([]models.DeadlineRule, 0, len(src)+len(overrides)) seen := make(map[uuid.UUID]bool, len(overrides)) for _, r := range src { if ov, ok := byID[r.ID]; ok { out = append(out, ov) seen[ov.ID] = true continue } out = append(out, r) } for _, o := range overrides { if seen[o.ID] { continue } out = append(out, o) } return out } // applyDuration is the unified date-arithmetic helper used by every // calculator path (Pipeline-A proceeding-tree, Pipeline-C trigger-event, // CalculateRule single-rule). Phase 3 Slice 4 (t-paliad-185) replaces // the prior split between addDuration (proceeding-tree, no timing / // working_days) and applyDurationOnCalendar (Pipeline-C, full support). // // Returns (raw, adjusted, didAdjust, reason): // // - raw: the date strictly implied by the rule before rollover. // - adjusted: post-rollover for calendar units. 'working_days' lands // on a working day by construction so raw == adjusted there. // - didAdjust: true iff rollover moved the date. // - reason: populated when didAdjust is true; nil otherwise. // // timing='before' negates the sign. timing='after' (or any other value // including the empty string) keeps it positive — preserves the // pre-Slice-4 behaviour for proceeding-tree rules whose Timing field // is sometimes NULL (mig 003 defaults to 'after' but legacy callers // pass r.Timing dereferenced). func applyDuration( base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService, ) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) { sign := 1 if timing == "before" { sign = -1 } switch unit { case "days": raw = base.AddDate(0, 0, sign*value) case "weeks": raw = base.AddDate(0, 0, sign*value*7) case "months": raw = base.AddDate(0, sign*value, 0) case "working_days": raw = addWorkingDays(base, sign*value, country, regime, holidays) // Working-day arithmetic lands on a working day by construction // — the per-step skip loop in addWorkingDays already passes over // weekends and holidays. No post-rollover required. return raw, raw, false, nil default: raw = base } adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime) return raw, adjusted, didAdjust, reason } // addWorkingDays advances from `from` by `n` working days, skipping // weekends and holidays applicable to the given country/regime. Negative // n walks backward. n=0 keeps the input date as-is (caller decides // whether to roll forward via AdjustForNonWorkingDays). // // Bounded by an inner 30-step skip per advance — vacation runs in our // holiday tables are < 14 consecutive days, so 30 is a safety margin. func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time { if n == 0 { return from } step := 1 if n < 0 { step = -1 n = -n } cur := from for i := 0; i < n; i++ { cur = cur.AddDate(0, 0, step) for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ { cur = cur.AddDate(0, 0, step) } } return cur } // calculateByTriggerEvent renders the Pipeline-C timeline for an event // trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id // chains), have no flag gating, no priority_date alt-anchor, no party // classification, and no IsRootEvent / IsCourtSet semantics. The math // is just: base + (timing-signed) duration → optional alt-leg combine // → optional weekend/holiday rollover for calendar units. // // UIResponse.ProceedingType / ProceedingName stay empty — EventDeadlineService // owns the trigger-event metadata (it's the caller that needed it // pre-Slice-3 and continues to load it for the legacy CalculateResponse // shape). Callers that don't need those fields can ignore them. func (s *FristenrechnerService) calculateByTriggerEvent( ctx context.Context, triggerEventID int64, triggerDateStr string, opts CalcOptions, ) (*UIResponse, error) { triggerDate, err := time.Parse("2006-01-02", triggerDateStr) if err != nil { return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) } // Pipeline-C rules originate from youpc's UPC-flavoured deadline // corpus — DE / UPC defaults match the legacy EventDeadlineService. country, regime, err := s.courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC) if err != nil { return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) } rules, err := s.rules.ListByTriggerEvent(ctx, triggerEventID) if err != nil { return nil, err } if len(opts.RuleOverrides) > 0 { rules = applyRuleOverrides(rules, opts.RuleOverrides) } deadlines := make([]UIDeadline, 0, len(rules)) for _, r := range rules { timing := "" if r.Timing != nil { timing = *r.Timing } baseRaw, baseAdj, baseChanged, baseReason := applyDuration( triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, s.holidays, ) picked := baseAdj original := baseRaw wasAdj := baseChanged reason := baseReason if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { altRaw, altAdj, altChanged, altReason := applyDuration( triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays, ) switch *r.CombineOp { case "max": if altAdj.After(baseAdj) { picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason } case "min": if altAdj.Before(baseAdj) { picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason } } } // Slice 9 (t-paliad-195) wire-shape cleanup: trigger-event // path emits Priority + ConditionExpr directly. The legacy // IsMandatory/IsOptional pair was retired with the column // drop; frontend reads priorityRendering(d) which now branches // on priority alone. d := UIDeadline{ RuleID: r.ID.String(), Name: r.Name, NameEN: r.NameEN, Priority: r.Priority, ConditionExpr: json.RawMessage(r.ConditionExpr), DueDate: picked.Format("2006-01-02"), OriginalDate: original.Format("2006-01-02"), WasAdjusted: wasAdj, AdjustmentReason: reason, } if r.SubmissionCode != nil { d.Code = *r.SubmissionCode } if r.PrimaryParty != nil { d.Party = *r.PrimaryParty } if r.RuleCode != nil { d.RuleRef = *r.RuleCode } if r.LegalSource != nil { d.LegalSource = *r.LegalSource d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) } if r.DeadlineNotes != nil { d.Notes = *r.DeadlineNotes } if r.DeadlineNotesEn != nil { d.NotesEN = *r.DeadlineNotesEn } deadlines = append(deadlines, d) } return &UIResponse{ // Trigger-event responses don't carry proceeding metadata — // EventDeadlineService.Calculate fills the trigger fields in the // legacy CalculateResponse shape. Leaving these empty is the // stable contract. ProceedingType: "", ProceedingName: "", TriggerDate: triggerDateStr, Deadlines: deadlines, }, nil } // DefaultsForJurisdiction maps the proceeding-type jurisdiction text // ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a // holiday lookup should default to when the caller didn't pass an explicit // CourtID. UPC proceedings get DE+UPC (München LD is HLC's most common // venue, German federal holidays plus UPC vacations apply); DE / DPMA / EPA // get DE-only (German federal). Future EPA-specific closures will require // callers to pick an EPA court explicitly so the EPO regime kicks in. // // Helper kept tiny and stateless — when a caller passes a real CourtID, // these defaults are bypassed entirely and the court's actual country + // regime are used. func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) { if jurisdiction == nil { return CountryDE, "" } switch *jurisdiction { case "UPC": return CountryDE, RegimeUPC default: return CountryDE, "" } }