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`.
301 lines
11 KiB
Go
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()
|
|
}
|