Files
paliad/internal/services/event_category_service.go
m 6ef14ddc39 feat(fristenrechner/inbox-chip): wire inbox into B1 cascade narrowing
Completes the #15 vision: the inbox chip now narrows the B1 decision
tree alongside Pathway A's picker and B2's fine-bucket forum filter.
Picking CMS hides DE / EPA / DPMA cascade entries; picking beA /
Posteingang hides UPC / EPA / DPMA entries. Neutral nodes (top-level
branches, Mündliche Verhandlung sub-states, court-generic events like
Ladung / Kostenfestsetzung) stay visible from every inbox setting so
the user can always reach the cross-jurisdictional middle of the tree.

Migration 065 adds paliad.event_categories.forums (text[]) with a
CHECK on {upc, de, epa, dpma}, a partial GIN index, and a two-step
backfill:

  1. Regex on slug for nodes that carry the forum token explicitly.
     Token-bounded by ^/./- so .dpma doesn't trip the de pattern.
  2. Explicit slug list for stragglers (BGH / BPatG / Versäumnisurteil /
     Hinweisbeschluss are DE-only; r116-eingaben is EPA-only).

NULL stays neutral. Migration applied to live Supabase; tracker at v65.

Backend: EventCategoryNode JSON gains an optional `forums` array;
EventCategoryService.Tree SELECT includes the column and threads it
through to the response.

Frontend: new module-level currentInboxChannel mirrors the chip state
so renderB1Cascade can ask "which forum is active?" without re-deriving
from the URL on every step. inboxFilterAllowsForums(forums) gates each
child node — neutral arrays (undefined / empty) always pass; tagged
arrays must include the active forum. applyInboxFilter re-renders the
cascade so chip clicks reflow B1 in place. Pathway A picker filter
and B2 fine-bucket sync remain orthogonal — same chip, three filters.

Refs m/paliad#15 (B1 follow-up).
2026-05-08 16:54:34 +02:00

291 lines
10 KiB
Go

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"`
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"`
}
// 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
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)
}
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
// and DPMA_OPP. 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()
}