diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts index a85b6a4..5fc8072 100644 --- a/frontend/src/client/fristenrechner.ts +++ b/frontend/src/client/fristenrechner.ts @@ -2409,6 +2409,9 @@ interface EventCategoryNode { icon?: string; sort_order: number; is_leaf: boolean; + // #15 follow-up: coarse forum tags ('upc' | 'de' | 'epa' | 'dpma'). + // Empty / undefined = neutral, node visible from every inbox setting. + forums?: string[]; children?: EventCategoryNode[]; } @@ -2494,7 +2497,12 @@ function renderB1Cascade(currentSlug: string) { const trail = buildBreadcrumb(eventCategoryTree, currentSlug); const node = trail.length > 0 ? trail[trail.length - 1] : null; - const childScope = node ? (node.children || []) : eventCategoryTree; + const rawChildScope = node ? (node.children || []) : eventCategoryTree; + // #15 follow-up: drop children whose forums tag doesn't match the + // active inbox channel. Nodes with no forums (neutral) stay visible + // so the user always reaches court-event / Schriftsatz parents that + // span jurisdictions. + const childScope = rawChildScope.filter((c) => inboxFilterAllowsForums(c.forums)); const breadcrumbHtml = trail.length === 0 ? "" @@ -2804,6 +2812,13 @@ type InboxChannel = "cms" | "bea" | "posteingang" | null; const INBOX_CHANNEL_VALUES = new Set(["cms", "bea", "posteingang"]); +// currentInboxChannel mirrors the chip's active state so the B1 cascade +// renderer (which lives in a different section of this file) can ask +// "which forum is active right now?" without re-deriving from URL on +// every render. Updated by applyInboxFilter on hydrate / click / +// popstate. +let currentInboxChannel: InboxChannel = null; + function readInboxFromURL(): InboxChannel { const raw = new URLSearchParams(window.location.search).get("inbox"); return raw && INBOX_CHANNEL_VALUES.has(raw) ? (raw as InboxChannel) : null; @@ -2854,6 +2869,7 @@ function applyFineForumsFromInbox(ch: InboxChannel) { } function applyInboxFilter(ch: InboxChannel) { + currentInboxChannel = ch; const forum = inboxChannelToForum(ch); document.querySelectorAll(".fristen-inbox-chip").forEach((btn) => { @@ -2871,6 +2887,25 @@ function applyInboxFilter(ch: InboxChannel) { const groupForum = g.dataset.forum || ""; g.hidden = forum !== null && groupForum !== forum; }); + + // Re-render the B1 cascade so its button set picks up the new forum + // narrowing. Render is a no-op if the tree hasn't loaded yet or the + // cascade DOM isn't mounted (Pathway B not visible) — both guards + // already inside renderB1Cascade. + if (eventCategoryTree) { + renderB1Cascade(readB1PathFromURL()); + } +} + +// inboxFilterAllowsForums returns true when a node with the given +// forums tags should be visible under the current inbox chip. Neutral +// nodes (forums undefined / empty) are always visible. When no inbox +// is active, every node is visible. +function inboxFilterAllowsForums(forums: string[] | undefined): boolean { + if (!forums || forums.length === 0) return true; + const active = inboxChannelToForum(currentInboxChannel); + if (active === null) return true; + return forums.includes(active); } async function persistInboxPref(ch: InboxChannel) { diff --git a/internal/db/migrations/065_event_categories_forums.down.sql b/internal/db/migrations/065_event_categories_forums.down.sql new file mode 100644 index 0000000..1f267f5 --- /dev/null +++ b/internal/db/migrations/065_event_categories_forums.down.sql @@ -0,0 +1,9 @@ +-- Reverse t-paliad-157 / m/paliad#15 follow-up: drop the forums column. + +DROP INDEX IF EXISTS paliad.event_categories_forums_gin; + +ALTER TABLE paliad.event_categories + DROP CONSTRAINT IF EXISTS event_categories_forums_check; + +ALTER TABLE paliad.event_categories + DROP COLUMN IF EXISTS forums; diff --git a/internal/db/migrations/065_event_categories_forums.up.sql b/internal/db/migrations/065_event_categories_forums.up.sql new file mode 100644 index 0000000..a7728b7 --- /dev/null +++ b/internal/db/migrations/065_event_categories_forums.up.sql @@ -0,0 +1,67 @@ +-- t-paliad-157 / m/paliad#15 follow-up: tag event_categories with the +-- coarse forums they belong to so the inbox-channel chip can narrow the +-- B1 cascade entry-points (the chip already narrows Pathway A's picker +-- and B2's fine-bucket forum filter; B1 was the last hold-out). +-- +-- Allowed values: upc | de | epa | dpma. NULL = "neutral" — node stays +-- reachable from every inbox setting. Mixed-jurisdiction nodes (top- +-- level branches, generic court actions like Ladung / Kostenfestsetzung, +-- mündliche Verhandlung sub-states) intentionally stay NULL. +-- +-- Two-step backfill: +-- +-- 1. Regex on slug for nodes that carry the forum token explicitly +-- (`upc-`, `.upc`, `-upc`, `.de-`, `-de-`, `.epa-`, `dpma`, …). +-- Tokens are bounded by `^` / dot / dash / `$` so e.g. `.dpma` +-- doesn't accidentally match the `de` rule. +-- +-- 2. Explicit slug list for stragglers whose name doesn't carry the +-- token (BGH / BPatG / Versäumnisurteil / Hinweisbeschluss are +-- DE-only; r116-eingaben is EPA-only). +-- +-- Anything still NULL after both passes is intentionally neutral. + +ALTER TABLE paliad.event_categories + ADD COLUMN forums text[]; + +ALTER TABLE paliad.event_categories + ADD CONSTRAINT event_categories_forums_check + CHECK (forums IS NULL OR forums <@ ARRAY['upc','de','epa','dpma']::text[]); + +COMMENT ON COLUMN paliad.event_categories.forums IS + 'Coarse forum tags driving the #15 inbox-channel chip narrowing. ' + 'Allowed: upc, de, epa, dpma. NULL = neutral (visible from every ' + 'inbox setting). Empty array is treated the same as NULL by the ' + 'frontend filter (CHECK constraint allows it for forward ' + 'compatibility but the migration writes NULL where neutral).'; + +CREATE INDEX event_categories_forums_gin + ON paliad.event_categories USING GIN (forums) + WHERE forums IS NOT NULL; + +UPDATE paliad.event_categories SET forums = ARRAY['upc'] + WHERE forums IS NULL AND slug ~ '(^|[\.-])upc([\.-]|$)'; + +UPDATE paliad.event_categories SET forums = ARRAY['de'] + WHERE forums IS NULL AND slug ~ '(^|[\.-])de([\.-]|$)'; + +UPDATE paliad.event_categories SET forums = ARRAY['epa'] + WHERE forums IS NULL AND slug ~ '(^|[\.-])epa([\.-]|$)'; + +UPDATE paliad.event_categories SET forums = ARRAY['dpma'] + WHERE forums IS NULL AND slug ~ '(^|[\.-])dpma([\.-]|$)'; + +UPDATE paliad.event_categories SET forums = ARRAY['de'] + WHERE forums IS NULL AND slug IN ( + 'beschluss-entscheidung.beschluss-bpatg-beschwerde', + 'beschluss-entscheidung.versaeumnisurteil', + 'cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde', + 'cms-eingang.gericht.endentscheidung.versaeumnisurteil', + 'cms-eingang.gericht.hinweisbeschluss', + 'ich-moechte-einreichen.berufung.bgh-rb', + 'ich-moechte-einreichen.berufung.bpatg-beschwerde' +); + +UPDATE paliad.event_categories SET forums = ARRAY['epa'] + WHERE forums IS NULL + AND slug = 'ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben'; diff --git a/internal/services/event_category_service.go b/internal/services/event_category_service.go index ce3bfd7..85058c8 100644 --- a/internal/services/event_category_service.go +++ b/internal/services/event_category_service.go @@ -33,6 +33,11 @@ func NewEventCategoryService(db *sqlx.DB) *EventCategoryService { // 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"` @@ -45,6 +50,7 @@ type EventCategoryNode struct { 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"` } @@ -70,6 +76,7 @@ type categoryRow struct { 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 @@ -82,7 +89,7 @@ func (s *EventCategoryService) Tree(ctx context.Context) ([]EventCategoryNode, e SELECT id, parent_id, slug, label_de, label_en, description_de, description_en, step_question_de, step_question_en, - icon, sort_order, is_leaf + icon, sort_order, is_leaf, forums FROM paliad.event_categories WHERE is_active = true ORDER BY sort_order ASC, slug ASC @@ -118,6 +125,9 @@ SELECT id, parent_id, slug, label_de, label_en, if r.Icon.Valid { n.Icon = &r.Icon.String } + if len(r.Forums) > 0 { + n.Forums = []string(r.Forums) + } nodes[r.ID] = &n }