diff --git a/.circleci/config.yml b/.circleci/config.yml index 5c9b27f03..974b981ab 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,8 +12,8 @@ defaults: &defaults # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images # documented at https://circleci.com/docs/2.0/circleci-images/ - - image: circleci/postgres:alpine - - image: circleci/redis:alpine + - image: cimg/postgres:14.1 + - image: cimg/redis:6.2.6 environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - RAILS_LOG_TO_STDOUT: false @@ -110,7 +110,7 @@ jobs: - run: name: Run backend tests command: | - bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 + bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 --format documentation ~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json - persist_to_workspace: root: ~/tmp diff --git a/.env.example b/.env.example index 36ca66be1..cc7e1c2dd 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,11 @@ REDIS_SENTINELS= # You can find list of master using "SENTINEL masters" command REDIS_SENTINEL_MASTER_NAME= +# Redis premium breakage in heroku fix +# enable the following configuration +# ref: https://github.com/chatwoot/chatwoot/issues/2420 +# REDIS_OPENSSL_VERIFY_MODE=none + # Postgres Database config variables POSTGRES_HOST=postgres POSTGRES_USERNAME=postgres diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml new file mode 100644 index 000000000..37f0f3e6e --- /dev/null +++ b/.github/workflows/publish_foss_docker.yml @@ -0,0 +1,62 @@ +# # +# # This action will publish Chatwoot CE docker image. +# # This is set to run against merges to develop, master +# # and when tags are created. +# # + +name: Publish Chatwoot CE docker images +on: + push: + branches: + - develop + - master + tags: + - v* + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile + + - name: set docker tag + run: | + echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV + + - name: replace docker tag if master + if: github.ref_name == 'master' + run: | + echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: docker/Dockerfile + push: true + tags: ${{ env.DOCKER_TAG }} diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml new file mode 100644 index 000000000..3a6ff5f7b --- /dev/null +++ b/.github/workflows/run_foss_spec.yml @@ -0,0 +1,72 @@ +# # +# # This action will strip the enterprise folder +# # and run the spec. +# # This is set to run against every PR. +# # + +name: Run Chatwoot CE spec +on: + push: + branches: + - develop + - master + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:10.8 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: "" + POSTGRES_DB: postgres + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + # tmpfs makes DB faster by using RAM + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.2 # Not needed with a .ruby-version file + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + + - name: yarn + run: yarn install + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Create database + run: bundle exec rake db:create + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: yarn check-files + run: yarn install --check-files + + # Run rails tests + - name: Run backend tests + run: | + bundle exec rspec --profile=10 --format documentation diff --git a/.rubocop.yml b/.rubocop.yml index c3096dd4a..898f1ff24 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,6 +17,7 @@ Metrics/ClassLength: - 'app/builders/messages/facebook/message_builder.rb' - 'app/controllers/api/v1/accounts/contacts_controller.rb' - 'app/listeners/action_cable_listener.rb' + - 'app/models/conversation.rb' RSpec/ExampleLength: Max: 25 Style/Documentation: diff --git a/Gemfile b/Gemfile index 297a88497..e94303f45 100644 --- a/Gemfile +++ b/Gemfile @@ -125,6 +125,9 @@ gem 'procore-sift' gem 'email_reply_trimmer' gem 'html2text' +# to calculate working hours +gem 'working_hours' + group :production, :staging do # we dont want request timing out in development while using byebug gem 'rack-timeout' diff --git a/Gemfile.lock b/Gemfile.lock index eb6022ac7..2789f09da 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -378,14 +378,14 @@ GEM netrc (0.11.0) newrelic_rpm (8.4.0) nio4r (2.5.8) - nokogiri (1.13.3) + nokogiri (1.13.4) mini_portile2 (~> 2.8.0) racc (~> 1.4) - nokogiri (1.13.3-arm64-darwin) + nokogiri (1.13.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.13.3-x86_64-darwin) + nokogiri (1.13.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.13.3-x86_64-linux) + nokogiri (1.13.4-x86_64-linux) racc (~> 1.4) oauth (0.5.8) orm_adapter (0.5.0) @@ -403,7 +403,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.6.2) + puma (5.6.4) nio4r (~> 2.0) pundit (2.2.0) activesupport (>= 3.0.0) @@ -636,6 +636,9 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.0) + working_hours (1.4.1) + activesupport (>= 3.2) + tzinfo zeitwerk (2.5.4) PLATFORMS @@ -746,6 +749,7 @@ DEPENDENCIES webpacker (~> 5.x) webpush wisper (= 2.0.0) + working_hours RUBY VERSION ruby 3.0.2p107 diff --git a/app.json b/app.json index 0d908761c..64edc4a81 100644 --- a/app.json +++ b/app.json @@ -32,6 +32,10 @@ "INSTALLATION_ENV": { "description": "Installation method used for Chatwoot.", "value": "heroku" + }, + "REDIS_OPENSSL_VERIFY_MODE":{ + "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", + "value": "none" } }, "formation": { diff --git a/app/builders/contact_builder.rb b/app/builders/contact_builder.rb index 14f26aa82..b63d72627 100644 --- a/app/builders/contact_builder.rb +++ b/app/builders/contact_builder.rb @@ -70,7 +70,7 @@ class ContactBuilder update_contact_avatar(contact) contact_inbox rescue StandardError => e - Rails.logger.info e + Rails.logger.error e raise e end end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 5410aa3c4..204b452d6 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end ensure_contact_avatar rescue Koala::Facebook::AuthenticationError - Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}" + Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}" rescue StandardError => e Sentry.capture_exception(e) true diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 1f17dbb08..67b80b7a5 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -1,5 +1,6 @@ class V2::ReportBuilder include DateRangeHelper + include ReportHelper attr_reader :account, :params DEFAULT_GROUP_BY = 'day'.freeze @@ -18,8 +19,14 @@ class V2::ReportBuilder # For backward compatible with old report def build - timeseries.each_with_object([]) do |p, arr| - arr << { value: p[1], timestamp: p[0].to_time.to_i } + if %w[avg_first_response_time avg_resolution_time].include?(params[:metric]) + timeseries.each_with_object([]) do |p, arr| + arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] } + end + else + timeseries.each_with_object([]) do |p, arr| + arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i } + end end end @@ -34,23 +41,16 @@ class V2::ReportBuilder } end - private - - def scope - case params[:type] - when :account - account - when :inbox - inbox - when :agent - user - when :label - label - when :team - team + def conversation_metrics + if params[:type].equal?(:account) + conversations + else + agent_metrics end end + private + def inbox @inbox ||= account.inboxes.find(params[:id]) end @@ -68,7 +68,7 @@ class V2::ReportBuilder end def get_grouped_values(object_scope) - object_scope.group_by_period( + @grouped_values = object_scope.group_by_period( params[:group_by] || DEFAULT_GROUP_BY, :created_at, default_value: 0, @@ -78,47 +78,26 @@ class V2::ReportBuilder ) end - def conversations_count - (get_grouped_values scope.conversations).count + def agent_metrics + users = @account.users + users = users.where(id: params[:user_id]) if params[:user_id].present? + users.each_with_object([]) do |user, arr| + @user = user + arr << { + user: { id: user.id, name: user.name, thumbnail: user.avatar_url }, + metric: conversations + } + end end - def incoming_messages_count - (get_grouped_values scope.messages.incoming.unscope(:order)).count - end - - def outgoing_messages_count - (get_grouped_values scope.messages.outgoing.unscope(:order)).count - end - - def resolutions_count - (get_grouped_values scope.conversations.resolved).count - end - - def avg_first_response_time - (get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value) - end - - def avg_resolution_time - (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value) - end - - def avg_resolution_time_summary - avg_rt = scope.reporting_events - .where(name: 'conversation_resolved', created_at: range) - .average(:value) - - return 0 if avg_rt.blank? - - avg_rt - end - - def avg_first_response_time_summary - avg_frt = scope.reporting_events - .where(name: 'first_response', created_at: range) - .average(:value) - - return 0 if avg_frt.blank? - - avg_frt + def conversations + @open_conversations = scope.conversations.open + first_response_count = scope.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count + metric = { + open: @open_conversations.count, + unattended: @open_conversations.count - first_response_count + } + metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account) + metric end end diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index 7348ef255..9f4ef2cd9 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController end def destroy - @agent_bot.destroy + @agent_bot.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 68389d3cd..09b648a6f 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def destroy - @agent.current_account_user.destroy + @agent.current_account_user.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 3971dbcaf..41296db62 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -7,13 +7,28 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont end def create - @automation_rule = Current.account.automation_rules.create(automation_rules_permit) + @automation_rule = Current.account.automation_rules.new(automation_rules_permit) + @automation_rule.actions = params[:actions] + + render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? + + @automation_rule.save! + process_attachments + @automation_rule end def show; end def update - @automation_rule.update(automation_rules_permit) + ActiveRecord::Base.transaction do + @automation_rule.update!(automation_rules_permit) + @automation_rule.actions = params[:actions] if params[:actions] + @automation_rule.save! + process_attachments + rescue StandardError => e + Rails.logger.error e + render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity + end end def destroy @@ -30,11 +45,20 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont private + def process_attachments + return if params[:attachments].blank? + + params[:attachments].each do |uploaded_attachment| + @automation_rule.files.attach(uploaded_attachment) + end + @automation_rule + end + def automation_rules_permit params.permit( :name, :description, :event_name, :account_id, :active, conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }], - actions: [:action_name, { action_params: [{}] }] + actions: [:action_name, { action_params: [] }] ) end diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 7c2469c05..842930874 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -77,7 +77,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', '')) koala.exchange_access_token_info(omniauth_token)['access_token'] rescue StandardError => e - Rails.logger.info e + Rails.logger.error e end def mark_already_existing_facebook_pages(data) diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index 18d0998c8..6d2fb7729 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController end def destroy - @campaign.destroy + @campaign.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/canned_responses_controller.rb b/app/controllers/api/v1/accounts/canned_responses_controller.rb index bbfa9c4b7..031ffc415 100644 --- a/app/controllers/api/v1/accounts/canned_responses_controller.rb +++ b/app/controllers/api/v1/accounts/canned_responses_controller.rb @@ -17,7 +17,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont end def destroy - @canned_response.destroy + @canned_response.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/contacts/notes_controller.rb b/app/controllers/api/v1/accounts/contacts/notes_controller.rb index fb9f3c5c3..7bc9dd121 100644 --- a/app/controllers/api/v1/accounts/contacts/notes_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/notes_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts end def destroy - @note.destroy + @note.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 419540438..3840644ce 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account end def destroy - @custom_attribute_definition.destroy + @custom_attribute_definition.destroy! head :no_content end diff --git a/app/controllers/api/v1/accounts/custom_filters_controller.rb b/app/controllers/api/v1/accounts/custom_filters_controller.rb index e6c7b6857..188f0e623 100644 --- a/app/controllers/api/v1/accounts/custom_filters_controller.rb +++ b/app/controllers/api/v1/accounts/custom_filters_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro end def destroy - @custom_filter.destroy + @custom_filter.destroy! head :no_content end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index bcea2d9d7..66a71985d 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -73,7 +73,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def destroy - @inbox.destroy + @inbox.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb index 18a16a30d..dd2af4ef2 100644 --- a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base end def destroy - @hook.destroy + @hook.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/integrations/slack_controller.rb b/app/controllers/api/v1/accounts/integrations/slack_controller.rb index 537ddd688..b5571b245 100644 --- a/app/controllers/api/v1/accounts/integrations/slack_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/slack_controller.rb @@ -20,7 +20,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base end def destroy - @hook.destroy + @hook.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/kbase/categories_controller.rb b/app/controllers/api/v1/accounts/kbase/categories_controller.rb index e114ee5e4..a40053dd2 100644 --- a/app/controllers/api/v1/accounts/kbase/categories_controller.rb +++ b/app/controllers/api/v1/accounts/kbase/categories_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase: end def destroy - @category.destroy + @category.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/kbase/portals_controller.rb b/app/controllers/api/v1/accounts/kbase/portals_controller.rb index e0788b587..804b2d421 100644 --- a/app/controllers/api/v1/accounts/kbase/portals_controller.rb +++ b/app/controllers/api/v1/accounts/kbase/portals_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::Ba end def destroy - @portal.destroy + @portal.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 547b9e6d6..54455943b 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController end def destroy - @label.destroy + @label.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb index adfeed62e..e8688dcfb 100644 --- a/app/controllers/api/v1/accounts/teams_controller.rb +++ b/app/controllers/api/v1/accounts/teams_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController end def destroy - @team.destroy + @team.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 58f9b21a0..0add18047 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -16,7 +16,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController end def destroy - @webhook.destroy + @webhook.destroy! head :ok end diff --git a/app/controllers/api/v1/notification_subscriptions_controller.rb b/app/controllers/api/v1/notification_subscriptions_controller.rb index 5f1cf30e4..a01c2ca03 100644 --- a/app/controllers/api/v1/notification_subscriptions_controller.rb +++ b/app/controllers/api/v1/notification_subscriptions_controller.rb @@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController def destroy notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first - notification_subscription.destroy + notification_subscription.destroy! head :ok end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 8df4737db..67132869d 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -39,7 +39,8 @@ class Api::V1::Widget::BaseController < ApplicationController browser: browser_params, referer: permitted_params[:message][:referer_url], initiated_at: timestamp_params - } + }, + custom_attributes: permitted_params[:custom_attributes].presence || {} } end @@ -52,16 +53,33 @@ class Api::V1::Widget::BaseController < ApplicationController mergee_contact: @contact ).perform else - @contact.update!(email: email, name: contact_name) + @contact.update!(email: email) + end + end + + def update_contact_phone_number(phone_number) + contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number) + if contact_with_phone_number + @contact = ::ContactMergeAction.new( + account: @current_account, + base_contact: contact_with_phone_number, + mergee_contact: @contact + ).perform + else + @contact.update!(phone_number: phone_number) end end def contact_email - permitted_params[:contact][:email].downcase + permitted_params[:contact][:email].downcase if permitted_params[:contact].present? end def contact_name - params[:contact][:name] || contact_email.split('@')[0] + params[:contact][:name] || contact_email.split('@')[0] if contact_email.present? + end + + def contact_phone_number + params[:contact][:phone_number] end def browser_params diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index cc1b16b75..ef2422bef 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -7,12 +7,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController def create ActiveRecord::Base.transaction do - update_contact(contact_email) if @contact.email.blank? && contact_email.present? + process_update_contact @conversation = create_conversation conversation.messages.create(message_params) end end + def process_update_contact + update_contact(contact_email) if @contact.email.blank? && contact_email.present? + update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present? + @contact.update!(name: contact_name) if contact_name.present? + end + def update_last_seen head :ok && return if conversation.nil? @@ -45,7 +51,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def toggle_status - head :not_found && return if conversation.nil? + return head :not_found if conversation.nil? + + return head :forbidden unless @web_widget.end_conversation? + unless conversation.resolved? conversation.status = :resolved conversation.save @@ -60,6 +69,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def permitted_params - params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id]) + params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id], + custom_attributes: {}) end end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index efa09a43c..227ba2500 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -35,41 +35,54 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' end + def conversations + return head :unprocessable_entity if params[:type].blank? + + render json: conversation_metrics + end + private def check_authorization raise Pundit::NotAuthorizedError unless Current.account_user.administrator? end - def current_summary_params + def common_params { type: params[:type].to_sym, id: params[:id], - since: range[:current][:since], - until: range[:current][:until], - group_by: params[:group_by] + group_by: params[:group_by], + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) } end + def current_summary_params + common_params.merge({ + since: range[:current][:since], + until: range[:current][:until] + }) + end + def previous_summary_params - { - type: params[:type].to_sym, - id: params[:id], - since: range[:previous][:since], - until: range[:previous][:until], - group_by: params[:group_by] - } + common_params.merge({ + since: range[:previous][:since], + until: range[:previous][:until] + }) end def report_params + common_params.merge({ + metric: params[:metric], + since: params[:since], + until: params[:until], + timezone_offset: params[:timezone_offset] + }) + end + + def conversation_params { - metric: params[:metric], type: params[:type].to_sym, - since: params[:since], - until: params[:until], - id: params[:id], - group_by: params[:group_by], - timezone_offset: params[:timezone_offset] + user_id: params[:user_id] } end @@ -91,4 +104,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary summary end + + def conversation_metrics + V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics + end end diff --git a/app/controllers/platform/api/v1/account_users_controller.rb b/app/controllers/platform/api/v1/account_users_controller.rb index 8f651cfd9..b8a8f701a 100644 --- a/app/controllers/platform/api/v1/account_users_controller.rb +++ b/app/controllers/platform/api/v1/account_users_controller.rb @@ -13,7 +13,7 @@ class Platform::Api::V1::AccountUsersController < PlatformController end def destroy - @resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy + @resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy! head :ok end diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index bf5b642f8..c9f256c6f 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -7,8 +7,8 @@ class Platform::Api::V1::UsersController < PlatformController def create @resource = (User.find_by(email: user_params[:email]) || User.new(user_params)) + @resource.skip_confirmation! @resource.save! - @resource.confirm @platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource) end diff --git a/app/controllers/twitter/callbacks_controller.rb b/app/controllers/twitter/callbacks_controller.rb index d484b0871..32fffbceb 100644 --- a/app/controllers/twitter/callbacks_controller.rb +++ b/app/controllers/twitter/callbacks_controller.rb @@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) end rescue StandardError => e - Rails.logger.info e + Rails.logger.error e redirect_to twitter_app_redirect_url end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 2338ca7d1..e6fe93566 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -17,7 +17,7 @@ class Webhooks::InstagramController < ApplicationController ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) render json: :ok else - Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}") + Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}") head :unprocessable_entity end end diff --git a/app/controllers/widget_tests_controller.rb b/app/controllers/widget_tests_controller.rb index fff47d907..6d6742cf4 100644 --- a/app/controllers/widget_tests_controller.rb +++ b/app/controllers/widget_tests_controller.rb @@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base before_action :ensure_widget_position before_action :ensure_widget_type before_action :ensure_widget_style + before_action :ensure_dark_mode def index render @@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base @widget_style = params[:widget_style] || 'standard' end + def ensure_dark_mode + @dark_mode = params[:dark_mode] || 'light' + end + def ensure_widget_position @widget_position = params[:position] || 'left' end diff --git a/app/finders/email_channel_finder.rb b/app/finders/email_channel_finder.rb new file mode 100644 index 000000000..1c8eaeaf2 --- /dev/null +++ b/app/finders/email_channel_finder.rb @@ -0,0 +1,15 @@ +class EmailChannelFinder + def initialize(email_object) + @email_object = email_object + end + + def perform + channel = nil + recipient_mails = @email_object.to.to_a + @email_object.cc.to_a + recipient_mails.each do |email| + channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase) + break if channel.present? + end + channel + end +end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8cdf8d987..56fb79908 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -14,7 +14,7 @@ module Api::V1::InboxesHelper Mail.defaults do retriever_method :imap, { address: channel_data[:imap_address], port: channel_data[:imap_port], - user_name: channel_data[:imap_email], + user_name: channel_data[:imap_login], password: channel_data[:imap_password], enable_ssl: channel_data[:imap_enable_ssl] } end @@ -29,8 +29,12 @@ module Api::V1::InboxesHelper smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port]) set_smtp_encryption(channel_data, smtp) + check_smtp_connection(channel_data, smtp) + end - smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login) + def check_smtp_connection(channel_data, smtp) + smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], + channel_data[:smtp_authentication]&.to_sym || :login) smtp.finish unless smtp&.nil? end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb new file mode 100644 index 000000000..9f37295cb --- /dev/null +++ b/app/helpers/report_helper.rb @@ -0,0 +1,68 @@ +module ReportHelper + private + + def scope + case params[:type] + when :account + account + when :inbox + inbox + when :agent + user + when :label + label + when :team + team + end + end + + def conversations_count + (get_grouped_values scope.conversations).count + end + + def incoming_messages_count + (get_grouped_values scope.messages.incoming.unscope(:order)).count + end + + def outgoing_messages_count + (get_grouped_values scope.messages.outgoing.unscope(:order)).count + end + + def resolutions_count + (get_grouped_values scope.conversations.resolved).count + end + + def avg_first_response_time + grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response')) + return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] + + grouped_reporting_events.average(:value) + end + + def avg_resolution_time + grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')) + return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] + + grouped_reporting_events.average(:value) + end + + def avg_resolution_time_summary + reporting_events = scope.reporting_events + .where(name: 'conversation_resolved', created_at: range) + avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) + + return 0 if avg_rt.blank? + + avg_rt + end + + def avg_first_response_time_summary + reporting_events = scope.reporting_events + .where(name: 'first_response', created_at: range) + avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) + + return 0 if avg_frt.blank? + + avg_frt + end +end diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb new file mode 100644 index 000000000..eee1af283 --- /dev/null +++ b/app/helpers/reporting_event_helper.rb @@ -0,0 +1,50 @@ +module ReportingEventHelper + def business_hours(inbox, from, to) + return 0 unless inbox.working_hours_enabled? + + inbox_working_hours = configure_working_hours(inbox.working_hours) + return 0 if inbox_working_hours.blank? + + # Configure working hours + WorkingHours::Config.working_hours = inbox_working_hours + + # Configure timezone + WorkingHours::Config.time_zone = inbox.timezone + + # Use inbox timezone to change from & to values. + from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time + to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time + from_in_inbox_timezone.working_time_until(to_in_inbox_timezone) + end + + private + + def configure_working_hours(working_hours) + working_hours.each_with_object({}) do |working_hour, object| + object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day? + end + end + + def day(day_of_week) + week_days = { + 0 => :sun, + 1 => :mon, + 2 => :tue, + 3 => :wed, + 4 => :thu, + 5 => :fri, + 6 => :sat + } + week_days[day_of_week] + end + + def working_hour_range(working_hour) + { format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) } + end + + def format_time(hour, minute) + hour = hour < 10 ? "0#{hour}" : hour + minute = minute < 10 ? "0#{minute}" : minute + "#{hour}:#{minute}" + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 2c91de16f..1e262f1ff 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,5 +1,5 @@ @@ -46,5 +54,9 @@ export default { .title--section { padding-right: var(--space-large); } + + .note { + font-weight: var(--font-weight-bold); + } } diff --git a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue index cc65c68b0..1a60b3a43 100644 --- a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue +++ b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue @@ -57,3 +57,13 @@ export default { }, }; + diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 1ad4a9be3..1c70e6755 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -53,7 +53,7 @@ export default { computed: { ...mapGetters({ getCurrentUserAvailability: 'getCurrentUserAvailability', - getCurrentAccountId: 'getCurrentAccountId', + currentAccountId: 'getCurrentAccountId', }), availabilityDisplayLabel() { const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex( @@ -63,9 +63,6 @@ export default { availabilityIndex ]; }, - currentAccountId() { - return this.getCurrentAccountId; - }, currentUserAvailability() { return this.getCurrentUserAvailability; }, diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index 826d766df..c7fda74f1 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -8,6 +8,7 @@ :active-menu-item="activePrimaryMenu.key" @toggle-accounts="toggleAccountModal" @key-shortcut-modal="toggleKeyShortcutModal" + @open-notification-panel="openNotificationPanel" /> diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue b/app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue index 82d858b30..375cd6ffa 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue @@ -1,19 +1,21 @@ @@ -37,4 +48,32 @@ export default { .notifications-link { margin-bottom: var(--space-small); } + +.badge { + position: absolute; + right: var(--space-minus-smaller); + top: var(--space-minus-smaller); +} +.notifications-link--button { + display: flex; + position: relative; + border-radius: var(--border-radius-large); + border: 1px solid transparent; + color: var(--s-600); + margin: var(--space-small) 0; + + &:hover { + background: var(--w-50); + color: var(--s-600); + } + + &:focus { + border-color: var(--w-500); + } + + &.is-active { + background: var(--w-50); + color: var(--w-500); + } +} diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue index 002f371bf..956daa5eb 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue @@ -16,7 +16,7 @@ /> { @@ -19,15 +20,32 @@ export default { }, displayMetric() { return metric_key => { - if ( - ['avg_first_response_time', 'avg_resolution_time'].includes( - metric_key - ) - ) { + if (this.isAverageMetricType(metric_key)) { return formatTime(this.accountSummary[metric_key]); } return this.accountSummary[metric_key]; }; }, + displayInfoText() { + return metric_key => { + if (this.metrics[this.currentSelection].KEY !== metric_key) { + return ''; + } + if (this.isAverageMetricType(metric_key)) { + const total = this.accountReport.data + .map(item => item.count) + .reduce((prev, curr) => prev + curr, 0); + return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`; + } + return ''; + }; + }, + isAverageMetricType() { + return metric_key => { + return ['avg_first_response_time', 'avg_resolution_time'].includes( + metric_key + ); + }; + }, }, }; diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js index 003295f95..aa3c451d9 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js @@ -11,6 +11,7 @@ describe('reportMixin', () => { beforeEach(() => { getters = { getAccountSummary: () => reportFixtures.summary, + getAccountReports: () => reportFixtures.report, }; store = new Vuex.Store({ getters }); }); @@ -24,7 +25,7 @@ describe('reportMixin', () => { const wrapper = shallowMount(Component, { store, localVue }); expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5); expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual( - '3 Min' + '3 Min 18 Sec' ); }); @@ -38,4 +39,67 @@ describe('reportMixin', () => { expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25); expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0); }); + + it('display info text', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + data() { + return { + currentSelection: 0, + }; + }, + computed: { + metrics() { + return [ + { + DESC: '( Avg )', + INFO_TEXT: 'Total number of conversations used for computation:', + KEY: 'avg_first_response_time', + NAME: 'First Response Time', + }, + ]; + }, + }, + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect(wrapper.vm.displayInfoText('avg_first_response_time')).toEqual( + 'Total number of conversations used for computation: 4' + ); + }); + + it('do not display info text', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + data() { + return { + currentSelection: 0, + }; + }, + computed: { + metrics() { + return [ + { + DESC: '( Total )', + INFO_TEXT: '', + KEY: 'conversation_count', + NAME: 'Conversations', + }, + { + DESC: '( Avg )', + INFO_TEXT: 'Total number of conversations used for computation:', + KEY: 'avg_first_response_time', + NAME: 'First Response Time', + }, + ]; + }, + }, + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect(wrapper.vm.displayInfoText('conversation_count')).toEqual(''); + expect(wrapper.vm.displayInfoText('incoming_messages_count')).toEqual(''); + }); }); diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js index 5c8315ab1..8402c3940 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js +++ b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js @@ -15,4 +15,15 @@ export default { }, resolutions_count: 3, }, + report: { + data: [ + { value: '0.00', timestamp: 1647541800, count: 0 }, + { value: '0.00', timestamp: 1647628200, count: 0 }, + { value: '0.00', timestamp: 1647714600, count: 0 }, + { value: '0.00', timestamp: 1647801000, count: 0 }, + { value: '0.01', timestamp: 1647887400, count: 4 }, + { value: '0.00', timestamp: 1647973800, count: 0 }, + { value: '0.00', timestamp: 1648060200, count: 0 }, + ], + }, }; diff --git a/app/javascript/dashboard/modules/notes/components/ContactNote.vue b/app/javascript/dashboard/modules/notes/components/ContactNote.vue index d29c5e7b0..b781d9639 100644 --- a/app/javascript/dashboard/modules/notes/components/ContactNote.vue +++ b/app/javascript/dashboard/modules/notes/components/ContactNote.vue @@ -21,9 +21,19 @@ size="tiny" icon="delete" color-scheme="secondary" - @click="onDelete" + @click="toggleDeleteModal" /> +

