# 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: ```ruby # 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 ```ruby # 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: ```json { "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 ```ruby # 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 ``` ```ruby # 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 ``` ```ruby # 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 ``` ```ruby # 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) ```ruby # 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) ```ruby # 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 ```ruby # 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 ```ruby 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: ```ruby 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: ```ruby 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: ```ruby # 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: ```ruby 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: ```ruby # 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: ```ruby 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: ```json { "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.