link-stack/docs/ticket-field-propagation-design.md
2025-12-19 11:37:20 +01:00

30 KiB

Ticket Field Propagation System

Overview

A configurable system for copying/syncing fields between related tickets (parent/child, merged, linked). This addresses the bridge channel preferences problem while providing a general-purpose solution for custom fields.

Problem Statement

Zammad creates relationships between tickets through:

  • Split: Creates child ticket from parent's article
  • Merge: Source ticket becomes child of target (merged state)
  • Manual linking: Agents can link tickets as parent/child or related

Currently, no field values are propagated across these relationships except basic attributes on split. This causes issues when:

  • Bridge channel metadata needs to follow the conversation
  • Custom fields (account ID, region, priority score) should be inherited
  • Parent ticket context should flow to children (or vice versa)

Use Cases

Use Case 1: Bridge Channel Inheritance (Immediate Need)

When a ticket is split or merged, the bridge channel metadata (preferences.cdr_whatsapp, preferences.cdr_signal) should be copied so agents can reply via the same channel.

Use Case 2: Custom Field Inheritance

Organization uses custom fields like account_tier, region, contract_id. When splitting a ticket, the child should inherit these values.

Use Case 3: Escalation Propagation

When a child ticket is escalated (custom escalation_level field), the parent should be updated to reflect this.

Use Case 4: SLA Context

Parent ticket has SLA deadline. Child tickets should inherit or reference this deadline.

Use Case 5: Bulk Operations

When updating a parent ticket's category, optionally cascade to all children.


Design

Terminology

Term Definition
Source The ticket providing the field value
Target The ticket receiving the field value
Direction Which way data flows (parent→child, child→parent, source→target on merge)
Trigger The event that initiates propagation (split, merge, update, link_create)
Condition When to apply the copy (always, if_empty, if_greater, custom)
Field Path Dot-notation path to the field (preferences.cdr_whatsapp.chat_id)

Field Types

The system must handle different field storage mechanisms:

# 1. Standard ticket attributes
ticket.group_id
ticket.priority_id
ticket.organization_id

# 2. Preferences hash (nested)
ticket.preferences['channel_id']
ticket.preferences['cdr_whatsapp']['chat_id']
ticket.preferences['cdr_signal']['group_joined']

# 3. Custom object attributes (Zammad ObjectManager)
ticket.custom_account_id      # Added via Admin → Objects → Ticket
ticket.custom_region
ticket.custom_escalation_level

# 4. Tags (special handling)
ticket.tag_list  # Array of strings

Configuration Schema

# Stored in Setting or dedicated table
TicketFieldPropagation.configure do |config|
  
  # Define field groups for convenience
  config.field_group :bridge_channel, [
    'preferences.channel_id',
    'preferences.cdr_whatsapp',      # Copies entire hash
    'preferences.cdr_signal',
    'preferences.cdr_voice'
  ]
  
  config.field_group :customer_context, [
    'organization_id',
    'custom_account_id',
    'custom_region',
    'custom_contract_id'
  ]
  
  config.field_group :sla_context, [
    'custom_sla_deadline',
    'custom_escalation_level'
  ]
  
  # Define propagation rules
  
  # Bridge preferences: copy to child on split if child doesn't have them
  config.rule :bridge_on_split do |r|
    r.fields        :bridge_channel
    r.trigger       :split
    r.direction     :parent_to_child
    r.condition     :if_target_empty
    r.timing        :immediate
  end
  
  # Bridge preferences: copy to target on merge if target doesn't have them
  config.rule :bridge_on_merge do |r|
    r.fields        :bridge_channel
    r.trigger       :merge
    r.direction     :source_to_target
    r.condition     :if_target_empty
    r.timing        :immediate
  end
  
  # Customer context: always copy to child on split
  config.rule :customer_context_on_split do |r|
    r.fields        :customer_context
    r.trigger       :split
    r.direction     :parent_to_child
    r.condition     :always
    r.timing        :immediate
  end
  
  # Escalation: propagate highest level to parent
  config.rule :escalation_to_parent do |r|
    r.fields        ['custom_escalation_level']
    r.trigger       :update
    r.direction     :child_to_parent
    r.condition     :if_greater
    r.timing        :deferred  # Use job queue
  end
  
  # Manual sync: allow agent to trigger full sync
  config.rule :manual_sync do |r|
    r.fields        [:customer_context, :sla_context]
    r.trigger       :manual
    r.direction     :parent_to_children  # All children
    r.condition     :always
    r.timing        :immediate
  end
