feat(deadline-rules): expose concept's canonical event_type per rule

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.
This commit is contained in:
m
2026-05-08 21:55:15 +02:00
parent 936aca5925
commit 0c12644563
4 changed files with 177 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
-- t-paliad-165 down: drop the concept→event_type junction.
DROP TABLE IF EXISTS paliad.deadline_concept_event_types;

View File

@@ -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;

View File

@@ -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"`

View File

@@ -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