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} } // ruleColumns lists every column scanned into models.DeadlineRule. // // Slice 9 (t-paliad-195, mig 091) dropped is_mandatory, is_optional, // condition_flag, and condition_rule_id — they were superseded by // priority / condition_expr / is_court_set in the unified Phase 3 // shape. The SELECT now reads only the live schema. const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, name_en, description, primary_party, event_type, duration_value, duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order, alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active, created_at, updated_at, trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr, priority, is_court_set, lifecycle_state, draft_of, published_at, choices_offered` const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction, category, default_color, sort_order, is_active, trigger_event_label_de, trigger_event_label_en` // 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 } // LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the // given id set, keyed by id. Returns nil, nil for an empty input set so // callers can blindly forward whatever they accumulated. Inactive rows // are included — the conditional-label resolution in fristenrechner.go // surfaces the trigger event's display name even when the catalog row // has been retired, which is preferable to silently falling back to // the (wrong) parent_id name. // // Used by FristenrechnerService.Calculate to redirect a conditional // rule's "abhängig von …" chip from parent_id to trigger_event_id — // the actual semantic anchor for rules whose data-model parent is the // proceeding root but whose real trigger sits in the trigger_events // catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the // opposing party's confidentiality application). See m/paliad#126. func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) { if len(ids) == 0 { return nil, nil } query, args, err := sqlx.In( `SELECT id, code, name, name_de, description, is_active, created_at FROM paliad.trigger_events WHERE id IN (?)`, ids) if err != nil { return nil, fmt.Errorf("build trigger_events IN query: %w", err) } query = s.db.Rebind(query) var rows []models.TriggerEvent if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil { return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err) } out := make(map[int64]models.TriggerEvent, len(rows)) for _, r := range rows { out[r.ID] = r } return out, nil } // ListByTriggerEvent returns active rules scoped to a single trigger // event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085). // These rules carry proceeding_type_id IS NULL (event-rooted) and have // no parent_id chain. // // Distinct from List: List filters by proceeding_type_id and runs // hydrateConceptDefaultEventTypes (which assumes a proceeding-type FK). // Pipeline-C rules don't have that FK, so hydration is skipped here. // // Order by sequence_order so the data-move's (1000 + ed.id) offset // preserves the original event_deadlines.id ordering. func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) { var rules []models.DeadlineRule if err := s.db.SelectContext(ctx, &rules, `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE trigger_event_id = $1 AND is_active = true ORDER BY sequence_order`, triggerEventID); err != nil { return nil, fmt.Errorf("list deadline rules by trigger_event_id=%d: %w", triggerEventID, err) } return rules, nil } // ListByProceedingTypeIDs returns active rules across a set of // proceeding types, ordered by (proceeding_type_id, sequence_order) so // callers can group + pick the "first rule" (lowest sequence_order) // per proceeding without a second sort. Phase 3 Slice 7 (t-paliad-188) // uses this for cross-proceeding spawn target expansion: given a list // of spawn_proceeding_type_id values, bulk-load every target // proceeding's rules in one round-trip. // // Empty input returns nil, nil (no SELECT issued). Distinct from // List(proceedingTypeID) which scopes to a single proceeding + runs // hydrateConceptDefaultEventTypes — this method skips hydration since // the SmartTimeline doesn't need concept-default event types on // spawned rules. func (s *DeadlineRuleService) ListByProceedingTypeIDs(ctx context.Context, ids []int) ([]models.DeadlineRule, error) { if len(ids) == 0 { return nil, nil } query, args, err := sqlx.In( `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE proceeding_type_id IN (?) AND is_active = true ORDER BY proceeding_type_id, sequence_order`, ids) if err != nil { return nil, fmt.Errorf("build IN query for proceeding ids: %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("list deadline rules by proceeding_type_ids %v: %w", ids, err) } return rules, nil } // ListByConcept returns active rules linked to a single // paliad.deadline_concepts row via the concept_id FK. Used by the // Phase 3 Slice 6 event-trigger endpoint (t-paliad-187) to discover // the rules a cascade leaf produces. // // Distinct from ListByTriggerEvent (Pipeline-C): this is the // Pipeline-A concept-keyed path. A concept may have rules across // multiple proceeding_types — the caller may want to narrow further // via event_category_concepts.proceeding_type_code, but the Slice 6 // service does no narrowing in v1 (returns every active rule on // the concept). // // Order by sequence_order so rules within a proceeding stay in their // canonical order. proceeding_type_id is a secondary sort so a // multi-proceeding concept doesn't interleave its constituent rules. func (s *DeadlineRuleService) ListByConcept(ctx context.Context, conceptID uuid.UUID) ([]models.DeadlineRule, error) { var rules []models.DeadlineRule if err := s.db.SelectContext(ctx, &rules, `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE concept_id = $1 AND is_active = true ORDER BY proceeding_type_id NULLS LAST, sequence_order`, conceptID); err != nil { return nil, fmt.Errorf("list deadline rules by concept_id=%s: %w", conceptID, err) } return rules, nil } // ListProceedingTypes returns active proceeding types ordered by sort_order. func (s *DeadlineRuleService) ListProceedingTypes(ctx context.Context) ([]models.ProceedingType, error) { return s.ListProceedingTypesByCategory(ctx, "") } // ListProceedingTypesByCategory returns active proceeding types // ordered by sort_order, optionally filtered to a single category. An // empty category returns every active row (preserves the legacy // ListProceedingTypes behaviour). // // Phase 3 Slice 5 (t-paliad-186): the project-create / project-edit // pickers pass category='fristenrechner' so users never see retired // litigation codes when binding a project to a proceeding (design §3.F). func (s *DeadlineRuleService) ListProceedingTypesByCategory(ctx context.Context, category string) ([]models.ProceedingType, error) { var types []models.ProceedingType if category == "" { 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 } if err := s.db.SelectContext(ctx, &types, `SELECT `+proceedingTypeColumns+` FROM paliad.proceeding_types WHERE is_active = true AND category = $1 ORDER BY sort_order`, category); err != nil { return nil, fmt.Errorf("list proceeding types by category %q: %w", category, 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 }