package services import ( "context" "database/sql" "fmt" "github.com/jmoiron/sqlx" "github.com/lib/pq" ) // EventCategoryService backs the Fristenrechner v3 decision tree // (Pathway B / B1, t-paliad-133). The taxonomy is a recursive tree of // "what happened" event categories; leaves map to deadline_concepts via // the paliad.event_category_concepts junction. // // Two main operations: // 1. Tree: hand the entire taxonomy to the frontend as a nested JSON // structure for the cascade UI (small dataset, ETag-cached). // 2. ConceptsForSlug: given a slug like "cms-eingang.gericht.hinweisbeschluss", // return the (concept_id, proceeding_type_code) tuples reachable from // that node OR any of its descendants. Drives B1's narrowing of the // shared concept-card list. type EventCategoryService struct { db *sqlx.DB } // NewEventCategoryService wires the service to its DB pool. func NewEventCategoryService(db *sqlx.DB) *EventCategoryService { return &EventCategoryService{db: db} } // EventCategoryNode is one row in the taxonomy with its children attached. // JSON shape is what the frontend consumes from // GET /api/tools/fristenrechner/event-categories. // // Forums carries the optional coarse forum tags (#15) — `upc` / `de` / // `epa` / `dpma`. Empty / nil = "neutral", node stays reachable from // every inbox-channel chip setting. The frontend uses this to hide // non-matching subtrees when an inbox is active. type EventCategoryNode struct { ID string `json:"id"` Slug string `json:"slug"` LabelDE string `json:"label_de"` LabelEN string `json:"label_en"` DescriptionDE *string `json:"description_de,omitempty"` DescriptionEN *string `json:"description_en,omitempty"` StepQuestionDE *string `json:"step_question_de,omitempty"` StepQuestionEN *string `json:"step_question_en,omitempty"` Icon *string `json:"icon,omitempty"` SortOrder int `json:"sort_order"` IsLeaf bool `json:"is_leaf"` Forums []string `json:"forums,omitempty"` // Party (#15 Slice 3c): coarse role tags — claimant, defendant, // both, court. NULL/empty = neutral, visible from every // perspective. The B1 panel's perspective chip uses this to hide // nodes that don't apply to the user's side (Klägerseite never // files Klageerwiderung; Beklagtenseite never files Klageschrift). Party []string `json:"party,omitempty"` Children []EventCategoryNode `json:"children,omitempty"` } // ConceptOutcome maps a leaf to one (concept, optional proceeding) pair. // Used to narrow the deadline_search matview by event_category. type ConceptOutcome struct { ConceptID string `db:"concept_id" json:"concept_id"` ProceedingTypeCode *string `db:"proceeding_type_code" json:"proceeding_type_code,omitempty"` SortOrder int `db:"sort_order" json:"sort_order"` } // categoryRow is the flat row shape from the DB. type categoryRow struct { ID string `db:"id"` ParentID sql.NullString `db:"parent_id"` Slug string `db:"slug"` LabelDE string `db:"label_de"` LabelEN string `db:"label_en"` DescriptionDE sql.NullString `db:"description_de"` DescriptionEN sql.NullString `db:"description_en"` StepQuestionDE sql.NullString `db:"step_question_de"` StepQuestionEN sql.NullString `db:"step_question_en"` Icon sql.NullString `db:"icon"` SortOrder int `db:"sort_order"` IsLeaf bool `db:"is_leaf"` Forums pq.StringArray `db:"forums"` Party pq.StringArray `db:"party"` } // Tree returns the full taxonomy as a list of root nodes with children // nested in. Inactive rows are excluded. // // Result is small (≤ ~100 nodes today) and stable across requests, so the // handler ETag-caches it. func (s *EventCategoryService) Tree(ctx context.Context) ([]EventCategoryNode, error) { const sqlText = ` SELECT id, parent_id, slug, label_de, label_en, description_de, description_en, step_question_de, step_question_en, icon, sort_order, is_leaf, forums, party FROM paliad.event_categories WHERE is_active = true ORDER BY sort_order ASC, slug ASC ` var rows []categoryRow if err := s.db.SelectContext(ctx, &rows, sqlText); err != nil { return nil, fmt.Errorf("event_categories list: %w", err) } // Build node map and stitch children to parents in one pass. nodes := make(map[string]*EventCategoryNode, len(rows)) for _, r := range rows { n := EventCategoryNode{ ID: r.ID, Slug: r.Slug, LabelDE: r.LabelDE, LabelEN: r.LabelEN, SortOrder: r.SortOrder, IsLeaf: r.IsLeaf, } if r.DescriptionDE.Valid { n.DescriptionDE = &r.DescriptionDE.String } if r.DescriptionEN.Valid { n.DescriptionEN = &r.DescriptionEN.String } if r.StepQuestionDE.Valid { n.StepQuestionDE = &r.StepQuestionDE.String } if r.StepQuestionEN.Valid { n.StepQuestionEN = &r.StepQuestionEN.String } if r.Icon.Valid { n.Icon = &r.Icon.String } if len(r.Forums) > 0 { n.Forums = []string(r.Forums) } if len(r.Party) > 0 { n.Party = []string(r.Party) } nodes[r.ID] = &n } var roots []EventCategoryNode for _, r := range rows { node := nodes[r.ID] if !r.ParentID.Valid { roots = append(roots, *node) continue } parent, ok := nodes[r.ParentID.String] if !ok { // Orphan (parent inactive or deleted) — surface as root so the // taxonomy doesn't disappear. Defensive; shouldn't happen in // practice given is_active=true filter applies to both sides. roots = append(roots, *node) continue } parent.Children = append(parent.Children, *node) } // Re-collect children from the map into the root copies — the // `roots = append(roots, *node)` above stored a snapshot, so we need // to walk back through and replace each with the live pointer's data. final := make([]EventCategoryNode, 0, len(roots)) for _, root := range roots { live := nodes[root.ID] final = append(final, *live) } return final, nil } // ConceptsForSlug returns the (concept, optional proceeding_code) tuples // reachable from the named slug OR any of its descendants. Empty slug // returns nothing (caller must validate). Unknown slug also returns // empty without error so the caller can render an empty result UI. func (s *EventCategoryService) ConceptsForSlug(ctx context.Context, slug string) ([]ConceptOutcome, error) { if slug == "" { return nil, nil } // Single recursive CTE walks the descendants of `slug`, then joins // the junction. text-cast on concept_id keeps the slice serialisable // to a stable JSON shape. const sqlText = ` WITH RECURSIVE descendants AS ( SELECT id FROM paliad.event_categories WHERE slug = $1 AND is_active = true UNION ALL SELECT c.id FROM paliad.event_categories c JOIN descendants d ON c.parent_id = d.id WHERE c.is_active = true ) SELECT DISTINCT ecc.concept_id::text AS concept_id, ecc.proceeding_type_code AS proceeding_type_code, min(ecc.sort_order) AS sort_order FROM paliad.event_category_concepts ecc JOIN descendants d ON d.id = ecc.event_category_id GROUP BY ecc.concept_id, ecc.proceeding_type_code ORDER BY sort_order ASC, concept_id ASC ` var rows []ConceptOutcome if err := s.db.SelectContext(ctx, &rows, sqlText, slug); err != nil { return nil, fmt.Errorf("event_category concepts for %q: %w", slug, err) } return rows, nil } // ConceptIDsForSlug is the convenience reduction of ConceptsForSlug to a // flat slice of concept_ids for use in `WHERE concept_id = ANY($1::uuid[])` // queries against paliad.deadline_search. func (s *EventCategoryService) ConceptIDsForSlug(ctx context.Context, slug string) ([]string, error) { rows, err := s.ConceptsForSlug(ctx, slug) if err != nil { return nil, err } if len(rows) == 0 { return nil, nil } seen := make(map[string]bool, len(rows)) out := make([]string, 0, len(rows)) for _, r := range rows { if seen[r.ConceptID] { continue } seen[r.ConceptID] = true out = append(out, r.ConceptID) } return out, nil } // AllOutcomes returns the distinct (concept_id, proceeding_type_code) // tuples present in paliad.event_category_concepts across the entire // taxonomy. Drives B1 browse-all mode (no slug picked yet, show every // concept-context the tree can reach). // // Distinct from "every concept_id ever mapped" because a concept can // appear at the root view in MULTIPLE proceeding contexts that the tree // authors intentionally surfaced — e.g. opposition under both epa.opp.opd // and dpma.opp.dpma. We respect those tuples even at the root so the // result-card pill set matches the junction's design. func (s *EventCategoryService) AllOutcomes(ctx context.Context) ([]ConceptOutcome, error) { const sqlText = ` SELECT DISTINCT ecc.concept_id::text AS concept_id, ecc.proceeding_type_code AS proceeding_type_code, min(ecc.sort_order) AS sort_order FROM paliad.event_category_concepts ecc GROUP BY ecc.concept_id, ecc.proceeding_type_code ORDER BY sort_order ASC, concept_id ASC ` var rows []ConceptOutcome if err := s.db.SelectContext(ctx, &rows, sqlText); err != nil { return nil, fmt.Errorf("event_category all outcomes: %w", err) } return rows, nil } // ProceedingCodesForSlug returns the distinct proceeding_type_code // values associated with the slug's reachable concept set. Used by the // search service to AND the user's forum filter against the leaf's // per-concept narrowing. // // NULL proceeding_type_code (concept applies to all contexts) is // reported as the empty string in the result; callers treat empty as // "no narrowing at this leaf". func (s *EventCategoryService) ProceedingCodesForSlug(ctx context.Context, slug string) ([]string, error) { if slug == "" { return nil, nil } const sqlText = ` WITH RECURSIVE descendants AS ( SELECT id FROM paliad.event_categories WHERE slug = $1 AND is_active = true UNION ALL SELECT c.id FROM paliad.event_categories c JOIN descendants d ON c.parent_id = d.id WHERE c.is_active = true ) SELECT DISTINCT COALESCE(ecc.proceeding_type_code, '') AS code FROM paliad.event_category_concepts ecc JOIN descendants d ON d.id = ecc.event_category_id ORDER BY code ` var codes pq.StringArray rows, err := s.db.QueryContext(ctx, sqlText, slug) if err != nil { return nil, fmt.Errorf("proceeding codes for %q: %w", slug, err) } defer rows.Close() for rows.Next() { var c string if err := rows.Scan(&c); err != nil { return nil, fmt.Errorf("scan proceeding code: %w", err) } codes = append(codes, c) } return []string(codes), rows.Err() }