@@ -59,7 +69,11 @@ export default { default: 0, }, }, - + data() { + return { + showDeleteModal: false, + }; + }, computed: { readableTime() { return this.dynamicTime(this.createdAt); @@ -73,9 +87,19 @@ export default { }, methods: { + toggleDeleteModal() { + this.showDeleteModal = !this.showDeleteModal; + }, onDelete() { this.$emit('delete', this.id); }, + confirmDeletion() { + this.onDelete(); + this.closeDelete(); + }, + closeDelete() { + this.showDeleteModal = false; + }, }, }; diff --git a/app/javascript/dashboard/routes/auth/Signup.vue b/app/javascript/dashboard/routes/auth/Signup.vue index 36b63662d..2ea4e443a 100644 --- a/app/javascript/dashboard/routes/auth/Signup.vue +++ b/app/javascript/dashboard/routes/auth/Signup.vue @@ -54,14 +54,9 @@ :class="{ error: $v.credentials.password.$error }" :label="$t('LOGIN.PASSWORD.LABEL')" :placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')" - :error=" - $v.credentials.password.$error - ? $t('SET_NEW_PASSWORD.PASSWORD.ERROR') - : '' - " + :error="passwordErrorText" @blur="$v.credentials.password.$touch" /> -

+ + {{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }} +
diff --git a/app/javascript/dashboard/routes/dashboard/Dashboard.vue b/app/javascript/dashboard/routes/dashboard/Dashboard.vue index 11903c9ed..cfcaccfe5 100644 --- a/app/javascript/dashboard/routes/dashboard/Dashboard.vue +++ b/app/javascript/dashboard/routes/dashboard/Dashboard.vue @@ -3,6 +3,7 @@ + @@ -40,6 +45,7 @@ import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShor import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal'; import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector'; import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue'; +import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue'; export default { components: { @@ -49,6 +55,7 @@ export default { AddAccountModal, AccountSelector, AddLabelModal, + NotificationPanel, }, data() { return { @@ -58,6 +65,7 @@ export default { showCreateAccountModal: false, showAddLabelModal: false, showShortcutModal: false, + isNotificationPanel: false, }; }, computed: { @@ -84,7 +92,6 @@ export default { }, }, mounted() { - this.$store.dispatch('setCurrentAccountId', this.$route.params.accountId); window.addEventListener('resize', this.handleResize); this.handleResize(); bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar); @@ -126,6 +133,12 @@ export default { hideAddLabelPopup() { this.showAddLabelModal = false; }, + openNotificationPanel() { + this.isNotificationPanel = true; + }, + closeNotificationPanel() { + this.isNotificationPanel = false; + }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue index fc0cb1681..2f309964a 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue @@ -59,7 +59,29 @@
-