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:
@@ -0,0 +1,2 @@
|
||||
-- t-paliad-165 down: drop the concept→event_type junction.
|
||||
DROP TABLE IF EXISTS paliad.deadline_concept_event_types;
|
||||
113
internal/db/migrations/072_deadline_concept_event_types.up.sql
Normal file
113
internal/db/migrations/072_deadline_concept_event_types.up.sql
Normal 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;
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user