Files
paliad/internal/services/deadline_rule_service.go
mAi 9da4715137
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(litigationplanner): Berufung tile UX — collapse side selectors + appeal-target trigger label (t-paliad-301, m/paliad#132)
Two bugs from the Slice B1 Berufung rollout, one fix surface:

Bug A — duplicate side selectors collapse into ONE proactive-side
picker with per-proceeding role labels. The Verfahrensablauf used to
show both ?side= (Klägerseite/Beklagtenseite) AND ?appellant= (same
labels in case-form) on the Berufung tile. Now: one side picker, with
labels that swap to Berufungskläger/Berufungsbeklagter on the unified
upc.apl.unified tile (and Antragsteller/Antragsgegner Nichtigkeit on
upc.rev.cfi, Einsprechende(r)/Patentinhaber(in) on epa.opp.*).

Bug B — 'Auslösendes Ereignis' label derives from appeal_target on
the unified Berufung tile (5 target-specific strings) instead of the
proceeding's own trigger_event_label. Endentscheidung (R.118) /
Kostenentscheidung / Anordnung / Entscheidung im
Schadensbemessungsverfahren / Anordnung der Bucheinsicht.

Migration 137 (additive, no triggers on proceeding_types — verified
via mcp__supabase__execute_sql before drafting; no updated_at on the
table — lesson from mig 134 HOTFIX 3; no audit_reason setup needed):
  - ADD COLUMN role_proactive_label_de  (text NULL)
  - ADD COLUMN role_proactive_label_en  (text NULL)
  - ADD COLUMN role_reactive_label_de   (text NULL)
  - ADD COLUMN role_reactive_label_en   (text NULL)
  - Audit-first DO block lists the rows the UPDATE will touch.
  - Backfill 4 proceedings (upc.apl.unified + upc.rev.cfi +
    epa.opp.opd + epa.opp.boa); every other proceeding stays NULL
    and the renderer falls back to default labels.
  - Down drops the 4 columns.

Package additions (pkg/litigationplanner):
  - ProceedingType gains 4 *string fields (RoleProactive/Reactive
    LabelDE/EN) — db tags match the new columns; existing scans pick
    them up via the proceedingTypeColumns extension.
  - TriggerEventLabelForAppealTarget(target, lang) — Go-side map of
    the 5 appeal-target slugs to their DE/EN trigger-event labels.
    Empty result on unknown target signals "fall back to proceeding's
    own trigger_event_label".
  - Engine override: when CalcOptions.AppealTarget is set, the
    resulting Timeline.TriggerEventLabel/EN are replaced from the
    per-target map.

Frontend:
  - Removed #appellant-row div (was a separate 3-radio selector
    duplicating side).
  - Dropped ?appellant= URL state + the change handler + the init
    readback. The engine still consumes "appellant" — sourced from
    currentSide for role-swap proceedings; null otherwise.
  - applyRoleLabels(proceedingType) swaps the side-row radio labels
    from a hardcoded ROLE_LABELS map mirroring mig 137's backfill.
    Falls back to deadlines.side.claimant/defendant i18n keys for
    proceedings without overrides.
  - syncTriggerEventLabel reads data.triggerEventLabel from the calc
    response — which the engine override now sets per appeal_target,
    so no client-side mapping needed.
  - i18n cleanup: removed orphan deadlines.appellant.* keys (label /
    claimant / defendant / none) in both DE + EN.

Tests:
  - pkg/litigationplanner/appeal_target_label_test.go pins the 5×2
    label matrix + a coverage test that fails if a new entry in
    AppealTargets is added without populating the label switch.

Acceptance:
  - go build + go test all green (incl. new lp test).
  - bun run build clean (i18n codegen drops 4 keys, regenerates).
  - Live-DB audit before drafting confirmed: 4 target columns don't
    exist on proceeding_types, zero triggers on the table, exact
    column inventory matches the design.
2026-05-26 15:37:10 +02:00

395 lines
15 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}
}
// 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, applies_to_target`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active,
trigger_event_label_de, trigger_event_label_en,
appeal_target,
role_proactive_label_de, role_proactive_label_en,
role_reactive_label_de, role_reactive_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
}