end

Alternative: JSON Configuration

For storage in Zammad's Setting table:

{
  "field_groups": {
    "bridge_channel": [
      "preferences.channel_id",
      "preferences.cdr_whatsapp",
      "preferences.cdr_signal"
    ],
    "customer_context": [
      "organization_id",
      "custom_account_id",
      "custom_region"
    ]
  },
  "rules": [
    {
      "name": "bridge_on_split",
      "fields": ["@bridge_channel"],
      "trigger": "split",
      "direction": "parent_to_child",
      "condition": "if_target_empty",
      "enabled": true
    },
    {
      "name": "bridge_on_merge", 
      "fields": ["@bridge_channel"],
      "trigger": "merge",
      "direction": "source_to_target",
      "condition": "if_target_empty",
      "enabled": true
    },
    {
      "name": "customer_context_inherit",
      "fields": ["@customer_context"],
      "trigger": "split",
      "direction": "parent_to_child",
      "condition": "always",
      "enabled": true
    }
  ]
}

Architecture

Components

┌─────────────────────────────────────────────────────────────────┐
│                    TicketFieldPropagation                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────────┐  │
│  │ Configuration│    │    Engine    │    │   FieldAccessor  │  │
│  │              │───▶│              │───▶│                  │  │
│  │ - field_groups    │ - execute()  │    │ - get(path)      │  │
│  │ - rules      │    │ - apply_rule │    │ - set(path, val) │  │
│  │ - load/save  │    │ - find_related    │ - deep_merge     │  │
│  └──────────────┘    └──────────────┘    └──────────────────┘  │
│                             │                                   │
│                             ▼                                   │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    RelationshipFinder                     │  │
│  │                                                           │  │
│  │  - find_parent(ticket)                                    │  │
│  │  - find_children(ticket)                                  │  │
│  │  - find_merge_target(ticket)                              │  │
│  │  - find_merge_source(ticket)                              │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                         Triggers                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌────────────────┐  ┌────────────────┐  ┌─────────────────┐   │
│  │ Ticket Concern │  │ Transaction    │  │ Manual API      │   │
│  │                │  │ Observer       │  │ Endpoint        │   │
│  │ after_create   │  │                │  │                 │   │
│  │ after_update   │  │ on merge event │  │ POST /tickets/  │   │
│  │ after_save     │  │ on split event │  │   :id/propagate │   │
│  └────────────────┘  └────────────────┘  └─────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Class Design

# lib/ticket_field_propagation/configuration.rb
module TicketFieldPropagation
  class Configuration
    attr_accessor :field_groups, :rules
    
    def self.load
      # Load from Setting table or YAML
    end
    
    def field_group(name, fields)
      @field_groups[name] = fields
    end
    
    def rule(name, &block)
      rule = Rule.new(name)
      block.call(rule)
      @rules << rule
    end
    
    def expand_fields(field_refs)
      # Expand @group_name references to actual field list
      field_refs.flat_map do |ref|
        if ref.start_with?('@')
          @field_groups[ref[1..].to_sym] || []
        else
          [ref]
        end
      end
    end
  end
  
  class Rule
    attr_accessor :name, :fields, :trigger, :direction, :condition, :timing
    
    def initialize(name)
      @name = name
      @timing = :immediate
      @condition = :always
    end
    
    def applies_to?(event_type)
      @trigger == event_type || (@trigger.is_a?(Array) && @trigger.include?(event_type))
    end
  end
