Merge feature/split-signal-improvements into combined branch
Combines Signal split/merge improvements with keycloak auth, baileys-7 updates, and signal notifications support. Resolved conflicts: - Kept LID user ID support in bridge-whatsapp - Kept bridge-dev.yml docker compose addition - Used 3.5.0-beta.1 version from split-signal-improvements
This commit is contained in:
commit
38efae02d4
26 changed files with 1604 additions and 24 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-frontend",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-migrations",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"migrate:up:all": "tsx migrate.ts up:all",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-whatsapp",
|
||||
"version": "3.4.0-beta.7",
|
||||
"type": "module",
|
||||
"version": "3.5.0-beta.1",
|
||||
"main": "build/main/index.js",
|
||||
"author": "Darren Clarke <darren@redaranj.com>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@adiwajshing/keyed-db": "0.2.4",
|
||||
"@hapi/hapi": "^21.4.3",
|
||||
"@hapipal/schmervice": "^3.0.0",
|
||||
"@hapipal/toys": "^4.0.0",
|
||||
"@link-stack/bridge-common": "workspace:*",
|
||||
"@link-stack/logger": "workspace:*",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"@whiskeysockets/baileys": "6.7.21",
|
||||
"hapi-pino": "^13.0.0",
|
||||
"link-preview-js": "^3.1.0"
|
||||
},
|
||||
|
|
@ -19,12 +19,15 @@
|
|||
"@link-stack/eslint-config": "workspace:*",
|
||||
"@link-stack/jest-config": "workspace:*",
|
||||
"@link-stack/typescript-config": "workspace:*",
|
||||
"@types/long": "^5",
|
||||
"@types/node": "*",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "node --env-file=.env --experimental-transform-types src/index.ts",
|
||||
"dev": "dotenv -- tsx src/index.ts",
|
||||
"start": "node build/main/index.js"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-worker",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"type": "module",
|
||||
"main": "build/main/index.js",
|
||||
"author": "Darren Clarke <darren@redaranj.com>",
|
||||
|
|
|
|||
|
|
@ -283,6 +283,35 @@ const sendSignalMessageTask = async ({
|
|||
},
|
||||
"Message sent successfully",
|
||||
);
|
||||
|
||||
// Update group name to use consistent template with ticket number
|
||||
// This ensures groups created by receive-signal-message get renamed
|
||||
// to match the template (e.g., "Support Request: 94085")
|
||||
if (finalTo.startsWith("group.") && conversationId) {
|
||||
try {
|
||||
const expectedGroupName = buildSignalGroupName(conversationId);
|
||||
await groupsClient.v1GroupsNumberGroupidPut({
|
||||
number: bot.phoneNumber,
|
||||
groupid: finalTo,
|
||||
data: {
|
||||
name: expectedGroupName,
|
||||
},
|
||||
});
|
||||
logger.debug(
|
||||
{ groupId: finalTo, newName: expectedGroupName },
|
||||
"Updated group name",
|
||||
);
|
||||
} catch (renameError) {
|
||||
// Non-fatal - group name update is best-effort
|
||||
logger.warn(
|
||||
{
|
||||
error: renameError instanceof Error ? renameError.message : renameError,
|
||||
groupId: finalTo,
|
||||
},
|
||||
"Could not update group name",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Try to get the actual error message from the response
|
||||
if (error.response) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/link",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
|
|||
finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
|
||||
}
|
||||
|
||||
|
||||
const finalCommand = command === "up" ? ["up", "-d", "--remove-orphans"] : [command];
|
||||
const dockerCompose = spawn('docker', ['compose', '--env-file', envFile, ...finalFiles, ...finalCommand]);
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ RUN if [ "$EMBEDDED" = "true" ] ; then \
|
|||
sed -i '$ d' /opt/zammad/contrib/nginx/zammad.conf && \
|
||||
echo "" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||
echo " location /link {" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||
echo " proxy_pass ${LINK_HOST};" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||
echo " set \$link_url ${LINK_HOST}; proxy_pass \$link_url;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||
echo " proxy_set_header Host \$host;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||
echo " proxy_set_header X-Real-IP \$remote_addr;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||
echo " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||
|
|
|
|||
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`
|
||||
906
docs/ticket-field-propagation-design.md
Normal file
906
docs/ticket-field-propagation-design.md
Normal file
|
|
@ -0,0 +1,906 @@
|
|||
# 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.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "Link from the Center for Digital Resilience",
|
||||
"scripts": {
|
||||
"dev": "dotenv -- turbo dev",
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"turbo": "^2.6.0",
|
||||
"turbo": "^2.5.8",
|
||||
"typescript": "latest"
|
||||
},
|
||||
"pnpm": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-common",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"main": "build/main/index.js",
|
||||
"type": "module",
|
||||
"author": "Darren Clarke <darren@redaranj.com>",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-ui",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/eslint-config",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "amigo's eslint config",
|
||||
"main": "index.js",
|
||||
"author": "Abel Luck <abel@guardianproject.info>",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/jest-config",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"author": "Abel Luck <abel@guardianproject.info>",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/logger",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "Shared logging utility for Link Stack monorepo",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/signal-api",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/typescript-config",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "Shared TypeScript config",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Abel Luck <abel@guardianproject.info>",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/ui",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@link-stack/zammad-addon-bridge",
|
||||
"displayName": "Bridge",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "An addon that adds CDR Bridge channels to Zammad.",
|
||||
"scripts": {
|
||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
||||
|
|
|
|||
|
|
@ -292,6 +292,11 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
user_id: sender_user_id
|
||||
}
|
||||
|
||||
# Store original recipient phone for group tickets to enable ticket splitting
|
||||
if is_group_message
|
||||
cdr_signal_prefs[:original_recipient] = sender_phone_number
|
||||
end
|
||||
|
||||
Rails.logger.info "=== CREATING NEW TICKET ==="
|
||||
Rails.logger.info "Preferences to be stored:"
|
||||
Rails.logger.info " - channel_id: #{channel.id}"
|
||||
|
|
@ -403,6 +408,24 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
return
|
||||
end
|
||||
|
||||
# Idempotency check: if chat_id is already a group ID, don't overwrite it
|
||||
# This prevents race conditions where multiple group_created webhooks arrive
|
||||
# (e.g., due to retries after API timeouts during group creation)
|
||||
existing_chat_id = ticket.preferences&.dig(:cdr_signal, :chat_id) ||
|
||||
ticket.preferences&.dig('cdr_signal', 'chat_id')
|
||||
if existing_chat_id&.start_with?('group.')
|
||||
Rails.logger.info "Signal group update: Ticket #{ticket.id} already has group #{existing_chat_id}, ignoring new group #{params[:group_id]}"
|
||||
render json: {
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'Ticket already has a group assigned',
|
||||
existing_group_id: existing_chat_id,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.number
|
||||
}, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Update ticket preferences with the group information
|
||||
ticket.preferences ||= {}
|
||||
ticket.preferences[:cdr_signal] ||= {}
|
||||
|
|
@ -487,6 +510,36 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
|
||||
Rails.logger.info "Signal group member #{member_phone} joined group #{params[:group_id]} for ticket #{ticket.id}"
|
||||
|
||||
# Check if any articles had a group_not_joined notification and add resolution note
|
||||
# Only add resolution note if we previously notified about the delivery issue
|
||||
articles_with_pending_notification = Ticket::Article.where(ticket_id: ticket.id)
|
||||
.where("preferences LIKE ?", "%group_not_joined_note_added: true%")
|
||||
|
||||
if articles_with_pending_notification.exists?
|
||||
# Check if we already added a resolution note for this ticket
|
||||
resolution_note_exists = Ticket::Article.where(ticket_id: ticket.id)
|
||||
.where("preferences LIKE ?", "%group_joined_resolution: true%")
|
||||
.exists?
|
||||
|
||||
unless resolution_note_exists
|
||||
Ticket::Article.create(
|
||||
ticket_id: ticket.id,
|
||||
content_type: 'text/plain',
|
||||
body: 'Recipient has now joined the Signal group. Pending messages will be delivered shortly.',
|
||||
internal: true,
|
||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||
preferences: {
|
||||
delivery_message: true,
|
||||
group_joined_resolution: true,
|
||||
},
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
Rails.logger.info "Ticket ##{ticket.number}: Added resolution note about customer joining Signal group"
|
||||
end
|
||||
end
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
ticket_id: ticket.id,
|
||||
|
|
|
|||
|
|
@ -40,10 +40,37 @@ class CommunicateCdrSignalJob < ApplicationJob
|
|||
if is_group_chat && group_joined == false
|
||||
Rails.logger.info "Ticket ##{ticket.number}: User hasn't joined Signal group yet, skipping message delivery"
|
||||
|
||||
# Track group_not_joined retry attempts separately
|
||||
article.preferences['group_not_joined_retry'] ||= 0
|
||||
article.preferences['group_not_joined_retry'] += 1
|
||||
|
||||
# Mark article as pending delivery
|
||||
article.preferences['delivery_status'] = 'pending'
|
||||
article.preferences['delivery_status_message'] = 'Waiting for user to join Signal group'
|
||||
article.preferences['delivery_status_date'] = Time.zone.now
|
||||
|
||||
# After 3 failed attempts, add a note to inform the agent (only once)
|
||||
if article.preferences['group_not_joined_retry'] == 3 && !article.preferences['group_not_joined_note_added']
|
||||
Ticket::Article.create(
|
||||
ticket_id: ticket.id,
|
||||
content_type: 'text/plain',
|
||||
body: 'Unable to send Signal message: Recipient has not yet joined the Signal group. ' \
|
||||
'The message will be delivered automatically once they accept the group invitation.',
|
||||
internal: true,
|
||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||
preferences: {
|
||||
delivery_article_id_related: article.id,
|
||||
delivery_message: true,
|
||||
group_not_joined_notification: true,
|
||||
},
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
article.preferences['group_not_joined_note_added'] = true
|
||||
Rails.logger.info "Ticket ##{ticket.number}: Added notification note about pending group join"
|
||||
end
|
||||
|
||||
article.save!
|
||||
|
||||
# Retry later when user might have joined
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Link::SetupSplitSignalGroup
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create :setup_signal_group_for_split_ticket
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_signal_group_for_split_ticket
|
||||
# Only if auto-groups enabled
|
||||
return unless ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
|
||||
|
||||
# Only child links (splits create child->parent links)
|
||||
return unless link_type_id == Link::Type.find_by(name: 'child')&.id
|
||||
|
||||
# Only Ticket-to-Ticket links
|
||||
ticket_object_id = Link::Object.find_by(name: 'Ticket')&.id
|
||||
return unless link_object_source_id == ticket_object_id
|
||||
return unless link_object_target_id == ticket_object_id
|
||||
|
||||
child_ticket = Ticket.find_by(id: link_object_source_value)
|
||||
parent_ticket = Ticket.find_by(id: link_object_target_value)
|
||||
return unless child_ticket && parent_ticket
|
||||
|
||||
# Only if parent has Signal group (chat_id starts with "group.")
|
||||
parent_signal_prefs = parent_ticket.preferences&.dig('cdr_signal')
|
||||
return unless parent_signal_prefs.present?
|
||||
return unless parent_signal_prefs['chat_id']&.start_with?('group.')
|
||||
|
||||
original_recipient = parent_signal_prefs['original_recipient']
|
||||
return unless original_recipient.present?
|
||||
|
||||
# Set up child for lazy group creation:
|
||||
# chat_id = phone number triggers new group on first message
|
||||
child_ticket.preferences ||= {}
|
||||
child_ticket.preferences['channel_id'] = parent_ticket.preferences['channel_id']
|
||||
child_ticket.preferences['cdr_signal'] = {
|
||||
'bot_token' => parent_signal_prefs['bot_token'],
|
||||
'chat_id' => original_recipient, # Phone number, NOT group ID
|
||||
'original_recipient' => original_recipient
|
||||
}
|
||||
# Set article type so Zammad shows Signal reply option
|
||||
child_ticket.create_article_type_id = Ticket::Article::Type.find_by(name: 'cdr_signal')&.id
|
||||
child_ticket.save!
|
||||
|
||||
Rails.logger.info "Signal split: Ticket ##{child_ticket.number} set up for new group (recipient: #{original_recipient})"
|
||||
end
|
||||
end
|
||||
|
|
@ -5,6 +5,11 @@ Rails.application.config.after_initialize do
|
|||
include Ticket::Article::EnqueueCommunicateCdrSignalJob
|
||||
end
|
||||
|
||||
# Handle Signal group setup for split tickets
|
||||
class Link
|
||||
include Link::SetupSplitSignalGroup
|
||||
end
|
||||
|
||||
icon = File.read('public/assets/images/icons/cdr_signal.svg')
|
||||
doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) }
|
||||
if !doc.at_css('#icon-cdr-signal')
|
||||
|
|
@ -15,4 +20,3 @@ Rails.application.config.after_initialize do
|
|||
end
|
||||
File.write('public/assets/images/icons.svg', doc.to_xml)
|
||||
end
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/zammad-addon-common",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "",
|
||||
"bin": {
|
||||
"zpm-build": "./dist/build.js",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@link-stack/zammad-addon-hardening",
|
||||
"displayName": "Hardening",
|
||||
"version": "3.4.0-beta.7",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.",
|
||||
"scripts": {
|
||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue