Merge: t-paliad-165 — Regel ↔ Typ collapse via auto-link on the deadline create form

Two slices on mai/noether/collapse-regel-typ-on:

  0c12644  feat(deadline-rules): expose concept's canonical event_type per rule
  1e97ecc  feat(deadlines/new): auto-link Typ to Regel's concept

What ships:

- New junction paliad.deadline_concept_event_types maps every
  paliad.deadline_concepts row to its canonical paliad.event_types
  row(s). Many-to-many for concepts with multiple legitimate variants
  (statement-of-defence ↔ base + with_ccr + no_ccr; opposition across
  EPO + DPMA). Exactly one row per concept marked is_default = true
  by a partial unique index — that is the row the deadline form
  auto-fills with.

- Backend: paliad.deadline_rules_with_concept_event_type view + the
  deadline-rules read path now expose the rule's default concept
  event_type so the form has the auto-fill target without an extra
  round-trip.

- Frontend deadline create / edit form: when the user picks a Regel,
  the Typ chip auto-fills with the rule's concept's default event_type.
  A small "vorgegeben durch Regel — überschreiben?" hint sits next to
  the chip so the auto-fill is visible. The user can override (free-
  text or pick a different type); the override is explicit, no
  blocking validation.

- Free-text Typ stays available — manual deadlines without a
  matching rule (e.g. "Call me" reminders) keep working as today.

Migration housekeeping
======================

noether authored her migration as 072 on her branch but main had
already taken 072 via minkowski's t-paliad-164 (paliad.projects.our_side).
Renumbered to 073 during merge resolution to resolve the same-number
collision. Added IF NOT EXISTS guards on CREATE TABLE / CREATE INDEX
for re-run safety (the seed INSERT already had ON CONFLICT DO NOTHING).

Live tracker bumped 72 → 73 in the same operation: both effects
(our_side column AND deadline_concept_event_types table) were
applied to live during dev (each worker against the same DB), so
the tracker advance reflects schema reality. Next deploy sees
tracker=73 with file 073 present and has nothing to apply.

Refs m/paliad#18.
This commit is contained in:
m
2026-05-08 22:01:44 +02:00
8 changed files with 284 additions and 0 deletions

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