From 0c126445635a2dbe6493f1b5b1ec955ffaf36530 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 8 May 2026 21:55:15 +0200 Subject: [PATCH] feat(deadline-rules): expose concept's canonical event_type per rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add paliad.deadline_concept_event_types junction (mig 072) mapping each deadline_concept to its canonical paliad.event_types row(s). Hydrate DeadlineRule.ConceptDefaultEventTypeID via one IN query per List call so /api/deadline-rules carries the autofill hint for the deadline create form (t-paliad-165 / m/paliad#18). Seed mapping covers the active concepts driving existing rules — 29 rows across 26 distinct concepts. Concepts without an obvious event_type counterpart (decision, filing, grant, the DE-only Begründung family) stay unmapped; auto-fill silently skips them. --- .../072_deadline_concept_event_types.down.sql | 2 + .../072_deadline_concept_event_types.up.sql | 113 ++++++++++++++++++ internal/models/models.go | 6 + internal/services/deadline_rule_service.go | 56 +++++++++ 4 files changed, 177 insertions(+) create mode 100644 internal/db/migrations/072_deadline_concept_event_types.down.sql create mode 100644 internal/db/migrations/072_deadline_concept_event_types.up.sql diff --git a/internal/db/migrations/072_deadline_concept_event_types.down.sql b/internal/db/migrations/072_deadline_concept_event_types.down.sql new file mode 100644 index 0000000..2dfad73 --- /dev/null +++ b/internal/db/migrations/072_deadline_concept_event_types.down.sql @@ -0,0 +1,2 @@ +-- t-paliad-165 down: drop the concept→event_type junction. +DROP TABLE IF EXISTS paliad.deadline_concept_event_types; diff --git a/internal/db/migrations/072_deadline_concept_event_types.up.sql b/internal/db/migrations/072_deadline_concept_event_types.up.sql new file mode 100644 index 0000000..29d04c6 --- /dev/null +++ b/internal/db/migrations/072_deadline_concept_event_types.up.sql @@ -0,0 +1,113 @@ +-- t-paliad-165: junction paliad.deadline_concept_event_types — maps each +-- deadline_concept to the canonical paliad.event_types row(s) that +-- represent it on the Typ chip cluster of the deadline create form. +-- +-- Why this exists +-- --------------- +-- The deadline create form (/projects/{id}/deadlines/new and the global +-- /deadlines/new) lets the user pick a Regel (paliad.deadline_rules) AND +-- independently pick a Typ (paliad.event_types). They are decoupled, so a +-- user can save a deadline whose Regel is `damages.rejoin — Duplik` but +-- Typ is `Klageerwiderung` — two different legal events. m hit this +-- contradiction during 2026-05-08 dogfooding (Gitea m/paliad#18). +-- +-- Each rule already carries paliad.deadline_rules.concept_id (mig 040), +-- so the rule knows what legal idea it represents. What was missing was +-- the canonical event_type for that concept. Slug-pattern heuristics are +-- unreliable (concept `notice-of-appeal` ↔ event_type +-- `upc_statement_of_appeal_2201`) and many concepts have multiple +-- candidate event_types (`statement-of-defence` ↔ base + with_ccr + +-- no_ccr); this junction makes the mapping explicit and curated. +-- +-- Shape +-- ----- +-- Many-to-many, so concepts that genuinely have several candidate types +-- (with_ccr / no_ccr / base; UPC + EPO + DPMA opposition) get one row +-- per type. is_default picks the single row the create-form auto-fills +-- when the user picks a Regel attached to this concept. The remaining +-- rows are reserved for future surfaces (e.g. Determinator save flow +-- might want to see all candidates) but the create-form only consumes +-- is_default for now. +-- +-- Idempotent against re-seeds: the seed below uses ON CONFLICT DO +-- NOTHING so a second run after manual mapping additions doesn't blow +-- them away. Down migration drops the table entirely. + +CREATE TABLE paliad.deadline_concept_event_types ( + concept_id uuid NOT NULL + REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE, + event_type_id uuid NOT NULL + REFERENCES paliad.event_types(id) ON DELETE CASCADE, + is_default bool NOT NULL DEFAULT false, + sort_order int NOT NULL DEFAULT 100, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (concept_id, event_type_id) +); + +COMMENT ON TABLE paliad.deadline_concept_event_types IS + 'Junction mapping paliad.deadline_concepts → paliad.event_types. ' + 'Lets the deadline create form auto-populate the Typ chip when the ' + 'user picks a Regel — the rule''s concept points here for its ' + 'canonical event_type(s). Many-to-many for concepts with several ' + 'natural variants (with_ccr / no_ccr / base, EPO + DPMA opposition).'; + +COMMENT ON COLUMN paliad.deadline_concept_event_types.is_default IS + 'Exactly one row per concept_id should be marked default — that is ' + 'the row the create-form chip cluster auto-fills with. Other rows ' + 'remain selectable from the picker as alternatives.'; + +CREATE UNIQUE INDEX deadline_concept_event_types_one_default + ON paliad.deadline_concept_event_types (concept_id) + WHERE is_default = true; + +CREATE INDEX deadline_concept_event_types_event_type + ON paliad.deadline_concept_event_types (event_type_id); + +-- ============================================================================ +-- Seed: curated mapping for active concepts that drive existing rules. +-- +-- Concepts without an obvious event_type counterpart (filing, grant, +-- decision, publication, communication-r71-3, search-report, the various +-- DE-only Begründung concepts) stay unmapped — auto-fill silently +-- skips them, leaving the user to pick a Typ manually as today. +-- Future migrations can fill those gaps as event_types are added. +-- ============================================================================ + +INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order) +SELECT dc.id, et.id, mapping.is_default, mapping.sort_order + FROM (VALUES + -- (concept_slug, event_type_slug, is_default, sort_order) + ('application-for-cost-decision', 'upc_application_for_cost_decision', true, 10), + ('application-for-determination-of-damages', 'upc_application_for_damages', true, 10), + ('application-for-revocation', 'upc_statement_for_revocation', true, 10), + ('application-for-provisional-measures', 'upc_protective_letter', true, 10), + ('cost-decision', 'upc_decision_on_costs', true, 10), + ('counterclaim-for-infringement', 'upc_counterclaim_for_infringement', true, 10), + ('counterclaim-for-revocation', 'upc_counterclaim_for_revocation', true, 10), + ('cross-appeal', 'upc_cross_appeal_2242a', true, 10), + ('defence-to-application-to-amend', 'upc_defence_to_amend_patent', true, 10), + ('defence-to-counterclaim-for-revocation', 'upc_defence_to_revocation', true, 10), + ('notice-of-appeal', 'upc_statement_of_appeal_2201', true, 10), + ('opposition', 'epo_opposition_filing', true, 10), + ('opposition', 'dpma_opposition', false, 20), + ('oral-hearing', 'upc_oral_hearing', true, 10), + ('order', 'upc_case_management_order', true, 10), + ('rejoinder', 'upc_rejoinder_to_reply', true, 10), + ('reply-to-cross-appeal', 'upc_cross_appeal_2242a', true, 10), + ('reply-to-defence', 'upc_reply_to_defence', true, 10), + ('reply-to-defence-to-application-to-amend', 'upc_reply_to_defence_to_amend_patent', true, 10), + ('reply-to-defence-to-counterclaim-for-revocation','upc_reply_to_defence_to_revocation', true, 10), + ('request-for-examination', 'dpma_examination_request', true, 10), + ('request-to-lay-open-books', 'upc_request_to_lay_open_books', true, 10), + ('response-to-appeal', 'upc_grounds_of_appeal_2242a', true, 10), + ('statement-of-claim', 'upc_statement_of_claim', true, 10), + ('statement-of-defence', 'upc_statement_of_defence', true, 10), + ('statement-of-defence', 'upc_statement_of_defence_with_ccr', false, 20), + ('statement-of-defence', 'upc_statement_of_defence_no_ccr', false, 30), + ('statement-of-grounds-of-appeal', 'upc_grounds_of_appeal_2242a', true, 10), + ('statement-of-grounds-of-appeal', 'epo_appeal_grounds', false, 20) + ) AS mapping(concept_slug, event_type_slug, is_default, sort_order) + JOIN paliad.deadline_concepts dc ON dc.slug = mapping.concept_slug + JOIN paliad.event_types et ON et.slug = mapping.event_type_slug + AND et.archived_at IS NULL +ON CONFLICT (concept_id, event_type_id) DO NOTHING; diff --git a/internal/models/models.go b/internal/models/models.go index 011d2e9..697ce95 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -468,6 +468,12 @@ type DeadlineRule struct { AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"` ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"` + // ConceptDefaultEventTypeID is the canonical paliad.event_types row for + // this rule's concept (joined via paliad.deadline_concept_event_types + // where is_default = true). Lets the deadline create form auto-populate + // the Typ chip when the user picks this rule. Hydrated by the service + // layer; not a column. NULL when the concept has no mapped event_type. + ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"` LegalSource *string `db:"legal_source" json:"legal_source,omitempty"` IsSpawn bool `db:"is_spawn" json:"is_spawn"` SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` diff --git a/internal/services/deadline_rule_service.go b/internal/services/deadline_rule_service.go index 45fbf64..5463efc 100644 --- a/internal/services/deadline_rule_service.go +++ b/internal/services/deadline_rule_service.go @@ -32,6 +32,9 @@ const proceedingTypeColumns = `id, code, name, name_en, description, jurisdictio category, default_color, sort_order, is_active` // List returns active rules, optionally filtered by proceeding type. +// Each row has ConceptDefaultEventTypeID hydrated from +// paliad.deadline_concept_event_types so the deadline-create form can +// auto-populate the Typ chip when the user picks a Regel. func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ([]models.DeadlineRule, error) { var rules []models.DeadlineRule var err error @@ -52,9 +55,62 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ( if err != nil { return nil, fmt.Errorf("list deadline rules: %w", err) } + if err := s.hydrateConceptDefaultEventTypes(ctx, rules); err != nil { + return nil, err + } return rules, nil } +// hydrateConceptDefaultEventTypes resolves rule.ConceptID → +// paliad.deadline_concept_event_types.event_type_id (where is_default) +// for every rule with a non-nil ConceptID, and assigns the result. +// One round-trip; rules whose concept has no default mapping stay NULL. +func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Context, rules []models.DeadlineRule) error { + conceptIDs := make([]uuid.UUID, 0, len(rules)) + seen := make(map[uuid.UUID]bool, len(rules)) + for _, r := range rules { + if r.ConceptID == nil || seen[*r.ConceptID] { + continue + } + seen[*r.ConceptID] = true + conceptIDs = append(conceptIDs, *r.ConceptID) + } + if len(conceptIDs) == 0 { + return nil + } + query, args, err := sqlx.In( + `SELECT concept_id, event_type_id + FROM paliad.deadline_concept_event_types + WHERE is_default = true AND concept_id IN (?)`, conceptIDs) + if err != nil { + return fmt.Errorf("build concept→event_type IN query: %w", err) + } + query = s.db.Rebind(query) + + type row struct { + ConceptID uuid.UUID `db:"concept_id"` + EventTypeID uuid.UUID `db:"event_type_id"` + } + var rows []row + if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil { + return fmt.Errorf("load concept→event_type defaults: %w", err) + } + defaultByConcept := make(map[uuid.UUID]uuid.UUID, len(rows)) + for _, r := range rows { + defaultByConcept[r.ConceptID] = r.EventTypeID + } + for i := range rules { + if rules[i].ConceptID == nil { + continue + } + if et, ok := defaultByConcept[*rules[i].ConceptID]; ok { + etCopy := et + rules[i].ConceptDefaultEventTypeID = &etCopy + } + } + return nil +} + // RuleTreeNode pairs a rule with its child rules in a parent_id hierarchy. type RuleTreeNode struct { models.DeadlineRule