diff --git a/.circleci/config.yml b/.circleci/config.yml index c67063ae6b31d..43b5228bc8fc0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ defaults: &defaults working_directory: ~/build docker: # specify the version you desire here - - image: cimg/ruby:3.2.2-browsers + - image: cimg/ruby:3.2.4-browsers # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 17021d1e7538b..1b7e6c8252322 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -12,7 +12,7 @@ services: args: VARIANT: "ubuntu-22.04" NODE_VERSION: "20.9.0" - RUBY_VERSION: "3.2.2" + RUBY_VERSION: "3.2.4" # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: "1000" USER_GID: "1000" @@ -25,7 +25,7 @@ services: args: VARIANT: "ubuntu-22.04" NODE_VERSION: "20.9.0" - RUBY_VERSION: "3.2.2" + RUBY_VERSION: "3.2.4" # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: "1000" USER_GID: "1000" diff --git a/.github/workflows/deploy_check.yml b/.github/workflows/deploy_check.yml index 7fda2b1a446c0..9954a13c1d3a8 100644 --- a/.github/workflows/deploy_check.yml +++ b/.github/workflows/deploy_check.yml @@ -3,8 +3,9 @@ ## deployment url will be of the form chatwoot-pr-.herokuapp.com name: Deploy Check -on: - pull_request: +# disable for DT +# on: +# pull_request: jobs: deployment_check: diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index b0b2372aed6bc..90338f03b099a 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -46,13 +46,23 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} + - name: install parquet libraries + run: | + sudo apt update + sudo apt install -y -V ca-certificates lsb-release wget + wget https://apache.jfrog.io/artifactory/arrow/$(lsb_release --id --short | tr 'A-Z' 'a-z')/apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb + sudo apt install -y -V ./apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb + sudo apt update + sudo apt-get install -y libarrow-dev libarrow-glib-dev libparquet-glib-dev libgirepository-1.0-1 libgirepository1.0-dev + sudo sed -i -e 's/-std=c++11//g' /usr/lib/x86_64-linux-gnu/pkgconfig/re2.pc + - uses: ruby/setup-ruby@v1 with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 21 cache: yarn - name: yarn diff --git a/.github/workflows/run_response_bot_spec.yml b/.github/workflows/run_response_bot_spec.yml index c594ff404593e..4bd2929622ceb 100644 --- a/.github/workflows/run_response_bot_spec.yml +++ b/.github/workflows/run_response_bot_spec.yml @@ -44,6 +44,16 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} + + - name: install parquet libraries + run: | + sudo apt update + sudo apt install -y -V ca-certificates lsb-release wget + wget https://apache.jfrog.io/artifactory/arrow/$(lsb_release --id --short | tr 'A-Z' 'a-z')/apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb + sudo apt install -y -V ./apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb + sudo apt update + sudo apt-get install -y libarrow-dev libarrow-glib-dev libparquet-glib-dev libgirepository-1.0-1 libgirepository1.0-dev + sudo sed -i -e 's/-std=c++11//g' /usr/lib/x86_64-linux-gnu/pkgconfig/re2.pc - uses: ruby/setup-ruby@v1 with: @@ -51,7 +61,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 21 cache: yarn - name: yarn diff --git a/.ruby-version b/.ruby-version index be94e6f53db6b..351227fca344e 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.2.4 diff --git a/Gemfile b/Gemfile index bee9eddc10137..d671463cfba46 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' -ruby '3.2.2' +ruby '3.2.4' ##-- base gems for rails --## gem 'rack-cors', '2.0.0', require: 'rack/cors' @@ -175,6 +175,10 @@ gem 'pgvector' # Convert Website HTML to Markdown gem 'reverse_markdown' +gem 'gobject-introspection' +gem 'red-arrow' +gem 'red-parquet' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 2f890deaa28fd..1cbc1432b2f94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -174,6 +174,7 @@ GEM crack (0.4.5) rexml crass (1.0.6) + csv (3.3.0) csv-safe (3.2.1) cypress-on-rails (1.16.0) rack @@ -227,6 +228,7 @@ GEM et-orbi (1.2.7) tzinfo execjs (2.8.1) + extpp (0.1.1) facebook-messenger (2.0.1) httparty (~> 0.13, >= 0.13.7) rack (>= 1.4.5) @@ -260,6 +262,7 @@ GEM ffi-compiler (1.0.1) ffi (>= 1.0.0) rake + fiddle (1.1.2) flag_shih_tzu (0.3.23) foreman (0.87.2) fugit (1.9.0) @@ -274,11 +277,19 @@ GEM googleauth (~> 1.0) grpc (~> 1.36) geocoder (1.8.1) + gio2 (4.2.2) + fiddle + gobject-introspection (= 4.2.2) gli (2.21.1) + glib2 (4.2.2) + native-package-installer (>= 1.0.3) + pkg-config (>= 1.3.5) globalid (1.2.1) activesupport (>= 6.1) gmail_xoauth (0.4.3) oauth (>= 0.3.6) + gobject-introspection (4.2.2) + glib2 (= 4.2.2) google-apis-core (0.11.0) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) @@ -468,6 +479,7 @@ GEM multi_json (1.15.0) multi_xml (0.6.0) multipart-post (2.3.0) + native-package-installer (1.1.9) neighbor (0.2.3) activerecord (>= 5.2) net-http (0.4.1) @@ -538,6 +550,7 @@ GEM activerecord (>= 5.2) activesupport (>= 5.2) pgvector (0.1.1) + pkg-config (1.5.6) procore-sift (1.0.0) activerecord (>= 6.1) pry (0.14.2) @@ -602,6 +615,15 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) + red-arrow (16.1.0) + bigdecimal (>= 3.1.0) + csv + extpp (>= 0.1.1) + gio2 (>= 3.5.0) + native-package-installer + pkg-config + red-parquet (16.1.0) + red-arrow (= 16.1.0) redis (5.0.6) redis-client (>= 0.9.0) redis-client (0.22.1) @@ -873,6 +895,7 @@ DEPENDENCIES foreman geocoder gmail_xoauth + gobject-introspection google-cloud-dialogflow-v2 google-cloud-storage google-cloud-translate-v3 @@ -917,6 +940,8 @@ DEPENDENCIES rack-mini-profiler (>= 3.2.0) rack-timeout rails (~> 7.0.8.1) + red-arrow + red-parquet redis redis-namespace responders (>= 3.1.1) @@ -960,7 +985,7 @@ DEPENDENCIES working_hours RUBY VERSION - ruby 3.2.2p185 + ruby 3.2.4p170 BUNDLED WITH - 2.4.6 + 2.5.14 diff --git a/app.json b/app.json index d50040814205e..edd55ad864103 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "description": "Chatwoot is a customer support tool for instant messaging channels", "website": "https://www.chatwoot.com/", "repository": "https://github.com/chatwoot/chatwoot", - "logo": "https://app.chatwoot.com/brand-assets/logo_thumbnail.svg", + "logo": "https://app.chatwoot.com/brand-assets/logo_thumbnail.png", "keywords": [ "live chat", "customer support", diff --git a/app/builders/csat_surveys/response_builder.rb b/app/builders/csat_surveys/response_builder.rb index 7c120ddb225cf..4a163c2df3c84 100644 --- a/app/builders/csat_surveys/response_builder.rb +++ b/app/builders/csat_surveys/response_builder.rb @@ -20,9 +20,26 @@ def process_csat_response(conversation, rating, feedback_message) message_id: message.id, account_id: message.account_id, conversation_id: message.conversation_id, contact_id: conversation.contact_id, assigned_agent: conversation.assignee ) + + update_message_content_attributes + csat_survey_response.csat_template_id = csat_template_question.csat_template_id + csat_survey_response.csat_template_question_id = csat_template_question.id csat_survey_response.rating = rating csat_survey_response.feedback_message = feedback_message csat_survey_response.save! csat_survey_response end + + def csat_template_question + @csat_template_question ||= (message.csat_template_question || CsatTemplateQuestion.load_by_content(message.content)) + end + + def update_message_content_attributes + return if (attrs = message.content_attributes[:submitted_values]).blank? + + attrs['csat_template_question_id'] = csat_template_question&.id + # rubocop:disable Rails/SkipsModelValidations + message.update_column(:content_attributes, attrs) + # rubocop:enable Rails/SkipsModelValidations + end end diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index 3d8ce96744984..aadc12d6f2a62 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -17,6 +17,7 @@ def user_subscribed_to_notification? return false if notification_setting.blank? return true if notification_setting.public_send("email_#{notification_type}?") + return false unless primary_actor.inbox.push_notification_enabled? return true if notification_setting.public_send("push_#{notification_type}?") false diff --git a/app/builders/smart_action_builder.rb b/app/builders/smart_action_builder.rb new file mode 100644 index 0000000000000..b0e64028d1ba0 --- /dev/null +++ b/app/builders/smart_action_builder.rb @@ -0,0 +1,46 @@ +class SmartActionBuilder + attr_accessor :errors + + def initialize(conversation, params) + @conversation = conversation + @params = params + @errors = [] + end + + def perform + validate_params + return if @errors.present? + + build_smart_action + rescue StandardError => e + @errors << e.message + nil + end + + private + + def validate_params + return if @params.present? + + @errors << 'Missing parameters' + end + + def build_smart_action + @smart_action = @conversation.smart_actions.create(smart_action_params) + end + + def smart_action_params + @params.permit( + :name, + :label, + :description, + :event, + :intent_type, + :message_id, + :to, + :from, + :link, + :content + ) + end +end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 4eff101275517..4f92e68a945bc 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -20,11 +20,13 @@ def create ) @agent = builder.perform + update_teams_and_inboxes end def update @agent.update!(agent_params.slice(:name).compact) @agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact) + update_teams_and_inboxes end def destroy @@ -59,6 +61,11 @@ def bulk_create private + def update_teams_and_inboxes + @agent.teams = Team.where(id: team_ids) if team_ids.present? + @agent.inboxes = Inbox.where(id: inbox_ids) if inbox_ids.present? + end + def check_authorization super(User) end @@ -75,6 +82,14 @@ def new_agent_params params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) end + def team_ids + params[:team_ids] || [] + end + + def inbox_ids + params[:inbox_ids] || [] + end + def agents @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index d164778ac1c9a..508a31118542c 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -7,8 +7,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController def index @portal_articles = @portal.articles @all_articles = @portal_articles.search(list_params) - @articles_count = @all_articles.count + @all_articles = @all_articles.published unless Current.user.administrator? + @articles_count = @all_articles.count @articles = if list_params[:category_slug].present? @all_articles.order_by_position.page(@current_page) else diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 250c64a86105d..0025d4036d1e5 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -12,9 +12,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController RESULTS_PER_PAGE = 15 before_action :check_authorization - before_action :set_current_page, only: [:index, :active, :search, :filter] + before_action :set_current_page, only: [:index, :active, :search, :filter, :phone_search, :email_search] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] - before_action :set_include_contact_inboxes, only: [:index, :search, :filter] + before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :phone_search, :email_search] def index @contacts_count = resolved_contacts.count @@ -33,6 +33,21 @@ def search @contacts = fetch_contacts(contacts) end + def phone_search + render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return + + contacts = resolved_contacts.where(phone_number: params[:q].strip) + render json: { found: contacts.exists?, search_key: params[:q] } + end + + def email_search + render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return + + contacts = resolved_contacts.where(email: params[:q].strip) + @contacts_count = contacts.count + @contacts = fetch_contacts(contacts) + end + def import render json: { error: I18n.t('errors.contacts.import.failed') }, status: :unprocessable_entity and return if params[:import_file].blank? diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index d34561e36589f..f81d3b6b228df 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -6,6 +6,7 @@ def index def create user = Current.user || @resource mb = Messages::MessageBuilder.new(user, @conversation, params) + deactivate_smart_actions @message = mb.perform rescue StandardError => e render_could_not_create_error(e.message) @@ -62,4 +63,13 @@ def permitted_params def already_translated_content_available? message.translations.present? && message.translations[permitted_params[:target_language]].present? end + + def deactivate_smart_actions + return unless Current.account.feature_enabled?('smart_actions') + return unless (copilot_draft = @conversation.smart_actions.ask_copilot.active.last&.content).present? + + if params[:content].to_s.include? copilot_draft + @conversation.smart_actions.update_all(active: false) + end + end end diff --git a/app/controllers/api/v1/accounts/conversations/smart_actions_controller.rb b/app/controllers/api/v1/accounts/conversations/smart_actions_controller.rb new file mode 100644 index 0000000000000..ebf4e8c84b2ab --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/smart_actions_controller.rb @@ -0,0 +1,26 @@ +class Api::V1::Accounts::Conversations::SmartActionsController < Api::V1::Accounts::Conversations::BaseController + def index + @smart_actions = @conversation.smart_actions.active + end + + def create + builder = SmartActionBuilder.new(@conversation, params) + @smart_action = builder.perform + + render json: { + success: @smart_action.present?, + message: builder.errors.present? ? builder.errors.join(', ') : 'Successfully created' + } + end + + def event_data + # TODO: move to service action + case params[:event] + when 'ask_copilot' + event = @conversation.smart_actions.ask_copilot.active.last + render json: event.present? ? event.event_data : {} + else + render json: { success: false } + end + end +end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 2aedf19284b3e..e96a5435c9949 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro include DateRangeHelper include HmacConcern - before_action :conversation, except: [:index, :meta, :search, :create, :filter] + before_action :conversation, except: [:index, :meta, :search, :create, :filter, :ticket, :ticket_issue, :search_by_email] before_action :inbox, :contact, :contact_inbox, only: [:create] def index @@ -87,7 +87,7 @@ def pending_to_open_by_bot? end def should_assign_conversation? - @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent? + (@conversation.status == 'open' || @conversation.assignee.blank?) && Current.user.is_a?(User) && Current.user&.agent? end def toggle_priority @@ -101,6 +101,18 @@ def toggle_typing_status head :ok end + def change_contact + service = Digitaltolk::ChangeContactService.new(Current.account, @conversation, params[:email]) + + render json: { success: service.perform } + end + + def change_contact_kind + service = Digitaltolk::ChangeContactKindService.new(Current.account, @conversation, params[:contact_kind]) + + render json: { success: service.perform } + end + def update_last_seen update_last_seen_on_conversation(DateTime.now.utc, assignee?) end @@ -116,6 +128,39 @@ def custom_attributes @conversation.save! end + def get + # TODO: unused? + render json: { total: @conversation.inbox.csat_template.questions_count } + end + + def ticket + result = Digitaltolk::SendEmailTicketService.new(Current.account, Current.user, params).perform + render json: result + end + + def ticket_issue + result = Digitaltolk::SendEmailTicketIssueService.new(Current.account, Current.user, params).perform + render json: result + end + + def related_emails + @conversations = Digitaltolk::RelatedEmailService.new(@conversation.display_id).perform + @conversations_count = @conversations.count + end + + def search_by_email + @conversations = Digitaltolk::FindConversationByEmailService.new(params).perform + end + + def reply + result = Digitaltolk::AddConversationReplyService.new(@conversation, params).perform + render json: result + end + + def close + render json: @conversation.update(closed: params[:closed]) + end + private def permitted_update_params diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb index f5bed6c34a9a0..ea5c8bab4dd91 100644 --- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb +++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb @@ -5,14 +5,21 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base RESULTS_PER_PAGE = 25 before_action :check_authorization - before_action :set_csat_survey_responses, only: [:index, :metrics, :download] + before_action :set_csat_survey_responses, only: [:index, :metrics, :download, :questions] before_action :set_current_page, only: [:index] before_action :set_current_page_surveys, only: [:index] before_action :set_total_sent_messages_count, only: [:metrics] sort_on :created_at, type: :datetime - def index; end + def index + if params[:export_as_parquet] + file_name = "csat_surveys_#{Time.now.to_i}.parquet" + Digitaltolk::StoreSurveyResponsesParquetJob.perform_later(@csat_survey_responses.pluck(:id), file_name) + + render json: { file_url: Digitaltolk::SurveyResponsesParquetService.new([], file_name).perform }.to_json and return + end + end def metrics @total_count = @csat_survey_responses.count @@ -25,6 +32,11 @@ def download render layout: false, template: 'api/v1/accounts/csat_survey_responses/download', formats: [:csv] end + def questions + @questions = CsatTemplateQuestion.joins(:csat_survey_responses).merge(@csat_survey_responses).reorder('content asc').distinct + render json: { questions: @questions } + end + private def set_total_sent_messages_count @@ -40,10 +52,12 @@ def set_csat_survey_responses .filter_by_inbox_id(params[:inbox_id]) .filter_by_team_id(params[:team_id]) .filter_by_rating(params[:rating]) + .filter_by_label(params[:label]) + .filter_by_question(params[:question]) end def set_current_page_surveys - @csat_survey_responses = @csat_survey_responses.page(@current_page).per(RESULTS_PER_PAGE) + @csat_survey_responses = @csat_survey_responses.page(@current_page).per(RESULTS_PER_PAGE) if params[:page].present? end def set_current_page diff --git a/app/controllers/api/v1/accounts/csat_templates_controller.rb b/app/controllers/api/v1/accounts/csat_templates_controller.rb new file mode 100644 index 0000000000000..ed7ba883ee886 --- /dev/null +++ b/app/controllers/api/v1/accounts/csat_templates_controller.rb @@ -0,0 +1,62 @@ +class Api::V1::Accounts::CsatTemplatesController < Api::V1::Accounts::BaseController + before_action :load_template, only: [:show, :update, :destroy] + def index + @templates = csat_templates + @templates_count = @templates.count + end + + def show; end + + def create + @template = csat_templates.create(csat_template_params) + # rubocop:disable Rails/SkipsModelValidations + Current.account.inboxes.where(id: inbox_id_params).update_all(csat_template_id: @template.id) + # rubocop:enable Rails/SkipsModelValidations + render json: @template + end + + def update + @template.update(csat_template_params) + # rubocop:disable Rails/SkipsModelValidations + Current.account.inboxes.where(csat_template_id: params[:id]).update_all(csat_template_id: nil) + # rubocop:enable Rails/SkipsModelValidations + + inboxes = Current.account.inboxes.where(id: inbox_id_params) + return if inboxes.blank? + + inboxes.each do |inbox| + inbox.update(csat_template_id: @template.id) + end + end + + def destroy + @template.destroy + render json: { success: @template.destroyed? } + end + + def inboxes + render json: { inboxes: Current.account.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } } } + end + + private + + def load_template + @template = csat_templates.find_by(id: params[:id]) + end + + def csat_templates + Current.account.csat_templates + end + + def csat_questions + @template.csat_template_questions + end + + def csat_template_params + params.require(:csat_template).permit(:name, csat_template_questions_attributes: [:id, :content, :_destroy]) + end + + def inbox_id_params + params[:csat_template][:inbox_ids] + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 011faaf280085..5d55d20eab6f0 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -124,7 +124,8 @@ def update_channel_feature_flags def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, - :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name] + :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :default_reply_action, :csat_trigger, + :push_notification_enabled, :audio_notification_enabled, :label_required, :csat_template_id] end def permitted_params(channel_attributes = []) diff --git a/app/controllers/api/v1/accounts/messages_controller.rb b/app/controllers/api/v1/accounts/messages_controller.rb new file mode 100644 index 0000000000000..cc0cd363e874a --- /dev/null +++ b/app/controllers/api/v1/accounts/messages_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Accounts::MessagesController < Api::V1::Accounts::BaseController + include Sift + include DateRangeHelper + + before_action :set_messages, only: [:index] + before_action :set_current_page, only: [:index] + before_action :set_current_page_messages, only: [:index] + + def index + if params[:export_as_parquet] + file_name = "messages_#{Time.now.to_i}.parquet" + Digitaltolk::StoreMessagesParquetJob.perform_later(@messages.pluck(:id), file_name) + + render json: { file_url: Digitaltolk::MessagesParquetService.new([], file_name).perform }.to_json and return + end + end + + private + + def set_messages + base_query = Current.account.messages.includes(:inbox, :conversation) + @messages = filtrate(base_query).filter_by_created_at(range) + .filter_by_inbox(params[:inbox_id]) + .filter_by_team(params[:team_id]) + .filter_by_label(params[:label]) + .order(created_at: :desc) + end + + def set_current_page_messages + @messages = @messages.page(@current_page).per(RESULTS_PER_PAGE) if params[:page].present? + end + + def set_current_page + @current_page = params[:page] || 1 + end +end diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 7ea257ed27a65..21086cfccff7e 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -23,7 +23,7 @@ def destroy private def webhook_params - params.require(:webhook).permit(:inbox_id, :url, subscriptions: []) + params.require(:webhook).permit(:inbox_id, :url, :enabled, subscriptions: []) end def fetch_webhook diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 5b87e2d1a7348..13cd39a4044f9 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -10,9 +10,9 @@ class Api::V1::Widget::BaseController < ApplicationController def conversations if @contact_inbox.hmac_verified? verified_contact_inbox_ids = @contact.contact_inboxes.where(inbox_id: auth_token_params[:inbox_id], hmac_verified: true).map(&:id) - @conversations = @contact.conversations.where(contact_inbox_id: verified_contact_inbox_ids) + @conversations = @contact.conversations.unclosed.where(contact_inbox_id: verified_contact_inbox_ids) else - @conversations = @contact_inbox.conversations.where(inbox_id: auth_token_params[:inbox_id]) + @conversations = @contact_inbox.conversations.unclosed.where(inbox_id: auth_token_params[:inbox_id]) end end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 7e6d84bd250d9..5bbb16bd885a0 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -73,6 +73,14 @@ def destroy_custom_attributes render json: conversation end + def total_csat_questions + render json: { total: inbox.csat_template&.questions_count } + end + + def csat_template_status + render json: { status: inbox.csat_template_enabled? } + end + private def trigger_typing_event(event) diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a51b4c2d6e8d5..d79b51ef31916 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -1,6 +1,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController before_action :set_conversation, only: [:create] - before_action :set_message, only: [:update] + before_action :set_message, only: [:update, :csat_question] def index @messages = conversation.nil? ? [] : message_finder.perform @@ -27,6 +27,8 @@ def update render json: { error: @contact.errors, message: e.message }.to_json, status: :internal_server_error end + def csat_question; end + private def build_attachment diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index c67b74a431f7a..b62ce54b6db7a 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -76,7 +76,8 @@ def common_params type: params[:type].to_sym, id: params[:id], group_by: params[:group_by], - business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]), + custom_filter: params[:custom_filter] } end diff --git a/app/controllers/public/api/v1/csat_survey_controller.rb b/app/controllers/public/api/v1/csat_survey_controller.rb index b839bee770ec9..4d5826b4f7915 100644 --- a/app/controllers/public/api/v1/csat_survey_controller.rb +++ b/app/controllers/public/api/v1/csat_survey_controller.rb @@ -1,6 +1,6 @@ class Public::Api::V1::CsatSurveyController < PublicController before_action :set_conversation - before_action :set_message + before_action :message def show; end @@ -18,14 +18,19 @@ def set_conversation @conversation = Conversation.find_by!(uuid: params[:id]) end - def set_message - @message = @conversation.messages.find_by!(content_type: 'input_csat') + def message + @message = @conversation.messages.find_by(id: message_id) if message_id.present? + @message ||= @conversation.messages.find_by!(content_type: 'input_csat') end def message_update_params params.permit(message: [{ submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }] }]) end + def message_id + params[:message_id] + end + def check_csat_locked (Time.zone.now.to_date - @message.created_at.to_date).to_i > 14 end diff --git a/app/controllers/webhooks/webflow_controller.rb b/app/controllers/webhooks/webflow_controller.rb new file mode 100644 index 0000000000000..6ba59c580927f --- /dev/null +++ b/app/controllers/webhooks/webflow_controller.rb @@ -0,0 +1,6 @@ +class Webhooks::WebflowController < ActionController::API + def process_payload + Webhooks::WebflowEventsJob.perform_later(params.to_unsafe_hash) + head :ok + end +end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 44592c201164c..1ddb45af61aa0 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -38,7 +38,7 @@ def initialize(current_user, params) def perform set_up - mine_count, unassigned_count, all_count, = set_count_for_all_conversations + mine_count, unassigned_count, all_count, all_inbox_open_count, my_teams_open_count, = set_count_for_all_conversations assigned_count = all_count - unassigned_count filter_by_assignee_type @@ -49,7 +49,9 @@ def perform mine_count: mine_count, assigned_count: assigned_count, unassigned_count: unassigned_count, - all_count: all_count + all_count: all_count, + all_inbox_open_count: all_inbox_open_count, + my_teams_open_count: my_teams_open_count } } end @@ -112,6 +114,8 @@ def filter_by_conversation_type @conversations = current_user.participating_conversations.where(account_id: current_account.id) when 'unattended' @conversations = @conversations.unattended + when 'recently_resolved' + @conversations = current_account.conversations.resolved.where(created_at: (7.days.ago..Date.current)).order(created_at: :desc) end @conversations end @@ -155,7 +159,9 @@ def set_count_for_all_conversations [ @conversations.assigned_to(current_user).count, @conversations.unassigned.count, - @conversations.count + @conversations.count, + @current_account.conversations.open.group(:inbox_id).count, + Conversation.open.where(team_id: @current_user.teams.pluck(:id)).group(:team_id).count ] end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 99f3fd36be6b3..c24fa929a0e53 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -16,6 +16,36 @@ def scope end end + def custom_filter(collection) + collection.filter_by_label(selected_label) + .filter_by_team(selected_team) + .filter_by_inbox(selected_inbox) + .filter_by_rating(selected_rating) + end + + def get_filter(key) + filter = params.dig(:custom_filter, key) + return [] if filter.blank? + + filter.to_unsafe_h.values + end + + def selected_label + get_filter(:selected_label) + end + + def selected_team + get_filter(:selected_team) + end + + def selected_inbox + get_filter(:selected_inbox) + end + + def selected_rating + get_filter(:selected_rating) + end + def conversations_count (get_grouped_values conversations).count end @@ -41,56 +71,56 @@ def bot_handoffs_count end def conversations - scope.conversations.where(account_id: account.id, created_at: range) + custom_filter(scope.conversations).where(account_id: account.id, created_at: range) end def incoming_messages - scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) + custom_filter(scope.messages).where(account_id: account.id, created_at: range).incoming.unscope(:order) end def outgoing_messages - scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) + custom_filter(scope.messages).where(account_id: account.id, created_at: range).outgoing.unscope(:order) end def resolutions - scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved, - conversations: { status: :resolved }, created_at: range).distinct + custom_filter(scope.reporting_events).joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved, + conversations: { status: :resolved }, created_at: range).distinct end def bot_resolutions - scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, - conversations: { status: :resolved }, created_at: range).distinct + custom_filter(scope.reporting_events).joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, + conversations: { status: :resolved }, created_at: range).distinct end def bot_handoffs - scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff, - created_at: range).distinct + custom_filter(scope.reporting_events).joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff, + created_at: range).distinct end def avg_first_response_time - grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id)) + grouped_reporting_events = (get_grouped_values custom_filter(scope.reporting_events).where(name: 'first_response', account_id: account.id)) return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] grouped_reporting_events.average(:value) end def reply_time - grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'reply_time', account_id: account.id)) + grouped_reporting_events = (get_grouped_values custom_filter(scope.reporting_events).where(name: 'reply_time', account_id: account.id)) 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', account_id: account.id)) + grouped_reporting_events = (get_grouped_values custom_filter(scope.reporting_events).where(name: 'conversation_resolved', account_id: account.id)) 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', account_id: account.id, created_at: range) + reporting_events = custom_filter(scope.reporting_events) + .where(name: 'conversation_resolved', account_id: account.id, created_at: range) avg_rt = if params[:business_hours].present? reporting_events.average(:value_in_business_hours) else @@ -103,8 +133,8 @@ def avg_resolution_time_summary end def reply_time_summary - reporting_events = scope.reporting_events - .where(name: 'reply_time', account_id: account.id, created_at: range) + reporting_events = custom_filter(scope.reporting_events) + .where(name: 'reply_time', account_id: account.id, created_at: range) reply_time = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) return 0 if reply_time.blank? @@ -113,8 +143,8 @@ def reply_time_summary end def avg_first_response_time_summary - reporting_events = scope.reporting_events - .where(name: 'first_response', account_id: account.id, created_at: range) + reporting_events = custom_filter(scope.reporting_events) + .where(name: 'first_response', account_id: account.id, created_at: range) avg_frt = if params[:business_hours].present? reporting_events.average(:value_in_business_hours) else diff --git a/app/javascript/dashboard/api/csatReports.js b/app/javascript/dashboard/api/csatReports.js index 2d3ce12e51922..2e985746536f2 100644 --- a/app/javascript/dashboard/api/csatReports.js +++ b/app/javascript/dashboard/api/csatReports.js @@ -6,7 +6,18 @@ class CSATReportsAPI extends ApiClient { super('csat_survey_responses', { accountScoped: true }); } - get({ page, from, to, user_ids, inbox_id, team_id, rating } = {}) { + get({ + page, + from, + to, + user_ids, + inbox_id, + team_id, + rating, + question_id, + label, + question, + } = {}) { return axios.get(this.url, { params: { page, @@ -17,11 +28,30 @@ class CSATReportsAPI extends ApiClient { inbox_id, team_id, rating, + question_id, + label, + question, }, }); } - download({ from, to, user_ids, inbox_id, team_id, rating } = {}) { + getQuestions({ from, to, user_ids, inbox_id, team_id, rating, label, question } = {}) { + return axios.get(`${this.url}/questions`, { + params: { + since: from, + until: to, + sort: '-created_at', + user_ids, + inbox_id, + team_id, + rating, + label, + question, + }, + }); + } + + download({ from, to, user_ids, inbox_id, team_id, rating, label, question } = {}) { return axios.get(`${this.url}/download`, { params: { since: from, @@ -31,14 +61,16 @@ class CSATReportsAPI extends ApiClient { inbox_id, team_id, rating, + label, + question, }, }); } - getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) { + getMetrics({ from, to, user_ids, inbox_id, team_id, rating, label, question } = {}) { // no ratings for metrics return axios.get(`${this.url}/metrics`, { - params: { since: from, until: to, user_ids, inbox_id, team_id, rating }, + params: { since: from, until: to, user_ids, inbox_id, team_id, rating, label, question }, }); } } diff --git a/app/javascript/dashboard/api/csatTemplates.js b/app/javascript/dashboard/api/csatTemplates.js new file mode 100644 index 0000000000000..a54d8c7380162 --- /dev/null +++ b/app/javascript/dashboard/api/csatTemplates.js @@ -0,0 +1,38 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class CsatTemplatesAPI extends ApiClient { + constructor() { + super('csat_templates', { accountScoped: true }); + } + + get({ page } = {}) { + return axios.get(this.url, { + params: { + page, + }, + }); + } + + getTemplate(id) { + return axios.get(`${this.url}/${id}`); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } + + create(params) { + return axios.post(this.url, params); + } + + update(id, params) { + return axios.patch(`${this.url}/${id}`, params); + } + + getInboxes() { + return axios.get(`${this.url}/inboxes`); + } +} + +export default new CsatTemplatesAPI(); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 94cc81354bfb2..f0f8a50cb56ee 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -54,6 +54,12 @@ class ConversationApi extends ApiClient { }); } + close({ conversationId, closed }){ + return axios.post(`${this.url}/${conversationId}/close`, { + closed + }) + } + togglePriority({ conversationId, priority }) { return axios.post(`${this.url}/${conversationId}/toggle_priority`, { priority, @@ -72,6 +78,16 @@ class ConversationApi extends ApiClient { return axios.post(`${this.url}/${conversationId}/assignments`, params); } + assignContactKind({ conversationId, contactKind }) { + const params = { contact_kind: contactKind }; + return axios.post(`${this.url}/${conversationId}/change_contact_kind`, params); + } + + changeContact({ conversationId, email }) { + const params = { email: email }; + return axios.post(`${this.url}/${conversationId}/change_contact`, params); + } + markMessageRead({ id }) { return axios.post(`${this.url}/${id}/update_last_seen`); } diff --git a/app/javascript/dashboard/api/inbox/draft_message.js b/app/javascript/dashboard/api/inbox/draft_message.js new file mode 100644 index 0000000000000..23424775b4262 --- /dev/null +++ b/app/javascript/dashboard/api/inbox/draft_message.js @@ -0,0 +1,25 @@ +/* eslint no-console: 0 */ +/* global axios */ +import ApiClient from '../ApiClient'; + +class DraftMessageApi extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + getDraft(conversationId) { + return axios.get(`${this.url}/${conversationId}/draft_messages`); + } + + updateDraft({ conversationId, message }) { + return axios.put(`${this.url}/${conversationId}/draft_messages`, { + message, + }); + } + + deleteDraft(conversationId) { + return axios.delete(`${this.url}/${conversationId}/draft_messages`); + } +} + +export default new DraftMessageApi(); diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 8f294a0eee7af..fad7822c5bda6 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -12,6 +12,7 @@ export const buildCreatePayload = ({ bccEmails = '', toEmails = '', templateParams, + contentType, }) => { let payload; if (files && files.length !== 0) { @@ -33,6 +34,9 @@ export const buildCreatePayload = ({ if (contentAttributes) { payload.append('content_attributes', JSON.stringify(contentAttributes)); } + if (contentType) { + payload.append('content_type', contentType); + } } else { payload = { content: message, @@ -44,7 +48,12 @@ export const buildCreatePayload = ({ to_emails: toEmails, template_params: templateParams, }; + + if (contentType) { + payload.content_type = contentType; + } } + return payload; }; @@ -64,6 +73,7 @@ class MessageApi extends ApiClient { bccEmails = '', toEmails = '', templateParams, + contentType, }) { return axios({ method: 'post', @@ -78,6 +88,7 @@ class MessageApi extends ApiClient { bccEmails, toEmails, templateParams, + contentType, }), }); } diff --git a/app/javascript/dashboard/api/inbox/smart_action.js b/app/javascript/dashboard/api/inbox/smart_action.js new file mode 100644 index 0000000000000..4c6476d6c72ae --- /dev/null +++ b/app/javascript/dashboard/api/inbox/smart_action.js @@ -0,0 +1,24 @@ +/* eslint no-console: 0 */ +/* global axios */ +import ApiClient from '../ApiClient'; +import { SMART_ACTION_EVENTS } from 'shared/constants/smartActionEvents'; + +class SmartActionApi extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + getSmartActions(conversationId) { + return axios.get(`${this.url}/${conversationId}/smart_actions`); + } + + askCopilot(conversationId) { + return axios.get(`${this.url}/${conversationId}/smart_actions/event_data`, { + params: { + event: SMART_ACTION_EVENTS.ASK_COPILOT, + }, + }); + } +} + +export default new SmartActionApi(); diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 52fa7f444d761..30b87654d1538 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -13,6 +13,10 @@ class ReportsAPI extends ApiClient { from, to, type = 'account', + selectedLabel, + selectedTeam, + selectedInbox, + selectedRating, id, groupBy, businessHours, @@ -22,6 +26,12 @@ class ReportsAPI extends ApiClient { metric, since: from, until: to, + custom_filter: { + selected_label: selectedLabel, + selected_team: selectedTeam, + selected_inbox: selectedInbox, + selected_rating: selectedRating + }, type, id, group_by: groupBy, @@ -32,11 +42,17 @@ class ReportsAPI extends ApiClient { } // eslint-disable-next-line default-param-last - getSummary(since, until, type = 'account', id, groupBy, businessHours) { + getSummary(since, until, type = 'account', selectedLabel, selectedTeam, selectedInbox, selectedRating, id, groupBy, businessHours) { return axios.get(`${this.url}/summary`, { params: { since, until, + custom_filter: { + selected_label: selectedLabel, + selected_team: selectedTeam, + selected_inbox: selectedInbox, + selected_rating: selectedRating + }, type, id, group_by: groupBy, diff --git a/app/javascript/dashboard/api/specs/csatTemplates.spec.js b/app/javascript/dashboard/api/specs/csatTemplates.spec.js new file mode 100644 index 0000000000000..6cf0a15a791fd --- /dev/null +++ b/app/javascript/dashboard/api/specs/csatTemplates.spec.js @@ -0,0 +1,72 @@ +import csatTemplatesAPI from '../csatTemplates'; +import ApiClient from '../ApiClient'; + +describe('#Templates API', () => { + it('creates correct instance', () => { + expect(csatTemplatesAPI).toBeInstanceOf(ApiClient); + expect(csatTemplatesAPI.apiVersion).toBe('/api/v1'); + expect(csatTemplatesAPI).toHaveProperty('get'); + expect(csatTemplatesAPI).toHaveProperty('getTemplate'); + expect(csatTemplatesAPI).toHaveProperty('create'); + expect(csatTemplatesAPI).toHaveProperty('delete'); + expect(csatTemplatesAPI).toHaveProperty('getInboxes'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get', () => { + csatTemplatesAPI.get({ page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/csat_templates', { + params: { + page: 1, + }, + }); + }); + + it('#getTemplate', () => { + csatTemplatesAPI.getTemplate(1); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/csat_templates/1'); + }); + + it('#delete', () => { + csatTemplatesAPI.delete(1); + expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/csat_templates/1'); + }); + + it('#create', () => { + csatTemplatesAPI.create({ + inbox_ids: [1, 2], + questions: [ + { + id: null, + content: 'test question', + }, + ], + }); + expect(axiosMock.post).toHaveBeenCalledWith('/api/v1/csat_templates', { + inbox_ids: [1, 2], + questions: [ + { + id: null, + content: 'test question', + }, + ], + }); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/inbox/draft_message.js b/app/javascript/dashboard/api/specs/inbox/draft_message.js new file mode 100644 index 0000000000000..e7491c99f5cfe --- /dev/null +++ b/app/javascript/dashboard/api/specs/inbox/draft_message.js @@ -0,0 +1,45 @@ +import draftMessageApi from '../../inbox/conversation'; +import ApiClient from '../../ApiClient'; +import describeWithAPIMock from '../apiSpecHelper'; + +describe('#DraftMessageApi', () => { + it('creates correct instance', () => { + expect(draftMessageApi).toBeInstanceOf(ApiClient); + expect(draftMessageApi).toHaveProperty('getDraft'); + expect(draftMessageApi).toHaveProperty('updateDraft'); + expect(draftMessageApi).toHaveProperty('deleteDraft'); + }); + + describeWithAPIMock('API calls', context => { + it('#getDraft', () => { + draftMessageApi.getDraft({ + conversationId: 2, + }); + expect(context.axiosMock.get).toHaveBeenCalledWith( + `/api/v1/conversations/2/draft_messages` + ); + }); + + it('#updateDraft', () => { + draftMessageApi.updateDraft({ + conversationId: 45, + message: 'Hello', + }); + expect(context.axiosMock.post).toHaveBeenCalledWith( + '/api/v1/conversations/45/draft_messages', + { + message: 'Hello', + } + ); + }); + + it('#deleteDraft', () => { + draftMessageApi.deleteDraft({ + conversationId: 12, + }); + expect(context.axiosMock.delete).toHaveBeenCalledWith( + `/api/v1/conversations/12/draft_messages` + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 348d8572adcd7..cb7c9045e6e75 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -203,6 +203,7 @@ import { conversationListPageURL } from '../helper/URLHelper'; import { isOnMentionsView, isOnUnattendedView, + isOnRecentlyResolvedView } from '../store/modules/conversations/helpers/actionHelpers'; import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; import IntersectionObserver from './IntersectionObserver.vue'; @@ -533,6 +534,10 @@ export default { conversationType() { this.resetAndFetchData(); this.updateVirtualListProps('conversationType', this.conversationType); + if (this.conversationType === 'recently_resolved') { + this.onBasicFilterChange('all', 'status'); + this.onBasicFilterChange('created_at_desc', 'sort'); + } }, activeFolder() { this.resetAndFetchData(); @@ -918,6 +923,8 @@ export default { conversationType = 'mention'; } else if (isOnUnattendedView({ route: { name } })) { conversationType = 'unattended'; + } else if (isOnRecentlyResolvedView({ route: {name}})) { + conversationType = 'recently_resolved' } this.$router.push( conversationListPageURL({ diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 61375dc21fd78..b5b2c2a5aa844 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -71,6 +71,28 @@ {{ $t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }} + + + {{ $t('CONVERSATION.RESOLVE_DROPDOWN.CLOSE') }} + + + + + {{ $t('CONVERSATION.RESOLVE_DROPDOWN.UNCLOSE') }} + + { + this.showAlert('Conversation is closed'); + this.currentChat.closed = true + }); + }, + uncloseConversation(){ + this.closeDropdown(); + this.$store.dispatch('closeConversation', { + conversationId: this.currentChat.id, + closed: false + }).then(() => { + this.showAlert('Conversation is unclosed'); + this.currentChat.closed = false + }); + } }, }; diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js b/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js index a8302bd585592..7676a3b1926bb 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js @@ -20,6 +20,7 @@ const conversations = accountId => ({ 'conversations_through_folders', 'conversation_unattended', 'conversation_through_unattended', + 'conversation_recently_resolved' ], menuItems: [ { @@ -44,6 +45,13 @@ const conversations = accountId => ({ toState: frontendURL(`accounts/${accountId}/unattended/conversations`), toStateName: 'conversation_unattended', }, + { + icon: 'checkmark-circle', + label: 'RECENTLY_RESOLVED', + key: 'conversation_recently_resolved', + toState: frontendURL(`accounts/${accountId}/recently_resolved/conversations`), + toStateName: 'conversation_recently_resolved', + } ], }); diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js b/app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js index 7513e3d1c5642..5b31d83ca0bc1 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js @@ -54,7 +54,7 @@ const primaryMenuItems = accountId => [ alwaysVisibleOnChatwootInstances: true, toState: frontendURL(`accounts/${accountId}/portals`), toStateName: 'default_portal_articles', - roles: ['administrator'], + roles: ['administrator', 'agent'], }, { icon: 'settings', diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js index f450bd7a78474..9eeb735edf9ad 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js @@ -39,6 +39,7 @@ const settings = accountId => ({ 'settings_teams_finish', 'settings_teams_list', 'settings_teams_new', + 'settings_csat_templates', 'sla_list', ], menuItems: [ @@ -152,6 +153,14 @@ const settings = accountId => ({ featureFlag: FEATURE_FLAGS.AUDIT_LOGS, beta: true, }, + { + icon: 'star-half', + label: 'CSAT_TEMPLATES', + hasSubMenu: false, + toState: frontendURL(`accounts/${accountId}/settings/csat_templates`), + toStateName: 'settings_csat_templates', + featureFlag: FEATURE_FLAGS.CSAT_TEMPLATES, + }, { icon: 'document-list-clock', label: 'SLA', diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index f291d221e9d54..a44f53f76b12a 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue @@ -72,6 +72,7 @@ export default { computed: { ...mapGetters({ isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + chatLists: 'getAllConversations', }), hasSecondaryMenu() { return this.menuConfig.menuItems && this.menuConfig.menuItems.length; @@ -118,6 +119,8 @@ export default { type: inbox.channel_type, phoneNumber: inbox.phone_number, reauthorizationRequired: inbox.reauthorization_required, + statsField: 'allInboxOpenCount', + showOpenConversationCount: true, })) .sort((a, b) => a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1 @@ -186,6 +189,8 @@ export default { id: team.id, label: team.name, truncateLabel: true, + statsField: 'myTeamsOpenCount', + showOpenConversationCount: true, toState: frontendURL(`accounts/${this.accountId}/team/${team.id}`), })), }; diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue index 8ea98295560e6..6913fa680b2ac 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue @@ -74,11 +74,16 @@ size="12" /> + + {{ openConversationCount }} + diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue index a7009523ab2f1..120e9000caba3 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue @@ -3,11 +3,17 @@
- {{ $t(`SIDEBAR.${menuItem.label}`) }} +
-
+ + + ({}), }, + csatMessages: { + type: Array, + default: () => [], + }, }, data() { return { @@ -224,6 +239,10 @@ export default { }; }, computed: { + ...mapGetters({ + isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + accountId: 'getCurrentAccountId', + }), attachments() { // Here it is used to get sender and created_at for each attachment return this.data?.attachments.map(attachment => ({ @@ -237,6 +256,15 @@ export default { return getDayDifferenceFromNow(new Date(), this.data?.created_at) >= 1; }, shouldRenderMessage() { + if (this.data.content_type === 'input_csat') { + if (!this.isFirstCsat) { + return false; + } + if (this.currentInbox.csat_trigger === 'conversation_all_reply') { + return false; + } + } + return ( this.hasAttachments || this.data.content || @@ -245,6 +273,9 @@ export default { this.isAnIntegrationMessage ); }, + currentInbox() { + return this.$store.getters['inboxes/getInbox'](this.data.inbox_id); + }, emailMessageContent() { const { html_content: { full: fullHTMLContent } = {}, @@ -282,10 +313,6 @@ export default { } ); - if (this.contentType === 'input_csat') { - return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE') + botMessageContent; - } - return ( this.formatMessage( this.data.content, @@ -306,12 +333,24 @@ export default { }, contextMenuEnabledOptions() { return { + smart_actions: this.enableSmartActions, copy: this.hasText, delete: this.hasText || this.hasAttachments, cannedResponse: this.isOutgoing && this.hasText, replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing, }; }, + enableSmartActions() { + const isFeatEnabled = this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.SMART_ACTIONS + ); + return ( + isFeatEnabled && + this.isIncoming && + (this.isAnEmailInbox || this.isWebWidgetInbox) + ); + }, contentAttributes() { return this.data.content_attributes || {}; }, @@ -465,6 +504,23 @@ export default { } return ''; }, + firstCsat() { + if (this.csatMessages.length === 0) { + return null; + } + + return this.csatMessages[0]; + }, + isFirstCsat() { + if (this.data.content_type !== 'input_csat' || !this.firstCsat) { + return false; + } + + return this.firstCsat.id === this.data.id; + }, + notCsat() { + return this.data.content && this.data.content_type !== 'input_csat'; + }, }, watch: { data() { diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagePreview.vue b/app/javascript/dashboard/components/widgets/conversation/MessagePreview.vue index 22d69439e89f3..a6aa6933d2024 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagePreview.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagePreview.vue @@ -82,9 +82,12 @@ export default { return isPrivate; }, parsedLastMessage() { + return this.getPlainText(this.subject + this.message.content); + }, + subject() { const { content_attributes: contentAttributes } = this.message; const { email: { subject } = {} } = contentAttributes || {}; - return this.getPlainText(subject || this.message.content); + return subject ? subject + ' - ' : ''; }, lastMessageFileType() { const [{ file_type: fileType } = {}] = this.message.attachments; diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 17d875f56ecc9..f33b23203f49e 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -40,6 +40,7 @@ :is-instagram="isInstagramDM" :inbox-supports-reply-to="inboxSupportsReplyTo" :in-reply-to="getInReplyToMessage(message)" + :csat-messages="getCsatMessages" />
  • @@ -64,6 +65,7 @@ :is-instagram-dm="isInstagramDM" :inbox-supports-reply-to="inboxSupportsReplyTo" :in-reply-to="getInReplyToMessage(message)" + :csat-messages="getCsatMessages" /> msg.content_type === 'input_csat'); + }, shouldShowSpinner() { return ( (this.currentChat && this.currentChat.dataFetched === undefined) || @@ -319,6 +326,7 @@ export default { } this.fetchAllAttachmentsFromCurrentChat(); this.fetchSuggestions(); + this.fetchSmartActions(); this.messageSentSinceOpened = false; }, }, @@ -338,6 +346,7 @@ export default { this.addScrollListener(); this.fetchAllAttachmentsFromCurrentChat(); this.fetchSuggestions(); + this.fetchSmartActions(); }, beforeDestroy() { @@ -346,6 +355,21 @@ export default { }, methods: { + async fetchSmartActions() { + if (this.enabledSmartActions()) { + const conversationId = this.currentChat.id; + this.$store.dispatch('getSmartActions', conversationId); + } + }, + + enabledSmartActions() { + const isFeatEnabled = this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.SMART_ACTIONS + ); + return isFeatEnabled; + }, + async fetchSuggestions() { // start empty, this ensures that the label suggestions are not shown this.labelSuggestions = []; diff --git a/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue b/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue index 78435144de6c7..eeaa9f42c2984 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue @@ -1,5 +1,14 @@ diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 42fa74ec7ba0e..08831b2a63787 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -86,6 +86,7 @@ :signature="signatureToApply" :allow-signature="true" :channel-type="channelType" + :enable-smart-actions="enableSmartActions" @typing-off="onTypingOff" @typing-on="onTypingOn" @focus="onFocus" @@ -196,6 +197,7 @@ import { import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; const EmojiInput = () => import('shared/components/emoji/EmojiInput'); @@ -257,6 +259,9 @@ export default { showCannedMenu: false, showVariablesMenu: false, newConversationModalActive: false, + draftUpdateDelayer: null, + previousTypingStatus: '', + previousDraftMessage: '', showArticleSearchPopover: false, }; }, @@ -269,6 +274,8 @@ export default { globalConfig: 'globalConfig/get', accountId: 'getCurrentAccountId', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + smartActions: 'getSmartActions', + copilotResponse: 'getCopilotResponse', }), currentContact() { return this.$store.getters['contacts/getContact']( @@ -343,8 +350,12 @@ export default { return this.$store.getters['inboxes/getInbox'](this.inboxId); }, messagePlaceHolder() { - return this.isPrivate - ? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT') + if (this.isPrivate) { + return this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT'); + } + + return this.enableCopilot + ? this.$t('CONVERSATION.FOOTER.SMART_AI_INPUT') : this.$t('CONVERSATION.FOOTER.MSG_INPUT'); }, isMessageLengthReachingThreshold() { @@ -472,6 +483,13 @@ export default { this.isAWhatsAppChannel ); }, + enableSmartActions() { + const isFeatEnabled = this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.SMART_ACTIONS + ); + return isFeatEnabled && (this.isAnEmailChannel || this.isAWebWidgetInbox); + }, isSignatureEnabledForInbox() { return !this.isPrivate && this.sendWithSignature; }, @@ -578,6 +596,9 @@ export default { this.setToDraft(this.conversationIdByRoute, oldReplyType); this.getFromDraft(); }, + smartActions(){ + this.onAskCopilot(this.copilotResponse) + } }, mounted() { @@ -662,21 +683,39 @@ export default { saveDraft(conversationId, replyType) { if (this.message || this.message === '') { const key = `draft-${conversationId}-${replyType}`; - const draftToSave = trimContent(this.message || ''); + const draftToSave = removeSignature( + trimContent(this.message || ''), + this.signatureToApply + ); - this.$store.dispatch('draftMessages/set', { - key, - message: draftToSave, - }); + if (this.previousDraftMessage === draftToSave) { + return; + } + + clearTimeout(this.draftUpdateDelayer); + + this.draftUpdateDelayer = setTimeout(() => { + this.previousDraftMessage = draftToSave; + this.$store.dispatch('draftMessages/set', { + key, + conversationId, + message: draftToSave, + }); + }, 1000); } }, setToDraft(conversationId, replyType) { this.saveDraft(conversationId, replyType); this.message = ''; }, - getFromDraft() { + async getFromDraft() { if (this.conversationIdByRoute) { const key = `draft-${this.conversationIdByRoute}-${this.replyType}`; + const conversationId = this.conversationIdByRoute; + await this.$store.dispatch('draftMessages/getFromRemote', { + key, + conversationId, + }); const messageFromStore = this.$store.getters['draftMessages/get'](key) || ''; @@ -696,7 +735,8 @@ export default { removeFromDraft() { if (this.conversationIdByRoute) { const key = `draft-${this.conversationIdByRoute}-${this.replyType}`; - this.$store.dispatch('draftMessages/delete', { key }); + const conversationId = this.conversationIdByRoute; + this.$store.dispatch('draftMessages/delete', { key, conversationId }); } }, getKeyboardEvents() { @@ -902,6 +942,31 @@ export default { this.message = updatedMessage; }, 100); }, + async onAskCopilot(response) { + if ( + !response && + !response.content && + !response.content.length + ) { + return; + } + + if (this.message.length > 0) { + return; + } + + const answer = response.content; + + let i = 0; + const interval = setInterval(() => { + if (i <= answer.length) { + this.message = answer.substring(0, i); + i += 1; + } else { + clearInterval(interval); + } + }, 10); + }, setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { const { can_reply: canReply } = this.currentChat; this.$store.dispatch('draftMessages/setReplyEditorMode', { @@ -1022,10 +1087,11 @@ export default { const conversationId = this.currentChat.id; const isPrivate = this.isPrivate; - if (!conversationId) { + if (!conversationId || this.previousTypingStatus === status) { return; } + this.previousTypingStatus = status; this.$store.dispatch('conversationTypingStatus/toggleTyping', { status, conversationId, @@ -1159,8 +1225,8 @@ export default { // If the last incoming message sender is different from the conversation contact, add them to the "to" // and add the conversation contact to the CC if (!emailAttributes.from.includes(conversationContact)) { - to.push(...emailAttributes.from); - cc.push(conversationContact); + cc.push(...emailAttributes.from); + // to.push(...emailAttributes.from); } // Remove the conversation contact's email from the BCC list if present @@ -1233,6 +1299,7 @@ export default { height 2s cubic-bezier(0.37, 0, 0.63, 1); @apply relative border-t border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-900; + min-width: 550px; &.is-focused { box-shadow: diff --git a/app/javascript/dashboard/components/widgets/conversation/SmartActions.vue b/app/javascript/dashboard/components/widgets/conversation/SmartActions.vue new file mode 100644 index 0000000000000..978cf21170408 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/SmartActions.vue @@ -0,0 +1,226 @@ +