diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index 8fea562..eb58e30 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -5,6 +5,8 @@ import ( "database/sql" "errors" "fmt" + "sort" + "strings" "time" "github.com/google/uuid" @@ -13,6 +15,17 @@ import ( 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 @@ -223,6 +236,296 @@ func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) 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) diff --git a/internal/services/lookup_events_test.go b/internal/services/lookup_events_test.go new file mode 100644 index 0000000..4a88a4b --- /dev/null +++ b/internal/services/lookup_events_test.go @@ -0,0 +1,159 @@ +package services + +import ( + "context" + "os" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/internal/db" + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" +) + +// TestLookupEvents covers the multi-axis catalog query API from Slice +// B2 (m/paliad#124 §18.2). Skipped when TEST_DATABASE_URL is unset, +// mirroring TestCalculateRule. +// +// Cases: +// - jurisdiction=UPC, depth=all-following → every active+published +// UPC rule, anchor depth=1 for all (no parent_id outside the +// filtered set lights up depth>1 because the entire UPC subset is +// a single anchor cohort). +// - proceeding_type_id (upc.inf.cfi) + party=defendant + depth=next +// → defendant rules in upc.inf.cfi at depth=1 + direct children +// of those at depth=2. +// - unknown jurisdiction value → silently ignored, no filter applied. +// - empty axes → all rules (no filter on any axis). +func TestLookupEvents(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set — skipping live DB test") + } + if err := db.ApplyMigrations(url); err != nil { + t.Fatalf("apply migrations: %v", err) + } + pool, err := sqlx.Connect("postgres", url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + ctx := context.Background() + rules := NewDeadlineRuleService(pool) + catalog := &paliadCatalog{rules: rules} + + t.Run("jurisdiction=UPC, all-following returns the UPC corpus", func(t *testing.T) { + matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ + Jurisdiction: "UPC", + }, lp.EventLookupDepthAllFollowing) + if err != nil { + t.Fatalf("LookupEvents: %v", err) + } + if len(matches) == 0 { + t.Fatal("expected non-empty UPC corpus") + } + // Every match must be a UPC rule. + for _, m := range matches { + if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" { + t.Errorf("non-UPC row leaked into UPC-axis query: code=%s jurisdiction=%v", + m.ProceedingType.Code, m.ProceedingType.Jurisdiction) + } + if m.DepthFromAnchor < 1 { + t.Errorf("depth=%d for rule %s, want >= 1", m.DepthFromAnchor, m.Rule.ID) + } + } + }) + + t.Run("party=defendant scopes to defendant rules", func(t *testing.T) { + matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ + Jurisdiction: "UPC", + Party: "defendant", + }, lp.EventLookupDepthNext) + if err != nil { + t.Fatalf("LookupEvents: %v", err) + } + if len(matches) == 0 { + t.Fatal("expected at least one defendant rule across the UPC corpus") + } + // Anchor matches (depth=1) must be primary_party=defendant. + // Depth=2 children appear under EventLookupDepthNext only as + // expansion from anchors — they may carry any party. + for _, m := range matches { + if m.DepthFromAnchor != 1 { + continue + } + if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" { + t.Errorf("anchor row %s (depth=1) is not defendant: %v", + m.Rule.Name, m.Rule.PrimaryParty) + } + } + }) + + t.Run("unknown jurisdiction value silently falls through", func(t *testing.T) { + matchesAll, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{}, + lp.EventLookupDepthAllFollowing) + if err != nil { + t.Fatalf("LookupEvents (all): %v", err) + } + matchesUnknown, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ + Jurisdiction: "XX-not-a-real-jurisdiction", + }, lp.EventLookupDepthAllFollowing) + if err != nil { + t.Fatalf("LookupEvents (unknown): %v", err) + } + if len(matchesAll) != len(matchesUnknown) { + t.Errorf("unknown jurisdiction should fall through to no-filter; got %d vs all-axes %d", + len(matchesUnknown), len(matchesAll)) + } + }) + + t.Run("appeal_target=endentscheidung returns upc.apl merits rules", func(t *testing.T) { + matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ + Jurisdiction: "UPC", + AppealTarget: lp.AppealTargetEndentscheidung, + }, lp.EventLookupDepthAllFollowing) + if err != nil { + t.Fatalf("LookupEvents: %v", err) + } + // Should hit the 7 rules under the unified upc.apl that + // carry applies_to_target={endentscheidung} (Slice B1 mig 134). + if len(matches) == 0 { + t.Fatal("expected upc.apl endentscheidung rules after B1 mig") + } + for _, m := range matches { + if m.DepthFromAnchor != 1 { + continue // children of anchors may be from other targets + } + found := false + for _, t := range m.Rule.AppliesToTarget { + if t == lp.AppealTargetEndentscheidung { + found = true + break + } + } + if !found { + t.Errorf("anchor row %s missing endentscheidung target: %v", + m.Rule.Name, m.Rule.AppliesToTarget) + } + if m.ProceedingType.Code != "upc.apl" { + t.Errorf("anchor row %s came from %s, want upc.apl", + m.Rule.Name, m.ProceedingType.Code) + } + } + }) + + t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) { + matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ + Jurisdiction: "UPC", + AppealTarget: lp.AppealTargetSchadensbemessung, + }, lp.EventLookupDepthAllFollowing) + if err != nil { + t.Fatalf("LookupEvents: %v", err) + } + if len(matches) != 0 { + t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches)) + } + }) +} diff --git a/pkg/litigationplanner/catalog.go b/pkg/litigationplanner/catalog.go index e5755e3..efdc3b6 100644 --- a/pkg/litigationplanner/catalog.go +++ b/pkg/litigationplanner/catalog.go @@ -46,4 +46,17 @@ type Catalog interface { // are simply absent (caller treats absence as "no override"). // Empty input returns an empty map without a DB roundtrip. LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error) + + // LookupEvents returns deadline rules matching any subset of the + // requested axes, at the requested sequence depth (Slice B2, + // m/paliad#124 §18.2). Used by the Determinator cascade, the + // scenarios surface (Slice D), and any future "show me events + // matching X" query. Empty result is NOT an error. + // + // Implementations must respect the catalog's "published + active" + // rule gate (rules with lifecycle_state='draft' or is_active=false + // must NEVER appear in the result). Sort order is + // (proceeding_type_id, sequence_order) so the frontend can render + // without re-sorting. + LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error) } diff --git a/pkg/litigationplanner/types.go b/pkg/litigationplanner/types.go index 0106c66..00e5a3c 100644 --- a/pkg/litigationplanner/types.go +++ b/pkg/litigationplanner/types.go @@ -426,6 +426,76 @@ type FristenrechnerType struct { 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