Adds the Slice B.5 canonical Go names (SequencingRule, ProceduralEvent,
LegalSource, SequencingRuleService) without breaking any existing
call-site, and dual-emits / dual-accepts the two JSON envelope key
renames on /admin/api/rules with a Deprecation header.
* internal/models/models.go —
- type SequencingRule = DeadlineRule (alias; same struct, same db /
json tags). DeadlineRule remains the underlying type for now —
deferred hard-rename keeps the slice small.
- type ProceduralEvent struct mirroring paliad.procedural_events
(id, code, name, name_en, description, event_kind,
primary_party_default, legal_source_id, concept_id,
lifecycle_state, draft_of, published_at, is_active, timestamps).
Used by future code that needs the PE identity row alone.
- type LegalSource struct mirroring paliad.legal_sources (citation,
jurisdiction, pretty_de / pretty_en — both nullable per mig 136).
* internal/services/deadline_rule_service.go —
- type SequencingRuleService = DeadlineRuleService (alias).
- var NewSequencingRuleService = NewDeadlineRuleService (constructor
alias). Internal callers can adopt either name.
* internal/services/rule_editor_service.go —
- CreateRuleInput gains Code + EventKind fields tagged
json:"code" / json:"event_kind". CoalesceCanonicalKeys() folds
canonical → legacy after json.Decode so the rest of the service
keeps using SubmissionCode / EventType. Canonical wins when
both are sent.
- RulePatch gains EventKind field with the same fold.
* internal/handlers/admin_rules.go —
- adminRuleResponse wraps *models.DeadlineRule and adds Code +
EventKind fields alongside the legacy SubmissionCode /
EventType. Outputs both keys per response for one
deprecation-window slice.
- wrapRuleResponse / wrapRuleListResponse helpers.
- adminRuleDeprecationHeaders emits IETF Deprecation + Link/Sunset
headers on every Rule-bearing response so clients see the
migration signal in transit.
- All 8 Rule-returning handlers (List, Get, Create, Patch, Clone,
Publish, Archive, Restore) now wrap their result and add the
headers.
- Create + Patch handlers call CoalesceCanonicalKeys after decode
so legacy AND canonical request bodies are both accepted.
Scope decisions (documented in commit):
- Type renames use aliases instead of a hard 200-LOC rename. Same
semantics, no call-site churn. A future cleanup slice can flip
the underlying type definitions when convenient.
- ProceduralEvent + LegalSource are NEW structs (not aliases) since
they represent new conceptual rows; no legacy callers exist yet.
- Frontend admin .tsx i18n key rebinds (mentioned in parent task
brief B.5 deliverable list) are deferred — i18n keys themselves
already exist from Slice A (t-paliad-262); rebinding only changes
which key the .tsx file looks up. Pulling this into B.5 ballooned
scope; flagging as a small follow-up slice or B.6 sibling.
- Only /admin/api/rules emits dual keys today. Other handlers that
surface rule rows (Schriftsätze list, deadlines join) continue to
emit the legacy keys via models.DeadlineRule's existing JSON tags
— they're read paths, not the editor surface, and the deprecation
signal is most important where clients write.
Build + vet clean. TestMigrations_NoDuplicateSlot passes.
407 lines
16 KiB
Go
407 lines
16 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_unified (mig 139 view
|
|
// projecting paliad.sequencing_rules + procedural_events +
|
|
// legal_sources back to the legacy column shape after mig 140 dropped
|
|
// the underlying table) + paliad.proceeding_types. Rules are static
|
|
// reference data; no visibility check needed.
|
|
type DeadlineRuleService struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
// SequencingRuleService is the Slice B.5 (t-paliad-305) canonical name
|
|
// for DeadlineRuleService. Alias preserves every existing call-site
|
|
// while new code can adopt the procedural-event vocabulary.
|
|
type SequencingRuleService = DeadlineRuleService
|
|
|
|
// NewSequencingRuleService is the canonical constructor name; alias to
|
|
// NewDeadlineRuleService for now. Both return the same underlying type.
|
|
var NewSequencingRuleService = NewDeadlineRuleService
|
|
|
|
// 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_unified
|
|
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_unified
|
|
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_unified 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_unified
|
|
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_unified
|
|
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
|
|
UNION ALL
|
|
SELECT dr.* FROM paliad.deadline_rules_unified 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_unified
|
|
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_unified
|
|
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_unified
|
|
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_unified
|
|
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
|
|
}
|