diff --git a/apps/bridge-frontend/package.json b/apps/bridge-frontend/package.json index 27c4a82..028641a 100644 --- a/apps/bridge-frontend/package.json +++ b/apps/bridge-frontend/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-frontend", - "version": "3.5.0-beta.1", + "version": "3.3.5", "type": "module", "scripts": { "dev": "next dev", diff --git a/apps/bridge-migrations/package.json b/apps/bridge-migrations/package.json index 3c23b1c..b6740ce 100644 --- a/apps/bridge-migrations/package.json +++ b/apps/bridge-migrations/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-migrations", - "version": "3.5.0-beta.1", + "version": "3.3.5", "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 97ea951..cef6520 100644 --- a/apps/bridge-whatsapp/package.json +++ b/apps/bridge-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-whatsapp", - "version": "3.5.0-beta.1", + "version": "3.3.5", "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 7d7e8ff..670d742 100644 --- a/apps/bridge-worker/package.json +++ b/apps/bridge-worker/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-worker", - "version": "3.5.0-beta.1", + "version": "3.3.5", "type": "module", "main": "build/main/index.js", "author": "Darren Clarke ", diff --git a/apps/bridge-worker/tasks/signal/send-signal-message.ts b/apps/bridge-worker/tasks/signal/send-signal-message.ts index 144dfe3..3235be2 100644 --- a/apps/bridge-worker/tasks/signal/send-signal-message.ts +++ b/apps/bridge-worker/tasks/signal/send-signal-message.ts @@ -282,35 +282,6 @@ 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/apps/link/package.json b/apps/link/package.json index 45e61a3..1f15196 100644 --- a/apps/link/package.json +++ b/apps/link/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/link", - "version": "3.5.0-beta.1", + "version": "3.3.5", "type": "module", "scripts": { "dev": "next dev -H 0.0.0.0", diff --git a/docker/scripts/docker.js b/docker/scripts/docker.js index deb78ab..3d6e278 100644 --- a/docker/scripts/docker.js +++ b/docker/scripts/docker.js @@ -20,6 +20,12 @@ 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 dc7bf5a..018304d 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 " set \$link_url ${LINK_HOST}; proxy_pass \$link_url;" >> /opt/zammad/contrib/nginx/zammad.conf && \ + echo " proxy_pass ${LINK_HOST};" >> /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/docs/bridge-ticket-split-merge-investigation.md b/docs/bridge-ticket-split-merge-investigation.md deleted file mode 100644 index ebf48b3..0000000 --- a/docs/bridge-ticket-split-merge-investigation.md +++ /dev/null @@ -1,506 +0,0 @@ -# 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 deleted file mode 100644 index 61d4bec..0000000 --- a/docs/ticket-field-propagation-design.md +++ /dev/null @@ -1,906 +0,0 @@ -# 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. diff --git a/package.json b/package.json index e5d4c1a..0a59707 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack", - "version": "3.5.0-beta.1", + "version": "3.3.5", "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 eecd5ee..91b9696 100644 --- a/packages/bridge-common/package.json +++ b/packages/bridge-common/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-common", - "version": "3.5.0-beta.1", + "version": "3.3.5", "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 a2ca393..2e4d6fa 100644 --- a/packages/bridge-ui/package.json +++ b/packages/bridge-ui/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/bridge-ui", - "version": "3.5.0-beta.1", + "version": "3.3.5", "scripts": { "build": "tsc -p tsconfig.json" }, diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 331d500..c579d56 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/eslint-config", - "version": "3.5.0-beta.1", + "version": "3.3.5", "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 1d845b3..591a33a 100644 --- a/packages/jest-config/package.json +++ b/packages/jest-config/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/jest-config", - "version": "3.5.0-beta.1", + "version": "3.3.5", "description": "", "main": "index.js", "author": "Abel Luck ", diff --git a/packages/logger/package.json b/packages/logger/package.json index 9948916..cda0e71 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/logger", - "version": "3.5.0-beta.1", + "version": "3.3.5", "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 bf17ff6..0905a16 100644 --- a/packages/signal-api/package.json +++ b/packages/signal-api/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/signal-api", - "version": "3.5.0-beta.1", + "version": "3.3.5", "type": "module", "main": "build/index.js", "exports": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index a5f03be..98021b8 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/typescript-config", - "version": "3.5.0-beta.1", + "version": "3.3.5", "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 bb5f0a1..a12655e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@link-stack/ui", - "version": "3.5.0-beta.1", + "version": "3.3.5", "description": "", "scripts": { "build": "tsc -p tsconfig.json" diff --git a/packages/zammad-addon-bridge/package.json b/packages/zammad-addon-bridge/package.json index f0f1e93..a7ef115 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.5.0-beta.1", + "version": "3.3.5", "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-bridge/src/app/controllers/channels_cdr_signal_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb index 4e85c9c..f09c458 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,11 +264,6 @@ 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}" @@ -379,24 +374,6 @@ 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] ||= {} @@ -481,36 +458,6 @@ 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 786be4b..37e026a 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,37 +40,10 @@ 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 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 deleted file mode 100644 index b7db245..0000000 --- a/packages/zammad-addon-bridge/src/app/models/link/setup_split_signal_group.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -module Link::SetupSplitSignalGroup - extend ActiveSupport::Concern - - included do - after_create :setup_signal_group_for_split_ticket - end - - private - - def setup_signal_group_for_split_ticket - # Only if auto-groups enabled - return unless ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true' - - # Only child links (splits create child->parent links) - return unless link_type_id == Link::Type.find_by(name: 'child')&.id - - # Only Ticket-to-Ticket links - ticket_object_id = Link::Object.find_by(name: 'Ticket')&.id - return unless link_object_source_id == ticket_object_id - return unless link_object_target_id == ticket_object_id - - child_ticket = Ticket.find_by(id: link_object_source_value) - parent_ticket = Ticket.find_by(id: link_object_target_value) - return unless child_ticket && parent_ticket - - # Only if parent has Signal group (chat_id starts with "group.") - parent_signal_prefs = parent_ticket.preferences&.dig('cdr_signal') - return unless parent_signal_prefs.present? - return unless parent_signal_prefs['chat_id']&.start_with?('group.') - - original_recipient = parent_signal_prefs['original_recipient'] - return unless original_recipient.present? - - # Set up child for lazy group creation: - # chat_id = phone number triggers new group on first message - child_ticket.preferences ||= {} - child_ticket.preferences['channel_id'] = parent_ticket.preferences['channel_id'] - child_ticket.preferences['cdr_signal'] = { - 'bot_token' => parent_signal_prefs['bot_token'], - 'chat_id' => original_recipient, # Phone number, NOT group ID - 'original_recipient' => original_recipient - } - # Set article type so Zammad shows Signal reply option - child_ticket.create_article_type_id = Ticket::Article::Type.find_by(name: 'cdr_signal')&.id - child_ticket.save! - - Rails.logger.info "Signal split: Ticket ##{child_ticket.number} set up for new group (recipient: #{original_recipient})" - end -end 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 c56d0ee..f00feca 100644 --- a/packages/zammad-addon-bridge/src/config/initializers/cdr_signal.rb +++ b/packages/zammad-addon-bridge/src/config/initializers/cdr_signal.rb @@ -1,15 +1,10 @@ # 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') @@ -20,3 +15,4 @@ Rails.application.config.after_initialize do end File.write('public/assets/images/icons.svg', doc.to_xml) end + \ No newline at end of file diff --git a/packages/zammad-addon-common/package.json b/packages/zammad-addon-common/package.json index 2b252e4..2b3c510 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.5.0-beta.1", + "version": "3.3.5", "description": "", "bin": { "zpm-build": "./dist/build.js", diff --git a/packages/zammad-addon-hardening/package.json b/packages/zammad-addon-hardening/package.json index dc1373e..725472b 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.5.0-beta.1", + "version": "3.3.5", "description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.", "scripts": { "build": "node '../zammad-addon-common/dist/build.js'",