Files
paliad/internal/services/event_category_service.go
mAi 216abbfc98 feat(t-paliad-206): switch Go layer to lowercase dot-form proceeding codes
Sweeps internal/services + internal/handlers + internal/models to use
the new proceeding codes landed by mig 096. Stable Code* constants
live in internal/services/proceeding_mapping.go so a future rename
needs to touch one file.

Substantive changes:
- proceeding_mapping.go gains ResolveCounterclaimRouting() — the
  cascade resolver that routes upc.ccr.cfi (illustrative peer) back
  to upc.inf.cfi with with_ccr=true as default flag (design doc S1).
- deadline_search_service.go forum-bucket map updated; upc.ccr.cfi
  added to upc_cfi since it is a CFI peer.
- project_service.go CreateCounterclaim default lookup parameterised
  so the SQL string carries the constant, not a literal.
- proceeding_codes_shape_test.go: new file. Validates the shape
  regex standalone (always runs) and walks live DB rows asserting
  every active fristenrechner row matches the new shape + every
  stable Code* constant resolves to exactly one active row.

Comments and test fixtures throughout the Go tree updated to the
new shape. Tests pass under `go test ./internal/... -short`.
2026-05-18 12:13:24 +02:00

301 lines
11 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"`
// 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()
}