Compare commits

...
Author SHA1 Message Date
Sojan Jose 59663dd558 Merge branch 'hotfix/4.9.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2026-01-12 09:15:17 -08:00
Sojan Jose ff68c3a74f Bump version to 4.9.2 2026-01-12 09:14:25 -08:00
Shivam MishraandGitHub 58cec84b93 feat: sanitize html before assiging it to tempDiv (#13252) 2026-01-12 22:41:37 +05:30
Sojan Jose 8311657f9c Merge branch 'release/4.9.1'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-12-22 18:56:05 -08:00
Sojan Jose 62376701da Bump version to 4.9.1 2025-12-22 18:55:42 -08:00
Gabriel JablonskiandGitHub d2e6d6aee3 fix: Improve handling of empty custom attributes list in settings (#13127)
## Description

This PR fixes a UX bug in the Custom Attributes settings page where
switching tabs becomes impossible when the currently selected tab has no
attributes.

Closes #13120

### The Problem
When viewing the Custom Attributes settings page, if one tab (e.g.,
Conversation) has no attributes, users could not switch to the other tab
(e.g., Contact) which might have attributes.

### Root Cause
The `SettingsLayout` component receives `no-records-found` prop which,
when true, hides the entire body content including the TabBar. Since the
TabBar was inside the body slot, it would be hidden whenever the current
tab had no attributes, preventing users from switching tabs.

### The Fix
- Removed the `no-records-found` and `no-records-message` props from
`SettingsLayout`
- Moved the empty state message inline within the body, displayed below
the TabBar
- The TabBar is now always visible regardless of whether there are
attributes in the selected tab

### Key Changes
- Modified `Index.vue` to handle empty state inline while keeping TabBar
accessible

## Type of change

- [X] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

1. Navigate to Settings > Custom Attributes
2. Ensure only one tab (e.g., Contact) has custom attributes
3. Switch to the empty tab (e.g., Conversation)
4. Verify the TabBar remains visible and the empty state message is
shown
5. Switch back to the tab with attributes
6. Verify attributes are displayed correctly
7. Repeat with both tabs empty and both tabs with attributes
2025-12-22 15:10:45 -08:00
Sivin VargheseandGitHub 53c21e6ad3 fix: Prevent invalid attachments from blocking text paste (#13135) 2025-12-22 21:17:35 +05:30
d408f664cb fix: add linear integration for the startup plan (#13136)
Co-authored-by: tanmay <tanmay-chatwoot@tanmays-MacBook-Pro.local>
2025-12-22 21:14:05 +05:30
Sojan Jose 20d97be4c9 Merge branch 'release/4.9.0' into develop 2025-12-19 19:13:03 -08:00
Sojan Jose 73140998ff Merge branch 'release/4.9.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-12-19 19:12:55 -08:00
Sojan Jose 6365a7ea53 Bump version to 4.9.0 2025-12-19 19:12:13 -08:00
PranavandGitHub 2adc040a8f fix: Validate blob before attaching it to a record (#13115)
Previously, attachments relied only on blob_id, which made it possible
to attach blobs across accounts by enumerating IDs. We now require both
blob_id and blob_key, add cross-account validation to prevent blob
reuse, and centralize the logic in a shared BlobOwnershipValidation
concern.

It also fixes a frontend bug where mixed-type action params (number +
string) were incorrectly dropped, causing attachment uploads to fail.
2025-12-19 19:02:21 -08:00
PranavandGitHub 86da3f7c06 fix: Remove account_id from params since it is not used (#13116)
account_id was permitted in strong parameters, allowing authenticated
admins to transfer resources (Portals, Automation Rules, Macros) to
arbitrary accounts.

 Fix: Removed account_id from permitted params in 4 controllers:
  - portals_controller.rb
  - automation_rules_controller.rb
  - macros_controller.rb
  - twilio_channels_controller.rb
2025-12-19 17:07:53 -08:00
c22a31c198 feat: Voice Channel (#11602)
Enables agents to initiate outbound calls and receive incoming calls
directly from the Chatwoot dashboard, with Twilio as the initial
provider.

Fixes:  #11481 

> This is an integration branch to ensure features works well and might
be often broken on down merges, we will be extracting the
functionalities via smaller PRs into develop

- [x] https://github.com/chatwoot/chatwoot/pull/11775
- [x] https://github.com/chatwoot/chatwoot/pull/12218
- [x] https://github.com/chatwoot/chatwoot/pull/12243
- [x] https://github.com/chatwoot/chatwoot/pull/12268
- [x] https://github.com/chatwoot/chatwoot/pull/12361
- [x]  https://github.com/chatwoot/chatwoot/pull/12782
- [x] #13064
- [ ] Ability for agents to join the inbound calls ( included in this PR
)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-12-19 12:41:33 -08:00
Sojan Jose 4e9f644646 Merge branch 'release/4.8.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-11-18 18:41:51 -08:00
Sojan Jose bfa8a8ed60 Merge branch 'release/4.7.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-10-15 22:11:02 -07:00
Sojan Jose 7ab60d9f9c Merge branch 'release/4.6.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-09-19 13:44:30 +05:30
Sojan Jose faac1486e9 Merge branch 'release/4.5.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-08-20 21:45:34 +02:00
Sojan Jose 8340bd615c Merge branch 'release/4.5.1'
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-08-20 16:25:15 +02:00
Sojan Jose 75d3569f22 Merge branch 'release/4.5.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-08-18 18:40:04 +02:00
Sojan Jose 545453537f Merge branch 'release/4.4.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-07-16 00:10:42 -07:00
Sojan d75702c6b2 Merge branch 'release/4.3.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-06-17 17:29:27 -07:00
Sojan b76ec878f1 Merge branch 'release/4.2.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-05-20 00:12:06 -07:00
Sojan a954e1eaca Merge branch 'release/4.1.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-04-16 23:10:46 -07:00
Sojan bc5f1722e1 Merge branch 'release/4.0.4'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-03-21 18:55:06 -07:00
Sojan 8f39e62570 Merge branch 'release/4.0.3'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-27 21:05:35 -08:00
Sojan 7520ca7a99 Merge branch 'release/4.0.2'
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-21 17:04:31 -08:00
Sojan 35f4e63605 Merge branch 'release/4.0.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-17 01:13:27 +05:30
Sojan 5773089865 Merge branch 'release/4.0.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-16 17:11:45 +05:30
Sojan d01c7d3fa7 Merge branch 'release/3.16.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-12-17 23:03:24 +05:30
Sojan 959d2c0d8c Merge branch 'release/3.15.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-11-19 18:02:07 +08:00
Vishnu Narayanan 622f29a0b7 Merge branch 'hotfix/3.14.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-10-22 16:03:47 +05:30
41 changed files with 1402 additions and 139 deletions
+16 -13
View File
@@ -140,24 +140,27 @@ GEM
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.2.0)
aws-partitions (1.760.0)
aws-sdk-core (3.188.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.64.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (~> 1.5)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.5.2)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9)
multi_json (~> 1)
+1 -1
View File
@@ -1 +1 @@
4.8.0
4.9.2
@@ -1,4 +1,6 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
@@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def show; end
def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
@automation_rule.actions = actions
@automation_rule.conditions = params[:conditions]
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
@automation_rule.save!
process_attachments
@automation_rule
blobs.each { |blob| @automation_rule.files.attach(blob) }
end
def update
ActiveRecord::Base.transaction do
automation_rule_update
process_attachments
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do
@automation_rule.assign_attributes(automation_rules_permit)
@automation_rule.actions = actions if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
render_could_not_create_error(@automation_rule.errors.messages)
end
end
@@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule
end
def process_attachments
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private
def automation_rule_update
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
end
def automation_rules_permit
params.permit(
:name, :description, :event_name, :account_id, :active,
:name, :description, :event_name, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
)
end
end
@@ -1,4 +1,6 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
@@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
end
def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@macro.actions = params[:actions]
@macro.actions = actions
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
@macro.save!
process_attachments
@macro
blobs.each { |blob| @macro.files.attach(blob) }
end
def update
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.assign_attributes(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.actions = actions if params[:actions]
@macro.save!
blobs.each { |blob| @macro.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
render_could_not_create_error(@macro.errors.messages)
end
end
@@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
private
def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@macro.files.attach(blob)
end
end
def permitted_params
params.permit(
:name, :account_id, :visibility,
:name, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
end
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
end
def render_error(message, status)
@@ -0,0 +1,35 @@
module AttachmentConcern
extend ActiveSupport::Concern
def validate_and_prepare_attachments(actions, record = nil)
blobs = []
return [blobs, actions, nil] if actions.blank?
sanitized = actions.map do |action|
next action unless action[:action_name] == 'send_attachment'
result = process_attachment_action(action, record, blobs)
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
result
end
[blobs, sanitized, nil]
end
private
def process_attachment_action(action, record, blobs)
blob_id = action[:action_params].first
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
return action if blob_already_attached?(record, blob_id)
nil
end
def blob_already_attached?(record, blob_id)
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
end
end
@@ -0,0 +1,95 @@
import { Device } from '@twilio/voice-sdk';
import VoiceAPI from './voiceAPIClient';
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
class TwilioVoiceClient extends EventTarget {
constructor() {
super();
this.device = null;
this.activeConnection = null;
this.initialized = false;
this.inboxId = null;
}
async initializeDevice(inboxId) {
this.destroyDevice();
const response = await VoiceAPI.getToken(inboxId);
const { token, account_id } = response || {};
if (!token) throw new Error('Invalid token');
this.device = new Device(token, {
allowIncomingWhileBusy: true,
disableAudioContextSounds: true,
appParams: { account_id },
});
this.device.removeAllListeners();
this.device.on('connect', conn => {
this.activeConnection = conn;
conn.on('disconnect', this.onDisconnect);
});
this.device.on('disconnect', this.onDisconnect);
this.device.on('tokenWillExpire', async () => {
const r = await VoiceAPI.getToken(this.inboxId);
if (r?.token) this.device.updateToken(r.token);
});
this.initialized = true;
this.inboxId = inboxId;
return this.device;
}
get hasActiveConnection() {
return !!this.activeConnection;
}
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
}
this.activeConnection = null;
if (this.device) {
this.device.disconnectAll();
}
}
destroyDevice() {
if (this.device) {
this.device.destroy();
}
this.activeConnection = null;
this.device = null;
this.initialized = false;
this.inboxId = null;
}
async joinClientCall({ to, conversationId }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
const params = {
To: to,
is_agent: 'true',
conversation_id: conversationId,
};
const connection = await this.device.connect({ params });
this.activeConnection = connection;
connection.on('disconnect', this.onDisconnect);
return connection;
}
onDisconnect = () => {
this.activeConnection = null;
this.dispatchEvent(createCallDisconnectedEvent());
};
}
export default new TwilioVoiceClient();
@@ -0,0 +1,40 @@
/* global axios */
import ApiClient from '../../ApiClient';
import ContactsAPI from '../../contacts';
class VoiceAPI extends ApiClient {
constructor() {
super('voice', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
initiateCall(contactId, inboxId) {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference(inboxId, conversationId) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId },
})
.then(r => r.data);
}
joinConference({ conversationId, inboxId, callSid }) {
return axios
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
conversation_id: conversationId,
call_sid: callSid,
})
.then(r => r.data);
}
getToken(inboxId) {
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
return axios
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
.then(r => r.data);
}
}
export default new VoiceAPI();
@@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -67,6 +68,17 @@ const startCall = async inboxId => {
contactId: props.contactId,
inboxId,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(response?.conversation_id);
} catch (error) {
@@ -171,10 +171,13 @@ const onPaste = e => {
const files = e.clipboardData?.files;
if (!files?.length) return;
Array.from(files).forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
});
// Filter valid files (non-zero size)
Array.from(files)
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
});
};
useEventListener(document, 'paste', onPaste);
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
outline:
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
@@ -0,0 +1,184 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const router = useRouter();
const store = useStore();
const {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
return {
conversation,
inbox,
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail,
};
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
if (!inboxId) return;
await endCallSession({
conversationId: call.conversationId,
inboxId,
});
};
const handleJoinCall = async call => {
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
const result = await joinCall({
conversationId: call.conversationId,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
// Auto-join outbound calls when window is visible
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
handleJoinCall(call);
}
},
{ immediate: true }
);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
>
<!-- Incoming Calls (shown above active call) -->
<div
v-for="call in hasActiveCall ? incomingCalls : []"
:key="call.callSid"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ getCallInfo(call).inboxName }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="dismissCall(call.callSid)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(call)"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
<!-- Main Call Widget -->
<div
v-if="hasActiveCall || incomingCalls.length"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
</p>
<p v-else class="text-xs text-n-slate-11">
{{
incomingCalls[0]?.callDirection === 'outbound'
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -676,11 +676,18 @@ function createEditorView() {
typingIndicator.stop();
emit('blur');
},
paste: (_view, event) => {
paste: (view, event) => {
if (props.disabled) return;
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
const { files } = event.clipboardData;
if (!files.length) return;
event.preventDefault();
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
// while ReplyBox filters and handles valid file attachments
const text = event.clipboardData.getData('text/plain');
if (text) {
view.dispatch(view.state.tr.insertText(text));
emitOnChange();
}
},
},
@@ -692,20 +692,20 @@ export default {
},
onPaste(e) {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) {
return;
}
if (this.newConversationModalActive) return;
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
if (!data.length || !data[0]) {
return;
}
data.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file: file });
});
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file });
});
},
toggleUserMention(currentMentionState) {
this.showUserMentions = currentMentionState;
@@ -0,0 +1,110 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
const callsStore = useCallsStore();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
watch(
hasActiveCall,
active => {
if (active) {
durationTimer.start();
} else {
durationTimer.stop();
callDuration.value = 0;
}
},
{ immediate: true }
);
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
};
const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (isJoining.value) return null;
isJoining.value = true;
try {
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
const joinResponse = await VoiceAPI.joinConference({
conversationId,
inboxId,
callSid,
});
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
return null;
} finally {
isJoining.value = false;
}
};
const rejectIncomingCall = callSid => {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
const dismissCall = callSid => {
callsStore.dismissCall(callSid);
};
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
return {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
formattedCallDuration,
joinCall,
endCall,
rejectIncomingCall,
dismissCall,
};
}
+28
View File
@@ -0,0 +1,28 @@
export default class Timer {
constructor(onTick = null) {
this.elapsed = 0;
this.intervalId = null;
this.onTick = onTick;
}
start() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.elapsed = 0;
this.intervalId = setInterval(() => {
this.elapsed += 1;
if (this.onTick) {
this.onTick(this.elapsed);
}
}, 1000);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.elapsed = 0;
}
}
@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify';
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
@@ -1,4 +1,5 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/**
* Extracts plain text from HTML content
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
tempDiv.innerHTML = DOMPurify.sanitize(html);
return tempDiv.textContent || tempDiv.innerText || '';
};
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Timer from '../Timer';
describe('Timer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('constructor', () => {
it('initializes with elapsed 0 and no interval', () => {
const timer = new Timer();
expect(timer.elapsed).toBe(0);
expect(timer.intervalId).toBeNull();
});
it('accepts an onTick callback', () => {
const onTick = vi.fn();
const timer = new Timer(onTick);
expect(timer.onTick).toBe(onTick);
});
});
describe('start', () => {
it('starts the timer and increments elapsed every second', () => {
const timer = new Timer();
timer.start();
expect(timer.elapsed).toBe(0);
vi.advanceTimersByTime(1000);
expect(timer.elapsed).toBe(1);
vi.advanceTimersByTime(1000);
expect(timer.elapsed).toBe(2);
vi.advanceTimersByTime(3000);
expect(timer.elapsed).toBe(5);
});
it('calls onTick callback with elapsed value', () => {
const onTick = vi.fn();
const timer = new Timer(onTick);
timer.start();
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(1);
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(2);
expect(onTick).toHaveBeenCalledTimes(2);
});
it('resets elapsed to 0 when restarted', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(5000);
expect(timer.elapsed).toBe(5);
timer.start();
expect(timer.elapsed).toBe(0);
vi.advanceTimersByTime(2000);
expect(timer.elapsed).toBe(2);
});
it('clears previous interval when restarted', () => {
const timer = new Timer();
timer.start();
const firstIntervalId = timer.intervalId;
timer.start();
expect(timer.intervalId).not.toBe(firstIntervalId);
});
});
describe('stop', () => {
it('stops the timer and resets elapsed to 0', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(3000);
expect(timer.elapsed).toBe(3);
timer.stop();
expect(timer.elapsed).toBe(0);
expect(timer.intervalId).toBeNull();
});
it('prevents further increments after stopping', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(2000);
timer.stop();
vi.advanceTimersByTime(5000);
expect(timer.elapsed).toBe(0);
});
it('handles stop when timer is not running', () => {
const timer = new Timer();
expect(() => timer.stop()).not.toThrow();
expect(timer.elapsed).toBe(0);
});
});
});
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
describe('HTML sanitization', () => {
it('removes onerror handlers from img tags in extractQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).toContain('<p>Hello</p>');
});
it('removes onerror handlers from img tags in hasQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
// Should not throw and should safely check for quotes
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
expect(result).toBe(false);
});
it('removes script tags in extractQuotes', () => {
const maliciousHtml =
'<p>Content</p><script>alert("xss")</script><p>More</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('<script');
expect(cleanedHtml).not.toContain('alert');
expect(cleanedHtml).toContain('<p>Content</p>');
expect(cleanedHtml).toContain('<p>More</p>');
});
it('removes onclick handlers in extractQuotes', () => {
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onclick');
expect(cleanedHtml).toContain('Click me');
});
it('removes javascript: URLs in extractQuotes', () => {
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
// eslint-disable-next-line no-script-url
expect(cleanedHtml).not.toContain('javascript:');
expect(cleanedHtml).toContain('Link');
});
it('removes encoded payloads with event handlers in extractQuotes', () => {
const maliciousHtml =
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).not.toContain('eval');
});
});
});
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
it('sanitizes onerror handlers from img tags', () => {
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello');
});
it('sanitizes script tags', () => {
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Safe');
expect(result).toContain('Content');
expect(result).not.toContain('alert');
});
it('sanitizes onclick handlers', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Click me');
});
});
describe('getEmailSenderName', () => {
+79
View File
@@ -0,0 +1,79 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'no-answer',
'canceled',
'missed',
'ended',
];
export const isInbound = direction => direction === 'inbound';
const isVoiceCallMessage = message => {
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
};
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
return {
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
conversationId: message?.conversation_id,
senderId: message?.sender?.id,
};
}
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
callDirection,
senderId,
});
}
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const callsStore = useCallsStore();
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (isNewCall) {
callsStore.addCall({
callSid,
conversationId,
callDirection,
senderId,
});
}
}
@@ -275,6 +275,16 @@
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {
@@ -1,5 +1,5 @@
<script>
import { defineAsyncComponent, ref } from 'vue';
import { defineAsyncComponent, ref, computed } from 'vue';
import NextSidebar from 'next/sidebar/Sidebar.vue';
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
@@ -16,10 +16,15 @@ const CommandBar = defineAsyncComponent(
() => import('./commands/commandbar.vue')
);
const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
export default {
components: {
@@ -30,6 +35,7 @@ export default {
UpgradePage,
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
MobileSidebarLauncher,
},
setup() {
@@ -37,6 +43,7 @@ export default {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
return {
uiSettings,
@@ -44,6 +51,8 @@ export default {
accountId,
upgradePageRef,
windowWidth,
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
};
},
data() {
@@ -151,6 +160,7 @@ export default {
@toggle="toggleMobileSidebar"
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@@ -150,8 +150,6 @@ const derivedAttributes = computed(() =>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
:no-records-found="!attributes.length"
:no-records-message="$t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404')"
>
<template #header>
<BaseSettingsHeader
@@ -177,7 +175,7 @@ const derivedAttributes = computed(() =>
class="max-w-xl"
@tab-changed="onClickTabChange"
/>
<div class="grid gap-3">
<div v-if="derivedAttributes.length" class="grid gap-3">
<AttributeListItem
v-for="attribute in derivedAttributes"
:key="attribute.id"
@@ -187,6 +185,12 @@ const derivedAttributes = computed(() =>
@delete="handleDeleteAttribute"
/>
</div>
<p
v-else
class="flex-1 py-20 text-n-slate-12 flex items-center justify-center text-base"
>
{{ $t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404') }}
</p>
</div>
</template>
<AddAttribute
@@ -12,6 +12,10 @@ import {
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
// This helper is used to check if the message has failed with an external error.
@@ -299,7 +303,7 @@ const actions = {
}
},
addMessage({ commit }, message) {
addMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
if (message.message_type === MESSAGE_TYPE.INCOMING) {
commit(types.SET_CONVERSATION_CAN_REPLY, {
@@ -308,10 +312,12 @@ const actions = {
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
},
updateMessage({ commit }, message) {
updateMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
},
deleteMessage: async function deleteLabels(
@@ -6,6 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
const state = {
allConversations: [],
@@ -24,6 +25,10 @@ const state = {
copilotAssistant: {},
};
const getConversationById = _state => conversationId => {
return _state.allConversations.find(c => c.id === conversationId);
};
// mutations
export const mutations = {
[types.SET_ALL_CONVERSATION](_state, conversationList) {
@@ -270,6 +275,36 @@ export const mutations = {
}
},
[types.UPDATE_CONVERSATION_CALL_STATUS](
_state,
{ conversationId, callStatus }
) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
chat.additional_attributes = {
...chat.additional_attributes,
call_status: callStatus,
};
},
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const lastCall = (chat.messages || []).findLast(
m => m.content_type === CONTENT_TYPES.VOICE_CALL
);
if (!lastCall) return;
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
},
[types.SET_ACTIVE_INBOX](_state, inboxId) {
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
},
@@ -0,0 +1,159 @@
import { mutations } from '../index';
import types from '../../../mutation-types';
describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('updates call_status preserving existing additional_attributes', () => {
const state = {
allConversations: [
{ id: 1, additional_attributes: { other_attr: 'value' } },
],
};
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(state.allConversations[0].additional_attributes).toEqual({
other_attr: 'value',
call_status: 'in-progress',
});
});
it('creates additional_attributes if it does not exist', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(state.allConversations[0].additional_attributes).toEqual({
call_status: 'completed',
});
});
});
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('does nothing if no voice call message exists', () => {
const state = {
allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations[0].messages[0]).toEqual({
id: 1,
content_type: 'text',
});
});
it('updates the last voice call message status', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
},
{
id: 2,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('ringing');
expect(
state.allConversations[0].messages[1].content_attributes.data.status
).toBe('in-progress');
});
it('creates content_attributes.data if it does not exist', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 1, content_type: 'voice_call' }],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('completed');
});
it('preserves existing data in content_attributes.data', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
content_attributes: {
data: { call_sid: 'CA123', status: 'ringing' },
},
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(
state.allConversations[0].messages[0].content_attributes.data
).toEqual({
call_sid: 'CA123',
status: 'in-progress',
});
});
it('handles empty messages array', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations[0].messages).toEqual([]);
});
});
});
@@ -51,6 +51,8 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
+59
View File
@@ -0,0 +1,59 @@
import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
export const useCallsStore = defineStore('calls', {
state: () => ({
calls: [],
}),
getters: {
activeCall: state => state.calls.find(call => call.isActive) || null,
hasActiveCall: state => state.calls.some(call => call.isActive),
incomingCalls: state => state.calls.filter(call => !call.isActive),
hasIncomingCall: state => state.calls.some(call => !call.isActive),
},
actions: {
handleCallStatusChanged({ callSid, status }) {
if (TERMINAL_STATUSES.includes(status)) {
this.removeCall(callSid);
}
},
addCall(callData) {
if (!callData?.callSid) return;
const exists = this.calls.some(call => call.callSid === callData.callSid);
if (exists) return;
this.calls.push({
...callData,
isActive: false,
});
},
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
setCallActive(callSid) {
this.calls = this.calls.map(call => ({
...call,
isActive: call.callSid === callSid,
}));
},
clearActiveCall() {
TwilioVoiceClient.endClientCall();
this.calls = this.calls.filter(call => !call.isActive);
},
dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid);
},
},
});
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.8.0'
version: '4.9.2'
development:
<<: *shared
+1 -1
View File
@@ -172,7 +172,7 @@
- name: channel_voice
display_name: Voice Channel
enabled: false
chatwoot_internal: true
premium: true
- name: notion_integration
display_name: Notion Integration
enabled: false
+2
View File
@@ -62,6 +62,8 @@ en:
failed: Signup failed
assignment_policy:
not_found: Assignment policy not found
attachments:
invalid: Invalid attachment
saml:
feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation
@@ -18,6 +18,7 @@ class Enterprise::Billing::HandleStripeEventService
captain_integration
advanced_search_indexing
advanced_search
linear_integration
].freeze
# Additional features available starting with the Business plan
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.8.0",
"version": "4.9.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -49,6 +49,7 @@
"@sindresorhus/slugify": "2.2.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/vue-table": "^8.20.5",
"@twilio/voice-sdk": "^2.12.4",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/compiler-sfc": "^3.5.8",
"@vuelidate/core": "^2.0.3",
+37
View File
@@ -67,6 +67,9 @@ importers:
'@tanstack/vue-table':
specifier: ^8.20.5
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
'@twilio/voice-sdk':
specifier: ^2.12.4
version: 2.17.0
'@vitejs/plugin-vue':
specifier: ^5.1.4
version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
@@ -1283,9 +1286,19 @@ packages:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
'@twilio/voice-errors@1.7.0':
resolution: {integrity: sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==}
'@twilio/voice-sdk@2.17.0':
resolution: {integrity: sha512-dAqAfQ59xexKdVi6U1TJAKlf6aDySAinjMvXrNdAFJDzSWJ5SNh49ITxdaKR2vaUdxTc7ncFgGVeI72W2dWHjg==}
engines: {node: '>= 12'}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/events@3.0.3':
resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
'@types/flexsearch@0.7.6':
resolution: {integrity: sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==}
@@ -2399,6 +2412,10 @@ packages:
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
expect-type@1.1.0:
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
engines: {node: '>=12.0.0'}
@@ -3119,6 +3136,10 @@ packages:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
@@ -5750,8 +5771,20 @@ snapshots:
'@tootallnate/once@2.0.0': {}
'@twilio/voice-errors@1.7.0': {}
'@twilio/voice-sdk@2.17.0':
dependencies:
'@twilio/voice-errors': 1.7.0
'@types/events': 3.0.3
events: 3.3.0
loglevel: 1.9.2
tslib: 2.8.1
'@types/estree@1.0.8': {}
'@types/events@3.0.3': {}
'@types/flexsearch@0.7.6': {}
'@types/fs-extra@9.0.13':
@@ -7137,6 +7170,8 @@ snapshots:
eventemitter3@5.0.1: {}
events@3.3.0: {}
expect-type@1.1.0: {}
extend-shallow@2.0.1:
@@ -7966,6 +8001,8 @@ snapshots:
strip-ansi: 7.1.0
wrap-ansi: 9.0.0
loglevel@1.9.2: {}
loupe@3.1.3: {}
lower-case@2.0.2:
@@ -141,21 +141,14 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
end
it 'Saves file in the automation actions to send an attachments' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
expect(account.automation_rules.count).to eq(0)
post "/api/v1/accounts/#{account.id}/upload/",
headers: administrator.create_new_auth_token,
params: { attachment: file }
expect(response).to have_http_status(:success)
blob = response.parsed_body
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
params[:actions] = [
{
'action_name': :send_message,
@@ -163,7 +156,7 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
},
{
'action_name': :send_attachment,
'action_params': [blob['blob_id']]
'action_params': [blob.signed_id]
}
]
@@ -177,29 +170,25 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
end
it 'Saves files in the automation actions to send multiple attachments' do
file_1 = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
file_2 = fixture_file_upload(Rails.root.join('spec/assets/sample.png'), 'image/png')
post "/api/v1/accounts/#{account.id}/upload/",
headers: administrator.create_new_auth_token,
params: { attachment: file_1 }
blob_1 = response.parsed_body
post "/api/v1/accounts/#{account.id}/upload/",
headers: administrator.create_new_auth_token,
params: { attachment: file_2 }
blob_2 = response.parsed_body
blob_1 = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
blob_2 = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/sample.png').open,
filename: 'sample.png',
content_type: 'image/png'
)
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob_1['blob_id']]
'action_params': [blob_1.signed_id]
},
{
'action_name': :send_attachment,
'action_params': [blob_2['blob_id']]
'action_params': [blob_2.signed_id]
}
]
@@ -210,6 +199,46 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
automation_rule = account.automation_rules.first
expect(automation_rule.files.count).to eq(2)
end
it 'returns error for invalid attachment blob_id' do
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': ['invalid_blob_id']
}
]
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: params
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
end
it 'stores the original blob_id in action_params after create' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob.signed_id]
}
]
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: params
automation_rule = account.automation_rules.first
attachment_action = automation_rule.actions.find { |a| a['action_name'] == 'send_attachment' }
expect(attachment_action['action_params'].first).to be_a(Integer)
expect(attachment_action['action_params'].first).to eq(automation_rule.files.first.blob_id)
end
end
end
@@ -328,6 +357,68 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
expect(body[:payload][:active]).to be(false)
expect(automation_rule.reload.active).to be(false)
end
it 'allows update with existing blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
automation_rule.update!(actions: [{ 'action_name' => 'send_attachment', 'action_params' => [blob.id] }])
automation_rule.files.attach(blob)
update_params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob.id]
}
]
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
headers: administrator.create_new_auth_token,
params: update_params
expect(response).to have_http_status(:success)
end
it 'returns error for invalid blob_id on update' do
update_params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [999_999]
}
]
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
headers: administrator.create_new_auth_token,
params: update_params
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
end
it 'allows adding new attachment on update with signed blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
update_params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob.signed_id]
}
]
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
headers: administrator.create_new_auth_token,
params: update_params
expect(response).to have_http_status(:success)
expect(automation_rule.reload.files.count).to eq(1)
end
end
end
@@ -127,18 +127,11 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
end
it 'Saves file in the macros actions to send an attachments' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
post "/api/v1/accounts/#{account.id}/upload/",
headers: administrator.create_new_auth_token,
params: { attachment: file }
expect(response).to have_http_status(:success)
blob = response.parsed_body
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
params[:actions] = [
{
@@ -147,7 +140,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
},
{
'action_name': :send_attachment,
'action_params': [blob['blob_id']]
'action_params': [blob.signed_id]
}
]
@@ -159,6 +152,46 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(macro.files.presence).to be_truthy
expect(macro.files.count).to eq(1)
end
it 'returns error for invalid attachment blob_id' do
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': ['invalid_blob_id']
}
]
post "/api/v1/accounts/#{account.id}/macros",
headers: administrator.create_new_auth_token,
params: params
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
end
it 'stores the original blob_id in action_params after create' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
params[:actions] = [
{
'action_name': :send_attachment,
'action_params': [blob.signed_id]
}
]
post "/api/v1/accounts/#{account.id}/macros",
headers: administrator.create_new_auth_token,
params: params
macro = account.macros.last
attachment_action = macro.actions.find { |a| a['action_name'] == 'send_attachment' }
expect(attachment_action['action_params'].first).to be_a(Integer)
expect(attachment_action['action_params'].first).to eq(macro.files.first.blob_id)
end
end
end
@@ -202,6 +235,47 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(response).to have_http_status(:unauthorized)
expect(json_response['error']).to eq('You are not authorized to do this action')
end
it 'allows update with existing blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
macro.update!(actions: [{ 'action_name' => 'send_attachment', 'action_params' => [blob.id] }])
macro.files.attach(blob)
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [blob.id] }] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
end
it 'returns error for invalid blob_id on update' do
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [999_999] }] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
end
it 'allows adding new attachment on update with signed blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [blob.signed_id] }] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
expect(macro.reload.files.count).to eq(1)
end
end
end
@@ -18,7 +18,6 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
blob = response.parsed_body
expect(blob['errors']).to be_nil
expect(blob['file_url']).to be_present
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
end
@@ -53,7 +52,6 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
blob = response.parsed_body
expect(blob['error']).to be_nil
expect(blob['file_url']).to be_present
expect(blob['blob_key']).to be_present
expect(blob['blob_id']).to be_present
end