end
# lib/ticket_field_propagation/engine.rb
module TicketFieldPropagation
  class Engine
    def initialize(source_ticket, event_type, target_ticket: nil)
      @source = source_ticket
      @event = event_type
      @explicit_target = target_ticket
      @config = Configuration.load
    end
    
    def execute
      applicable_rules.each do |rule|
        if rule.timing == :deferred
          PropagationJob.perform_later(@source.id, rule.name)
        else
          apply_rule(rule)
        end
      end
    end
    
    private
    
    def applicable_rules
      @config.rules.select { |r| r.applies_to?(@event) && r.enabled }
    end
    
    def apply_rule(rule)
      targets = find_targets(rule.direction)
      fields = @config.expand_fields(rule.fields)
      
      targets.each do |target|
        source = determine_source(rule.direction, target)
        PropagationResult.log(@source, target, rule)
        
        fields.each do |field_path|
          copy_field(source, target, field_path, rule.condition)
        end
        
        target.save! if target.changed?
      end
    end
    
    def find_targets(direction)
      case direction
      when :parent_to_child, :parent_to_children
        RelationshipFinder.find_children(@source)
      when :child_to_parent
        [RelationshipFinder.find_parent(@source)].compact
      when :source_to_target
        [@explicit_target || RelationshipFinder.find_merge_target(@source)].compact
      else
        []
      end
    end
    
    def determine_source(direction, target)
      case direction
      when :parent_to_child, :parent_to_children, :source_to_target
        @source
      when :child_to_parent
        target  # We're copying FROM child TO parent, so target is source here
        # Wait, this is confusing. Let me reconsider...
        @source  # The ticket that triggered the event is the source
      end
    end
    
    def copy_field(source, target, field_path, condition)
      source_value = FieldAccessor.get(source, field_path)
      return if source_value.nil?
      
      target_value = FieldAccessor.get(target, field_path)
      
      case condition
      when :if_target_empty
        return if target_value.present?
      when :if_greater
        return if target_value.present? && target_value >= source_value
      when :always
        # proceed
      end
      
      FieldAccessor.set(target, field_path, source_value)
    end
  end
end
# lib/ticket_field_propagation/field_accessor.rb
module TicketFieldPropagation
  class FieldAccessor
    class << self
      def get(ticket, field_path)
        parts = field_path.split('.')
        
        value = ticket
        parts.each do |part|
          value = access_part(value, part)
          return nil if value.nil?
        end
        
        # Deep dup hashes to prevent mutation
        value.is_a?(Hash) ? value.deep_dup : value
      end
      
      def set(ticket, field_path, value)
        parts = field_path.split('.')
        
        if parts.length == 1
          # Direct attribute
          set_attribute(ticket, parts[0], value)
        else
          # Nested in preferences or similar
          set_nested(ticket, parts, value)
        end
      end
      
      private
      
      def access_part(object, part)
        if object.is_a?(Hash)
          object[part] || object[part.to_sym]
        elsif object.respond_to?(part)
          object.send(part)
        elsif object.respond_to?(:[])
          object[part]
        else
          nil
        end
      end
      
      def set_attribute(ticket, attr_name, value)
        if ticket.respond_to?("#{attr_name}=")
          ticket.send("#{attr_name}=", value)
        else
          raise ArgumentError, "Unknown attribute: #{attr_name}"
        end
      end
      
      def set_nested(ticket, parts, value)
        # e.g., ['preferences', 'cdr_whatsapp']
        root = parts[0]
        
        if root == 'preferences'
          ticket.preferences ||= {}
          set_hash_path(ticket.preferences, parts[1..], value)
        else
          raise ArgumentError, "Unsupported nested path root: #{root}"
        end
      end
      
      def set_hash_path(hash, remaining_parts, value)
        if remaining_parts.length == 1
          key = remaining_parts[0]
          if value.is_a?(Hash) && hash[key].is_a?(Hash)
            # Deep merge for hash values
            hash[key] = hash[key].deep_merge(value)
          else
            hash[key] = value
          end
        else
          key = remaining_parts[0]
          hash[key] ||= {}
          set_hash_path(hash[key], remaining_parts[1..], value)
        end
      end
    end
  end
