16 KiB
Zammad Ticket Splits & Merges with Bridge Channels
Investigation Summary
This document analyzes how Zammad handles ticket splits and merges, and the implications for our custom bridge channels (WhatsApp, Signal, Voice).
Current State
How Zammad Handles Split/Merge (Built-in)
Merge (ticket.merge_to in app/models/ticket.rb:330)
When ticket A is merged into ticket B:
- All articles from A are moved to B
- A "parent" link is created between A and B
- Mentions and external links are migrated
- Source ticket A's state is set to "merged"
- Source ticket A's owner is reset to System (id: 1)
Critical issue: Ticket preferences are NOT copied or migrated. The target ticket B keeps its original preferences, and source ticket A's preferences become orphaned.
# From app/models/ticket.rb - merge_to method
# Articles are moved:
Ticket::Article.where(ticket_id: id).update_all(['ticket_id = ?', data[:ticket_id]])
# But preferences are never touched - they stay on the source ticket
Split (app/models/form_updater/concerns/applies_split_ticket_article.rb)
When an article is split from ticket A to create new ticket C:
- Basic ticket attributes are copied (group, customer, state, priority, title)
- Attachments are cloned
- A link is created to the original ticket
owner_idis explicitly deleted (not copied)
Critical issue: Preferences are NOT copied. The new ticket C has no channel metadata.
# From applies_split_ticket_article.rb
def attributes_to_apply
attrs = selected_ticket_article.ticket.attributes
attrs['title'] = selected_ticket_article.subject if selected_ticket_article.subject.present?
attrs['body'] = body_with_form_id_urls
attrs.delete 'owner_id' # Explicitly deleted
attrs
# Note: preferences are NOT included in .attributes
end
Email Follow-up Handling (app/models/channel/filter/follow_up_merged.rb)
Zammad has a postmaster filter that handles incoming emails to merged tickets:
def self.run(_channel, mail, _transaction_params)
return if mail[:'x-zammad-ticket-id'].blank?
referenced_ticket = Ticket.find_by(id: mail[:'x-zammad-ticket-id'])
return if referenced_ticket.blank?
new_target_ticket = find_merge_follow_up_ticket(referenced_ticket)
return if new_target_ticket.blank?
mail[:'x-zammad-ticket-id'] = new_target_ticket.id
end
This follows the parent link to find the active target ticket. This only works for email - no equivalent exists for other channels like Telegram, WhatsApp, or Signal.
Bridge Channel Metadata Structure
Our bridge channels store critical routing metadata in ticket.preferences:
ticket.preferences = {
channel_id: 123,
cdr_whatsapp: {
bot_token: "abc123", # Identifies which bot/channel
chat_id: "+1234567890" # Customer's phone number - WHERE TO SEND
}
}
Signal (Direct Message)
ticket.preferences = {
channel_id: 456,
cdr_signal: {
bot_token: "xyz789",
chat_id: "+1234567890" # Customer's phone number
}
}
Signal (Group)
ticket.preferences = {
channel_id: 456,
cdr_signal: {
bot_token: "xyz789",
chat_id: "group.abc123...", # Signal group ID
group_joined: true, # Whether customer accepted invite
group_joined_at: "2024-01-01",
original_recipient: "+1234567890"
}
}
How Bridge Channels Use This Metadata
Outgoing Messages
The communication jobs (CommunicateCdrWhatsappJob, CommunicateCdrSignalJob) rely entirely on ticket preferences:
# From communicate_cdr_whatsapp_job.rb
def perform(article_id)
article = Ticket::Article.find(article_id)
ticket = Ticket.lookup(id: article.ticket_id)
# These MUST exist or the job fails:
unless ticket.preferences['cdr_whatsapp']['bot_token']
log_error(article, "Can't find ticket.preferences['cdr_whatsapp']['bot_token']")
end
unless ticket.preferences['cdr_whatsapp']['chat_id']
log_error(article, "Can't find ticket.preferences['cdr_whatsapp']['chat_id']")
end
channel = Channel.lookup(id: ticket.preferences['channel_id'])
result = channel.deliver(article) # Uses chat_id to know where to send
end
Incoming Messages
The webhook controllers look up existing tickets:
WhatsApp (channels_cdr_whatsapp_controller.rb):
# Find open ticket for this customer
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
ticket = Ticket.where(customer_id: customer.id)
.where.not(state_id: state_ids)
.order(:updated_at).first
Signal Groups (channels_cdr_signal_controller.rb):
# Find ticket by group ID in preferences
ticket = Ticket.where.not(state_id: state_ids)
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
.order(updated_at: :desc)
.first
Problem Scenarios
Scenario 1: Merge Bridge Ticket → Non-Bridge Ticket
Setup: Ticket A (has WhatsApp metadata) merged into Ticket B (no bridge metadata)
What happens:
- A's articles move to B
- A's preferences stay on A (now in merged state)
- B still has no bridge preferences
Result: Agent replies on ticket B fail - no chat_id to send to.
Scenario 2: Merge Bridge Ticket → Different Bridge Ticket
Setup: Ticket A (WhatsApp to +111) merged into Ticket B (WhatsApp to +222)
What happens:
- A's articles move to B
- B keeps its preferences (
chat_id: +222)
Result: Agent replies go to +222, not to +111. Customer +111 never receives responses.
Scenario 3: Split Article from Bridge Ticket
Setup: Split an article from Ticket A (has WhatsApp metadata) to create Ticket C
What happens:
- New ticket C is created with no preferences
- C is linked to A
Result: Agent cannot reply via WhatsApp on ticket C at all - job fails immediately.
Scenario 4: Incoming Message to Merged Ticket's Customer
Setup: Ticket A (customer +111) was merged into B. Customer +111 sends new message.
What happens:
- Webhook finds customer by phone number
- Looks for open ticket for customer
- A is excluded (merged state)
- Either finds B (if same customer) or creates new ticket
Result: May work if B has same customer, but conversation context is fragmented.
Scenario 5: Signal Group Ticket Merged
Setup: Ticket A (Signal group X) merged into Ticket B (no Signal metadata)
What happens:
- All group messages went to A
- A is now merged, B has no group reference
- New messages from group X create a new ticket (can't find existing by group ID)
Result: Conversation splits into multiple tickets unexpectedly.
Recommended Solutions
Option 1: Preferences Migration on Merge (Recommended)
Create a concern that copies bridge channel metadata when tickets are merged:
# app/models/ticket/merge_bridge_channel_preferences.rb
module Ticket::MergeBridgeChannelPreferences
extend ActiveSupport::Concern
included do
after_update :migrate_bridge_preferences_on_merge
end
private
def migrate_bridge_preferences_on_merge
return unless saved_change_to_state_id?
return unless state.state_type.name == 'merged'
target_ticket = find_merge_target
return unless target_ticket
# Copy bridge preferences if target doesn't have them
%w[cdr_whatsapp cdr_signal cdr_voice].each do |channel_key|
next unless preferences[channel_key].present?
next if target_ticket.preferences[channel_key].present?
target_ticket.preferences[channel_key] = preferences[channel_key].deep_dup
target_ticket.preferences['channel_id'] ||= preferences['channel_id']
end
target_ticket.save! if target_ticket.changed?
end
def find_merge_target
Link.list(link_object: 'Ticket', link_object_value: id)
.find { |l| l['link_type'] == 'parent' && l['link_object'] == 'Ticket' }
&.then { |l| Ticket.find_by(id: l['link_object_value']) }
end
end
Pros:
- Handles the common case (merging bridge ticket into non-bridge ticket)
- Automatic, no agent action required
- Non-destructive (doesn't overwrite existing preferences)
Cons:
- Doesn't handle case where both tickets have different bridge metadata
- May need additional logic for conflicting preferences
Option 2: Follow-up Filter for Bridge Channels
Create filters similar to FollowUpMerged that redirect incoming bridge messages:
# Modify webhook controllers to check for merged tickets
def find_active_ticket_for_customer(customer, state_ids)
ticket = Ticket.where(customer_id: customer.id)
.where.not(state_id: state_ids)
.order(:updated_at).first
# If ticket is merged, follow parent link
if ticket&.state&.state_type&.name == 'merged'
ticket = find_merge_target(ticket) || ticket
end
ticket
end
def find_merge_target(ticket)
Link.list(link_object: 'Ticket', link_object_value: ticket.id)
.filter_map do |link|
next if link['link_type'] != 'parent'
next if link['link_object'] != 'Ticket'
Ticket.joins(state: :state_type)
.where.not(ticket_state_types: { name: 'merged' })
.find_by(id: link['link_object_value'])
end.first
end
Pros:
- Handles incoming messages to merged tickets correctly
- Follows same pattern as Zammad's email handling
Cons:
- Requires modifying webhook controllers
- Only handles incoming direction, not outgoing
Option 3: Copy Preferences on Split
Modify the split form updater or add a callback to copy bridge preferences:
# Add to ticket creation from split
module Ticket::SplitBridgeChannelPreferences
extend ActiveSupport::Concern
included do
after_create :copy_bridge_preferences_from_source
end
private
def copy_bridge_preferences_from_source
# Find source ticket via link
source_link = Link.list(link_object: 'Ticket', link_object_value: id)
.find { |l| l['link_type'] == 'child' }
return unless source_link
source_ticket = Ticket.find_by(id: source_link['link_object_value'])
return unless source_ticket
# Copy bridge preferences
%w[cdr_whatsapp cdr_signal cdr_voice channel_id].each do |key|
next unless source_ticket.preferences[key].present?
self.preferences[key] = source_ticket.preferences[key].deep_dup
end
save! if changed?
end
end
Option 4: UI Warning + Manual Handling
Add frontend validation to warn agents:
- Check for bridge preferences before merge/split
- Show warning dialog explaining implications
- Optionally provide UI to manually transfer channel association
// In merge confirmation dialog
const hasBridgeChannel = ticket.preferences?.cdr_whatsapp ||
ticket.preferences?.cdr_signal;
if (hasBridgeChannel) {
showWarning(
"This ticket uses WhatsApp/Signal messaging. " +
"Merging may affect message routing. " +
"Replies will be sent to the target ticket's contact."
);
}
Option 5: Multi-Channel Preferences (Long-term)
Allow tickets to have multiple channel associations:
ticket.preferences = {
bridge_channels: [
{ type: 'cdr_whatsapp', chat_id: '+111...', channel_id: 1, customer_id: 100 },
{ type: 'cdr_whatsapp', chat_id: '+222...', channel_id: 1, customer_id: 101 },
{ type: 'cdr_signal', chat_id: 'group.xxx', channel_id: 2 }
]
}
This would require significant refactoring of communication jobs to handle multiple recipients.
Signal Groups - Special Considerations
Signal groups add complexity:
- Group ID is the routing key, not phone number
- Multiple customers might be in the same group
group_joinedflag tracks invite acceptance - messages can't be sent until true- Group membership changes could affect ticket routing
Merge Rules for Signal Groups
| Source Ticket | Target Ticket | Recommendation |
|---|---|---|
| Signal group A | No Signal | Copy preferences (Option 1) |
| Signal group A | Signal group A (same) | Safe to merge |
| Signal group A | Signal group B (different) | Block or warn - can't merge different group conversations |
| Signal group A | Signal DM | Block or warn - different communication modes |
Consider adding validation:
def validate_signal_group_merge(source, target)
source_group = source.preferences.dig('cdr_signal', 'chat_id')
target_group = target.preferences.dig('cdr_signal', 'chat_id')
return true if source_group.blank? || target_group.blank?
return true if source_group == target_group
# Different groups - this is problematic
raise Exceptions::UnprocessableEntity,
"Cannot merge tickets from different Signal groups"
end
Recommended Implementation Path
Phase 1: Immediate (Low Risk)
-
Add preferences migration on merge (Option 1)
- Only copies if target doesn't have existing preferences
- Handles most common case safely
-
Add preferences copy on split (Option 3)
- New tickets get parent's channel metadata
- Enables replies on split tickets
Phase 2: Short-term
-
Add follow-up handling in webhooks (Option 2)
- Modify webhook controllers to follow merge parent links
- Handles incoming messages to merged ticket's customer
-
Add UI warnings (Option 4)
- Warn agents about implications
- Especially for conflicting metadata scenarios
Phase 3: Medium-term
-
Add merge validation for Signal groups
- Block merging tickets from different groups
- Or add clear warning about implications
-
Add audit logging
- Track when preferences are migrated
- Help agents understand what happened
Files to Modify
Zammad Addon (zammad-addon-bridge)
| File | Change |
|---|---|
src/app/models/ticket/merge_bridge_channel_preferences.rb |
New - preferences migration |
src/app/models/ticket/split_bridge_channel_preferences.rb |
New - preferences copy on split |
src/app/controllers/channels_cdr_whatsapp_controller.rb |
Add merge follow-up handling |
src/app/controllers/channels_cdr_signal_controller.rb |
Add merge follow-up handling |
src/config/initializers/bridge.rb |
Include new concerns in Ticket model |
Link Frontend (optional)
| File | Change |
|---|---|
| Merge dialog component | Add warning for bridge tickets |
Testing Scenarios
- Merge WhatsApp ticket → empty ticket → verify agent can reply
- Merge WhatsApp ticket → WhatsApp ticket (same number) → verify routing
- Merge WhatsApp ticket → WhatsApp ticket (different number) → verify warning/behavior
- Split article from WhatsApp ticket → verify new ticket has preferences
- Customer sends message after their ticket was merged → verify routing
- Merge Signal group ticket → verify group_joined flag is preserved
- Merge two different Signal group tickets → verify validation/warning
References
- Zammad merge implementation:
app/models/ticket.rb:330-450 - Zammad split implementation:
app/models/form_updater/concerns/applies_split_ticket_article.rb - Zammad email follow-up filter:
app/models/channel/filter/follow_up_merged.rb - Bridge WhatsApp controller:
packages/zammad-addon-bridge/src/app/controllers/channels_cdr_whatsapp_controller.rb - Bridge Signal controller:
packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb - Bridge WhatsApp job:
packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_whatsapp_job.rb - Bridge Signal job:
packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb