package workflow import ( "encoding/json" "fmt" "strings" "switchboard-core/models" ) // ── Conditional Routing Engine (v0.35.0) ──── // // Evaluates transition_rules.conditions[] against accumulated stage_data // to determine which stage to advance to. Falls back to ordinal + 1 // when no conditions are defined or none match. // Condition is a single routing condition within transition_rules. type Condition struct { Field string `json:"field"` Op string `json:"op"` Value any `json:"value"` TargetStage string `json:"target_stage"` // stage name or ordinal as string } // TransitionRulesWithConditions extends the existing transition_rules JSON. type TransitionRulesWithConditions struct { AutoAssign string `json:"auto_assign,omitempty"` Conditions []Condition `json:"conditions,omitempty"` } // ResolveNextStage evaluates conditions from the current stage's transition_rules // against accumulated stageData. Returns the target stage ordinal. // Falls back to currentStage + 1 if no conditions match or none are defined. func ResolveNextStage(stages []models.WorkflowStage, currentStage int, stageData json.RawMessage) (int, error) { if currentStage < 0 || currentStage >= len(stages) { return currentStage + 1, nil } stage := stages[currentStage] var rules TransitionRulesWithConditions if len(stage.TransitionRules) > 0 { _ = json.Unmarshal(stage.TransitionRules, &rules) } if len(rules.Conditions) == 0 { return currentStage + 1, nil } var data map[string]any if len(stageData) > 0 { _ = json.Unmarshal(stageData, &data) } if data == nil { data = map[string]any{} } for _, cond := range rules.Conditions { if evaluateCondition(cond, data) { target, err := resolveTarget(stages, cond.TargetStage) if err != nil { return 0, fmt.Errorf("condition matched but target invalid: %w", err) } return target, nil } } return currentStage + 1, nil } // ResolveStageByName finds a stage ordinal by name (case-insensitive). func ResolveStageByName(stages []models.WorkflowStage, name string) (int, error) { lower := strings.ToLower(strings.TrimSpace(name)) for _, s := range stages { if strings.ToLower(s.Name) == lower { return s.Ordinal, nil } } return 0, fmt.Errorf("stage %q not found", name) } // resolveTarget resolves a target_stage string to an ordinal. // Accepts either a stage name or a numeric ordinal string. func resolveTarget(stages []models.WorkflowStage, target string) (int, error) { // Try as stage name first ordinal, err := ResolveStageByName(stages, target) if err == nil { return ordinal, nil } // Try as numeric ordinal var n int if _, err := fmt.Sscanf(target, "%d", &n); err == nil { if n >= 0 && n < len(stages) { return n, nil } return 0, fmt.Errorf("ordinal %d out of range (0..%d)", n, len(stages)-1) } return 0, fmt.Errorf("cannot resolve target stage %q", target) } // evaluateCondition checks if a single condition matches against data. func evaluateCondition(cond Condition, data map[string]any) bool { val, exists := data[cond.Field] switch cond.Op { case "exists": return exists case "not_exists": return !exists } if !exists { return false } switch cond.Op { case "eq": return compareEq(val, cond.Value) case "neq": return !compareEq(val, cond.Value) case "gt": return compareNum(val, cond.Value) > 0 case "lt": return compareNum(val, cond.Value) < 0 case "gte": return compareNum(val, cond.Value) >= 0 case "lte": return compareNum(val, cond.Value) <= 0 case "in": return compareIn(val, cond.Value) case "contains": return compareContains(val, cond.Value) default: return false } } // compareEq does loose equality: normalizes both sides to string for comparison. func compareEq(a, b any) bool { return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) } // compareNum extracts float64 from both sides and returns -1, 0, or 1. // Returns 0 if either side is not numeric. func compareNum(a, b any) int { af := toFloat(a) bf := toFloat(b) if af == nil || bf == nil { return 0 } switch { case *af < *bf: return -1 case *af > *bf: return 1 default: return 0 } } func toFloat(v any) *float64 { switch n := v.(type) { case float64: return &n case int: f := float64(n) return &f case json.Number: if f, err := n.Float64(); err == nil { return &f } case string: var f float64 if _, err := fmt.Sscanf(n, "%f", &f); err == nil { return &f } } return nil } // compareIn checks if val is in the list (cond.Value must be []any). func compareIn(val, list any) bool { arr, ok := list.([]any) if !ok { return false } vs := fmt.Sprintf("%v", val) for _, item := range arr { if fmt.Sprintf("%v", item) == vs { return true } } return false } // compareContains checks if val (as string) contains the target substring. func compareContains(val, target any) bool { s := fmt.Sprintf("%v", val) t := fmt.Sprintf("%v", target) return strings.Contains(s, t) }