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 9081d23a5ff7b6a8eb4c186a85450371f20d4bf2 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Tue, 13 Jan 2026 15:36:25 +0100 Subject: [PATCH 7/7] Fix Docker-in-Docker connectivity for GitLab CI --- .gitlab-ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 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