diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index c68251f32..000000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,62 +0,0 @@ -version: '2' -plugins: - rubocop: - enabled: false - channel: rubocop-0-73 - eslint: - enabled: false - csslint: - enabled: true - scss-lint: - enabled: true - brakeman: - enabled: false -checks: - similar-code: - enabled: false - method-count: - enabled: true - config: - threshold: 32 - file-lines: - enabled: true - config: - threshold: 300 - method-lines: - config: - threshold: 50 -exclude_patterns: - - 'spec/' - - '**/specs/**/**' - - '**/spec/**/**' - - 'db/*' - - 'bin/**/*' - - 'db/**/*' - - 'config/**/*' - - 'public/**/*' - - 'vendor/**/*' - - 'node_modules/**/*' - - 'lib/tasks/auto_annotate_models.rake' - - 'app/test-matchers.js' - - 'docs/*' - - '**/*.md' - - '**/*.yml' - - 'app/javascript/dashboard/i18n/locale' - - '**/*.stories.js' - - 'stories/' - - 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js' - - 'app/javascript/shared/constants/countries.js' - - 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js' - - 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js' - - 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js' - - 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js' - - 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js' - - 'app/javascript/dashboard/store/captain/storeFactory.js' - - 'app/javascript/dashboard/i18n/index.js' - - 'app/javascript/widget/i18n/index.js' - - 'app/javascript/survey/i18n/index.js' - - 'app/javascript/shared/constants/locales.js' - - 'app/javascript/dashboard/helper/specs/macrosFixtures.js' - - 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js' - - '**/fixtures/**' - - '**/*/fixtures.js' diff --git a/.eslintrc.js b/.eslintrc.js index 6c867f557..6b5205ad7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -103,6 +103,7 @@ module.exports = { '⌘', '📄', '🎉', + '🚀', '💬', '👥', '📥', diff --git a/.github/workflows/auto-assign-pr.yml b/.github/workflows/auto-assign-pr.yml new file mode 100644 index 000000000..98df89707 --- /dev/null +++ b/.github/workflows/auto-assign-pr.yml @@ -0,0 +1,28 @@ +name: Auto-assign PR to Author + +on: + pull_request: + types: [opened] + +jobs: + auto-assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Auto-assign PR to author + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: pull_number, + assignees: [author] + }); + + console.log(`Assigned PR #${pull_number} to ${author}`); \ No newline at end of file diff --git a/.github/workflows/deploy_check.yml b/.github/workflows/deploy_check.yml index 7fda2b1a4..9f295a6c8 100644 --- a/.github/workflows/deploy_check.yml +++ b/.github/workflows/deploy_check.yml @@ -6,6 +6,11 @@ name: Deploy Check on: pull_request: +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + jobs: deployment_check: name: Check Deployment diff --git a/.github/workflows/logging_percentage_check.yml b/.github/workflows/logging_percentage_check.yml index e9f84c313..5c45ba635 100644 --- a/.github/workflows/logging_percentage_check.yml +++ b/.github/workflows/logging_percentage_check.yml @@ -5,6 +5,11 @@ on: branches: - develop +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + jobs: log_lines_check: runs-on: ubuntu-latest diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 9be79da05..c2a4bd174 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -5,6 +5,11 @@ on: branches: - develop +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index c64fb5c1b..bb0df62a8 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,4 @@ yarn-debug.log* .vscode .claude/settings.local.json .cursor +CLAUDE.local.md diff --git a/.qlty/.gitignore b/.qlty/.gitignore new file mode 100644 index 000000000..30366188d --- /dev/null +++ b/.qlty/.gitignore @@ -0,0 +1,7 @@ +* +!configs +!configs/** +!hooks +!hooks/** +!qlty.toml +!.gitignore diff --git a/.qlty/configs/.hadolint.yaml b/.qlty/configs/.hadolint.yaml new file mode 100644 index 000000000..8f7e23e45 --- /dev/null +++ b/.qlty/configs/.hadolint.yaml @@ -0,0 +1,2 @@ +ignored: + - DL3008 diff --git a/.qlty/configs/.shellcheckrc b/.qlty/configs/.shellcheckrc new file mode 100644 index 000000000..6a38d9281 --- /dev/null +++ b/.qlty/configs/.shellcheckrc @@ -0,0 +1 @@ +source-path=SCRIPTDIR \ No newline at end of file diff --git a/.qlty/configs/.yamllint.yaml b/.qlty/configs/.yamllint.yaml new file mode 100644 index 000000000..d22fa7799 --- /dev/null +++ b/.qlty/configs/.yamllint.yaml @@ -0,0 +1,8 @@ +rules: + document-start: disable + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml new file mode 100644 index 000000000..57981a5f7 --- /dev/null +++ b/.qlty/qlty.toml @@ -0,0 +1,84 @@ +# This file was automatically generated by `qlty init`. +# You can modify it to suit your needs. +# We recommend you to commit this file to your repository. +# +# This configuration is used by both Qlty CLI and Qlty Cloud. +# +# Qlty CLI -- Code quality toolkit for developers +# Qlty Cloud -- Fully automated Code Health Platform +# +# Try Qlty Cloud: https://qlty.sh +# +# For a guide to configuration, visit https://qlty.sh/d/config +# Or for a full reference, visit https://qlty.sh/d/qlty-toml +config_version = "0" + +exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", + "**/.yarn/**", + "**/*.d.ts", + "**/assets/**", + "**/bower_components/**", + "**/build/**", + "**/cache/**", + "**/config/**", + "**/db/**", + "**/deps/**", + "**/dist/**", + "**/extern/**", + "**/external/**", + "**/generated/**", + "**/Godeps/**", + "**/gradlew/**", + "**/mvnw/**", + "**/node_modules/**", + "**/protos/**", + "**/seed/**", + "**/target/**", + "**/templates/**", + "**/testdata/**", + "**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js", +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", + "**/test_*.*", + "**/spec_*.*", +] + +[smells] +mode = "comment" + +[smells.boolean_logic] +threshold = 4 + +[smells.file_complexity] +threshold = 66 +enabled = true + +[smells.return_statements] +threshold = 4 + +[smells.nested_control_flow] +threshold = 4 + +[smells.function_parameters] +threshold = 4 + +[smells.function_complexity] +threshold = 5 + +[smells.duplication] +enabled = true +threshold = 20 + +[[source]] +name = "default" +default = true diff --git a/.rubocop.yml b/.rubocop.yml index 12e756af6..e30a71ee9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod: Enabled: false Layout/TrailingEmptyLines: - Enabled: false + Enabled: true Style/SafeNavigationChainLength: Enabled: false diff --git a/Gemfile b/Gemfile index b4752c745..9929575d4 100644 --- a/Gemfile +++ b/Gemfile @@ -121,6 +121,8 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false gem 'sidekiq', '>= 7.3.1' # We want cron jobs gem 'sidekiq-cron', '>= 1.12.0' +# for sidekiq healthcheck +gem 'sidekiq_alive' ##-- Push notification service --## gem 'fcm' @@ -177,6 +179,10 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' +gem 'ai-agents', '>= 0.4.3' + +# TODO: Move this gem as a dependency of ai-agents +gem 'ruby_llm-schema' gem 'shopify_api' diff --git a/Gemfile.lock b/Gemfile.lock index 8315a5374..1c64914b3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,6 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) + ai-agents (0.4.3) + ruby_llm (~> 1.3) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -357,6 +359,7 @@ GEM grpc (1.72.0-x86_64-linux) google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) + gserver (0.0.1) haikunator (1.1.1) hairtrigger (1.0.0) activerecord (>= 6.0, < 8) @@ -475,7 +478,7 @@ GEM mime-types-data (3.2023.0218.1) mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.8) + mini_portile2 (2.8.9) minitest (5.25.5) mock_redis (0.36.0) ruby2_keywords @@ -506,14 +509,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.18.8) + nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.8-arm64-darwin) + nokogiri (1.18.9-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.8-x86_64-darwin) + nokogiri (1.18.9-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-gnu) + nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -717,6 +720,16 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) + ruby_llm (1.5.1) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1.0) + zeitwerk (~> 2) + ruby_llm-schema (0.1.0) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -774,6 +787,9 @@ GEM fugit (~> 1.8) globalid (>= 1.0.1) sidekiq (>= 6) + sidekiq_alive (2.5.0) + gserver (~> 0.0.1) + sidekiq (>= 5, < 9) signet (0.17.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -812,7 +828,7 @@ GEM stripe (8.5.0) telephone_number (1.4.20) test-prof (1.2.1) - thor (1.3.1) + thor (1.4.0) tilt (2.3.0) time_diff (0.3.0) activesupport @@ -895,6 +911,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) + ai-agents (>= 0.4.3) annotate attr_extras audited (~> 5.4, >= 5.4.1) @@ -988,6 +1005,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai + ruby_llm-schema scout_apm scss_lint seed_dump @@ -998,6 +1016,7 @@ DEPENDENCIES shoulda-matchers sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) + sidekiq_alive simplecov (= 0.17.1) slack-ruby-client (~> 2.5.2) spring diff --git a/VERSION_CW b/VERSION_CW index 4eba2a62e..fdc669880 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -3.13.0 +4.4.0 diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 944880fa1..4d9d11cf5 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.2.0 +3.4.2 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index a88d3535b..bcf5a93c3 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -6,6 +6,7 @@ # We don't want to update the name of the identified original contact. class ContactIdentifyAction + include UrlHelper pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }] def perform @@ -104,7 +105,14 @@ class ContactIdentifyAction # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded @contact.discard_invalid_attrs if discard_invalid_attrs @contact.save! - Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached? + enqueue_avatar_job + end + + def enqueue_avatar_job + return unless params[:avatar_url].present? && !@contact.avatar.attached? + return unless url_valid?(params[:avatar_url]) + + Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) end def merge_contact(base_contact, merge_contact) diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 6e119ca3d..90cdf2418 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -30,7 +30,14 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController end def facebook_pages - @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts')) + pages = [] + fb_pages = fb_object.get_connections('me', 'accounts') + pages.concat(fb_pages) + while fb_pages.respond_to?(:next_page) && (next_page = fb_pages.next_page) + fb_pages = next_page + pages.concat(fb_pages) + end + @page_details = mark_already_existing_facebook_pages(pages) end def set_instagram_id(page_access_token, facebook_channel) diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index b1a132246..2a1650e53 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController def campaign_params params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id, - :scheduled_at, audience: [:type, :id], trigger_rules: {}) + :scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {}) end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 4fbe50902..039786905 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -122,7 +122,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def resolved_contacts return @resolved_contacts if @resolved_contacts - @resolved_contacts = Current.account.contacts.resolved_contacts + @resolved_contacts = Current.account.contacts.resolved_contacts(use_crm_v2: Current.account.feature_enabled?('crm_v2')) @resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present? @resolved_contacts diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index e7b3b197b..78b4b9e2f 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -69,6 +69,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } end + def sync_templates + unless @inbox.channel.is_a?(Channel::Whatsapp) + return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } + end + + Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + render status: :ok, json: { message: 'Template sync initiated successfully' } + rescue StandardError => e + render status: :internal_server_error, json: { error: e.message } + end + private def fetch_inbox diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index bfdfff058..eb6525bb1 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas before_action :fetch_hook, only: [:destroy] def destroy + revoke_linear_token @hook.destroy! head :ok end @@ -27,7 +28,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def create_issue - issue = linear_processor_service.create_issue(permitted_params) + issue = linear_processor_service.create_issue(permitted_params, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else @@ -44,7 +45,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def link_issue issue_id = permitted_params[:issue_id] title = permitted_params[:title] - issue = linear_processor_service.link_issue(conversation_link, issue_id, title) + issue = linear_processor_service.link_issue(conversation_link, issue_id, title, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else @@ -120,4 +121,15 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def fetch_hook @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') end + + def revoke_linear_token + return unless @hook&.access_token + + begin + linear_client = Linear.new(@hook.access_token) + linear_client.revoke_token + rescue StandardError => e + Rails.logger.error "Failed to revoke Linear token: #{e.message}" + end + end end diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb new file mode 100644 index 000000000..ecf6bae6e --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb @@ -0,0 +1,14 @@ +class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end + + private + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion') + end +end diff --git a/app/controllers/api/v1/accounts/notion/authorizations_controller.rb b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb new file mode 100644 index 000000000..3e0f6586a --- /dev/null +++ b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb @@ -0,0 +1,21 @@ +class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include NotionConcern + + def create + redirect_url = notion_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/notion/callback", + response_type: 'code', + owner: 'user', + state: state, + client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil) + } + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 6cfed161f..af96441f8 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? # @portal.custom_domain = parsed_custom_domain process_attached_logo if params[:blob_id].present? - rescue StandardError => e - Rails.logger.error e - render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + render_record_invalid(e) end end @@ -47,6 +46,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController head :ok end + def send_instructions + email = permitted_params[:email] + return render_could_not_create_error(I18n.t('portals.send_instructions.email_required')) if email.blank? + return render_could_not_create_error(I18n.t('portals.send_instructions.invalid_email_format')) unless valid_email?(email) + return render_could_not_create_error(I18n.t('portals.send_instructions.custom_domain_not_configured')) if @portal.custom_domain.blank? + + PortalInstructionsMailer.send_cname_instructions( + portal: @portal, + recipient_email: email + ).deliver_later + + render json: { message: I18n.t('portals.send_instructions.instructions_sent_successfully') }, status: :ok + end + def process_attached_logo blob_id = params[:blob_id] blob = ActiveStorage::Blob.find_by(id: blob_id) @@ -60,12 +73,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def permitted_params - params.permit(:id) + params.permit(:id, :email) end def portal_params params.require(:portal).permit( - :account_id, :color, :custom_domain, :header_text, :homepage_link, + :id, :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } ) end @@ -88,4 +101,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController domain = URI.parse(@portal.custom_domain) domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain end + + def valid_email?(email) + ValidEmail2::Address.new(email).valid? + end end + +Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController') diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb new file mode 100644 index 000000000..3e7d876c3 --- /dev/null +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -0,0 +1,87 @@ +class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + before_action :validate_feature_enabled! + before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } + + # POST /api/v1/accounts/:account_id/whatsapp/authorization + # Handles both initial authorization and reauthorization + # If inbox_id is present in params, it performs reauthorization + def create + validate_embedded_signup_params! + channel = process_embedded_signup + render_success_response(channel.inbox) + rescue StandardError => e + render_error_response(e) + end + + private + + def process_embedded_signup + service = Whatsapp::EmbeddedSignupService.new( + account: Current.account, + params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys, + inbox_id: params[:inbox_id] + ) + service.perform + end + + def fetch_and_validate_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + validate_reauthorization_required + end + + def validate_reauthorization_required + return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup? + + render json: { + success: false, + message: I18n.t('inbox.reauthorization.not_required') + }, status: :unprocessable_entity + end + + def can_upgrade_to_embedded_signup? + channel = @inbox.channel + return false unless channel.provider == 'whatsapp_cloud' + + true + end + + def render_success_response(inbox) + response = { + success: true, + id: inbox.id, + name: inbox.name, + channel_type: 'whatsapp' + } + response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present? + render json: response + end + + def render_error_response(error) + Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}" + Rails.logger.error error.backtrace.join("\n") + render json: { + success: false, + error: error.message + }, status: :unprocessable_entity + end + + def validate_feature_enabled! + return if Current.account.feature_whatsapp_embedded_signup? + + render json: { + success: false, + error: 'WhatsApp embedded signup is not enabled for this account' + }, status: :forbidden + end + + def validate_embedded_signup_params! + missing_params = [] + missing_params << 'code' if params[:code].blank? + missing_params << 'business_id' if params[:business_id].blank? + missing_params << 'waba_id' if params[:waba_id].blank? + + return if missing_params.empty? + + raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}" + end +end diff --git a/app/controllers/concerns/notion_concern.rb b/app/controllers/concerns/notion_concern.rb new file mode 100644 index 000000000..2b94fe63b --- /dev/null +++ b/app/controllers/concerns/notion_concern.rb @@ -0,0 +1,21 @@ +module NotionConcern + extend ActiveSupport::Concern + + def notion_client + app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil) + + ::OAuth2::Client.new(app_id, app_secret, { + site: 'https://api.notion.com', + authorize_url: 'https://api.notion.com/v1/oauth/authorize', + token_url: 'https://api.notion.com/v1/oauth/token', + auth_scheme: :basic_auth + }) + end + + private + + def scope + '' + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 6a4ce2461..4a2df5ee5 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -67,6 +67,8 @@ class DashboardController < ActionController::Base FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), + WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), + WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), GIT_SHA: GIT_HASH diff --git a/app/controllers/microsoft_controller.rb b/app/controllers/microsoft_controller.rb index e6a12dafa..3071eac31 100644 --- a/app/controllers/microsoft_controller.rb +++ b/app/controllers/microsoft_controller.rb @@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController after_action :set_version_header def identity_association - microsoft_indentity + microsoft_identity end private @@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length end - def microsoft_indentity + def microsoft_identity @identity_json = GlobalConfigService.load('AZURE_APP_ID', nil) end end diff --git a/app/controllers/notion/callbacks_controller.rb b/app/controllers/notion/callbacks_controller.rb new file mode 100644 index 000000000..22dcf6d30 --- /dev/null +++ b/app/controllers/notion/callbacks_controller.rb @@ -0,0 +1,36 @@ +class Notion::CallbacksController < OauthCallbackController + include NotionConcern + + private + + def provider_name + 'notion' + end + + def oauth_client + notion_client + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'notion', + settings: { + token_type: parsed_body['token_type'], + workspace_name: parsed_body['workspace_name'], + workspace_id: parsed_body['workspace_id'], + workspace_icon: parsed_body['workspace_icon'], + bot_id: parsed_body['bot_id'], + owner: parsed_body['owner'] + } + ) + + hook.save! + redirect_to notion_redirect_uri + end + + def notion_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion" + end +end diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb index e11cf9d4a..4521930a6 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -1,4 +1,11 @@ class Platform::Api::V1::AccountsController < PlatformController + def index + @resources = @platform_app.platform_app_permissibles + .where(permissible_type: 'Account') + .includes(:permissible) + .map(&:permissible) + end + def show; end def create diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 02023559b..e6cc2aa69 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -17,7 +17,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B limit_results end - def show; end + def show + @og_image_url = helpers.set_og_image_url(@portal.name, @article.title) + end def tracking_pixel @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index c3a7b59e9..ebfcb310a 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -8,7 +8,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals: @categories = @portal.categories.order(position: :asc) end - def show; end + def show + @og_image_url = helpers.set_og_image_url(@portal.name, @category.name) + end private diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index e8fb867cb..df4552432 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -4,7 +4,9 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl before_action :redirect_to_portal_with_locale, only: [:show] layout 'portal' - def show; end + def show + @og_image_url = helpers.set_og_image_url('', @portal.header_text) + end def sitemap @help_center_url = @portal.custom_domain || ChatwootApp.help_center_root diff --git a/app/controllers/slack_uploads_controller.rb b/app/controllers/slack_uploads_controller.rb index 127e77649..6f157c7d5 100644 --- a/app/controllers/slack_uploads_controller.rb +++ b/app/controllers/slack_uploads_controller.rb @@ -17,7 +17,12 @@ class SlackUploadsController < ApplicationController end def blob_url - url_for(@blob.representation(resize_to_fill: [250, nil])) + # Only generate representations for images + if @blob.content_type.start_with?('image/') + url_for(@blob.representation(resize_to_fill: [250, nil])) + else + url_for(@blob) + end end def avatar_url diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 204bfc95b..5cf158b98 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -39,7 +39,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], - 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] + 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], + 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], + 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], + 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI] } @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) diff --git a/app/controllers/super_admin/application_controller.rb b/app/controllers/super_admin/application_controller.rb index 775fb34fc..5b04fbb44 100644 --- a/app/controllers/super_admin/application_controller.rb +++ b/app/controllers/super_admin/application_controller.rb @@ -7,8 +7,9 @@ class SuperAdmin::ApplicationController < Administrate::ApplicationController include ActionView::Helpers::TagHelper include ActionView::Context + include SuperAdmin::NavigationHelper - helper_method :render_vue_component + helper_method :render_vue_component, :settings_open?, :settings_pages # authenticiation done via devise : SuperAdmin Model before_action :authenticate_super_admin! diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index ff16e9386..d607ba151 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -27,7 +27,11 @@ class Twilio::CallbackController < ApplicationController *Array.new(10) { |i| :"MediaUrl#{i}" }, *Array.new(10) { |i| :"MediaContentType#{i}" }, :MessagingServiceSid, - :NumMedia + :NumMedia, + :Latitude, + :Longitude, + :MessageType, + :ProfileName ) end end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 3d46334ca..569c2524b 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -4,7 +4,16 @@ class Webhooks::InstagramController < ActionController::API def events Rails.logger.info('Instagram webhook received events') if params['object'].casecmp('instagram').zero? - ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) + entry_params = params.to_unsafe_hash[:entry] + + if contains_echo_event?(entry_params) + # Add delay to prevent race condition where echo arrives before send message API completes + # This avoids duplicate messages when echo comes early during API processing + ::Webhooks::InstagramEventsJob.set(wait: 2.seconds).perform_later(entry_params) + else + ::Webhooks::InstagramEventsJob.perform_later(entry_params) + end + render json: :ok else Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}") @@ -14,6 +23,16 @@ class Webhooks::InstagramController < ActionController::API private + def contains_echo_event?(entry_params) + return false unless entry_params.is_a?(Array) + + entry_params.any? do |entry| + # Check messaging array for echo events + messaging_events = entry[:messaging] || [] + messaging_events.any? { |messaging| messaging.dig(:message, :is_echo).present? } + end + end + def valid_token?(token) # Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and # INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login) diff --git a/app/finders/notification_finder.rb b/app/finders/notification_finder.rb index ccfe470a0..e1958827a 100644 --- a/app/finders/notification_finder.rb +++ b/app/finders/notification_finder.rb @@ -15,7 +15,13 @@ class NotificationFinder end def unread_count - @notifications.where(read_at: nil).count + if type_included?('read') + # If we're including read notifications, filter to unread + @notifications.where(read_at: nil).count + else + # Already filtered to unread notifications, just count + @notifications.count + end end def count @@ -27,7 +33,7 @@ class NotificationFinder def set_up find_all_notifications filter_snoozed_notifications - fitler_read_notifications + filter_read_notifications end def find_all_notifications @@ -38,7 +44,7 @@ class NotificationFinder @notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed') end - def fitler_read_notifications + def filter_read_notifications @notifications = @notifications.where(read_at: nil) unless type_included?('read') end diff --git a/app/helpers/message_format_helper.rb b/app/helpers/message_format_helper.rb index 2c50fd609..8b271e3a3 100644 --- a/app/helpers/message_format_helper.rb +++ b/app/helpers/message_format_helper.rb @@ -1,9 +1,13 @@ module MessageFormatHelper - include RegexHelper - def transform_user_mention_content(message_content) # attachment message without content, message_content is nil - message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : '' + return '' unless message_content.presence + + # Use CommonMarker to convert markdown to plain text for notifications + # This handles all markdown formatting (links, bold, italic, etc.) not just mentions + # Converts: [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) + # To: @👍 customer support + CommonMarker.render_doc(message_content).to_plaintext.strip end def render_message_content(message_content) diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 2b1ab7b21..3ed303556 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -1,4 +1,21 @@ module PortalHelper + def set_og_image_url(portal_name, title) + cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL'] + return if cdn_url.blank? + + client_ref = GlobalConfig.get('OG_IMAGE_CLIENT_REF')['OG_IMAGE_CLIENT_REF'] + + uri = URI.parse(cdn_url) + uri.path = '/og' + uri.query = URI.encode_www_form( + clientRef: client_ref, + title: title, + portalName: portal_name + ) + + uri.to_s + end + def generate_portal_bg_color(portal_color, theme) base_color = theme == 'dark' ? 'black' : 'white' "color-mix(in srgb, #{portal_color} 20%, #{base_color})" diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb index e08b5691c..f0a419cc8 100644 --- a/app/helpers/reporting_event_helper.rb +++ b/app/helpers/reporting_event_helper.rb @@ -18,12 +18,25 @@ module ReportingEventHelper end def last_non_human_activity(conversation) - # check if a handoff event already exists - handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last + # Try to get either a handoff or reopened event first + # These will always take precedence over any other activity + # Also, any of these events can happen at any time in the course of a conversation lifecycle. + # So we pick the latest event + event = ReportingEvent.where( + conversation_id: conversation.id, + name: %w[conversation_bot_handoff conversation_opened] + ).order(event_end_time: :desc).first - # if a handoff exists, last non human activity is when the handoff ended, - # otherwise it's when the conversation was created - handoff_event&.event_end_time || conversation.created_at + return event.event_end_time if event&.event_end_time + + # Fallback to bot resolved event + # Because this will be closest to the most accurate activity instead of conversation.created_at + bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last + + return bot_event.event_end_time if bot_event&.event_end_time + + # If no events found, return conversation creation time + conversation.created_at end private diff --git a/enterprise/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml similarity index 81% rename from enterprise/app/helpers/super_admin/features.yml rename to app/helpers/super_admin/features.yml index c20de2dfa..f49004e79 100644 --- a/enterprise/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -1,5 +1,7 @@ # TODO: Move this values to features.yml itself # No need to replicate the same values in two places + +# ------- Premium Features ------- # captain: name: 'Captain' description: 'Enable AI-powered conversations with your customers.' @@ -32,6 +34,15 @@ disable_branding: enabled: <%= (ChatwootHub.pricing_plan != 'community') %> icon: 'icon-sailbot-fill' enterprise: true + +# ------- Product Features ------- # +help_center: + name: 'Help Center' + description: 'Allow agents to create help center articles and publish them in a portal.' + enabled: true + icon: 'icon-book-2-line' + +# ------- Communication Channels ------- # live_chat: name: 'Live Chat' description: 'Improve your customer experience using a live chat on your website.' @@ -42,6 +53,12 @@ email: description: 'Manage your email customer interactions from Chatwoot.' enabled: true icon: 'icon-mail-send-fill' + config_key: 'email' +sms: + name: 'SMS' + description: 'Manage your SMS customer interactions from Chatwoot.' + enabled: true + icon: 'icon-message-line' messenger: name: 'Messenger' description: 'Stay connected with your customers on Facebook & Instagram.' @@ -69,34 +86,46 @@ line: description: 'Manage your Line customer interactions from Chatwoot.' enabled: true icon: 'icon-line-line' -sms: - name: 'SMS' - description: 'Manage your SMS customer interactions from Chatwoot.' + +# ------- OAuth & Authentication ------- # +google: + name: 'Google' + description: 'Configuration for setting up Google OAuth Integration' enabled: true - icon: 'icon-message-line' -help_center: - name: 'Help Center' - description: 'Allow agents to create help center articles and publish them in a portal.' - enabled: true - icon: 'icon-book-2-line' + icon: 'icon-google' + config_key: 'google' microsoft: name: 'Microsoft' description: 'Configuration for setting up Microsoft Email' enabled: true icon: 'icon-microsoft' config_key: 'microsoft' + +# ------- Third-party Integrations ------- # linear: name: 'Linear' description: 'Configuration for setting up Linear Integration' enabled: true icon: 'icon-linear' config_key: 'linear' +notion: + name: 'Notion' + description: 'Configuration for setting up Notion Integration' + enabled: true + icon: 'icon-notion' + config_key: 'notion' slack: name: 'Slack' description: 'Configuration for setting up Slack Integration' enabled: true icon: 'icon-slack' config_key: 'slack' +whatsapp_embedded: + name: 'WhatsApp Embedded' + description: 'Configuration for setting up WhatsApp Embedded Integration' + enabled: true + icon: 'icon-whatsapp-line' + config_key: 'whatsapp_embedded' shopify: name: 'Shopify' description: 'Configuration for setting up Shopify Integration' diff --git a/enterprise/app/helpers/super_admin/features_helper.rb b/app/helpers/super_admin/features_helper.rb similarity index 78% rename from enterprise/app/helpers/super_admin/features_helper.rb rename to app/helpers/super_admin/features_helper.rb index 2fbcd1715..475ad6d25 100644 --- a/enterprise/app/helpers/super_admin/features_helper.rb +++ b/app/helpers/super_admin/features_helper.rb @@ -1,6 +1,6 @@ module SuperAdmin::FeaturesHelper def self.available_features - YAML.load(ERB.new(Rails.root.join('enterprise/app/helpers/super_admin/features.yml').read).result).with_indifferent_access + YAML.load(ERB.new(Rails.root.join('app/helpers/super_admin/features.yml').read).result).with_indifferent_access end def self.plan_details diff --git a/app/helpers/super_admin/navigation_helper.rb b/app/helpers/super_admin/navigation_helper.rb new file mode 100644 index 000000000..5fca3fa76 --- /dev/null +++ b/app/helpers/super_admin/navigation_helper.rb @@ -0,0 +1,16 @@ +module SuperAdmin::NavigationHelper + def settings_open? + params[:controller].in? %w[super_admin/settings super_admin/app_configs] + end + + def settings_pages + features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs| + attrs['config_key'].present? && attrs['enabled'] + end + + # Add general at the beginning + general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]] + + general_feature + features.to_a + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index e51958e9e..8da7e7476 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,6 +1,6 @@ diff --git a/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue b/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue new file mode 100644 index 000000000..ab01acb4a --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue index 574b06125..36cb7c7e2 100644 --- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue @@ -40,9 +40,9 @@ const handleSubmit = campaignDetails => {