From d4ce94ddf83145b6c0ffdfc226ef177c74273cd1 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Wed, 17 Dec 2025 14:53:13 +0100 Subject: [PATCH 1/7] Split/merge WIP --- ...bridge-ticket-split-merge-investigation.md | 506 ++++++++++ docs/ticket-field-propagation-design.md | 906 ++++++++++++++++++ 2 files changed, 1412 insertions(+) create mode 100644 docs/bridge-ticket-split-merge-investigation.md create mode 100644 docs/ticket-field-propagation-design.md diff --git a/docs/bridge-ticket-split-merge-investigation.md b/docs/bridge-ticket-split-merge-investigation.md new file mode 100644 index 0000000..ebf48b3 --- /dev/null +++ b/docs/bridge-ticket-split-merge-investigation.md @@ -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` diff --git a/docs/ticket-field-propagation-design.md b/docs/ticket-field-propagation-design.md new file mode 100644 index 0000000..61d4bec --- /dev/null +++ b/docs/ticket-field-propagation-design.md @@ -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. From f059e75acd68bb15c728149c4a8a3d03351edb47 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Fri, 19 Dec 2025 11:20:28 +0100 Subject: [PATCH 2/7] Add warning for unsent Signal groups messages. --- .../channels_cdr_signal_controller.rb | 30 +++++++++++++++++++ .../app/jobs/communicate_cdr_signal_job.rb | 27 +++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb index f09c458..409eac7 100644 --- a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb +++ b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb @@ -458,6 +458,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, diff --git a/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb b/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb index 37e026a..786be4b 100644 --- a/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb +++ b/packages/zammad-addon-bridge/src/app/jobs/communicate_cdr_signal_job.rb @@ -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 From 0b2ea19ebc19057e686276e9b67c690b8674ccf0 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Fri, 19 Dec 2025 12:38:49 +0100 Subject: [PATCH 3/7] Add Signal group ticket split compatibility --- .../models/link/setup_split_signal_group.rb | 49 +++++++++++++++++++ .../src/config/initializers/cdr_signal.rb | 8 ++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb diff --git a/packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb b/packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb new file mode 100644 index 0000000..df07bba --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb @@ -0,0 +1,49 @@ +# 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 + } + child_ticket.save! + + Rails.logger.info "Signal split: Ticket ##{child_ticket.number} set up for new group (recipient: #{original_recipient})" + end +end diff --git a/packages/zammad-addon-bridge/src/config/initializers/cdr_signal.rb b/packages/zammad-addon-bridge/src/config/initializers/cdr_signal.rb index f00feca..c56d0ee 100644 --- a/packages/zammad-addon-bridge/src/config/initializers/cdr_signal.rb +++ b/packages/zammad-addon-bridge/src/config/initializers/cdr_signal.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true -Rails.application.config.after_initialize do +Rails.application.config.after_initialize do class Ticket::Article 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 - \ No newline at end of file From 69394c813d5843de45279a5f3d7926170986b1ed Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Fri, 19 Dec 2025 12:52:47 +0100 Subject: [PATCH 4/7] Prevent overwriting a Signal group in Zammad if one already exists --- .../channels_cdr_signal_controller.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb index 409eac7..6446ba2 100644 --- a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb +++ b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb @@ -374,6 +374,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] ||= {} From a882c9ecffb90e281abd721cf6b102256b55791f Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Fri, 19 Dec 2025 15:27:27 +0100 Subject: [PATCH 5/7] Split ticket and group name fixes --- .../tasks/signal/send-signal-message.ts | 29 +++++++++++++++++++ docker/scripts/docker.js | 6 ---- docker/zammad/Dockerfile | 2 +- .../channels_cdr_signal_controller.rb | 5 ++++ .../models/link/setup_split_signal_group.rb | 2 ++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/bridge-worker/tasks/signal/send-signal-message.ts b/apps/bridge-worker/tasks/signal/send-signal-message.ts index 3235be2..144dfe3 100644 --- a/apps/bridge-worker/tasks/signal/send-signal-message.ts +++ b/apps/bridge-worker/tasks/signal/send-signal-message.ts @@ -282,6 +282,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) { diff --git a/docker/scripts/docker.js b/docker/scripts/docker.js index 3d6e278..deb78ab 100644 --- a/docker/scripts/docker.js +++ b/docker/scripts/docker.js @@ -20,12 +20,6 @@ const envFile = path.resolve(process.cwd(), '.env'); const finalFiles = files[app] .map((file) => ['-f', `docker/compose/${file}.yml`]).flat(); -// Add bridge-dev.yml for dev commands that include zammad -const devAppsWithZammad = ['linkDev', 'bridgeDev', 'all']; -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]); diff --git a/docker/zammad/Dockerfile b/docker/zammad/Dockerfile index 018304d..dc7bf5a 100644 --- a/docker/zammad/Dockerfile +++ b/docker/zammad/Dockerfile @@ -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 && \ diff --git a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb index 6446ba2..4e85c9c 100644 --- a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb +++ b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb @@ -264,6 +264,11 @@ class ChannelsCdrSignalController < ApplicationController chat_id: chat_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}" diff --git a/packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb b/packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb index df07bba..b7db245 100644 --- a/packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb +++ b/packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb @@ -42,6 +42,8 @@ module Link::SetupSplitSignalGroup '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})" From 3b91c98d5e8d25ad14dc5820ab595c7d8a1524b5 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Mon, 12 Jan 2026 10:01:44 +0100 Subject: [PATCH 6/7] Bump version to 3.5.0-beta.1 --- .gitlab-ci.yml | 10 ++++++++-- apps/bridge-frontend/package.json | 2 +- apps/bridge-migrations/package.json | 2 +- apps/bridge-whatsapp/package.json | 2 +- apps/bridge-worker/package.json | 2 +- apps/link/package.json | 2 +- package.json | 2 +- packages/bridge-common/package.json | 2 +- packages/bridge-ui/package.json | 2 +- packages/eslint-config/package.json | 2 +- packages/jest-config/package.json | 2 +- packages/logger/package.json | 2 +- packages/signal-api/package.json | 2 +- packages/typescript-config/package.json | 2 +- packages/ui/package.json | 2 +- packages/zammad-addon-bridge/package.json | 2 +- packages/zammad-addon-common/package.json | 2 +- packages/zammad-addon-hardening/package.json | 2 +- 18 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f848a7f..8fcd82b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,11 +20,13 @@ build-all: - turbo build .docker-build: - image: registry.gitlab.com/digiresilience/link/link-stack/buildx:${CI_COMMIT_REF_NAME} + image: registry.gitlab.com/digiresilience/link/link-stack/buildx:main services: - docker:dind stage: docker-build variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" DOCKER_TAG: ${CI_COMMIT_SHORT_SHA} BUILD_CONTEXT: . only: @@ -37,11 +39,13 @@ build-all: - docker push ${DOCKER_NS}:${DOCKER_TAG} .docker-release: - image: registry.gitlab.com/digiresilience/link/link-stack/buildx:${CI_COMMIT_REF_NAME} + image: registry.gitlab.com/digiresilience/link/link-stack/buildx:main services: - docker:dind stage: docker-release variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" DOCKER_TAG: ${CI_COMMIT_SHORT_SHA} DOCKER_TAG_NEW: ${CI_COMMIT_REF_NAME} only: @@ -195,6 +199,7 @@ zammad-docker-build: PNPM_HOME: "/pnpm" before_script: - export PATH="$PNPM_HOME:$PATH" + - corepack enable && corepack prepare pnpm@9.15.4 --activate script: - pnpm add -g turbo - pnpm install --frozen-lockfile @@ -217,6 +222,7 @@ zammad-standalone-docker-build: PNPM_HOME: "/pnpm" before_script: - export PATH="$PNPM_HOME:$PATH" + - corepack enable && corepack prepare pnpm@9.15.4 --activate script: - pnpm add -g turbo - pnpm install --frozen-lockfile diff --git a/apps/bridge-frontend/package.json b/apps/bridge-frontend/package.json index 028641a..27c4a82 100644 --- a/apps/bridge-frontend/package.json +++ b/apps/bridge-frontend/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-frontend", - "version": "3.3.5", + "version": "3.5.0-beta.1", "type": "module", "scripts": { "dev": "next dev", diff --git a/apps/bridge-migrations/package.json b/apps/bridge-migrations/package.json index b6740ce..3c23b1c 100644 --- a/apps/bridge-migrations/package.json +++ b/apps/bridge-migrations/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-migrations", - "version": "3.3.5", + "version": "3.5.0-beta.1", "type": "module", "scripts": { "migrate:up:all": "tsx migrate.ts up:all", diff --git a/apps/bridge-whatsapp/package.json b/apps/bridge-whatsapp/package.json index cef6520..97ea951 100644 --- a/apps/bridge-whatsapp/package.json +++ b/apps/bridge-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-whatsapp", - "version": "3.3.5", + "version": "3.5.0-beta.1", "main": "build/main/index.js", "author": "Darren Clarke ", "license": "AGPL-3.0-or-later", diff --git a/apps/bridge-worker/package.json b/apps/bridge-worker/package.json index 670d742..7d7e8ff 100644 --- a/apps/bridge-worker/package.json +++ b/apps/bridge-worker/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-worker", - "version": "3.3.5", + "version": "3.5.0-beta.1", "type": "module", "main": "build/main/index.js", "author": "Darren Clarke ", diff --git a/apps/link/package.json b/apps/link/package.json index 1f15196..45e61a3 100644 --- a/apps/link/package.json +++ b/apps/link/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/link", - "version": "3.3.5", + "version": "3.5.0-beta.1", "type": "module", "scripts": { "dev": "next dev -H 0.0.0.0", diff --git a/package.json b/package.json index 0a59707..e5d4c1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack", - "version": "3.3.5", + "version": "3.5.0-beta.1", "description": "Link from the Center for Digital Resilience", "scripts": { "dev": "dotenv -- turbo dev", diff --git a/packages/bridge-common/package.json b/packages/bridge-common/package.json index 91b9696..eecd5ee 100644 --- a/packages/bridge-common/package.json +++ b/packages/bridge-common/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-common", - "version": "3.3.5", + "version": "3.5.0-beta.1", "main": "build/main/index.js", "type": "module", "author": "Darren Clarke ", diff --git a/packages/bridge-ui/package.json b/packages/bridge-ui/package.json index 2e4d6fa..a2ca393 100644 --- a/packages/bridge-ui/package.json +++ b/packages/bridge-ui/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-ui", - "version": "3.3.5", + "version": "3.5.0-beta.1", "scripts": { "build": "tsc -p tsconfig.json" }, diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index c579d56..331d500 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/eslint-config", - "version": "3.3.5", + "version": "3.5.0-beta.1", "description": "amigo's eslint config", "main": "index.js", "author": "Abel Luck ", diff --git a/packages/jest-config/package.json b/packages/jest-config/package.json index 591a33a..1d845b3 100644 --- a/packages/jest-config/package.json +++ b/packages/jest-config/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/jest-config", - "version": "3.3.5", + "version": "3.5.0-beta.1", "description": "", "main": "index.js", "author": "Abel Luck ", diff --git a/packages/logger/package.json b/packages/logger/package.json index cda0e71..9948916 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/logger", - "version": "3.3.5", + "version": "3.5.0-beta.1", "description": "Shared logging utility for Link Stack monorepo", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/packages/signal-api/package.json b/packages/signal-api/package.json index 0905a16..bf17ff6 100644 --- a/packages/signal-api/package.json +++ b/packages/signal-api/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/signal-api", - "version": "3.3.5", + "version": "3.5.0-beta.1", "type": "module", "main": "build/index.js", "exports": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 98021b8..a5f03be 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/typescript-config", - "version": "3.3.5", + "version": "3.5.0-beta.1", "description": "Shared TypeScript config", "license": "AGPL-3.0-or-later", "author": "Abel Luck ", diff --git a/packages/ui/package.json b/packages/ui/package.json index a12655e..bb5f0a1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/ui", - "version": "3.3.5", + "version": "3.5.0-beta.1", "description": "", "scripts": { "build": "tsc -p tsconfig.json" diff --git a/packages/zammad-addon-bridge/package.json b/packages/zammad-addon-bridge/package.json index a7ef115..f0f1e93 100644 --- a/packages/zammad-addon-bridge/package.json +++ b/packages/zammad-addon-bridge/package.json @@ -1,7 +1,7 @@ { "name": "@link-stack/zammad-addon-bridge", "displayName": "Bridge", - "version": "3.3.5", + "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'", diff --git a/packages/zammad-addon-common/package.json b/packages/zammad-addon-common/package.json index 2b3c510..2b252e4 100644 --- a/packages/zammad-addon-common/package.json +++ b/packages/zammad-addon-common/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/zammad-addon-common", - "version": "3.3.5", + "version": "3.5.0-beta.1", "description": "", "bin": { "zpm-build": "./dist/build.js", diff --git a/packages/zammad-addon-hardening/package.json b/packages/zammad-addon-hardening/package.json index 725472b..dc1373e 100644 --- a/packages/zammad-addon-hardening/package.json +++ b/packages/zammad-addon-hardening/package.json @@ -1,7 +1,7 @@ { "name": "@link-stack/zammad-addon-hardening", "displayName": "Hardening", - "version": "3.3.5", + "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'", From 2db6bc5047ba68be311189a1f18128a16eb6b69e Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Thu, 15 Jan 2026 10:01:15 +0100 Subject: [PATCH 7/7] Fix: Use senderPn for phone number instead of LID from remoteJid Baileys 7 uses LIDs (Linked IDs) in remoteJid for some messages instead of phone numbers. This caused messages to be matched to wrong tickets because the LID was used as the sender identifier instead of the actual phone number. Now we: - Extract senderPn/participantPn from message key (Baileys 7 fields) - Prefer these phone number fields over remoteJid - Skip messages if we can't determine the phone number (LID with no phone) --- apps/bridge-whatsapp/src/service.ts | 38 ++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/bridge-whatsapp/src/service.ts b/apps/bridge-whatsapp/src/service.ts index 69a1895..6b32600 100644 --- a/apps/bridge-whatsapp/src/service.ts +++ b/apps/bridge-whatsapp/src/service.ts @@ -175,18 +175,20 @@ export default class WhatsappService extends Service { } private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) { - const { - key: { id, fromMe, remoteJid }, - message, - messageTimestamp, - } = webMessageInfo; - logger.info("Message type debug"); - for (const key in message) { - logger.info( - { key, exists: !!message[key as keyof proto.IMessage] }, - "Message field", - ); + const { key, message, messageTimestamp } = webMessageInfo; + if (!key) { + logger.warn("Message missing key, skipping"); + return; } + const { id, fromMe, remoteJid } = key; + // Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases. + // senderPn contains the actual phone number when available. + const senderPn = (key as any).senderPn as string | undefined; + const participantPn = (key as any).participantPn as string | undefined; + logger.info( + { remoteJid, senderPn, participantPn, fromMe }, + "Processing incoming message", + ); const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe; if (isValidMessage) { const { audioMessage, documentMessage, imageMessage, videoMessage } = message; @@ -244,9 +246,21 @@ export default class WhatsappService extends Service { videoMessage, ].find((text) => text && text !== ""); + // Prefer phone number fields (senderPn/participantPn) over remoteJid + // remoteJid may contain LIDs which are not phone numbers + const phoneFromJid = remoteJid?.split("@")[0]; + const isLidJid = remoteJid?.endsWith("@lid"); + // Use senderPn/participantPn if available, otherwise use remoteJid only if it's not a LID + const senderPhone = senderPn?.split("@")[0] || participantPn?.split("@")[0] || (!isLidJid ? phoneFromJid : undefined); + + if (!senderPhone) { + logger.warn({ remoteJid, senderPn, participantPn }, "Could not determine sender phone number, skipping message"); + return; + } + const payload = { to: botID, - from: remoteJid?.split("@")[0], + from: senderPhone, messageId: id, sentAt: new Date((messageTimestamp as number) * 1000).toISOString(), message: messageText,