package services import ( "context" "database/sql" "errors" "fmt" "sort" "strings" "time" "github.com/google/uuid" "mgit.msbls.de/m/paliad/internal/models" lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // isValidPartyForLookup mirrors the four-value primary_party vocab the // engine knows about. B2 inlines this check; B3 will add a canonical // lp.IsValidPrimaryParty + tighten the column to a CHECK constraint. func isValidPartyForLookup(s string) bool { switch s { case "claimant", "defendant", "court", "both": return true } return false } // FristenrechnerService renders the Paliad public Fristenrechner's // response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it // is a thin adapter: the compute engine + types live in // pkg/litigationplanner, and FristenrechnerService just wires the // Postgres-backed Catalog + HolidayCalendar + CourtRegistry // implementations and delegates Calculate / CalculateRule across the // boundary. // // The package owns the wire shape (Timeline / TimelineEntry); paliad's // historical aliases (UIResponse / UIDeadline) keep call-sites // unchanged. type FristenrechnerService struct { rules *DeadlineRuleService holidays *HolidayService courts *CourtService catalog lp.Catalog } // NewFristenrechnerService wires the service to its dependencies. func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService { s := &FristenrechnerService{rules: rules, holidays: holidays, courts: courts} s.catalog = &paliadCatalog{rules: rules} return s } // Type aliases keep call-sites byte-identical with the pre-Slice-A // shape. The wire JSON tags are owned by the package. // (AdjustmentReason + HolidayDTO are aliased in holidays.go.) type ( UIResponse = lp.Timeline UIDeadline = lp.TimelineEntry CalcOptions = lp.CalcOptions CalcRuleParams = lp.CalcRuleParams RuleCalculation = lp.RuleCalculation RuleCalculationRule = lp.RuleCalculationRule RuleCalculationProceeding = lp.RuleCalculationProceeding SubTrackRouting = lp.SubTrackRouting ) // Sentinel errors. Re-exported as package-level vars so handlers that // errors.Is(..., services.ErrUnknownProceedingType) continue to work. var ( ErrUnknownProceedingType = lp.ErrUnknownProceedingType ErrUnknownRule = lp.ErrUnknownRule ) // Calculate delegates to litigationplanner.Calculate with paliad's // Postgres-backed Catalog / HolidayCalendar / CourtRegistry implementations. func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) { return lp.Calculate(ctx, proceedingCode, triggerDateStr, opts, s.catalog, s.holidays, s.courts) } // CalculateRule delegates to litigationplanner.CalculateRule. Distinct // from Calculate: no parent-chain walk, no full-timeline rendering — // just one date out. func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) { return lp.CalculateRule(ctx, params, s.catalog, s.holidays, s.courts) } // ListFristenrechnerTypes returns the proceeding types that populate // the Fristenrechner UI (category='fristenrechner'), ordered by // sort_order. Stays on the service because the response is a paliad- // specific surface (the wire shape FristenrechnerType is owned by the // package but the SQL filter is paliad-side). func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.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 []lp.FristenrechnerType for rows.Next() { var t lp.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 is paliad's local alias for lp.FristenrechnerType // so historical call-sites (services.FristenrechnerType) keep working. type FristenrechnerType = lp.FristenrechnerType // --------------------------------------------------------------------- // paliadCatalog is the paliad-side litigationplanner.Catalog adapter. // Wraps DeadlineRuleService to expose proceeding + rule lookups against // paliad.proceeding_types + paliad.deadline_rules. // --------------------------------------------------------------------- type paliadCatalog struct { rules *DeadlineRuleService } // proceedingTypeColumns is canonically defined in // deadline_rule_service.go; the catalog adapter reuses it via the // shared package-level const. // LoadProceeding returns the proceeding-type metadata + rules. The // ProjectHint is currently ignored on paliad's side (per m's 2026-05-26 // decision dropping the Slice E user-authored rules); kept on the // interface for forward-compat. func (c *paliadCatalog) LoadProceeding(ctx context.Context, code string, _ lp.ProjectHint) (*models.ProceedingType, []models.DeadlineRule, error) { var pt models.ProceedingType err := c.rules.db.GetContext(ctx, &pt, `SELECT `+proceedingTypeColumns+` FROM paliad.proceeding_types WHERE code = $1 AND is_active = true`, code) if errors.Is(err, sql.ErrNoRows) { return nil, nil, lp.ErrUnknownProceedingType } if err != nil { return nil, nil, fmt.Errorf("resolve proceeding %q: %w", code, err) } rules, err := c.rules.List(ctx, &pt.ID) if err != nil { return nil, nil, err } return &pt, rules, nil } // LoadProceedingByID is the resolver for a rule's parent proceeding. func (c *paliadCatalog) LoadProceedingByID(ctx context.Context, id int) (*models.ProceedingType, error) { var pt models.ProceedingType err := c.rules.db.GetContext(ctx, &pt, `SELECT `+proceedingTypeColumns+` FROM paliad.proceeding_types WHERE id = $1`, id) if errors.Is(err, sql.ErrNoRows) { return nil, lp.ErrUnknownProceedingType } if err != nil { return nil, fmt.Errorf("resolve proceeding by id %d: %w", id, err) } return &pt, nil } // LoadRuleByID resolves a rule UUID to the rule row. func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*models.DeadlineRule, error) { var rule models.DeadlineRule err := c.rules.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1 AND is_active = true`, ruleID) if errors.Is(err, sql.ErrNoRows) { return nil, lp.ErrUnknownRule } if err != nil { return nil, fmt.Errorf("resolve rule by id %q: %w", ruleID, err) } if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil { return nil, err } return &rule, nil } // LoadRuleByCode resolves a rule by (proceedingCode, submissionCode) // + returns the parent proceeding for use in the response identity. func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*models.DeadlineRule, *models.ProceedingType, error) { var pt models.ProceedingType err := c.rules.db.GetContext(ctx, &pt, `SELECT `+proceedingTypeColumns+` FROM paliad.proceeding_types WHERE code = $1 AND is_active = true`, proceedingCode) if errors.Is(err, sql.ErrNoRows) { return nil, nil, lp.ErrUnknownProceedingType } if err != nil { return nil, nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err) } var rule models.DeadlineRule err = c.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, submissionCode) if errors.Is(err, sql.ErrNoRows) { return nil, nil, lp.ErrUnknownRule } if err != nil { return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", submissionCode, proceedingCode, err) } if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil { return nil, nil, err } return &rule, &pt, nil } // LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules. func (c *paliadCatalog) LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) { return c.rules.ListByTriggerEvent(ctx, triggerEventID) } // LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for // the conditional-label override (t-paliad-294 / m/paliad#126). func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) { return c.rules.LoadTriggerEventsByIDs(ctx, ids) } // LookupEvents queries paliad.deadline_rules for rules matching the // requested axes, then walks the parent_id graph in Go to honour the // requested depth. Slice B2 (m/paliad#124 §18.2). // // Filter axes apply at the SQL layer: // - Jurisdiction: WHERE paliad.proceeding_types.jurisdiction = $X // - ProceedingTypeID: WHERE deadline_rules.proceeding_type_id = $X // - Party: WHERE deadline_rules.primary_party = $X // - EventCategoryID: EXISTS subquery on // paliad.event_category_concepts joined via concept_id // - AppealTarget: WHERE $X = ANY(deadline_rules.applies_to_target) // // Depth is applied post-fetch: for EventLookupDepthNext, anchor rules // (matched directly) are returned at depth=1 + their immediate // children (parent_id IN matched-set) at depth=2. For // EventLookupDepthAllFollowing, the parent_id walk continues // recursively. The walk stays within the per-proceeding rule set // (cross-proceeding spawn following is handled by the engine, not by // LookupEvents). // // "published + active" gate: lifecycle_state='published' AND // is_active=true (matches LoadProceeding's WHERE clause). func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) { // Validate axis values up front; unknown values fall through as // "no filter on this axis" so a stale frontend chip doesn't // silently drop the entire result set. jurisdiction := axes.Jurisdiction if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" && jurisdiction != "EPA" && jurisdiction != "DPMA" { jurisdiction = "" } party := axes.Party if party != "" && !isValidPartyForLookup(party) { party = "" } appealTarget := axes.AppealTarget if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) { appealTarget = "" } // Build the WHERE clause progressively. Each axis adds a $N // placeholder + appends to the args slice. where := []string{ "dr.is_active = true", "dr.lifecycle_state = 'published'", "pt.is_active = true", } args := []any{} add := func(clause string, val any) { args = append(args, val) where = append(where, fmt.Sprintf(clause, len(args))) } if jurisdiction != "" { add("pt.jurisdiction = $%d", jurisdiction) } if axes.ProceedingTypeID != nil { add("dr.proceeding_type_id = $%d", *axes.ProceedingTypeID) } if party != "" { add("dr.primary_party = $%d", party) } if axes.EventCategoryID != nil { // Junction-table EXISTS: the rule's concept_id must appear in // paliad.event_category_concepts with the matching // event_category_id. add(`EXISTS ( SELECT 1 FROM paliad.event_category_concepts ecc WHERE ecc.event_category_id = $%d AND ecc.concept_id = dr.concept_id )`, *axes.EventCategoryID) } if appealTarget != "" { add("$%d = ANY(dr.applies_to_target)", appealTarget) } query := ` SELECT ` + ruleColumns + `, pt.id AS pt_id, pt.code AS pt_code, pt.name AS pt_name, pt.name_en AS pt_name_en, pt.description AS pt_description, pt.jurisdiction AS pt_jurisdiction, pt.category AS pt_category, pt.default_color AS pt_default_color, pt.sort_order AS pt_sort_order, pt.is_active AS pt_is_active, pt.trigger_event_label_de AS pt_trigger_event_label_de, pt.trigger_event_label_en AS pt_trigger_event_label_en, pt.appeal_target AS pt_appeal_target FROM paliad.deadline_rules dr JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id WHERE ` + strings.Join(where, "\n AND ") + ` ORDER BY dr.proceeding_type_id, dr.sequence_order` var rows []lookupEventsRow if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil { return nil, fmt.Errorf("lookup events: %w", err) } if len(rows) == 0 { return []lp.EventMatch{}, nil } // matchedIDs is the set of rule IDs that satisfied the axes (the // "anchor" matches at depth=1). For EventLookupDepthNext we add // their direct children. For EventLookupDepthAllFollowing we walk // the parent_id chain transitively. matchedIDs := make(map[uuid.UUID]bool, len(rows)) anchorMatch := make(map[uuid.UUID]bool, len(rows)) rowByID := make(map[uuid.UUID]lookupEventsRow, len(rows)) for _, r := range rows { matchedIDs[r.ID] = true anchorMatch[r.ID] = true rowByID[r.ID] = r } // For depth control we need the full per-proceeding rule corpus // (so we can find children whose parent_id ∈ matchedIDs even when // those children don't match the axes themselves). Skip this when // depth is empty (treated as "anchors only" — undocumented but // useful as a degenerate case). expandFromCorpus := func(corpus []models.DeadlineRule, joinedFor map[int]lookupEventsRow) { // We loop until no new descendants are added (transitive // closure under parent_id ∈ matchedIDs). EventLookupDepthNext // stops after one pass; AllFollowing iterates to fixpoint. for { grew := false for _, r := range corpus { if r.ParentID == nil { continue } if !matchedIDs[*r.ParentID] { continue } if matchedIDs[r.ID] { continue } matchedIDs[r.ID] = true j := joinedFor[*r.ProceedingTypeID] rowByID[r.ID] = lookupEventsRow{ DeadlineRule: r, PTID: j.PTID, PTCode: j.PTCode, PTName: j.PTName, PTNameEN: j.PTNameEN, PTDescription: j.PTDescription, PTJurisdiction: j.PTJurisdiction, PTCategory: j.PTCategory, PTDefaultColor: j.PTDefaultColor, PTSortOrder: j.PTSortOrder, PTIsActive: j.PTIsActive, PTTriggerEventLabelDE: j.PTTriggerEventLabelDE, PTTriggerEventLabelEN: j.PTTriggerEventLabelEN, PTAppealTarget: j.PTAppealTarget, } grew = true } if !grew || depth == lp.EventLookupDepthNext { break } } } if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing { // Load the proceeding-scoped corpus for every proceeding_type // that appeared in the anchor set. The walk needs full // visibility into each proceeding's rule tree so it can // resolve parent_id chains. procIDs := make(map[int]struct{}) joinedFor := make(map[int]lookupEventsRow) for _, r := range rows { procIDs[r.PTID] = struct{}{} if _, ok := joinedFor[r.PTID]; !ok { joinedFor[r.PTID] = r } } for ptID := range procIDs { corpus, err := c.rules.List(ctx, &ptID) if err != nil { return nil, fmt.Errorf("lookup events: load proceeding %d corpus: %w", ptID, err) } expandFromCorpus(corpus, joinedFor) } } // depths[id] = sequence-depth from the closest anchor ancestor. // Anchors are depth=1; their direct children are depth=2; etc. depths := computeDepths(rowByID, anchorMatch) // Compose the result slice ordered by (PTID, sequence_order). type withKey struct { match lp.EventMatch key int64 } items := make([]withKey, 0, len(matchedIDs)) for id := range matchedIDs { r := rowByID[id] var parentRuleID *uuid.UUID if r.ParentID != nil && matchedIDs[*r.ParentID] { p := *r.ParentID parentRuleID = &p } items = append(items, withKey{ match: lp.EventMatch{ Rule: r.DeadlineRule, ProceedingType: lp.ProceedingType{ ID: r.PTID, Code: r.PTCode, Name: r.PTName, NameEN: r.PTNameEN, Description: r.PTDescription, Jurisdiction: r.PTJurisdiction, Category: r.PTCategory, DefaultColor: r.PTDefaultColor, SortOrder: r.PTSortOrder, IsActive: r.PTIsActive, TriggerEventLabelDE: r.PTTriggerEventLabelDE, TriggerEventLabelEN: r.PTTriggerEventLabelEN, AppealTarget: r.PTAppealTarget, }, Priority: r.Priority, DepthFromAnchor: depths[id], ParentRuleID: parentRuleID, }, key: int64(r.PTID)*1_000_000 + int64(r.SequenceOrder), }) } sort.Slice(items, func(a, b int) bool { return items[a].key < items[b].key }) out := make([]lp.EventMatch, len(items)) for i, it := range items { out[i] = it.match } return out, nil } // lookupEventsRow is the joined SELECT shape for LookupEvents — one // deadline_rules row plus its proceeding_types parent columns. Kept // at package scope so computeDepths can reference it. type lookupEventsRow struct { models.DeadlineRule PTID int `db:"pt_id"` PTCode string `db:"pt_code"` PTName string `db:"pt_name"` PTNameEN string `db:"pt_name_en"` PTDescription *string `db:"pt_description"` PTJurisdiction *string `db:"pt_jurisdiction"` PTCategory *string `db:"pt_category"` PTDefaultColor string `db:"pt_default_color"` PTSortOrder int `db:"pt_sort_order"` PTIsActive bool `db:"pt_is_active"` PTTriggerEventLabelDE *string `db:"pt_trigger_event_label_de"` PTTriggerEventLabelEN *string `db:"pt_trigger_event_label_en"` PTAppealTarget *string `db:"pt_appeal_target"` } // computeDepths walks from each rule up the parent_id chain until it // hits an anchor match (or runs out). The depth of the anchor is 1; // each step away adds one. Rules whose entire chain has no anchor // (defensive — shouldn't happen given the expand-from-corpus walk // only adds children of matched parents) get depth=1. // // Iteration-bounded by the corpus size to prevent infinite loops on // hypothetical parent_id cycles (mig 134 + the schema CHECKs already // preclude cycles, but the bound is cheap insurance). func computeDepths( rowByID map[uuid.UUID]lookupEventsRow, anchors map[uuid.UUID]bool, ) map[uuid.UUID]int { depths := make(map[uuid.UUID]int, len(rowByID)) for id := range rowByID { if anchors[id] { depths[id] = 1 continue } // Walk parents until we find an anchor or run out. d := 1 cur := id maxIter := len(rowByID) + 1 for i := 0; i < maxIter; i++ { r := rowByID[cur] if r.ParentID == nil { break } d++ cur = *r.ParentID if anchors[cur] { break } } depths[id] = d } return depths } // _ proves paliadCatalog satisfies lp.Catalog at compile time. var _ lp.Catalog = (*paliadCatalog)(nil) // Ensure HolidayService satisfies lp.HolidayCalendar at compile time. // HolidayService.AdjustForNonWorkingDaysWithReason returns the // AdjustmentReason via paliad's internal type — since lp.AdjustmentReason // is now the canonical definition and AdjustmentReason inside services // is aliased to it, the signatures align verbatim. var _ lp.HolidayCalendar = (*HolidayService)(nil) // Ensure CourtService satisfies lp.CourtRegistry at compile time. var _ lp.CourtRegistry = (*CourtService)(nil) // --------------------------------------------------------------------- // Helpers used by sibling services (event_trigger_service, // event_deadline_service). Re-exported as thin wrappers so the existing // call-sites in those services continue to compile without an import // rewrite. A future slice can collapse them onto direct lp.* imports. // --------------------------------------------------------------------- // applyDuration delegates to litigationplanner.ApplyDuration. func applyDuration(base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) { return lp.ApplyDuration(base, value, unit, timing, country, regime, holidays) } // addWorkingDays delegates to litigationplanner.AddWorkingDays. // //nolint:unused // referenced for forward-compat with sibling services func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time { return lp.AddWorkingDays(from, n, country, regime, holidays) } // evalConditionExpr delegates to litigationplanner.EvalConditionExpr. func evalConditionExpr(expr []byte, flags map[string]struct{}) bool { return lp.EvalConditionExpr(expr, flags) } // hasConditionExpr delegates to litigationplanner.HasConditionExpr. func hasConditionExpr(expr models.NullableJSON) bool { return lp.HasConditionExpr(expr) } // extractFlagsFromExpr delegates to litigationplanner.ExtractFlagsFromExpr. // //nolint:unused // retained for sibling services that may want it func extractFlagsFromExpr(expr models.NullableJSON) []string { return lp.ExtractFlagsFromExpr(expr) } // allFlagsSet delegates to litigationplanner.AllFlagsSet. Retained for // the paliad-side test suite that asserts the helper's contract. func allFlagsSet(required []string, set map[string]struct{}) bool { return lp.AllFlagsSet(required, set) } // wireFlagsFromPriority delegates to // litigationplanner.WireFlagsFromPriority. Retained for the paliad-side // test suite that asserts the priority → (isMandatory, isOptional) // mapping. func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { return lp.WireFlagsFromPriority(priority) } // sortDeadlinesByDurationWithinTriggerGroup is the paliad-side wrapper // retained for the t-paliad-296 sort tests. Delegates to the // package-internal sort over the lp.TimelineEntry shape — which is // just an alias for UIDeadline, so callers pass []UIDeadline directly. func sortDeadlinesByDurationWithinTriggerGroup( deadlines []UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule, ) { lp.SortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) } // DefaultsForJurisdiction delegates to // litigationplanner.DefaultsForJurisdiction. Public re-export so // handlers (deadline_rules_db.go) can keep using // services.DefaultsForJurisdiction without an import-rewrite. func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) { return lp.DefaultsForJurisdiction(jurisdiction) } // applyRuleOverrides delegates to litigationplanner.ApplyRuleOverrides. // //nolint:unused // retained for sibling services that may want it func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule { return lp.ApplyRuleOverrides(src, overrides) }