end
# lib/ticket_field_propagation/relationship_finder.rb
module TicketFieldPropagation
  class RelationshipFinder
    class << self
      def find_parent(ticket)
        # In Zammad links: parent ticket has link_type 'child' pointing to it
        # Wait, need to verify Zammad's link semantics...
        # 
        # From merge: source ticket gets a 'parent' link pointing TO target
        # Link.add(link_type: 'parent', source: target_id, target: source_id)
        #
        # So to find parent of a ticket, look for 'parent' links where
        # this ticket is the target (link_object_target_value)
        
        links = Link.list(
          link_object: 'Ticket',
          link_object_value: ticket.id
        )
        
        parent_link = links.find { |l| l['link_type'] == 'parent' }
        return nil unless parent_link
        
        Ticket.find_by(id: parent_link['link_object_value'])
      end
      
      def find_children(ticket)
        links = Link.list(
          link_object: 'Ticket',
          link_object_value: ticket.id
        )
        
        child_links = links.select { |l| l['link_type'] == 'child' }
        child_ids = child_links.map { |l| l['link_object_value'] }
        
        Ticket.where(id: child_ids).to_a
      end
      
      def find_merge_target(ticket)
        # Merged ticket has 'parent' link to target
        return nil unless ticket.state.state_type.name == 'merged'
        find_parent(ticket)
      end
      
      def find_merge_sources(ticket)
        # Find tickets that were merged into this one
        links = Link.list(
          link_object: 'Ticket',
          link_object_value: ticket.id
        )
        
        # Look for child links where the child is in merged state
        child_links = links.select { |l| l['link_type'] == 'child' }
        
        child_links.filter_map do |link|
          child = Ticket.find_by(id: link['link_object_value'])
          child if child&.state&.state_type&.name == 'merged'
        end
      end
    end
  end
end

Integration Points

1. Ticket Concern (for create/update triggers)

# app/models/ticket/field_propagation.rb
module Ticket::FieldPropagation
  extend ActiveSupport::Concern
  
  included do
    after_create :trigger_propagation_on_create
    after_update :trigger_propagation_on_update
  end
  
  private
  
  def trigger_propagation_on_create
    # Check if this is a split (has parent link created simultaneously)
    # This is tricky because link might be created after ticket...
    # May need to hook into Link.add instead
  end
  
  def trigger_propagation_on_update
    return unless saved_change_to_attribute?(:state_id)
    
    if state.state_type.name == 'merged'
      TicketFieldPropagation::Engine.new(self, :merge).execute
    end
  end
end

2. Transaction Observer (for merge/split events)

# app/models/transaction/ticket_field_propagation.rb
class Transaction::TicketFieldPropagation
  def self.execute(object, type, _changes, user_id, _options)
    return unless object.is_a?(Ticket)
    
    case type
    when 'update.merged_into'
      # Source ticket was merged - propagate to target
      target = TicketFieldPropagation::RelationshipFinder.find_merge_target(object)
      TicketFieldPropagation::Engine.new(object, :merge, target_ticket: target).execute
      
    when 'update.received_merge'
      # Target ticket received a merge - could trigger reverse propagation if needed
      
    when 'create'
      # Check if this is from a split (check for immediate parent link)
      parent = TicketFieldPropagation::RelationshipFinder.find_parent(object)
      if parent.present?
        TicketFieldPropagation::Engine.new(parent, :split, target_ticket: object).execute
      end
    end
  end
end

3. Manual API Endpoint

