Files
paliad/internal/services/deadline_rule_service.go
m 6058d21ce6 fix(deadline-rules): pick rule's jurisdiction-aware event_type default
m's 2026-05-08 22:08 dogfood: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung'
(DE) auto-filled to 'Klageerwiderung' label but the chosen event_type was
upc_statement_of_defence (UPC). Both render as 'Klageerwiderung' in the
UI, but they are different legal events in different jurisdictions.

Migration 074 adds a jurisdiction column to
paliad.deadline_concept_event_types and swaps the unique-default index
from per-concept to per-(concept, jurisdiction). Backfills jurisdiction
from each event_type's own column, then re-elects DE / DPMA / EPO
defaults where a non-UPC event_type genuinely exists. Idempotent: uses
ADD COLUMN IF NOT EXISTS, ON CONFLICT DO UPDATE, partial unique index.

DeadlineRuleService.hydrateConceptDefaultEventTypes now JOINs
paliad.proceeding_types and matches on (rule.concept, rule.jurisdiction)
with EPA→EPO canonicalisation. Rules whose (concept, jurisdiction) has
no default stay NULL — silent no-op on the form, better than a wrong
jurisdictional default. UPC rules unchanged; DE rules now resolve to
de_klageerwiderung when concept = statement-of-defence, else no autofill.

Live audit confirms: every active rule now resolves to a same-
jurisdiction event_type or no event_type at all. No more cross-
jurisdiction matches in the seed.
2026-05-08 22:16:55 +02:00

234 lines
8.0 KiB
Go

package services
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// DeadlineRuleService reads paliad.deadline_rules + paliad.proceeding_types.
// Rules are static reference data; no visibility check needed.
type DeadlineRuleService struct {
db *sqlx.DB
}
// NewDeadlineRuleService wires the service to the pool.
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
return &DeadlineRuleService{db: db}
}
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type, is_mandatory, duration_value,
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
condition_rule_id, condition_flag, alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_optional, is_active,
created_at, updated_at`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
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
if proceedingTypeID != nil {
err = s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
}
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 each rule's (concept_id,
// proceeding_type.jurisdiction) pair to the canonical paliad.event_types
// row from paliad.deadline_concept_event_types (where is_default and
// jurisdiction matches), and assigns it to ConceptDefaultEventTypeID.
//
// One round-trip via JOIN to paliad.proceeding_types so we can match on
// the rule's jurisdiction without a per-rule second query. EPA→EPO
// canonicalisation is done in SQL because event_types use 'EPO' but
// proceeding_types use 'EPA' — the two columns disagreed before this
// mapping table existed (mig 074).
//
// Rules whose (concept, jurisdiction) has no default stay NULL —
// silent no-op on the form, better than a wrong-jurisdiction default.
func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Context, rules []models.DeadlineRule) error {
ruleIDs := make([]uuid.UUID, 0, len(rules))
for _, r := range rules {
if r.ConceptID == nil {
continue
}
ruleIDs = append(ruleIDs, r.ID)
}
if len(ruleIDs) == 0 {
return nil
}
query, args, err := sqlx.In(
`SELECT dr.id AS rule_id, j.event_type_id
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concept_event_types j
ON j.concept_id = dr.concept_id
AND j.is_default = true
AND j.jurisdiction = CASE WHEN pt.jurisdiction = 'EPA' THEN 'EPO' ELSE pt.jurisdiction END
WHERE dr.id IN (?)`, ruleIDs)
if err != nil {
return fmt.Errorf("build rule→event_type IN query: %w", err)
}
query = s.db.Rebind(query)
type row struct {
RuleID uuid.UUID `db:"rule_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 rule→event_type defaults: %w", err)
}
defaultByRule := make(map[uuid.UUID]uuid.UUID, len(rows))
for _, r := range rows {
defaultByRule[r.RuleID] = r.EventTypeID
}
for i := range rules {
if et, ok := defaultByRule[rules[i].ID]; 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
Children []RuleTreeNode `json:"children,omitempty"`
}
// GetRuleTree returns rules for a proceeding type as a tree (same proceeding type only).
func (s *DeadlineRuleService) GetRuleTree(ctx context.Context, proceedingTypeCode string) ([]RuleTreeNode, error) {
var pt models.ProceedingType
if err := s.db.GetContext(ctx, &pt,
`SELECT `+proceedingTypeColumns+`
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, proceedingTypeCode); err != nil {
return nil, fmt.Errorf("resolve proceeding type %q: %w", proceedingTypeCode, err)
}
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID); err != nil {
return nil, fmt.Errorf("list rules for %q: %w", proceedingTypeCode, err)
}
return buildTree(rules), nil
}
// GetFullTimeline returns all rules in the tree starting at the given proceeding
// type, following parent_id even across proceeding types (for cross-type spawns
// like "Appeal" hanging off an INF Decision).
func (s *DeadlineRuleService) GetFullTimeline(ctx context.Context, proceedingTypeCode string) ([]models.DeadlineRule, *models.ProceedingType, error) {
var pt models.ProceedingType
if err := s.db.GetContext(ctx, &pt,
`SELECT `+proceedingTypeColumns+`
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, proceedingTypeCode); err != nil {
return nil, nil, fmt.Errorf("resolve proceeding type %q: %w", proceedingTypeCode, err)
}
var rules []models.DeadlineRule
err := s.db.SelectContext(ctx, &rules, `
WITH RECURSIVE tree AS (
SELECT * FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
UNION ALL
SELECT dr.* FROM paliad.deadline_rules dr
JOIN tree t ON dr.parent_id = t.id
WHERE dr.is_active = true
)
SELECT `+ruleColumns+` FROM tree ORDER BY sequence_order`, pt.ID)
if err != nil {
return nil, nil, fmt.Errorf("fetch timeline for %q: %w", proceedingTypeCode, err)
}
return rules, &pt, nil
}
// GetByIDs fetches a set of rules by UUID.
func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]models.DeadlineRule, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
if err != nil {
return nil, fmt.Errorf("build IN query: %w", err)
}
query = s.db.Rebind(query)
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules, query, args...); err != nil {
return nil, fmt.Errorf("fetch rules by IDs: %w", err)
}
return rules, nil
}
// ListProceedingTypes returns active proceeding types ordered by sort_order.
func (s *DeadlineRuleService) ListProceedingTypes(ctx context.Context) ([]models.ProceedingType, error) {
var types []models.ProceedingType
if err := s.db.SelectContext(ctx, &types,
`SELECT `+proceedingTypeColumns+`
FROM paliad.proceeding_types
WHERE is_active = true
ORDER BY sort_order`); err != nil {
return nil, fmt.Errorf("list proceeding types: %w", err)
}
return types, nil
}
// buildTree converts a flat rule slice into a parent_id-rooted tree.
func buildTree(rules []models.DeadlineRule) []RuleTreeNode {
nodeMap := make(map[uuid.UUID]*RuleTreeNode, len(rules))
var roots []RuleTreeNode
for _, r := range rules {
nodeMap[r.ID] = &RuleTreeNode{DeadlineRule: r}
}
for _, r := range rules {
node := nodeMap[r.ID]
if r.ParentID != nil {
if parent, ok := nodeMap[*r.ParentID]; ok {
parent.Children = append(parent.Children, *node)
continue
}
}
roots = append(roots, *node)
}
return roots
}