Split/merge WIP
This commit is contained in:
parent
78d2ff66b2
commit
d4ce94ddf8
2 changed files with 1412 additions and 0 deletions
506
docs/bridge-ticket-split-merge-investigation.md
Normal file
506
docs/bridge-ticket-split-merge-investigation.md
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
# 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:
|
||||
|
||||
1. All articles from A are moved to B
|
||||
2. A "parent" link is created between A and B
|
||||
3. Mentions and external links are migrated
|
||||
4. Source ticket A's state is set to "merged"
|
||||
5. 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.
|
||||
|
||||
```ruby
|
||||
# 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:
|
||||
|
||||
1. Basic ticket attributes are copied (group, customer, state, priority, title)
|
||||
2. Attachments are cloned
|
||||
3. A link is created to the original ticket
|
||||
4. `owner_id` is explicitly deleted (not copied)
|
||||
|
||||
**Critical issue:** Preferences are NOT copied. The new ticket C has no channel metadata.
|
||||
|
||||
```ruby
|
||||
# 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:
|
||||
|
||||
```ruby
|
||||
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`:
|
||||
|
||||
### WhatsApp
|
||||
|
||||
```ruby
|
||||
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)
|
||||
|
||||
```ruby
|
||||
ticket.preferences = {
|
||||
channel_id: 456,
|
||||
cdr_signal: {
|
||||
bot_token: "xyz789",
|
||||
chat_id: "+1234567890" # Customer's phone number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Signal (Group)
|
||||
|
||||
```ruby
|
||||
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:
|
||||
|
||||
```ruby
|
||||
# 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`):
|
||||
```ruby
|
||||
# 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`):
|
||||
```ruby
|
||||
# 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:
|
||||
|
||||
```ruby
|
||||
# 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:
|
||||
|
||||
```ruby
|
||||
# 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:
|
||||
|
||||
```ruby
|
||||
# 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:
|
||||
|
||||
1. Check for bridge preferences before merge/split
|
||||
2. Show warning dialog explaining implications
|
||||
3. Optionally provide UI to manually transfer channel association
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
|
||||
```ruby
|
||||
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:
|
||||
|
||||
1. **Group ID is the routing key**, not phone number
|
||||
2. **Multiple customers** might be in the same group
|
||||
3. **`group_joined` flag** tracks invite acceptance - messages can't be sent until true
|
||||
4. **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:
|
||||
|
||||
```ruby
|
||||
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)
|
||||
|
||||
1. **Add preferences migration on merge** (Option 1)
|
||||
- Only copies if target doesn't have existing preferences
|
||||
- Handles most common case safely
|
||||
|
||||
2. **Add preferences copy on split** (Option 3)
|
||||
- New tickets get parent's channel metadata
|
||||
- Enables replies on split tickets
|
||||
|
||||
### Phase 2: Short-term
|
||||
|
||||
3. **Add follow-up handling in webhooks** (Option 2)
|
||||
- Modify webhook controllers to follow merge parent links
|
||||
- Handles incoming messages to merged ticket's customer
|
||||
|
||||
4. **Add UI warnings** (Option 4)
|
||||
- Warn agents about implications
|
||||
- Especially for conflicting metadata scenarios
|
||||
|
||||
### Phase 3: Medium-term
|
||||
|
||||
5. **Add merge validation for Signal groups**
|
||||
- Block merging tickets from different groups
|
||||
- Or add clear warning about implications
|
||||
|
||||
6. **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
|
||||
|
||||
1. Merge WhatsApp ticket → empty ticket → verify agent can reply
|
||||
2. Merge WhatsApp ticket → WhatsApp ticket (same number) → verify routing
|
||||
3. Merge WhatsApp ticket → WhatsApp ticket (different number) → verify warning/behavior
|
||||
4. Split article from WhatsApp ticket → verify new ticket has preferences
|
||||
5. Customer sends message after their ticket was merged → verify routing
|
||||
6. Merge Signal group ticket → verify group_joined flag is preserved
|
||||
7. 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`
|
||||
Loading…
Add table
Add a link
Reference in a new issue