# app/controllers/ticket_field_propagation_controller.rb
class TicketFieldPropagationController < ApplicationController
  before_action :authenticate_and_authorize
  
  # POST /api/v1/tickets/:id/propagate
  def propagate
    ticket = Ticket.find(params[:id])
    direction = params[:direction] || 'to_children'
    fields = params[:fields] || 'all'
    
    case direction
    when 'to_children'
      engine = TicketFieldPropagation::Engine.new(ticket, :manual)
      engine.execute_for_fields(fields, direction: :parent_to_children)
    when 'from_parent'
      parent = TicketFieldPropagation::RelationshipFinder.find_parent(ticket)
      return render json: { error: 'No parent ticket' }, status: :not_found unless parent
      
      engine = TicketFieldPropagation::Engine.new(parent, :manual, target_ticket: ticket)
      engine.execute_for_fields(fields, direction: :parent_to_child)
    end
    
    render json: { success: true }
  end
  
  # GET /api/v1/tickets/:id/propagation_preview
  def preview
    # Show what would be copied without doing it
  end
end

Handling Edge Cases

1. Circular Reference Prevention

class Engine
  MAX_DEPTH = 5
  
  def execute(depth: 0)
    return if depth >= MAX_DEPTH
    
    # Track processed tickets in this chain
    Thread.current[:propagation_chain] ||= Set.new
    return if Thread.current[:propagation_chain].include?(@source.id)
    
    Thread.current[:propagation_chain].add(@source.id)
    
    begin
      # ... execute rules
    ensure
      Thread.current[:propagation_chain].delete(@source.id)
    end
  end
end

2. Conflicting Values on Merge

When both source and target have values, the default is "don't overwrite" (if_target_empty). But we could support strategies:

config.rule :merge_preferences do |r|
  r.fields      ['preferences.cdr_whatsapp']
  r.trigger     :merge
  r.direction   :source_to_target
  r.condition   :merge_hash  # Deep merge instead of replace
end

Or with explicit conflict resolution:

r.on_conflict do |source_val, target_val, field|
  case field
  when /escalation/
    [source_val, target_val].max
  when /preferences\.cdr_/
    target_val.presence || source_val  # Keep target if present
  else
    source_val  # Default: source wins
  end
end

3. Multiple Bridge Channels

If source has WhatsApp and target has Signal, we might want both:

# Current behavior with if_target_empty:
# - Source: {cdr_whatsapp: {...}}
# - Target: {cdr_signal: {...}}
# - Result: Target keeps cdr_signal, gains cdr_whatsapp (both present)

# This works because we check per-field, not per-category

4. Signal Group Merge Validation

Special case: don't allow merging tickets from different Signal groups:

config.rule :validate_signal_merge do |r|
  r.trigger    :merge
  r.validator  ->(source, target) {
    source_group = source.preferences.dig('cdr_signal', 'chat_id')
    target_group = target.preferences.dig('cdr_signal', 'chat_id')
    
    # OK if either doesn't have signal, or same group
    return true if source_group.blank? || target_group.blank?
    return true if source_group == target_group
    
    # Different groups - block the merge
    raise Exceptions::UnprocessableEntity,
      "Cannot merge tickets from different Signal groups"
  }
end

Audit Trail

Track what was propagated for debugging and transparency:

# app/models/ticket_field_propagation_log.rb
class TicketFieldPropagationLog < ApplicationRecord
  belongs_to :source_ticket, class_name: 'Ticket'
  belongs_to :target_ticket, class_name: 'Ticket'
  
  # Columns:
  # - source_ticket_id
  # - target_ticket_id
  # - rule_name
  # - field_path
  # - old_value (serialized)
  # - new_value (serialized)
  # - trigger_event
  # - created_by_id
  # - created_at
end

Or simpler: add to ticket history:

target_ticket.history_log(
  'field_propagated',
  UserInfo.current_user_id,
  value_from: source_ticket.id,
  value_to: { field: field_path, value: new_value.to_s.truncate(100) }
)

Configuration UI (Future)

Admin interface at Settings → Ticket → Field Propagation:

┌─────────────────────────────────────────────────────────────────┐
│ Field Propagation Rules                                    [+Add]│
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [✓] Bridge Channel on Split                          [Edit] │ │
│ │     Copy: preferences.cdr_whatsapp, preferences.cdr_signal  │ │
│ │     When: Ticket is split                                   │ │
│ │     Direction: Parent → Child                               │ │
│ │     Condition: Only if child field is empty                 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│                                                                 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [✓] Bridge Channel on Merge                          [Edit] │ │
│ │     Copy: preferences.cdr_whatsapp, preferences.cdr_signal  │ │
│ │     When: Ticket is merged                                  │ │
│ │     Direction: Source → Target                              │ │
│ │     Condition: Only if target field is empty                │ │
│ └─────────────────────────────────────────────────────────────┘ │
│                                                                 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [ ] Customer Context Inheritance                     [Edit] │ │
│ │     Copy: organization_id, custom_account_id, custom_region │ │
│ │     When: Ticket is split                                   │ │
│ │     Direction: Parent → Child                               │ │
│ │     Condition: Always                                       │ │
│ └─────────────────────────────────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Default Configuration

Out-of-the-box settings that solve the bridge preferences problem:

{
  "field_groups": {
    "bridge_channel": [
      "preferences.channel_id",
      "preferences.cdr_whatsapp",
      "preferences.cdr_signal",
      "preferences.cdr_voice"
    ]
  },
  "rules": [
    {
      "name": "bridge_on_split",
      "description": "Copy bridge channel info when splitting tickets",
      "fields": ["@bridge_channel"],
      "trigger": "split",
      "direction": "parent_to_child",
      "condition": "if_target_empty",
      "enabled": true
    },
    {
      "name": "bridge_on_merge",
      "description": "Copy bridge channel info when merging tickets",
      "fields": ["@bridge_channel"],
      "trigger": "merge",
      "direction": "source_to_target",
      "condition": "if_target_empty",
      "enabled": true
    }
  ]
}

Implementation Phases

Phase 1: Core Engine (Solves Bridge Problem)

  • FieldAccessor with dot-notation support
  • RelationshipFinder for parent/child/merge relationships
  • Engine with basic rule processing
  • Hardcoded rules for bridge channel propagation
  • Integration with Ticket merge (via concern or observer)

Phase 2: Configuration System

  • JSON configuration in Setting table
  • Field groups support
  • Multiple condition types (if_empty, always, if_greater)
  • Deferred execution via jobs

Phase 3: Split Integration

  • Hook into ticket split workflow
  • Detect parent relationship after split
  • Apply split rules

Phase 4: Manual Triggers

  • API endpoint for manual propagation
  • Preview endpoint
  • Audit logging

Phase 5: Admin UI

  • Configuration interface in Zammad admin
  • Visual rule builder
  • Field picker for custom object attributes

Phase 6: Advanced Features

  • Bidirectional sync
  • Conflict resolution strategies
  • Cascading updates
  • Validation rules (like Signal group merge prevention)

Files to Create

packages/zammad-addon-bridge/src/
├── lib/
│   └── ticket_field_propagation/
│       ├── configuration.rb
│       ├── engine.rb
│       ├── field_accessor.rb
│       ├── relationship_finder.rb
│       └── propagation_job.rb
├── app/
│   ├── models/
│   │   └── ticket/
│   │       └── field_propagation.rb    # Concern
│   └── controllers/
│       └── ticket_field_propagation_controller.rb
├── config/
│   └── initializers/
│       └── ticket_field_propagation.rb # Default config & include concern
└── db/
    └── seeds/
        └── field_propagation_settings.rb

Relationship to Bridge Preferences Problem

The bridge preferences problem from the previous investigation is solved by:

  1. Default rule bridge_on_merge: Copies preferences.cdr_whatsapp and preferences.cdr_signal from source to target when tickets are merged, if target doesn't already have them.

  2. Default rule bridge_on_split: Copies the same preferences from parent to child when tickets are split.

  3. Extensibility: Additional custom fields can be added to propagation rules without code changes.

This makes the field propagation system a superset solution that handles the immediate bridge problem while providing a framework for future field synchronization needs.