link-stack/docs/ticket-field-propagation-design.md

907 lines
30 KiB
Markdown
Raw Permalink Normal View History

2025-12-17 14:53:13 +01:00
# 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.