From a0886d37bc19e4556c4f4c6d48db45e985fa5bff Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 21 Jul 2021 01:26:32 +0530 Subject: [PATCH 01/66] chore: Expose widget config via an API (#2645) Expose widget config via an API so that the UI could be detached from the rails Application. --- .../api/v1/widget/base_controller.rb | 4 ++ .../api/v1/widget/campaigns_controller.rb | 6 -- .../api/v1/widget/configs_controller.rb | 41 ++++++++++++ .../api/v1/widget/inbox_members_controller.rb | 6 -- app/controllers/widgets_controller.rb | 1 + .../v1/widget/configs/create.json.jbuilder | 28 ++++++++ config/routes.rb | 1 + .../api/v1/widget/configs_controller_spec.rb | 67 +++++++++++++++++++ 8 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 app/controllers/api/v1/widget/configs_controller.rb create mode 100644 app/views/api/v1/widget/configs/create.json.jbuilder create mode 100644 spec/controllers/api/v1/widget/configs_controller_spec.rb diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 4c7dd4220..38c880526 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -94,6 +94,10 @@ class Api::V1::Widget::BaseController < ApplicationController { timestamp: permitted_params[:message][:timestamp] } end + def permitted_params + params.permit(:website_token) + end + def message_params { account_id: conversation.account_id, diff --git a/app/controllers/api/v1/widget/campaigns_controller.rb b/app/controllers/api/v1/widget/campaigns_controller.rb index fc26b18c8..cb9b96a38 100644 --- a/app/controllers/api/v1/widget/campaigns_controller.rb +++ b/app/controllers/api/v1/widget/campaigns_controller.rb @@ -4,10 +4,4 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController def index @campaigns = @web_widget.inbox.campaigns.where(enabled: true) end - - private - - def permitted_params - params.permit(:website_token) - end end diff --git a/app/controllers/api/v1/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb new file mode 100644 index 000000000..ac88c595a --- /dev/null +++ b/app/controllers/api/v1/widget/configs_controller.rb @@ -0,0 +1,41 @@ +class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController + before_action :set_global_config + + def create + build_contact + set_token + end + + private + + def set_global_config + @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL') + end + + def set_contact + @contact_inbox = @web_widget.inbox.contact_inboxes.find_by( + source_id: auth_token_params[:source_id] + ) + @contact = @contact_inbox&.contact + end + + def build_contact + return if @contact.present? + + @contact_inbox = @web_widget.create_contact_inbox(additional_attributes) + @contact = @contact_inbox.contact + end + + def set_token + payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id } + @token = ::Widget::TokenService.new(payload: payload).generate_token + end + + def additional_attributes + if @web_widget.inbox.account.feature_enabled?('ip_lookup') + { created_at_ip: request.remote_ip } + else + {} + end + end +end diff --git a/app/controllers/api/v1/widget/inbox_members_controller.rb b/app/controllers/api/v1/widget/inbox_members_controller.rb index da7d6256b..c4bc377ea 100644 --- a/app/controllers/api/v1/widget/inbox_members_controller.rb +++ b/app/controllers/api/v1/widget/inbox_members_controller.rb @@ -4,10 +4,4 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController def index @inbox_members = @web_widget.inbox.inbox_members.includes(:user) end - - private - - def permitted_params - params.permit(:website_token) - end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 44a23568e..f19a24888 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -1,3 +1,4 @@ +# TODO : Delete this and associated spec once 'api/widget/config' end point is merged class WidgetsController < ActionController::Base before_action :set_global_config before_action :set_web_widget diff --git a/app/views/api/v1/widget/configs/create.json.jbuilder b/app/views/api/v1/widget/configs/create.json.jbuilder new file mode 100644 index 000000000..8a7696a87 --- /dev/null +++ b/app/views/api/v1/widget/configs/create.json.jbuilder @@ -0,0 +1,28 @@ +json.chatwoot_website_channel do + json.avatar_url @web_widget.inbox.avatar_url + json.has_a_connected_agent_bot @web_widget.inbox.agent_bot&.name + json.locale @web_widget.account.locale + json.website_name @web_widget.inbox.name + json.website_token @web_widget.website_token + json.welcome_tagline @web_widget.welcome_tagline + json.welcome_title @web_widget.welcome_title + json.widget_color @web_widget.widget_color + json.enabled_features @web_widget.selected_feature_flags + json.enabled_languages available_locales_with_name + json.reply_time @web_widget.reply_time + json.pre_chat_form_enabled @web_widget.pre_chat_form_enabled + json.pre_chat_form_options @web_widget.pre_chat_form_options + json.working_hours_enabled @web_widget.inbox.working_hours_enabled + json.csat_survey_enabled @web_widget.inbox.csat_survey_enabled + json.working_hours @web_widget.inbox.working_hours + json.out_of_office_message @web_widget.inbox.out_of_office_message + json.utc_off_set ActiveSupport::TimeZone[@web_widget.inbox.timezone].formatted_offset +end +json.chatwoot_widget_defaults do + json.use_inbox_avatar_for_bot ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) +end +json.contact do + json.pubsub_token @contact.pubsub_token +end +json.auth_token @token +json.global_config @global_config diff --git a/config/routes.rb b/config/routes.rb index be7d26eb2..6d2ed531b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -152,6 +152,7 @@ Rails.application.routes.draw do resource :notification_subscriptions, only: [:create] namespace :widget do + resource :config, only: [:create] resources :campaigns, only: [:index] resources :events, only: [:create] resources :messages, only: [:index, :create, :update] diff --git a/spec/controllers/api/v1/widget/configs_controller_spec.rb b/spec/controllers/api/v1/widget/configs_controller_spec.rb new file mode 100644 index 000000000..b6bc0fba3 --- /dev/null +++ b/spec/controllers/api/v1/widget/configs_controller_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe '/api/v1/widget/config', type: :request do + let(:account) { create(:account) } + let(:web_widget) { create(:channel_widget, account: account) } + let!(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) } + let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } + let(:token) { ::Widget::TokenService.new(payload: payload).generate_token } + + describe 'POST /api/v1/widget/config' do + let(:params) { { website_token: web_widget.website_token } } + let(:response_keys) { %w[chatwoot_website_channel chatwoot_widget_defaults contact auth_token global_config] } + + context 'with invalid website token' do + it 'returns not found' do + post '/api/v1/widget/config', params: { website_token: '' } + expect(response).to have_http_status(:not_found) + end + end + + context 'with correct website token and missing X-Auth-Token' do + it 'returns widget config along with a new contact' do + expect do + post '/api/v1/widget/config', + params: params, + as: :json + end.to change(Contact, :count).by(1) + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body) + expect(response_data.keys).to include(*response_keys) + end + end + + context 'with correct website token and valid X-Auth-Token' do + it 'returns widget config along with the same contact' do + expect do + post '/api/v1/widget/config', + params: params, + headers: { 'X-Auth-Token' => token }, + as: :json + end.to change(Contact, :count).by(0) + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body) + expect(response_data.keys).to include(*response_keys) + expect(response_data['contact']['pubsub_token']).to eq(contact.pubsub_token) + end + end + + context 'with correct website token and invalid X-Auth-Token' do + it 'returns widget config and new contact with error message' do + expect do + post '/api/v1/widget/config', + params: params, + headers: { 'X-Auth-Token' => 'invalid token' }, + as: :json + end.to change(Contact, :count).by(1) + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body) + expect(response_data.keys).to include(*response_keys) + end + end + end +end From a7ca55c0801561bbd0c4a9e21a775569914a7d6c Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 21 Jul 2021 22:02:43 +0530 Subject: [PATCH 02/66] chore: Change the conversation `bot` status to `pending` (#2677) fixes: #2649 --- .../v1/accounts/conversations_controller.rb | 6 +++- .../components/buttons/ResolveAction.vue | 16 ++++----- app/javascript/dashboard/constants.js | 2 +- .../dashboard/i18n/locale/en/chatlist.json | 4 +-- .../i18n/locale/en/conversation.json | 2 +- .../conversationAttributes/actions.spec.js | 8 ++--- .../conversationAttributes/getters.spec.js | 4 +-- .../conversationAttributes/mutations.spec.js | 6 ++-- app/listeners/notification_listener.rb | 4 +-- app/models/conversation.rb | 11 +++--- config/locales/en.yml | 2 +- .../dialogflow/processor_service.rb | 2 +- spec/controllers/api/base_controller_spec.rb | 2 +- .../accounts/conversations_controller_spec.rb | 36 ++++++++++++++++--- .../dialogflow/processor_service_spec.rb | 2 +- .../concerns/round_robin_handler_spec.rb | 2 +- spec/models/conversation_spec.rb | 8 ++--- swagger/definitions/resource/conversation.yml | 2 +- swagger/paths/conversation/create.yml | 2 +- swagger/paths/conversation/index.yml | 6 ++-- swagger/paths/conversation/toggle_status.yml | 2 +- swagger/swagger.json | 12 +++---- 22 files changed, 87 insertions(+), 54 deletions(-) diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 9f4f0b45d..8d719e46c 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -49,7 +49,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def toggle_status if params[:status] - @conversation.status = params[:status] + status = params[:status] == 'bot' ? 'pending' : params[:status] + @conversation.status = status @status = @conversation.save else @status = @conversation.toggle_status @@ -106,6 +107,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def conversation_params additional_attributes = params[:additional_attributes]&.permit! || {} status = params[:status].present? ? { status: params[:status] } : {} + + # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases + status = { status: 'pending' } if status[:status] == 'bot' { account_id: Current.account.id, inbox_id: @contact_inbox.inbox_id, diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 5c429d237..2ab3ad83b 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -24,7 +24,7 @@ {{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }} - + - {{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.OPEN_BOT') }} + {{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }} @@ -91,20 +91,20 @@ export default { isOpen() { return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN; }, - isBot() { - return this.currentChat.status === wootConstants.STATUS_TYPE.BOT; + isPending() { + return this.currentChat.status === wootConstants.STATUS_TYPE.PENDING; }, isResolved() { return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED; }, buttonClass() { - if (this.isBot) return 'primary'; + if (this.isPending) return 'primary'; if (this.isOpen) return 'success'; if (this.isResolved) return 'warning'; return ''; }, showDropDown() { - return !this.isBot; + return !this.isPending; }, }, methods: { diff --git a/app/javascript/dashboard/constants.js b/app/javascript/dashboard/constants.js index 34f4b9845..b981fd31d 100644 --- a/app/javascript/dashboard/constants.js +++ b/app/javascript/dashboard/constants.js @@ -8,7 +8,7 @@ export default { STATUS_TYPE: { OPEN: 'open', RESOLVED: 'resolved', - BOT: 'bot', + PENDING: 'pending', }, }; export const DEFAULT_REDIRECT_URL = '/app/'; diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index ef4e0629a..a8847e758 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -47,8 +47,8 @@ "VALUE": "resolved" }, { - "TEXT": "Bot", - "VALUE": "bot" + "TEXT": "Pending", + "VALUE": "pending" } ], "ATTACHMENTS": { diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 81c023aa5..831bad781 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -41,7 +41,7 @@ "DETAILS": "details" }, "RESOLVE_DROPDOWN": { - "OPEN_BOT": "Open with bot" + "MARK_PENDING": "Mark as pending" }, "FOOTER": { "MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", diff --git a/app/javascript/widget/store/modules/specs/conversationAttributes/actions.spec.js b/app/javascript/widget/store/modules/specs/conversationAttributes/actions.spec.js index 51c864ad2..241e620cf 100644 --- a/app/javascript/widget/store/modules/specs/conversationAttributes/actions.spec.js +++ b/app/javascript/widget/store/modules/specs/conversationAttributes/actions.spec.js @@ -7,10 +7,10 @@ jest.mock('widget/helpers/axios'); describe('#actions', () => { describe('#get attributes', () => { it('sends mutation if api is success', async () => { - API.get.mockResolvedValue({ data: { id: 1, status: 'bot' } }); + API.get.mockResolvedValue({ data: { id: 1, status: 'pending' } }); await actions.getAttributes({ commit }); expect(commit.mock.calls).toEqual([ - ['SET_CONVERSATION_ATTRIBUTES', { id: 1, status: 'bot' }], + ['SET_CONVERSATION_ATTRIBUTES', { id: 1, status: 'pending' }], ['conversation/setMetaUserLastSeenAt', undefined, { root: true }], ]); }); @@ -23,10 +23,10 @@ describe('#actions', () => { describe('#update attributes', () => { it('sends correct mutations', () => { - actions.update({ commit }, { id: 1, status: 'bot' }); + actions.update({ commit }, { id: 1, status: 'pending' }); expect(commit).toBeCalledWith('UPDATE_CONVERSATION_ATTRIBUTES', { id: 1, - status: 'bot', + status: 'pending', }); }); }); diff --git a/app/javascript/widget/store/modules/specs/conversationAttributes/getters.spec.js b/app/javascript/widget/store/modules/specs/conversationAttributes/getters.spec.js index 478c97d2c..0fe40bf11 100644 --- a/app/javascript/widget/store/modules/specs/conversationAttributes/getters.spec.js +++ b/app/javascript/widget/store/modules/specs/conversationAttributes/getters.spec.js @@ -4,11 +4,11 @@ describe('#getters', () => { it('getConversationParams', () => { const state = { id: 1, - status: 'bot', + status: 'pending', }; expect(getters.getConversationParams(state)).toEqual({ id: 1, - status: 'bot', + status: 'pending', }); }); }); diff --git a/app/javascript/widget/store/modules/specs/conversationAttributes/mutations.spec.js b/app/javascript/widget/store/modules/specs/conversationAttributes/mutations.spec.js index 2b2bd62fc..ede3c3a8f 100644 --- a/app/javascript/widget/store/modules/specs/conversationAttributes/mutations.spec.js +++ b/app/javascript/widget/store/modules/specs/conversationAttributes/mutations.spec.js @@ -14,7 +14,7 @@ describe('#mutations', () => { describe('#UPDATE_CONVERSATION_ATTRIBUTES', () => { it('update status if it is same conversation', () => { - const state = { id: 1, status: 'bot' }; + const state = { id: 1, status: 'pending' }; mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, { id: 1, status: 'open', @@ -22,12 +22,12 @@ describe('#mutations', () => { expect(state).toEqual({ id: 1, status: 'open' }); }); it('doesnot update status if it is not the same conversation', () => { - const state = { id: 1, status: 'bot' }; + const state = { id: 1, status: 'pending' }; mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, { id: 2, status: 'open', }); - expect(state).toEqual({ id: 1, status: 'bot' }); + expect(state).toEqual({ id: 1, status: 'pending' }); }); }); diff --git a/app/listeners/notification_listener.rb b/app/listeners/notification_listener.rb index ad7bf2d14..b619dfa88 100644 --- a/app/listeners/notification_listener.rb +++ b/app/listeners/notification_listener.rb @@ -1,7 +1,7 @@ class NotificationListener < BaseListener def conversation_created(event) conversation, account = extract_conversation_and_account(event) - return if conversation.bot? + return if conversation.pending? conversation.inbox.members.each do |agent| NotificationBuilder.new( @@ -17,7 +17,7 @@ class NotificationListener < BaseListener conversation, account = extract_conversation_and_account(event) assignee = conversation.assignee return unless conversation.notifiable_assignee_change? - return if conversation.bot? + return if conversation.pending? NotificationBuilder.new( notification_type: 'conversation_assignment', diff --git a/app/models/conversation.rb b/app/models/conversation.rb index e0760deea..865ff0b56 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -45,7 +45,7 @@ class Conversation < ApplicationRecord validates :inbox_id, presence: true before_validation :validate_additional_attributes - enum status: { open: 0, resolved: 1, bot: 2 } + enum status: { open: 0, resolved: 1, pending: 2 } scope :latest, -> { order(last_activity_at: :desc) } scope :unassigned, -> { where(assignee_id: nil) } @@ -64,7 +64,7 @@ class Conversation < ApplicationRecord has_one :csat_survey_response, dependent: :destroy has_many :notifications, as: :primary_actor, dependent: :destroy - before_create :set_bot_conversation + before_create :mark_conversation_pending_if_bot # wanted to change this to after_update commit. But it ended up creating a loop # reinvestigate in future and identity the implications @@ -91,7 +91,7 @@ class Conversation < ApplicationRecord def toggle_status # FIXME: implement state machine with aasm self.status = open? ? :resolved : :open - self.status = :open if bot? + self.status = :open if pending? save end @@ -144,8 +144,9 @@ class Conversation < ApplicationRecord self.additional_attributes = {} unless additional_attributes.is_a?(Hash) end - def set_bot_conversation - self.status = :bot if inbox.agent_bot_inbox&.active? || inbox.hooks.pluck(:app_id).include?('dialogflow') + def mark_conversation_pending_if_bot + # TODO: make this an inbox config instead of assuming bot conversations should start as pending + self.status = :pending if inbox.agent_bot_inbox&.active? || inbox.hooks.pluck(:app_id).include?('dialogflow') end def notify_conversation_creation diff --git a/config/locales/en.yml b/config/locales/en.yml index 2e7e85d03..cae92afb8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -63,7 +63,7 @@ en: status: resolved: "Conversation was marked resolved by %{user_name}" open: "Conversation was reopened by %{user_name}" - bot: "Conversation was transferred to bot by %{user_name}" + pending: "Conversation was marked as pending by %{user_name}" auto_resolved: "Conversation was marked resolved by system due to %{duration} days of inactivity" assignee: self_assigned: "%{user_name} self-assigned this conversation" diff --git a/lib/integrations/dialogflow/processor_service.rb b/lib/integrations/dialogflow/processor_service.rb index 7a425be68..4c490d6b4 100644 --- a/lib/integrations/dialogflow/processor_service.rb +++ b/lib/integrations/dialogflow/processor_service.rb @@ -5,7 +5,7 @@ class Integrations::Dialogflow::ProcessorService message = event_data[:message] return if message.private? return unless processable_message?(message) - return unless message.conversation.bot? + return unless message.conversation.pending? response = get_dialogflow_response(message.conversation.contact_inbox.source_id, message_content(message)) process_response(message, response) diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb index 4ee5e7abe..969b83f68 100644 --- a/spec/controllers/api/base_controller_spec.rb +++ b/spec/controllers/api/base_controller_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'API Base', type: :request do describe 'request with api_access_token for bot' do let!(:agent_bot) { create(:agent_bot) } let!(:inbox) { create(:inbox, account: account) } - let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user, status: 'bot') } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user, status: 'pending') } context 'when it is an unauthorized url' do it 'returns unauthorized' do diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 82beb18cc..bb75ba54e 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -200,6 +200,20 @@ RSpec.describe 'Conversations API', type: :request do end it 'creates a conversation in specificed status' do + allow(Rails.configuration.dispatcher).to receive(:dispatch) + post "/api/v1/accounts/#{account.id}/conversations", + headers: agent.create_new_auth_token, + params: { source_id: contact_inbox.source_id, status: 'pending' }, + as: :json + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body, symbolize_names: true) + expect(response_data[:status]).to eq('pending') + end + + # TODO: remove this spec when we remove the condition check in controller + # Added for backwards compatibility for bot status + it 'creates a conversation as pending if status is specified as bot' do allow(Rails.configuration.dispatcher).to receive(:dispatch) post "/api/v1/accounts/#{account.id}/conversations", headers: agent.create_new_auth_token, @@ -208,7 +222,7 @@ RSpec.describe 'Conversations API', type: :request do expect(response).to have_http_status(:success) response_data = JSON.parse(response.body, symbolize_names: true) - expect(response_data[:status]).to eq('bot') + expect(response_data[:status]).to eq('pending') end it 'creates a new conversation with message when message is passed' do @@ -269,8 +283,8 @@ RSpec.describe 'Conversations API', type: :request do expect(conversation.reload.status).to eq('resolved') end - it 'toggles the conversation status to open from bot' do - conversation.update!(status: 'bot') + it 'toggles the conversation status to open from pending' do + conversation.update!(status: 'pending') post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", headers: agent.create_new_auth_token, @@ -283,13 +297,27 @@ RSpec.describe 'Conversations API', type: :request do it 'toggles the conversation status to specific status when parameter is passed' do expect(conversation.status).to eq('open') + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", + headers: agent.create_new_auth_token, + params: { status: 'pending' }, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.status).to eq('pending') + end + + # TODO: remove this spec when we remove the condition check in controller + # Added for backwards compatibility for bot status + it 'toggles the conversation status to pending status when parameter bot is passed' do + expect(conversation.status).to eq('open') + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", headers: agent.create_new_auth_token, params: { status: 'bot' }, as: :json expect(response).to have_http_status(:success) - expect(conversation.reload.status).to eq('bot') + expect(conversation.reload.status).to eq('pending') end end end diff --git a/spec/lib/integrations/dialogflow/processor_service_spec.rb b/spec/lib/integrations/dialogflow/processor_service_spec.rb index c0c6ac795..a0bf083f9 100644 --- a/spec/lib/integrations/dialogflow/processor_service_spec.rb +++ b/spec/lib/integrations/dialogflow/processor_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Integrations::Dialogflow::ProcessorService do let(:account) { create(:account) } let(:hook) { create(:integrations_hook, :dialogflow, account: account) } - let(:conversation) { create(:conversation, account: account, status: :bot) } + let(:conversation) { create(:conversation, account: account, status: :pending) } let(:message) { create(:message, account: account, conversation: conversation) } let(:event_name) { 'message.created' } let(:event_data) { { message: message } } diff --git a/spec/models/concerns/round_robin_handler_spec.rb b/spec/models/concerns/round_robin_handler_spec.rb index 4c4c654cc..9ec499ed2 100644 --- a/spec/models/concerns/round_robin_handler_spec.rb +++ b/spec/models/concerns/round_robin_handler_spec.rb @@ -40,7 +40,7 @@ shared_examples_for 'round_robin_handler' do account: account, contact: create(:contact, account: account), inbox: inbox, - status: 'bot', + status: 'pending', assignee: nil ) diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index cbbd97855..c21db3044 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -345,8 +345,8 @@ RSpec.describe Conversation, type: :model do let!(:bot_inbox) { create(:agent_bot_inbox) } let(:conversation) { create(:conversation, inbox: bot_inbox.inbox) } - it 'returns conversation status as bot' do - expect(conversation.status).to eq('bot') + it 'returns conversation status as pending' do + expect(conversation.status).to eq('pending') end end @@ -354,8 +354,8 @@ RSpec.describe Conversation, type: :model do let(:hook) { create(:integrations_hook, :dialogflow) } let(:conversation) { create(:conversation, inbox: hook.inbox) } - it 'returns conversation status as bot' do - expect(conversation.status).to eq('bot') + it 'returns conversation status as pending' do + expect(conversation.status).to eq('pending') end end diff --git a/swagger/definitions/resource/conversation.yml b/swagger/definitions/resource/conversation.yml index 46c2efd2e..9de4dafb8 100644 --- a/swagger/definitions/resource/conversation.yml +++ b/swagger/definitions/resource/conversation.yml @@ -13,7 +13,7 @@ properties: description: ID of the inbox status: type: string - enum: ['open', 'resolved', 'bot'] + enum: ['open', 'resolved', 'pending'] description: The status of the conversation timestamp: type: string diff --git a/swagger/paths/conversation/create.yml b/swagger/paths/conversation/create.yml index e13d17cfb..104460bae 100644 --- a/swagger/paths/conversation/create.yml +++ b/swagger/paths/conversation/create.yml @@ -14,7 +14,7 @@ get: - name: status in: query type: string - enum: ['open', 'resolved', 'bot'] + enum: ['open', 'resolved', 'pending'] required: true - name: page in: query diff --git a/swagger/paths/conversation/index.yml b/swagger/paths/conversation/index.yml index 35707b65a..7c690fe56 100644 --- a/swagger/paths/conversation/index.yml +++ b/swagger/paths/conversation/index.yml @@ -15,7 +15,7 @@ get: - name: status in: query type: string - enum: ['open', 'resolved', 'bot'] + enum: ['open', 'resolved', 'pending'] - name: page in: query type: integer @@ -71,8 +71,8 @@ post: description: Lets you specify attributes like browser information status: type: string - enum: ['open', 'resolved', 'bot'] - description: Specify the conversation whether it's bot, open, closed + enum: ['open', 'resolved', 'pending'] + description: Specify the conversation whether it's pending, open, closed responses: 200: diff --git a/swagger/paths/conversation/toggle_status.yml b/swagger/paths/conversation/toggle_status.yml index 250e050e5..a737281a4 100644 --- a/swagger/paths/conversation/toggle_status.yml +++ b/swagger/paths/conversation/toggle_status.yml @@ -15,7 +15,7 @@ parameters: properties: status: type: string - enum: ["open", "resolved", "bot"] + enum: ["open", "resolved", "pending"] required: true description: The status of the conversation responses: diff --git a/swagger/swagger.json b/swagger/swagger.json index 561a1bd66..935044734 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1417,7 +1417,7 @@ "enum": [ "open", "resolved", - "bot" + "pending" ] }, { @@ -1508,9 +1508,9 @@ "enum": [ "open", "resolved", - "bot" + "pending" ], - "description": "Specify the conversation whether it's bot, open, closed" + "description": "Specify the conversation whether it's pending, open, closed" } } } @@ -1574,7 +1574,7 @@ "enum": [ "open", "resolved", - "bot" + "pending" ], "required": true }, @@ -1688,7 +1688,7 @@ "enum": [ "open", "resolved", - "bot" + "pending" ], "required": true, "description": "The status of the conversation" @@ -2850,7 +2850,7 @@ "enum": [ "open", "resolved", - "bot" + "pending" ], "description": "The status of the conversation" }, From 6b6df7a70d14ca270a8c684ba64b8620315e80f6 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 22 Jul 2021 16:31:53 +0530 Subject: [PATCH 03/66] fix: Disable "none" option from agent dropdown If agent is not selected (#2687) --- app/javascript/dashboard/mixins/agentMixin.js | 23 ++++++++++++------- .../dashboard/mixins/specs/agentMixin.spec.js | 5 +++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/app/javascript/dashboard/mixins/agentMixin.js b/app/javascript/dashboard/mixins/agentMixin.js index 335390089..295dafa8a 100644 --- a/app/javascript/dashboard/mixins/agentMixin.js +++ b/app/javascript/dashboard/mixins/agentMixin.js @@ -10,17 +10,24 @@ export default { ...mapGetters({ currentUser: 'getCurrentUser', }), + isAgentSelected() { + return this.currentChat?.meta?.assignee; + }, agentsList() { const agents = this.assignableAgents || []; return [ - { - confirmed: true, - name: 'None', - id: 0, - role: 'agent', - account_id: 0, - email: 'None', - }, + ...(this.isAgentSelected + ? [ + { + confirmed: true, + name: 'None', + id: 0, + role: 'agent', + account_id: 0, + email: 'None', + }, + ] + : []), ...agents, ].map(item => item.id === this.currentUser.id diff --git a/app/javascript/dashboard/mixins/specs/agentMixin.spec.js b/app/javascript/dashboard/mixins/specs/agentMixin.spec.js index 41d94e114..7cd2ad0db 100644 --- a/app/javascript/dashboard/mixins/specs/agentMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/agentMixin.spec.js @@ -24,7 +24,10 @@ describe('agentMixin', () => { title: 'TestComponent', mixins: [agentMixin], data() { - return { inboxId: 1 }; + return { + inboxId: 1, + currentChat: { meta: { assignee: { name: 'John' } } }, + }; }, computed: { assignableAgents() { From ecdf977de70f1a1972dbb0f7dd48b31fe1427761 Mon Sep 17 00:00:00 2001 From: Sanju Date: Thu, 22 Jul 2021 18:36:33 +0530 Subject: [PATCH 04/66] Live chat widget preview #2441 (#2523) * update widget preview on storybook * removed default value for logo * add online dot * resolve PR comments - split widget to head, body & footer - updated reply time to a select box * update spacing with variables * update reply-time with i18 * update with spacing variables * update padding with space variable * resolved PR comments * update background color Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> --- .../widget-preview/components/Widget.vue | 83 +++++++++++++ .../widget-preview/components/WidgetBody.vue | 82 +++++++++++++ .../components/WidgetFooter.vue | 38 ++++++ .../widget-preview/components/WidgetHead.vue | 112 ++++++++++++++++++ .../widget-preview/stories/Widget.stories.js | 29 +++++ 5 files changed, 344 insertions(+) create mode 100644 app/javascript/dashboard/modules/widget-preview/components/Widget.vue create mode 100644 app/javascript/dashboard/modules/widget-preview/components/WidgetBody.vue create mode 100644 app/javascript/dashboard/modules/widget-preview/components/WidgetFooter.vue create mode 100644 app/javascript/dashboard/modules/widget-preview/components/WidgetHead.vue create mode 100644 app/javascript/dashboard/modules/widget-preview/stories/Widget.stories.js diff --git a/app/javascript/dashboard/modules/widget-preview/components/Widget.vue b/app/javascript/dashboard/modules/widget-preview/components/Widget.vue new file mode 100644 index 000000000..0f630cbf7 --- /dev/null +++ b/app/javascript/dashboard/modules/widget-preview/components/Widget.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/app/javascript/dashboard/modules/widget-preview/components/WidgetBody.vue b/app/javascript/dashboard/modules/widget-preview/components/WidgetBody.vue new file mode 100644 index 000000000..3bba815db --- /dev/null +++ b/app/javascript/dashboard/modules/widget-preview/components/WidgetBody.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/app/javascript/dashboard/modules/widget-preview/components/WidgetFooter.vue b/app/javascript/dashboard/modules/widget-preview/components/WidgetFooter.vue new file mode 100644 index 000000000..1150b884e --- /dev/null +++ b/app/javascript/dashboard/modules/widget-preview/components/WidgetFooter.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/app/javascript/dashboard/modules/widget-preview/components/WidgetHead.vue b/app/javascript/dashboard/modules/widget-preview/components/WidgetHead.vue new file mode 100644 index 000000000..7857df8a4 --- /dev/null +++ b/app/javascript/dashboard/modules/widget-preview/components/WidgetHead.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/app/javascript/dashboard/modules/widget-preview/stories/Widget.stories.js b/app/javascript/dashboard/modules/widget-preview/stories/Widget.stories.js new file mode 100644 index 000000000..91383eeb6 --- /dev/null +++ b/app/javascript/dashboard/modules/widget-preview/stories/Widget.stories.js @@ -0,0 +1,29 @@ +import Widget from '../components/Widget'; + +const ReplyTime = { + 'In a few minutes': 'in_a_few_minutes', + 'In a few hours': 'in_a_few_hours', + 'In a few day': 'in_a_day', +}; + +export default { + title: 'components/Widget', + component: Widget, + argTypes: { + replyTime: { + control: { + type: 'select', + options: ReplyTime, + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { Widget }, + template: '', +}); + +export const DefaultWidget = Template.bind({}); +DefaultWidget.args = {}; From 4d45ac3bfc82e7d32b810a62ea5c01c6c99691b2 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Thu, 22 Jul 2021 19:49:12 +0530 Subject: [PATCH 05/66] chore: Switch from addgroup to adduser (#2688) --- deployment/setup_20.04.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/setup_20.04.sh b/deployment/setup_20.04.sh index 69683b695..384ffdda0 100644 --- a/deployment/setup_20.04.sh +++ b/deployment/setup_20.04.sh @@ -25,7 +25,7 @@ adduser --disabled-login --gecos "" chatwoot gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB gpg2 --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB curl -sSL https://get.rvm.io | bash -s stable -addgroup chatwoot rvm +adduser chatwoot rvm pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '') sudo -i -u postgres psql << EOF From d955d8e7dc73d1cec4e27bb25de1f3302afbba07 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 23 Jul 2021 15:24:07 +0530 Subject: [PATCH 06/66] feat: Ability to snooze conversations (#2682) Co-authored-by: Pranav Raj S --- .../v1/accounts/conversations_controller.rb | 15 +++- .../dashboard/api/inbox/conversation.js | 3 +- .../api/specs/inbox/conversation.spec.js | 1 + .../components/buttons/ResolveAction.vue | 78 ++++++++++++++++--- app/javascript/dashboard/constants.js | 1 + .../dashboard/i18n/locale/en/chatlist.json | 4 + .../i18n/locale/en/conversation.json | 8 +- .../store/modules/conversations/actions.js | 6 +- .../ui/dropdown/DropdownSubMenu.vue | 46 +++++++++++ .../reopen_snoozed_conversations_job.rb | 7 ++ app/jobs/trigger_scheduled_items_job.rb | 3 + app/models/conversation.rb | 10 ++- app/models/message.rb | 5 +- config/locales/en.yml | 1 + ...2458_add_snoozed_until_to_conversations.rb | 5 ++ db/schema.rb | 3 +- .../accounts/conversations_controller_spec.rb | 13 ++++ .../reopen_snoozed_conversations_job_spec.rb | 22 ++++++ spec/jobs/trigger_scheduled_items_job_spec.rb | 5 ++ spec/models/conversation_spec.rb | 36 +++++++-- spec/models/message_spec.rb | 24 ++++++ 21 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 app/javascript/shared/components/ui/dropdown/DropdownSubMenu.vue create mode 100644 app/jobs/conversations/reopen_snoozed_conversations_job.rb create mode 100644 db/migrate/20210721182458_add_snoozed_until_to_conversations.rb create mode 100644 spec/jobs/conversations/reopen_snoozed_conversations_job_spec.rb diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 8d719e46c..d360723a6 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -1,5 +1,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController include Events::Types + include DateRangeHelper before_action :conversation, except: [:index, :meta, :search, :create] before_action :contact_inbox, only: [:create] @@ -49,9 +50,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def toggle_status if params[:status] - status = params[:status] == 'bot' ? 'pending' : params[:status] - @conversation.status = status - @status = @conversation.save + set_conversation_status + @status = @conversation.save! else @status = @conversation.toggle_status end @@ -74,6 +74,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro private + def set_conversation_status + status = params[:status] == 'bot' ? 'pending' : params[:status] + @conversation.status = status + @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] + end + def trigger_typing_event(event) user = current_user.presence || @resource Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user) @@ -115,7 +121,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro inbox_id: @contact_inbox.inbox_id, contact_id: @contact_inbox.contact_id, contact_inbox_id: @contact_inbox.id, - additional_attributes: additional_attributes + additional_attributes: additional_attributes, + snoozed_until: params[:snoozed_until] }.merge(status) end diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 9388fc3bb..d5599ade0 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -28,9 +28,10 @@ class ConversationApi extends ApiClient { }); } - toggleStatus({ conversationId, status }) { + toggleStatus({ conversationId, status, snoozedUntil = null }) { return axios.post(`${this.url}/${conversationId}/toggle_status`, { status, + snoozed_until: snoozedUntil, }); } diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index b4951804a..f068b4a4f 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -69,6 +69,7 @@ describe('#ConversationAPI', () => { `/api/v1/conversations/12/toggle_status`, { status: 'online', + snoozed_until: null, } ); }); diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 2ab3ad83b..cf984c531 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -24,7 +24,7 @@ {{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }} @@ -67,20 +101,29 @@ import { mixin as clickaway } from 'vue-clickaway'; import alertMixin from 'shared/mixins/alertMixin'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; +import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import wootConstants from '../../constants'; +import { + getUnixTime, + addHours, + addWeeks, + startOfTomorrow, + startOfWeek, +} from 'date-fns'; export default { components: { WootDropdownItem, WootDropdownMenu, + WootDropdownSubMenu, }, mixins: [clickaway, alertMixin], props: { conversationId: { type: [String, Number], required: true } }, data() { return { isLoading: false, - showDropdown: false, + showActionsDropdown: false, STATUS_TYPE: wootConstants.STATUS_TYPE, }; }, @@ -97,30 +140,47 @@ export default { isResolved() { return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED; }, + isSnoozed() { + return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED; + }, buttonClass() { if (this.isPending) return 'primary'; if (this.isOpen) return 'success'; if (this.isResolved) return 'warning'; return ''; }, - showDropDown() { - return !this.isPending; + showAdditionalActions() { + return !this.isPending && !this.isSnoozed; + }, + snoozeTimes() { + return { + // tomorrow = 9AM next day + tomorrow: getUnixTime(addHours(startOfTomorrow(), 9)), + // next week = 9AM Monday, next week + nextWeek: getUnixTime( + addHours(startOfWeek(addWeeks(new Date(), 1), { weekStartsOn: 1 }), 9) + ), + }; }, }, methods: { + showOpenButton() { + return this.isResolved || this.isSnoozed; + }, closeDropdown() { - this.showDropdown = false; + this.showActionsDropdown = false; }, openDropdown() { - this.showDropdown = true; + this.showActionsDropdown = true; }, - toggleStatus(status) { + toggleStatus(status, snoozedUntil) { this.closeDropdown(); this.isLoading = true; this.$store .dispatch('toggleStatus', { conversationId: this.currentChat.id, status, + snoozedUntil, }) .then(() => { this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS')); diff --git a/app/javascript/dashboard/constants.js b/app/javascript/dashboard/constants.js index b981fd31d..58fdabdc1 100644 --- a/app/javascript/dashboard/constants.js +++ b/app/javascript/dashboard/constants.js @@ -9,6 +9,7 @@ export default { OPEN: 'open', RESOLVED: 'resolved', PENDING: 'pending', + SNOOZED: 'snoozed', }, }; export const DEFAULT_REDIRECT_URL = '/app/'; diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index a8847e758..f30c89196 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -49,6 +49,10 @@ { "TEXT": "Pending", "VALUE": "pending" + }, + { + "TEXT": "Snoozed", + "VALUE": "snoozed" } ], "ATTACHMENTS": { diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 831bad781..4e7a9701d 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -41,7 +41,13 @@ "DETAILS": "details" }, "RESOLVE_DROPDOWN": { - "MARK_PENDING": "Mark as pending" + "MARK_PENDING": "Mark as pending", + "SNOOZE": { + "TITLE": "Snooze until", + "NEXT_REPLY": "Next reply", + "TOMORROW": "Tomorrow", + "NEXT_WEEK": "Next week" + } }, "FOOTER": { "MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 042c3e2a1..6030b7b8d 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -135,11 +135,15 @@ const actions = { commit(types.default.ASSIGN_TEAM, team); }, - toggleStatus: async ({ commit }, { conversationId, status }) => { + toggleStatus: async ( + { commit }, + { conversationId, status, snoozedUntil = null } + ) => { try { const response = await ConversationApi.toggleStatus({ conversationId, status, + snoozedUntil, }); commit( types.default.RESOLVE_CONVERSATION, diff --git a/app/javascript/shared/components/ui/dropdown/DropdownSubMenu.vue b/app/javascript/shared/components/ui/dropdown/DropdownSubMenu.vue new file mode 100644 index 000000000..95813f68b --- /dev/null +++ b/app/javascript/shared/components/ui/dropdown/DropdownSubMenu.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/jobs/conversations/reopen_snoozed_conversations_job.rb b/app/jobs/conversations/reopen_snoozed_conversations_job.rb new file mode 100644 index 000000000..c11298585 --- /dev/null +++ b/app/jobs/conversations/reopen_snoozed_conversations_job.rb @@ -0,0 +1,7 @@ +class Conversations::ReopenSnoozedConversationsJob < ApplicationJob + queue_as :low + + def perform + Conversation.where(status: :snoozed).where(snoozed_until: 3.days.ago..Time.current).all.each(&:open!) + end +end diff --git a/app/jobs/trigger_scheduled_items_job.rb b/app/jobs/trigger_scheduled_items_job.rb index 6bf85e1e4..16094dc4a 100644 --- a/app/jobs/trigger_scheduled_items_job.rb +++ b/app/jobs/trigger_scheduled_items_job.rb @@ -6,5 +6,8 @@ class TriggerScheduledItemsJob < ApplicationJob Campaign.where(campaign_type: :one_off, campaign_status: :active).where(scheduled_at: 3.days.ago..Time.current).all.each do |campaign| Campaigns::TriggerOneoffCampaignJob.perform_later(campaign) end + + # Job to reopen snoozed conversations + Conversations::ReopenSnoozedConversationsJob.perform_later end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 865ff0b56..f60551a87 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -8,6 +8,7 @@ # contact_last_seen_at :datetime # identifier :string # last_activity_at :datetime not null +# snoozed_until :datetime # status :integer default("open"), not null # uuid :uuid not null # created_at :datetime not null @@ -45,7 +46,7 @@ class Conversation < ApplicationRecord validates :inbox_id, presence: true before_validation :validate_additional_attributes - enum status: { open: 0, resolved: 1, pending: 2 } + enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 } scope :latest, -> { order(last_activity_at: :desc) } scope :unassigned, -> { where(assignee_id: nil) } @@ -64,6 +65,7 @@ class Conversation < ApplicationRecord has_one :csat_survey_response, dependent: :destroy has_many :notifications, as: :primary_actor, dependent: :destroy + before_save :ensure_snooze_until_reset before_create :mark_conversation_pending_if_bot # wanted to change this to after_update commit. But it ended up creating a loop @@ -91,7 +93,7 @@ class Conversation < ApplicationRecord def toggle_status # FIXME: implement state machine with aasm self.status = open? ? :resolved : :open - self.status = :open if pending? + self.status = :open if pending? || snoozed? save end @@ -140,6 +142,10 @@ class Conversation < ApplicationRecord private + def ensure_snooze_until_reset + self.snoozed_until = nil unless snoozed? + end + def validate_additional_attributes self.additional_attributes = {} unless additional_attributes.is_a?(Hash) end diff --git a/app/models/message.rb b/app/models/message.rb index 0f2ec21c5..f9c473dbe 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -164,7 +164,10 @@ class Message < ApplicationRecord end def reopen_conversation - conversation.open! if incoming? && conversation.resolved? && !conversation.muted? + return if conversation.muted? + return unless incoming? + + conversation.open! if conversation.resolved? || conversation.snoozed? end def execute_message_template_hooks diff --git a/config/locales/en.yml b/config/locales/en.yml index cae92afb8..4ef8178d4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -64,6 +64,7 @@ en: resolved: "Conversation was marked resolved by %{user_name}" open: "Conversation was reopened by %{user_name}" pending: "Conversation was marked as pending by %{user_name}" + snoozed: "Conversation was snoozed by %{user_name}" auto_resolved: "Conversation was marked resolved by system due to %{duration} days of inactivity" assignee: self_assigned: "%{user_name} self-assigned this conversation" diff --git a/db/migrate/20210721182458_add_snoozed_until_to_conversations.rb b/db/migrate/20210721182458_add_snoozed_until_to_conversations.rb new file mode 100644 index 000000000..9e4fbca41 --- /dev/null +++ b/db/migrate/20210721182458_add_snoozed_until_to_conversations.rb @@ -0,0 +1,5 @@ +class AddSnoozedUntilToConversations < ActiveRecord::Migration[6.0] + def change + add_column :conversations, :snoozed_until, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 3eb64e8f8..b8e10c455 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_07_14_110714) do +ActiveRecord::Schema.define(version: 2021_07_21_182458) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -267,6 +267,7 @@ ActiveRecord::Schema.define(version: 2021_07_14_110714) do t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "team_id" t.bigint "campaign_id" + t.datetime "snoozed_until" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id"], name: "index_conversations_on_account_id" t.index ["campaign_id"], name: "index_conversations_on_campaign_id" diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index bb75ba54e..7b66656c2 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -306,6 +306,19 @@ RSpec.describe 'Conversations API', type: :request do expect(conversation.reload.status).to eq('pending') end + it 'toggles the conversation status to snoozed when parameter is passed' do + expect(conversation.status).to eq('open') + snoozed_until = (DateTime.now.utc + 2.days).to_i + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", + headers: agent.create_new_auth_token, + params: { status: 'snoozed', snoozed_until: snoozed_until }, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.status).to eq('snoozed') + expect(conversation.reload.snoozed_until.to_i).to eq(snoozed_until) + end + # TODO: remove this spec when we remove the condition check in controller # Added for backwards compatibility for bot status it 'toggles the conversation status to pending status when parameter bot is passed' do diff --git a/spec/jobs/conversations/reopen_snoozed_conversations_job_spec.rb b/spec/jobs/conversations/reopen_snoozed_conversations_job_spec.rb new file mode 100644 index 000000000..d58b13d52 --- /dev/null +++ b/spec/jobs/conversations/reopen_snoozed_conversations_job_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe Conversations::ReopenSnoozedConversationsJob, type: :job do + let!(:snoozed_till_5_minutes_ago) { create(:conversation, status: :snoozed, snoozed_until: 5.minutes.ago) } + let!(:snoozed_till_tomorrow) { create(:conversation, status: :snoozed, snoozed_until: 1.day.from_now) } + let!(:snoozed_indefinitely) { create(:conversation, status: :snoozed) } + + it 'enqueues the job' do + expect { described_class.perform_later }.to have_enqueued_job(described_class) + .on_queue('low') + end + + context 'when called' do + it 'reopens snoozed conversations whose snooze until has passed' do + described_class.perform_now + + expect(snoozed_till_5_minutes_ago.reload.status).to eq 'open' + expect(snoozed_till_tomorrow.reload.status).to eq 'snoozed' + expect(snoozed_indefinitely.reload.status).to eq 'snoozed' + end + end +end diff --git a/spec/jobs/trigger_scheduled_items_job_spec.rb b/spec/jobs/trigger_scheduled_items_job_spec.rb index 4ec0d331e..2851996b6 100644 --- a/spec/jobs/trigger_scheduled_items_job_spec.rb +++ b/spec/jobs/trigger_scheduled_items_job_spec.rb @@ -20,5 +20,10 @@ RSpec.describe TriggerScheduledItemsJob, type: :job do expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once described_class.perform_now end + + it 'triggers Conversations::ReopenSnoozedConversationsJob' do + expect(Conversations::ReopenSnoozedConversationsJob).to receive(:perform_later).once + described_class.perform_now + end end end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index c21db3044..08f9b001e 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -181,14 +181,38 @@ RSpec.describe Conversation, type: :model do end describe '#toggle_status' do - subject(:toggle_status) { conversation.toggle_status } - - let(:conversation) { create(:conversation, status: :open) } - - it 'toggles conversation status' do - expect(toggle_status).to eq(true) + it 'toggles conversation status to resolved when open' do + conversation = create(:conversation, status: 'open') + expect(conversation.toggle_status).to eq(true) expect(conversation.reload.status).to eq('resolved') end + + it 'toggles conversation status to open when resolved' do + conversation = create(:conversation, status: 'resolved') + expect(conversation.toggle_status).to eq(true) + expect(conversation.reload.status).to eq('open') + end + + it 'toggles conversation status to open when pending' do + conversation = create(:conversation, status: 'pending') + expect(conversation.toggle_status).to eq(true) + expect(conversation.reload.status).to eq('open') + end + + it 'toggles conversation status to open when snoozed' do + conversation = create(:conversation, status: 'snoozed') + expect(conversation.toggle_status).to eq(true) + expect(conversation.reload.status).to eq('open') + end + end + + describe '#ensure_snooze_until_reset' do + it 'resets the snoozed_until when status is toggled' do + conversation = create(:conversation, status: 'snoozed', snoozed_until: 2.days.from_now) + expect(conversation.snoozed_until).not_to eq nil + expect(conversation.toggle_status).to eq(true) + expect(conversation.reload.snoozed_until).to eq(nil) + end end describe '#mute!' do diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index e08a152d9..43b997b47 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -9,6 +9,30 @@ RSpec.describe Message, type: :model do it { is_expected.to validate_presence_of(:account_id) } end + describe '#reopen_conversation' do + let(:conversation) { create(:conversation) } + let(:message) { build(:message, message_type: :incoming, conversation: conversation) } + + it 'reopens resolved conversation when the message is from a contact' do + conversation.resolved! + message.save! + expect(message.conversation.open?).to eq true + end + + it 'reopens snoozed conversation when the message is from a contact' do + conversation.snoozed! + message.save! + expect(message.conversation.open?).to eq true + end + + it 'will not reopen if the conversation is muted' do + conversation.resolved! + conversation.mute! + message.save! + expect(message.conversation.open?).to eq false + end + end + context 'when message is created' do let(:message) { build(:message, account: create(:account)) } From 7e0937f3edcca6ac93078f69ebdf090120c04e12 Mon Sep 17 00:00:00 2001 From: Sanju Date: Fri, 23 Jul 2021 16:38:44 +0530 Subject: [PATCH 07/66] chore: Cypress test case for create label flow --- .../dashboard/contacts/components/Header.vue | 1 + .../dashboard/settings/labels/AddLabel.vue | 3 +++ spec/cypress.json | 4 +++- .../admin_dashboard_authentication.js | 10 ++++----- .../admin_dashboard_create_label.js | 21 +++++++++++++++++++ 5 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 spec/cypress/integration/happy_paths/admin_dashboard_create_label.js diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue index 1f7df7448..93e471a76 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue @@ -30,6 +30,7 @@ color-scheme="success" icon="ion-android-add-circle" @click="onToggleCreate" + data-testid="create-new-contact" > {{ $t('CREATE_CONTACT.BUTTON_LABEL') }} diff --git a/app/javascript/dashboard/routes/dashboard/settings/labels/AddLabel.vue b/app/javascript/dashboard/routes/dashboard/settings/labels/AddLabel.vue index 29c2ddd6d..cbf0ca3bf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/labels/AddLabel.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/labels/AddLabel.vue @@ -12,6 +12,7 @@ :label="$t('LABEL_MGMT.FORM.NAME.LABEL')" :placeholder="$t('LABEL_MGMT.FORM.NAME.PLACEHOLDER')" :error="getLabelTitleErrorMessage" + data-testid="label-title" @input="$v.title.$touch" /> @@ -21,6 +22,7 @@ class="medium-12 columns" :label="$t('LABEL_MGMT.FORM.DESCRIPTION.LABEL')" :placeholder="$t('LABEL_MGMT.FORM.DESCRIPTION.PLACEHOLDER')" + data-testid="label-description" @input="$v.description.$touch" /> @@ -41,6 +43,7 @@ {{ $t('LABEL_MGMT.FORM.CREATE') }} diff --git a/spec/cypress.json b/spec/cypress.json index 7b1481225..dff0b2b58 100644 --- a/spec/cypress.json +++ b/spec/cypress.json @@ -1,4 +1,6 @@ { "baseUrl": "http://localhost:5050", - "defaultCommandTimeout": 10000 + "defaultCommandTimeout": 10000, + "viewportWidth": 1250, + "viewportHeight": 800 } diff --git a/spec/cypress/integration/happy_paths/admin_dashboard_authentication.js b/spec/cypress/integration/happy_paths/admin_dashboard_authentication.js index 20ec5f63d..fc7f35f1f 100644 --- a/spec/cypress/integration/happy_paths/admin_dashboard_authentication.js +++ b/spec/cypress/integration/happy_paths/admin_dashboard_authentication.js @@ -1,10 +1,10 @@ -describe('AdminDashboardAuthentication', function() { +describe('AdminDashboardAuthentication', function () { before(() => { cy.app('clean'); - cy.appScenario('default') + cy.appScenario('default'); }); - it('authenticates an admin ', function() { + it('authenticates an admin ', function () { cy.visit('/'); cy.get("[data-testid='email_input']") @@ -12,9 +12,9 @@ describe('AdminDashboardAuthentication', function() { .type('john@acme.inc'); cy.get("[data-testid='password_input']") .clear() - .type('123456'); + .type('Password1!'); cy.get("[data-testid='submit_button']").click(); - cy.contains('Conversations'); }); + }); diff --git a/spec/cypress/integration/happy_paths/admin_dashboard_create_label.js b/spec/cypress/integration/happy_paths/admin_dashboard_create_label.js new file mode 100644 index 000000000..c219a760a --- /dev/null +++ b/spec/cypress/integration/happy_paths/admin_dashboard_create_label.js @@ -0,0 +1,21 @@ +describe('AdminCreateLabel', () => { + before(() => { + cy.wait(3000); + }); + + it('open add label modal', () => { + cy.get( + 'ul.menu.vertical > li:last-child > a.sub-menu-title.side-menu > span.child-icon.ion-android-add-circle' + ).click(); + }); + it('create a label', () => { + cy.get("[data-testid='label-title'] > input") + .clear() + .type(`show_stopper_${new Date().getTime()}`); + cy.get("[data-testid='label-description'] > input") + .clear() + .type('denote it with show show stopper cases'); + + cy.get("[data-testid='label-submit']").click(); + }); +}); From 6e1493501a7f3c2131ddbfd65c84718e44beab8d Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Fri, 23 Jul 2021 17:04:33 +0530 Subject: [PATCH 08/66] feat: Add APIs for custom attribute definitions (#2689) --- ...custom_attribute_definitions_controller.rb | 50 ++++++ app/models/account.rb | 2 + app/models/custom_attribute_definition.rb | 34 +++++ .../create.json.jbuilder | 1 + .../index.json.jbuilder | 3 + .../show.json.jbuilder | 1 + .../update.json.jbuilder | 1 + ..._custom_attribute_definition.json.jbuilder | 7 + config/routes.rb | 1 + ...2095814_add_custom_attribute_definition.rb | 16 ++ db/schema.rb | 15 +- ...m_attribute_definitions_controller_spec.rb | 142 ++++++++++++++++++ .../factories/custom_attribute_definitions.rb | 12 ++ 13 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb create mode 100644 app/models/custom_attribute_definition.rb create mode 100644 app/views/api/v1/accounts/custom_attribute_definitions/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/custom_attribute_definitions/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/custom_attribute_definitions/show.json.jbuilder create mode 100644 app/views/api/v1/accounts/custom_attribute_definitions/update.json.jbuilder create mode 100644 app/views/api/v1/models/_custom_attribute_definition.json.jbuilder create mode 100644 db/migrate/20210722095814_add_custom_attribute_definition.rb create mode 100644 spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb create mode 100644 spec/factories/custom_attribute_definitions.rb diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb new file mode 100644 index 000000000..687da55bd --- /dev/null +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -0,0 +1,50 @@ +class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController + before_action :fetch_custom_attributes_definitions, except: [:create] + before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy] + DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze + + def index; end + + def show; end + + def create + @custom_attribute_definition = Current.account.custom_attribute_definitions.create!( + permitted_payload + ) + end + + def update + @custom_attribute_definition.update!(permitted_payload) + end + + def destroy + @custom_attribute_definition.destroy + head :no_content + end + + private + + def fetch_custom_attributes_definitions + @custom_attribute_definitions = Current.account.custom_attribute_definitions.where( + attribute_model: permitted_params[:attribute_model] || DEFAULT_ATTRIBUTE_MODEL + ) + end + + def fetch_custom_attribute_definition + @custom_attribute_definition = @custom_attribute_definitions.find(permitted_params[:id]) + end + + def permitted_payload + params.require(:custom_attribute_definition).permit( + :attribute_display_name, + :attribute_display_type, + :attribute_key, + :attribute_model, + :default_value + ) + end + + def permitted_params + params.permit(:id, :filter_type) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index ba73d1eaa..62bb0d450 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -61,6 +61,8 @@ class Account < ApplicationRecord has_many :kbase_articles, dependent: :destroy, class_name: '::Kbase::Article' has_many :teams, dependent: :destroy has_many :custom_filters, dependent: :destroy + has_many :custom_attribute_definitions, dependent: :destroy + has_flags ACCOUNT_SETTINGS_FLAGS.merge(column: 'settings_flags').merge(DEFAULT_QUERY_SETTING) enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb new file mode 100644 index 000000000..41fd40a2c --- /dev/null +++ b/app/models/custom_attribute_definition.rb @@ -0,0 +1,34 @@ +# == Schema Information +# +# Table name: custom_attribute_definitions +# +# id :bigint not null, primary key +# attribute_display_name :string +# attribute_display_type :integer default("text") +# attribute_key :string +# attribute_model :integer default("conversation_attribute") +# default_value :integer +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint +# +# Indexes +# +# attribute_key_model_index (attribute_key,attribute_model) UNIQUE +# index_custom_attribute_definitions_on_account_id (account_id) +# +class CustomAttributeDefinition < ApplicationRecord + validates :attribute_display_name, presence: true + + validates :attribute_key, + presence: true, + uniqueness: { scope: :attribute_model } + + validates :attribute_display_type, presence: true + validates :attribute_model, presence: true + + enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 } + enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4 } + + belongs_to :account +end diff --git a/app/views/api/v1/accounts/custom_attribute_definitions/create.json.jbuilder b/app/views/api/v1/accounts/custom_attribute_definitions/create.json.jbuilder new file mode 100644 index 000000000..d5be04284 --- /dev/null +++ b/app/views/api/v1/accounts/custom_attribute_definitions/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/custom_attribute_definition.json.jbuilder', resource: @custom_attribute_definition diff --git a/app/views/api/v1/accounts/custom_attribute_definitions/index.json.jbuilder b/app/views/api/v1/accounts/custom_attribute_definitions/index.json.jbuilder new file mode 100644 index 000000000..7bf1dd8aa --- /dev/null +++ b/app/views/api/v1/accounts/custom_attribute_definitions/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @custom_attribute_definitions do |custom_attribute_definition| + json.partial! 'api/v1/models/custom_attribute_definition.json.jbuilder', resource: custom_attribute_definition +end diff --git a/app/views/api/v1/accounts/custom_attribute_definitions/show.json.jbuilder b/app/views/api/v1/accounts/custom_attribute_definitions/show.json.jbuilder new file mode 100644 index 000000000..d5be04284 --- /dev/null +++ b/app/views/api/v1/accounts/custom_attribute_definitions/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/custom_attribute_definition.json.jbuilder', resource: @custom_attribute_definition diff --git a/app/views/api/v1/accounts/custom_attribute_definitions/update.json.jbuilder b/app/views/api/v1/accounts/custom_attribute_definitions/update.json.jbuilder new file mode 100644 index 000000000..d5be04284 --- /dev/null +++ b/app/views/api/v1/accounts/custom_attribute_definitions/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/custom_attribute_definition.json.jbuilder', resource: @custom_attribute_definition diff --git a/app/views/api/v1/models/_custom_attribute_definition.json.jbuilder b/app/views/api/v1/models/_custom_attribute_definition.json.jbuilder new file mode 100644 index 000000000..e26c65bf2 --- /dev/null +++ b/app/views/api/v1/models/_custom_attribute_definition.json.jbuilder @@ -0,0 +1,7 @@ +json.attribute_display_name resource.attribute_display_name +json.attribute_display_type resource.attribute_display_type +json.attribute_key resource.attribute_key +json.attribute_model resource.attribute_model +json.default_value resource.default_value +json.created_at resource.created_at +json.updated_at resource.updated_at diff --git a/config/routes.rb b/config/routes.rb index 6d2ed531b..103063b13 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -92,6 +92,7 @@ Rails.application.routes.draw do get :metrics end end + resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy] resources :custom_filters, only: [:index, :show, :create, :update, :destroy] resources :inboxes, only: [:index, :create, :update, :destroy] do get :assignable_agents, on: :member diff --git a/db/migrate/20210722095814_add_custom_attribute_definition.rb b/db/migrate/20210722095814_add_custom_attribute_definition.rb new file mode 100644 index 000000000..3aae0b2f7 --- /dev/null +++ b/db/migrate/20210722095814_add_custom_attribute_definition.rb @@ -0,0 +1,16 @@ +class AddCustomAttributeDefinition < ActiveRecord::Migration[6.0] + def change + create_table :custom_attribute_definitions do |t| + t.string :attribute_display_name + t.string :attribute_key + t.integer :attribute_display_type, default: 0 + t.integer :default_value + t.integer :attribute_model, default: 0 + t.references :account, index: true + + t.timestamps + end + + add_index :custom_attribute_definitions, [:attribute_key, :attribute_model], unique: true, name: 'attribute_key_model_index' + end +end diff --git a/db/schema.rb b/db/schema.rb index b8e10c455..fed4bb135 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_07_21_182458) do +ActiveRecord::Schema.define(version: 2021_07_22_095814) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -292,6 +292,19 @@ ActiveRecord::Schema.define(version: 2021_07_21_182458) do t.index ["message_id"], name: "index_csat_survey_responses_on_message_id", unique: true end + create_table "custom_attribute_definitions", force: :cascade do |t| + t.string "attribute_display_name" + t.string "attribute_key" + t.integer "attribute_display_type", default: 0 + t.integer "default_value" + t.integer "attribute_model", default: 0 + t.bigint "account_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id" + t.index ["attribute_key", "attribute_model"], name: "attribute_key_model_index", unique: true + end + create_table "custom_filters", force: :cascade do |t| t.string "name", null: false t.integer "filter_type", default: 0, null: false diff --git a/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb b/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb new file mode 100644 index 000000000..5b0430e47 --- /dev/null +++ b/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb @@ -0,0 +1,142 @@ +require 'rails_helper' + +RSpec.describe 'Custom Attribute Definitions API', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + + describe 'GET /api/v1/accounts/{account.id}/custom_attribute_definitions' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/custom_attribute_definitions" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let!(:custom_attribute_definition) { create(:custom_attribute_definition, account: account) } + + it 'returns all customer attribute definitions related to the account' do + create(:custom_attribute_definition, attribute_model: 'contact_attribute', account: account) + + get "/api/v1/accounts/#{account.id}/custom_attribute_definitions", + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + response_body = JSON.parse(response.body) + + expect(response_body.count).to eq(1) + expect(response_body.first['attribute_key']).to eq(custom_attribute_definition.attribute_key) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/custom_attribute_definitions/:id' do + let!(:custom_attribute_definition) { create(:custom_attribute_definition, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'shows the custom attribute definition' do + get "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}", + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.body).to include(custom_attribute_definition.attribute_key) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/custom_attribute_definitions' do + let(:payload) do + { + custom_attribute_definition: { + attribute_display_name: 'Developer ID', + attribute_key: 'developer_id', + attribute_model: 'contact_attribute', + attribute_display_type: 'text', + default_value: '' + } + } + end + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + expect do + post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", + params: payload + end.to change(CustomAttributeDefinition, :count).by(0) + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'creates the filter' do + expect do + post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", headers: user.create_new_auth_token, + params: payload + end.to change(CustomAttributeDefinition, :count).by(1) + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['attribute_key']).to eq 'developer_id' + end + end + end + + describe 'PATCH /api/v1/accounts/{account.id}/custom_attribute_definitions/:id' do + let(:payload) { { custom_attribute_definition: { attribute_display_name: 'Developer ID', attribute_key: 'developer_id' } } } + let!(:custom_attribute_definition) { create(:custom_attribute_definition, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + put "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}", + params: payload + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'updates the custom attribute definition' do + patch "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}", + headers: user.create_new_auth_token, + params: payload, + as: :json + expect(response).to have_http_status(:success) + expect(custom_attribute_definition.reload.attribute_display_name).to eq('Developer ID') + expect(custom_attribute_definition.reload.attribute_key).to eq('developer_id') + expect(custom_attribute_definition.reload.attribute_model).to eq('conversation_attribute') + end + end + end + + describe 'DELETE /api/v1/accounts/{account.id}/custom_attribute_definitions/:id' do + let!(:custom_attribute_definition) { create(:custom_attribute_definition, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin user' do + it 'deletes custom attribute' do + delete "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}", + headers: user.create_new_auth_token, + as: :json + expect(response).to have_http_status(:no_content) + expect(account.custom_attribute_definitions.count).to be 0 + end + end + end +end diff --git a/spec/factories/custom_attribute_definitions.rb b/spec/factories/custom_attribute_definitions.rb new file mode 100644 index 000000000..9ac6df4ca --- /dev/null +++ b/spec/factories/custom_attribute_definitions.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :custom_attribute_definition do + sequence(:attribute_display_name) { |n| "Custom Attribute Definition #{n}" } + sequence(:attribute_key) { |n| "custom_attribute_#{n}" } + attribute_display_type { 1 } + attribute_model { 0 } + default_value { nil } + account + end +end From 766400662546e5ce93d41756a16e3da76de9e007 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Fri, 23 Jul 2021 18:39:24 +0530 Subject: [PATCH 09/66] chore: Improve search, list performance of contact/conversation APIs (#2696) --- .../api/v1/accounts/contacts_controller.rb | 23 +++++++++++++++---- app/finders/conversation_finder.rb | 2 +- app/javascript/dashboard/api/contacts.js | 2 +- .../dashboard/api/specs/contacts.spec.js | 14 +++++++---- app/models/contact.rb | 9 ++++---- app/models/conversation.rb | 12 ++++++---- .../v1/accounts/contacts/index.json.jbuilder | 2 +- .../v1/accounts/contacts/search.json.jbuilder | 2 +- app/views/api/v1/models/_team.json.jbuilder | 2 +- ...210723094412_add_index_to_conversations.rb | 6 +++++ .../20210723095657_add_index_to_contacts.rb | 5 ++++ db/schema.rb | 5 +++- .../v1/accounts/contacts_controller_spec.rb | 11 +++++++++ 13 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 db/migrate/20210723094412_add_index_to_conversations.rb create mode 100644 db/migrate/20210723095657_add_index_to_contacts.rb diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 6ffa15172..c7b2348ab 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -11,6 +11,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search] before_action :fetch_contact, only: [:show, :update, :contactable_inboxes] + before_action :set_include_contact_inboxes, only: [:index, :search] def index @contacts_count = resolved_contacts.count @@ -87,11 +88,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end def fetch_contacts_with_conversation_count(contacts) - filtrate(contacts).left_outer_joins(:conversations) - .select('contacts.*, COUNT(conversations.id) as conversations_count') - .group('contacts.id') - .includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }]) - .page(@current_page).per(RESULTS_PER_PAGE) + contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations) + .select('contacts.*, COUNT(conversations.id) as conversations_count') + .group('contacts.id') + .includes([{ avatar_attachment: [:blob] }]) + .page(@current_page).per(RESULTS_PER_PAGE) + + return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes + + contacts_with_conversation_count end def build_contact_inbox @@ -117,6 +122,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes }) end + def set_include_contact_inboxes + @include_contact_inboxes = if params[:include_contact_inboxes].present? + params[:include_contact_inboxes] == 'true' + else + true + end + end + def fetch_contact @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 66602988a..6ee4a4d21 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -119,7 +119,7 @@ class ConversationFinder def conversations @conversations = @conversations.includes( - :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } } + :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team ) current_page ? @conversations.latest.page(current_page) : @conversations.latest end diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 443a5b8dd..0ed9bb101 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -2,7 +2,7 @@ import ApiClient from './ApiClient'; export const buildContactParams = (page, sortAttr, label, search) => { - let params = `page=${page}&sort=${sortAttr}`; + let params = `include_contact_inboxes=false&page=${page}&sort=${sortAttr}`; if (search) { params = `${params}&q=${search}`; } diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index 08f6ace03..0c0a21125 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -17,7 +17,7 @@ describe('#ContactsAPI', () => { it('#get', () => { contactAPI.get(1, 'name', 'customer-support'); expect(context.axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts?page=1&sort=name&labels[]=customer-support' + '/api/v1/contacts?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support' ); }); @@ -56,7 +56,7 @@ describe('#ContactsAPI', () => { it('#search', () => { contactAPI.search('leads', 1, 'date', 'customer-support'); expect(context.axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/search?page=1&sort=date&q=leads&labels[]=customer-support' + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' ); }); }); @@ -64,12 +64,16 @@ describe('#ContactsAPI', () => { describe('#buildContactParams', () => { it('returns correct string', () => { - expect(buildContactParams(1, 'name', '', '')).toBe('page=1&sort=name'); + expect(buildContactParams(1, 'name', '', '')).toBe( + 'include_contact_inboxes=false&page=1&sort=name' + ); expect(buildContactParams(1, 'name', 'customer-support', '')).toBe( - 'page=1&sort=name&labels[]=customer-support' + 'include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support' ); expect( buildContactParams(1, 'name', 'customer-support', 'message-content') - ).toBe('page=1&sort=name&q=message-content&labels[]=customer-support'); + ).toBe( + 'include_contact_inboxes=false&page=1&sort=name&q=message-content&labels[]=customer-support' + ); }); }); diff --git a/app/models/contact.rb b/app/models/contact.rb index 01efc8a51..391c0a2bf 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -17,10 +17,11 @@ # # Indexes # -# index_contacts_on_account_id (account_id) -# index_contacts_on_pubsub_token (pubsub_token) UNIQUE -# uniq_email_per_account_contact (email,account_id) UNIQUE -# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE +# index_contacts_on_account_id (account_id) +# index_contacts_on_phone_number_and_account_id (phone_number,account_id) +# index_contacts_on_pubsub_token (pubsub_token) UNIQUE +# uniq_email_per_account_contact (email,account_id) UNIQUE +# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE # class Contact < ApplicationRecord diff --git a/app/models/conversation.rb b/app/models/conversation.rb index f60551a87..eaf47885b 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -24,11 +24,13 @@ # # Indexes # -# index_conversations_on_account_id (account_id) -# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE -# index_conversations_on_campaign_id (campaign_id) -# index_conversations_on_contact_inbox_id (contact_inbox_id) -# index_conversations_on_team_id (team_id) +# index_conversations_on_account_id (account_id) +# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE +# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id) +# index_conversations_on_campaign_id (campaign_id) +# index_conversations_on_contact_inbox_id (contact_inbox_id) +# index_conversations_on_status_and_account_id (status,account_id) +# index_conversations_on_team_id (team_id) # # Foreign Keys # diff --git a/app/views/api/v1/accounts/contacts/index.json.jbuilder b/app/views/api/v1/accounts/contacts/index.json.jbuilder index 70e112b56..b3af9a8b7 100644 --- a/app/views/api/v1/accounts/contacts/index.json.jbuilder +++ b/app/views/api/v1/accounts/contacts/index.json.jbuilder @@ -5,6 +5,6 @@ end json.payload do json.array! @contacts do |contact| - json.partial! 'api/v1/models/contact.json.jbuilder', resource: contact, with_contact_inboxes: true + json.partial! 'api/v1/models/contact.json.jbuilder', resource: contact, with_contact_inboxes: @include_contact_inboxes end end diff --git a/app/views/api/v1/accounts/contacts/search.json.jbuilder b/app/views/api/v1/accounts/contacts/search.json.jbuilder index 70e112b56..b3af9a8b7 100644 --- a/app/views/api/v1/accounts/contacts/search.json.jbuilder +++ b/app/views/api/v1/accounts/contacts/search.json.jbuilder @@ -5,6 +5,6 @@ end json.payload do json.array! @contacts do |contact| - json.partial! 'api/v1/models/contact.json.jbuilder', resource: contact, with_contact_inboxes: true + json.partial! 'api/v1/models/contact.json.jbuilder', resource: contact, with_contact_inboxes: @include_contact_inboxes end end diff --git a/app/views/api/v1/models/_team.json.jbuilder b/app/views/api/v1/models/_team.json.jbuilder index a9ef430a7..9aaab89e8 100644 --- a/app/views/api/v1/models/_team.json.jbuilder +++ b/app/views/api/v1/models/_team.json.jbuilder @@ -2,5 +2,5 @@ json.id resource.id json.name resource.name json.description resource.description json.allow_auto_assign resource.allow_auto_assign -json.account_id resource.account.id +json.account_id resource.account_id json.is_member Current.user.teams.include?(resource) diff --git a/db/migrate/20210723094412_add_index_to_conversations.rb b/db/migrate/20210723094412_add_index_to_conversations.rb new file mode 100644 index 000000000..ad31696d1 --- /dev/null +++ b/db/migrate/20210723094412_add_index_to_conversations.rb @@ -0,0 +1,6 @@ +class AddIndexToConversations < ActiveRecord::Migration[6.0] + def change + add_index :conversations, [:status, :account_id] + add_index :conversations, [:assignee_id, :account_id] + end +end diff --git a/db/migrate/20210723095657_add_index_to_contacts.rb b/db/migrate/20210723095657_add_index_to_contacts.rb new file mode 100644 index 000000000..aeccfa1de --- /dev/null +++ b/db/migrate/20210723095657_add_index_to_contacts.rb @@ -0,0 +1,5 @@ +class AddIndexToContacts < ActiveRecord::Migration[6.0] + def change + add_index :contacts, [:phone_number, :account_id] + end +end diff --git a/db/schema.rb b/db/schema.rb index fed4bb135..77cf9d447 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_07_22_095814) do +ActiveRecord::Schema.define(version: 2021_07_23_095657) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -246,6 +246,7 @@ ActiveRecord::Schema.define(version: 2021_07_22_095814) do t.index ["account_id"], name: "index_contacts_on_account_id" t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true + t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id" t.index ["pubsub_token"], name: "index_contacts_on_pubsub_token", unique: true end @@ -270,8 +271,10 @@ ActiveRecord::Schema.define(version: 2021_07_22_095814) do t.datetime "snoozed_until" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id"], name: "index_conversations_on_account_id" + t.index ["assignee_id", "account_id"], name: "index_conversations_on_assignee_id_and_account_id" t.index ["campaign_id"], name: "index_conversations_on_campaign_id" t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id" + t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id" t.index ["team_id"], name: "index_conversations_on_team_id" end diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 0ebe6b8a0..1dd152898 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -29,6 +29,17 @@ RSpec.describe 'Contacts API', type: :request do expect(response_body['payload'].first['contact_inboxes'].first['inbox']['name']).to eq(contact_inbox.inbox.name) end + it 'returns all contacts without contact inboxes' do + get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + response_body = JSON.parse(response.body) + expect(response_body['payload'].first['email']).to eq(contact.email) + expect(response_body['payload'].first['contact_inboxes'].blank?).to eq(true) + end + it 'returns includes conversations count and last seen at' do create(:conversation, contact: contact, account: account, inbox: contact_inbox.inbox, contact_last_seen_at: Time.now.utc) get "/api/v1/accounts/#{account.id}/contacts", From d7da6fbfb58d94e947ed99a11bb92bf892576fe3 Mon Sep 17 00:00:00 2001 From: sbreiler-work <82504024+sbreiler-work@users.noreply.github.com> Date: Mon, 26 Jul 2021 11:03:42 +0200 Subject: [PATCH 10/66] fix: Make "Search Results" in PopOverSearch translateable (#2705) --- app/javascript/dashboard/i18n/locale/de/conversation.json | 1 + app/javascript/dashboard/i18n/locale/en/conversation.json | 1 + .../routes/dashboard/conversation/search/PopOverSearch.vue | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/i18n/locale/de/conversation.json b/app/javascript/dashboard/i18n/locale/de/conversation.json index 0290d66a1..14cb3b439 100644 --- a/app/javascript/dashboard/i18n/locale/de/conversation.json +++ b/app/javascript/dashboard/i18n/locale/de/conversation.json @@ -9,6 +9,7 @@ "SEARCH_MESSAGES": "Nachrichten durchsuchen", "SEARCH": { "TITLE": "Nachrichten durchsuchen", + "RESULT_TITLE": "Suchergebnisse", "LOADING_MESSAGE": "Daten werden geladen...", "PLACEHOLDER": "Geben Sie einen Text ein, um danach zu suchen", "NO_MATCHING_RESULTS": "Keine Ergebnisse gefunden." diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 4e7a9701d..7f724f28e 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -9,6 +9,7 @@ "SEARCH_MESSAGES": "Search for messages in conversations", "SEARCH": { "TITLE": "Search messages", + "RESULT_TITLE": "Search Results", "LOADING_MESSAGE": "Crunching data...", "PLACEHOLDER": "Type any text to search messages", "NO_MATCHING_RESULTS": "No results found." diff --git a/app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue b/app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue index 34bd044bf..566d4d74b 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue @@ -17,7 +17,7 @@
- Search Results + {{ $t('CONVERSATION.SEARCH.RESULT_TITLE') }} ({{ resultsCount }}) From 359c3c8ccb387450279485a27a1cd52e28302c96 Mon Sep 17 00:00:00 2001 From: Sanju Date: Tue, 27 Jul 2021 07:38:27 +0530 Subject: [PATCH 11/66] Fix: type error trim of undefined#2595 (#2702) --- .../components/widgets/conversation/ReplyBox.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 5d97076bb..9a2434fbf 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -169,7 +169,7 @@ export default { return this.maxLength - this.message.length; }, isReplyButtonDisabled() { - const isMessageEmpty = !this.message.trim().replace(/\n/g, '').length; + const isMessageEmpty = this.isMessageEmpty; if (this.hasAttachments) return false; return ( @@ -235,6 +235,12 @@ export default { isOnPrivateNote() { return this.replyType === REPLY_EDITOR_MODES.NOTE; }, + isMessageEmpty() { + if(!this.message) { + this.message = ''; + } + return !this.message.trim().replace(/\n/g, '').length; + }, }, watch: { currentChat(conversation) { From b44f9b792b1af87a4af09d7a16341f5bcb643eaf Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 27 Jul 2021 21:27:23 +0530 Subject: [PATCH 12/66] chore: Block & throttle abusive requests (#2706) Co-authored-by: Pranav Raj S --- .env.example | 4 ++ .rubocop.yml | 3 +- Gemfile | 3 +- Gemfile.lock | 11 +-- config/initializers/{redis.rb => 01_redis.rb} | 4 ++ config/initializers/rack_attack.rb | 72 +++++++++++++++++++ 6 files changed, 87 insertions(+), 10 deletions(-) rename config/initializers/{redis.rb => 01_redis.rb} (67%) create mode 100644 config/initializers/rack_attack.rb diff --git a/.env.example b/.env.example index 6dffe6b38..ea246c6f8 100644 --- a/.env.example +++ b/.env.example @@ -147,6 +147,10 @@ USE_INBOX_AVATAR_FOR_BOT=true # maxmindb api key to use geoip2 service # IP_LOOKUP_API_KEY= +## Rack Attack configuration +## To prevent and throttle abusive requests +# ENABLE_RACK_ATTACK=false + ## Running chatwoot as an API only server ## setting this value to true will disable the frontend dashboard endpoints diff --git a/.rubocop.yml b/.rubocop.yml index 4bec94c82..133f8e15c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -29,7 +29,8 @@ Style/OptionalBooleanParameter: - 'app/dispatchers/dispatcher.rb' Style/GlobalVars: Exclude: - - 'config/initializers/redis.rb' + - 'config/initializers/01_redis.rb' + - 'config/initializers/rack_attack.rb' - 'lib/redis/alfred.rb' - 'lib/global_config.rb' Style/ClassVars: diff --git a/Gemfile b/Gemfile index 0f285c6fd..29fbb8af2 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,8 @@ gem 'liquid' gem 'commonmarker' # Validate Data against JSON Schema gem 'json_schemer' +# Rack middleware for blocking & throttling abusive requests +gem 'rack-attack' ##-- for active storage --## gem 'aws-sdk-s3', require: false @@ -45,7 +47,6 @@ gem 'groupdate' gem 'pg' gem 'redis' gem 'redis-namespace' -gem 'redis-rack-cache' # super fast record imports in bulk gem 'activerecord-import' diff --git a/Gemfile.lock b/Gemfile.lock index 86d421fa7..fc581ae27 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -376,8 +376,8 @@ GEM raabro (1.4.0) racc (1.5.2) rack (2.2.3) - rack-cache (1.12.0) - rack (>= 0.4) + rack-attack (6.5.0) + rack (>= 1.0, < 3) rack-cors (1.1.1) rack (>= 2.0.0) rack-proxy (0.6.5) @@ -419,11 +419,6 @@ GEM redis (4.2.1) redis-namespace (1.8.0) redis (>= 3.0.4) - redis-rack-cache (2.2.1) - rack-cache (>= 1.10, < 2) - redis-store (>= 1.6, < 2) - redis-store (1.9.0) - redis (>= 4, < 5) regexp_parser (1.7.1) representable (3.0.4) declarative (< 0.1.0) @@ -666,12 +661,12 @@ DEPENDENCIES pry-rails puma pundit + rack-attack rack-cors rack-timeout rails redis redis-namespace - redis-rack-cache responders rest-client rspec-rails (~> 4.0.0.beta2) diff --git a/config/initializers/redis.rb b/config/initializers/01_redis.rb similarity index 67% rename from config/initializers/redis.rb rename to config/initializers/01_redis.rb index 1566098c9..c5607bb2c 100644 --- a/config/initializers/redis.rb +++ b/config/initializers/01_redis.rb @@ -4,3 +4,7 @@ redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app) # Add here as you use it for more features # Used for Round Robin, Conversation Emails & Online Presence $alfred = Redis::Namespace.new('alfred', redis: redis, warning: true) + +# Velma : Determined protector +# used in rack attack +$velma = Redis::Namespace.new('velma', redis: redis, warning: true) diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 000000000..3070d2877 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,72 @@ +class Rack::Attack + ### Configure Cache ### + + # If you don't want to use Rails.cache (Rack::Attack's default), then + # configure it here. + # + # Note: The store is only used for throttling (not blocklisting and + # safelisting). It must implement .increment and .write like + # ActiveSupport::Cache::Store + + # Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + + # https://github.com/rack/rack-attack/issues/102 + Rack::Attack.cache.store = Rack::Attack::StoreProxy::RedisProxy.new($velma) + + class Request < ::Rack::Request + # You many need to specify a method to fetch the correct remote IP address + # if the web server is behind a load balancer. + def remote_ip + @remote_ip ||= (env['action_dispatch.remote_ip'] || ip).to_s + end + + def allowed_ip? + allowed_ips = ['127.0.0.1', '::1'] + allowed_ips.include?(remote_ip) + end + end + + ### Throttle Spammy Clients ### + + # If any single client IP is making tons of requests, then they're + # probably malicious or a poorly-configured scraper. Either way, they + # don't deserve to hog all of the app server's CPU. Cut them off! + # + # Note: If you're serving assets through rack, those requests may be + # counted by rack-attack and this throttle may be activated too + # quickly. If so, enable the condition to exclude them from tracking. + + # Throttle all requests by IP (60rpm) + # + # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}" + + throttle('req/ip', limit: 300, period: 1.minute, &:ip) + + ### Prevent Brute-Force Login Attacks ### + throttle('login/ip', limit: 5, period: 20.seconds) do |req| + req.ip if req.path == '/auth/sign_in' && req.post? + end + + ## Prevent Brute-Force Signup Attacks ### + throttle('accounts/ip', limit: 5, period: 5.minutes) do |req| + req.ip if req.path == '/api/v1/accounts' && req.post? + end + + # ref: https://github.com/rack/rack-attack/issues/399 + throttle('login/email', limit: 20, period: 5.minutes) do |req| + email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence + email.to_s.downcase.gsub(/\s+/, '') if req.path == '/auth/sign_in' && req.post? + end + + throttle('reset_password/email', limit: 5, period: 1.hour) do |req| + email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence + email.to_s.downcase.gsub(/\s+/, '') if req.path == '/auth/password' && req.post? + end +end + +# Log blocked events +ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start, _finish, _request_id, payload| + Rails.logger.info "[Rack::Attack][Blocked] remote_ip: \"#{payload[:request].remote_ip}\", path: \"#{payload[:request].path}\"" +end + +Rack::Attack.enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', false)) From a47ca9cf4b6b15dceb75a723ae7385a95210431d Mon Sep 17 00:00:00 2001 From: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> Date: Wed, 28 Jul 2021 14:43:44 +0530 Subject: [PATCH 13/66] feat: Show e-mail meta data for conversations (#2708) Co-authored-by: Pranav Raj S --- .../widgets/conversation/Message.vue | 7 ++ .../widgets/conversation/bubble/MailHead.vue | 82 +++++++++++++++++++ .../i18n/locale/en/conversation.json | 6 ++ 3 files changed, 95 insertions(+) create mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/MailHead.vue diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index 58ef6e216..c757b37a0 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -2,6 +2,11 @@
  • + +
    +
    + {{ $t('EMAIL_HEADER.TO') }}: + {{ toMails }} +
    +
    + {{ $t('EMAIL_HEADER.CC') }}: + {{ ccMails }} +
    +
    + {{ $t('EMAIL_HEADER.BCC') }}: + {{ bccMails }} +
    +
    + + {{ $t('EMAIL_HEADER.SUBJECT') }}: + + {{ subject }} +
    +
    + + + + diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 7f724f28e..7ea1d7e25 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -130,5 +130,11 @@ "SELECT": { "PLACEHOLDER": "None" } + }, + "EMAIL_HEADER": { + "TO": "To", + "BCC": "Bcc", + "CC": "Cc", + "SUBJECT": "Subject" } } From 7662fdce4791af91307e5b223285ab2d5e90b0a5 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 28 Jul 2021 14:59:13 +0530 Subject: [PATCH 14/66] feat: CSAT response public APIs (#2670) --- .../public/api/v1/csat_survey_controller.rb | 32 ++++++++++++ .../api/v1/csat_survey/show.json.jbuilder | 1 + .../api/v1/csat_survey/update.json.jbuilder | 1 + .../api/v1/models/_csat_survey.json.jbuilder | 5 ++ config/routes.rb | 1 + .../api/v1/csat_survey_controller.spec.rb | 50 +++++++++++++++++++ 6 files changed, 90 insertions(+) create mode 100644 app/controllers/public/api/v1/csat_survey_controller.rb create mode 100644 app/views/public/api/v1/csat_survey/show.json.jbuilder create mode 100644 app/views/public/api/v1/csat_survey/update.json.jbuilder create mode 100644 app/views/public/api/v1/models/_csat_survey.json.jbuilder create mode 100644 spec/controllers/public/api/v1/csat_survey_controller.spec.rb diff --git a/app/controllers/public/api/v1/csat_survey_controller.rb b/app/controllers/public/api/v1/csat_survey_controller.rb new file mode 100644 index 000000000..b839bee77 --- /dev/null +++ b/app/controllers/public/api/v1/csat_survey_controller.rb @@ -0,0 +1,32 @@ +class Public::Api::V1::CsatSurveyController < PublicController + before_action :set_conversation + before_action :set_message + + def show; end + + def update + render json: { error: 'You cannot update the CSAT survey after 14 days' }, status: :unprocessable_entity and return if check_csat_locked + + @message.update!(message_update_params[:message]) + end + + private + + def set_conversation + return if params[:id].blank? + + @conversation = Conversation.find_by!(uuid: params[:id]) + end + + def set_message + @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 check_csat_locked + (Time.zone.now.to_date - @message.created_at.to_date).to_i > 14 + end +end diff --git a/app/views/public/api/v1/csat_survey/show.json.jbuilder b/app/views/public/api/v1/csat_survey/show.json.jbuilder new file mode 100644 index 000000000..1ab9e564d --- /dev/null +++ b/app/views/public/api/v1/csat_survey/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'public/api/v1/models/csat_survey.json.jbuilder', resource: @message diff --git a/app/views/public/api/v1/csat_survey/update.json.jbuilder b/app/views/public/api/v1/csat_survey/update.json.jbuilder new file mode 100644 index 000000000..1ab9e564d --- /dev/null +++ b/app/views/public/api/v1/csat_survey/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'public/api/v1/models/csat_survey.json.jbuilder', resource: @message diff --git a/app/views/public/api/v1/models/_csat_survey.json.jbuilder b/app/views/public/api/v1/models/_csat_survey.json.jbuilder new file mode 100644 index 000000000..f11dd61a6 --- /dev/null +++ b/app/views/public/api/v1/models/_csat_survey.json.jbuilder @@ -0,0 +1,5 @@ +json.id resource.id +json.csat_survey_response resource.csat_survey_response +json.inbox_avatar_url resource.inbox.avatar_url +json.conversation_id resource.conversation_id +json.created_at resource.created_at diff --git a/config/routes.rb b/config/routes.rb index 103063b13..e118ca0bb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -220,6 +220,7 @@ Rails.application.routes.draw do end end end + resources :csat_survey, only: [:show, :update] end end end diff --git a/spec/controllers/public/api/v1/csat_survey_controller.spec.rb b/spec/controllers/public/api/v1/csat_survey_controller.spec.rb new file mode 100644 index 000000000..1cc411118 --- /dev/null +++ b/spec/controllers/public/api/v1/csat_survey_controller.spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +RSpec.describe 'Public Survey Responses API', type: :request do + describe 'GET public/api/v1/csat_survey/{uuid}' do + it 'return the csat response for that conversation' do + conversation = create(:conversation) + create(:message, conversation: conversation, content_type: 'input_csat') + get "/public/api/v1/csat_survey/#{conversation.uuid}" + data = JSON.parse(response.body) + expect(response).to have_http_status(:success) + expect(data['conversation_id']).to eq conversation.id + end + + it 'returns not found error for the open conversation' do + conversation = create(:conversation) + create(:message, conversation: conversation, content_type: 'text') + get "/public/api/v1/csat_survey/#{conversation.uuid}" + expect(response).to have_http_status(:not_found) + end + end + + describe 'PUT public/api/v1/csat_survey/{uuid}' do + params = { message: { submitted_values: { csat_survey_response: { rating: 4, feedback_message: 'amazing experience' } } } } + it 'update csat survey response for the conversation' do + conversation = create(:conversation) + message = create(:message, conversation: conversation, content_type: 'input_csat') + # since csat survey is created in async job, we are mocking the creation. + create(:csat_survey_response, conversation: conversation, message: message, rating: 4, feedback_message: 'amazing experience') + patch "/public/api/v1/csat_survey/#{conversation.uuid}", + params: params, + as: :json + expect(response).to have_http_status(:success) + data = JSON.parse(response.body) + expect(data['conversation_id']).to eq conversation.id + expect(data['csat_survey_response']['conversation_id']).to eq conversation.id + expect(data['csat_survey_response']['feedback_message']).to eq 'amazing experience' + expect(data['csat_survey_response']['rating']).to eq 4 + end + + it 'returns update error if CSAT message sent more than 14 days' do + conversation = create(:conversation) + message = create(:message, conversation: conversation, content_type: 'input_csat', created_at: 15.days.ago) + create(:csat_survey_response, conversation: conversation, message: message, rating: 4, feedback_message: 'amazing experience') + patch "/public/api/v1/csat_survey/#{conversation.uuid}", + params: params, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + end +end From 223385d1348602f8b27fe2755fedcc4f6ee93acc Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 28 Jul 2021 19:36:51 +0530 Subject: [PATCH 15/66] fix: Specify external db with non-standard port (#2711) POSTGRES_PORT was not taking effect if provided separately instead of using DATABASE_URL. This adds support for using databases running on non-standard ports. #1145 #1147 Co-authored-by: Pranav Raj S --- app/models/account.rb | 3 ++- app/models/channel/web_widget.rb | 4 +++- app/models/concerns/featurable.rb | 3 ++- app/models/notification_setting.rb | 3 ++- config/database.yml | 1 + docker/entrypoints/helpers/pg_database_url.sh | 6 +++--- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index 62bb0d450..0cfc14293 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -21,7 +21,8 @@ class Account < ApplicationRecord include Featurable DEFAULT_QUERY_SETTING = { - flag_query_mode: :bit_operator + flag_query_mode: :bit_operator, + check_for_column: false }.freeze ACCOUNT_SETTINGS_FLAGS = { diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb index 793544531..591efcaab 100644 --- a/app/models/channel/web_widget.rb +++ b/app/models/channel/web_widget.rb @@ -39,7 +39,9 @@ class Channel::WebWidget < ApplicationRecord has_flags 1 => :attachments, 2 => :emoji_picker, - :column => 'feature_flags' + :column => 'feature_flags', + :check_for_column => false + enum reply_time: { in_a_few_minutes: 0, in_a_few_hours: 1, in_a_day: 2 } def name diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb index f8ffde185..5e6b8b8db 100644 --- a/app/models/concerns/featurable.rb +++ b/app/models/concerns/featurable.rb @@ -2,7 +2,8 @@ module Featurable extend ActiveSupport::Concern QUERY_MODE = { - flag_query_mode: :bit_operator + flag_query_mode: :bit_operator, + check_for_column: false }.freeze FEATURE_LIST = YAML.safe_load(File.read(Rails.root.join('config/features.yml'))).freeze diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index d31d003a5..bf030304f 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -23,7 +23,8 @@ class NotificationSetting < ApplicationRecord belongs_to :user DEFAULT_QUERY_SETTING = { - flag_query_mode: :bit_operator + flag_query_mode: :bit_operator, + check_for_column: false }.freeze EMAIL_NOTIFICATION_FLAGS = ::Notification::NOTIFICATION_TYPES.transform_keys { |key| "email_#{key}".to_sym }.invert.freeze diff --git a/config/database.yml b/config/database.yml index e60358062..b2e012f4d 100644 --- a/config/database.yml +++ b/config/database.yml @@ -3,6 +3,7 @@ default: &default encoding: unicode pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> host: <%= ENV.fetch('POSTGRES_HOST', 'localhost') %> + port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> development: <<: *default diff --git a/docker/entrypoints/helpers/pg_database_url.sh b/docker/entrypoints/helpers/pg_database_url.sh index 05424ceb0..89a36c164 100755 --- a/docker/entrypoints/helpers/pg_database_url.sh +++ b/docker/entrypoints/helpers/pg_database_url.sh @@ -5,6 +5,6 @@ require 'uri' if !ENV['DATABASE_URL'].nil? && ENV['DATABASE_URL'] != '' uri = URI(ENV['DATABASE_URL']) puts "export POSTGRES_HOST=#{uri.host} POSTGRES_PORT=#{uri.port} POSTGRES_USERNAME=#{uri.user}" -else - puts "export POSTGRES_PORT=5432" -end \ No newline at end of file +elif ENV['POSTGRES_PORT'].nil? || ENV['POSTGRES_PORT'] == '' + puts "export POSTGRES_PORT=5432" +end From 8f30abb98bcc94cedb8b2f1ddc6172246a7a84dc Mon Sep 17 00:00:00 2001 From: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> Date: Wed, 28 Jul 2021 21:36:35 +0530 Subject: [PATCH 16/66] feat: Show links for custom attributes (#2723) --- .../dashboard/conversation/ContactCustomAttributes.vue | 10 +++++++++- .../routes/dashboard/conversation/ContactPanel.vue | 5 ++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactCustomAttributes.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactCustomAttributes.vue index bc3550817..7639e08e3 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactCustomAttributes.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactCustomAttributes.vue @@ -14,7 +14,7 @@ {{ attribute }}
    - {{ customAttributes[attribute] }} +
  • @@ -22,11 +22,13 @@ diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index e6cddb584..f2e7a8870 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -195,9 +195,8 @@ export default { return this.additionalAttributes.initiated_at; }, browserName() { - return `${this.browser.browser_name || ''} ${ - this.browser.browser_version || '' - }`; + return `${this.browser.browser_name || ''} ${this.browser + .browser_version || ''}`; }, contactAdditionalAttributes() { return this.contact.additional_attributes || {}; From 672e5874fb7a9537d13a9d26e61320c9bff9d4c7 Mon Sep 17 00:00:00 2001 From: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> Date: Thu, 29 Jul 2021 11:53:28 +0530 Subject: [PATCH 17/66] fix: Appending markdown as HTML into editor (#2720) --- .../components/widgets/WootWriter/Editor.vue | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index ab4f5b5f1..76939bbb7 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -139,8 +139,17 @@ export default { value(newValue = '') { if (newValue !== this.lastValue) { const { tr } = this.state; - tr.insertText(newValue, 0, tr.doc.content.size); - this.state = this.view.state.apply(tr); + if (this.isFormatMode) { + this.state = createState( + newValue, + this.placeholder, + this.plugins, + this.isFormatMode + ); + } else { + tr.insertText(newValue, 0, tr.doc.content.size); + this.state = this.view.state.apply(tr); + } this.view.updateState(this.state); } }, From f2b5e328bb6bd3fa614288bce96eea258e8c7bf8 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 29 Jul 2021 17:44:37 +0530 Subject: [PATCH 18/66] feat: Update agent and team multi-select with new multi-select dropdown (#2516) * feat: Update agent/team multiselect styles * Component name fixes * Adds key control for our multiselect dropdown and component name spell fix * Minor fixes * Review fixes * Minor fixes * Minor fixes * Review fixes Co-authored-by: Muhsin Keloth Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> --- .../dashboard/i18n/locale/en/agentMgmt.json | 17 ++++ .../dashboard/conversation/ContactPanel.vue | 79 +++++++++++-------- .../ui/MultiselectDropdown.stories.js | 2 +- ...ctDropdown.vue => MultiselectDropdown.vue} | 6 +- .../ui/MultiselectDropdownItems.vue | 54 +++++++++++-- 5 files changed, 117 insertions(+), 41 deletions(-) rename app/javascript/shared/components/ui/{MutiselectDropdown.vue => MultiselectDropdown.vue} (97%) diff --git a/app/javascript/dashboard/i18n/locale/en/agentMgmt.json b/app/javascript/dashboard/i18n/locale/en/agentMgmt.json index dcc8c9fda..0f965c717 100644 --- a/app/javascript/dashboard/i18n/locale/en/agentMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/agentMgmt.json @@ -91,6 +91,23 @@ }, "SEARCH": { "NO_RESULTS": "No results found." + }, + "MULTI_SELECTOR": { + "PLACEHOLDER": "None", + "TITLE": { + "AGENT": "Select agent", + "TEAM": "Select team" + }, + "SEARCH": { + "NO_RESULTS": { + "AGENT": "No agents found", + "TEAM": "No teams found" + }, + "PLACEHOLDER": { + "AGENT": "Search agents", + "TEAM": "Search teams" + } + } } } } diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index f2e7a8870..0231d3e01 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -24,27 +24,21 @@ - - - {{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }} - + :selected-item="assignedAgent" + :multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.AGENT')" + :multiselector-placeholder=" + $t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER') + " + :no-search-result=" + $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.AGENT') + " + :input-placeholder=" + $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT') + " + @click="onClickAssignAgent" + />
    - - {{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }} - + :selected-item="assignedTeam" + :multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')" + :multiselector-placeholder=" + $t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER') + " + :no-search-result=" + $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM') + " + :input-placeholder=" + $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM') + " + @click="onClickAssignTeam" + />
    @@ -138,7 +134,7 @@ import ContactDetailsItem from './ContactDetailsItem.vue'; import ContactInfo from './contact/ContactInfo'; import ConversationLabels from './labels/LabelBox.vue'; import ContactCustomAttributes from './ContactCustomAttributes'; -import AvailabilityStatusBadge from 'dashboard/components/widgets/conversation/AvailabilityStatusBadge.vue'; +import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue'; import flag from 'country-code-emoji'; @@ -149,7 +145,7 @@ export default { ContactDetailsItem, ContactInfo, ConversationLabels, - AvailabilityStatusBadge, + MultiselectDropdown, }, mixins: [alertMixin, agentMixin], props: { @@ -330,6 +326,21 @@ export default { }; this.assignedAgent = selfAssign; }, + onClickAssignAgent(selectedItem) { + if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) { + this.assignedAgent = null; + } else { + this.assignedAgent = selectedItem; + } + }, + + onClickAssignTeam(selectedItemTeam) { + if (this.assignedTeam && this.assignedTeam.id === selectedItemTeam.id) { + this.assignedTeam = null; + } else { + this.assignedTeam = selectedItemTeam; + } + }, }, }; diff --git a/app/javascript/shared/components/ui/MultiselectDropdown.stories.js b/app/javascript/shared/components/ui/MultiselectDropdown.stories.js index ff3f366f0..8b067e489 100644 --- a/app/javascript/shared/components/ui/MultiselectDropdown.stories.js +++ b/app/javascript/shared/components/ui/MultiselectDropdown.stories.js @@ -1,5 +1,5 @@ import { action } from '@storybook/addon-actions'; -import Dropdown from './MutiselectDropdown'; +import Dropdown from './MultiselectDropdown'; export default { title: 'Components/Dropdown/Multiselect Dropdown', diff --git a/app/javascript/shared/components/ui/MutiselectDropdown.vue b/app/javascript/shared/components/ui/MultiselectDropdown.vue similarity index 97% rename from app/javascript/shared/components/ui/MutiselectDropdown.vue rename to app/javascript/shared/components/ui/MultiselectDropdown.vue index 5f99a8dbd..cf90179b2 100644 --- a/app/javascript/shared/components/ui/MutiselectDropdown.vue +++ b/app/javascript/shared/components/ui/MultiselectDropdown.vue @@ -1,5 +1,9 @@ + diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 988f27923..a5405bd63 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -144,6 +144,7 @@ "CSAT": "CSAT" }, "CREATE_ACCOUNT": { + "NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.", "NEW_ACCOUNT": "New Account", "SELECTOR_SUBTITLE": "Create a new account", "API": { From d88e3e3596d20e0397198933787a8785ae07f5ba Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Mon, 2 Aug 2021 13:11:07 +0530 Subject: [PATCH 25/66] fix: Resolve conversation with id instead of current conversation (#2731) --- .../store/modules/conversations/actions.js | 8 +++--- .../store/modules/conversations/getters.js | 4 ++- .../store/modules/conversations/index.js | 7 ++--- .../specs/conversations/actions.spec.js | 16 +++++++++++ .../specs/conversations/mutations.spec.js | 27 +++++++++++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 6030b7b8d..0415d8724 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -145,10 +145,10 @@ const actions = { status, snoozedUntil, }); - commit( - types.default.RESOLVE_CONVERSATION, - response.data.payload.current_status - ); + commit(types.default.RESOLVE_CONVERSATION, { + conversationId, + status: response.data.payload.current_status, + }); } catch (error) { // Handle error } diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js index b7f277a1e..79bd0e50a 100644 --- a/app/javascript/dashboard/store/modules/conversations/getters.js +++ b/app/javascript/dashboard/store/modules/conversations/getters.js @@ -64,7 +64,9 @@ const getters = { getChatStatusFilter: ({ chatStatusFilter }) => chatStatusFilter, getSelectedInbox: ({ currentInbox }) => currentInbox, getConversationById: _state => conversationId => { - return _state.allConversations.find(value => value.id === conversationId); + return _state.allConversations.find( + value => value.id === Number(conversationId) + ); }, }; diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index d9721b4fc..f0005c627 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -69,9 +69,10 @@ export const mutations = { Vue.set(chat.meta, 'team', team); }, - [types.default.RESOLVE_CONVERSATION](_state, status) { - const [chat] = getSelectedChatConversation(_state); - chat.status = status; + [types.default.RESOLVE_CONVERSATION](_state, { conversationId, status }) { + const conversation = + getters.getConversationById(_state)(conversationId) || {}; + Vue.set(conversation, 'status', status); }, [types.default.MUTE_CONVERSATION](_state) { diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index ad794611c..ce939ea3c 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -214,6 +214,22 @@ describe('#actions', () => { }); }); + describe('#toggleStatus', () => { + it('sends correct mutations if toggle status is successful', async () => { + axios.post.mockResolvedValue({ + data: { payload: { conversation_id: 1, current_status: 'resolved' } }, + }); + await actions.toggleStatus( + { commit }, + { conversationId: 1, status: 'resolved' } + ); + expect(commit).toHaveBeenCalledTimes(1); + expect(commit.mock.calls).toEqual([ + ['RESOLVE_CONVERSATION', { conversationId: 1, status: 'resolved' }], + ]); + }); + }); + describe('#assignTeam', () => { it('sends correct mutations if assignment is successful', async () => { axios.post.mockResolvedValue({ diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index 1074d11eb..3ffc2b505 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -160,4 +160,31 @@ describe('#mutations', () => { expect(global.bus.$emit).not.toHaveBeenCalled(); }); }); + + describe('#RESOLVE_CONVERSATION', () => { + it('updates the conversation status correctly', () => { + const state = { + allConversations: [ + { + id: 1, + messages: [], + status: 'open', + }, + ], + }; + + mutations[types.RESOLVE_CONVERSATION](state, { + conversationId: '1', + status: 'resolved', + }); + + expect(state.allConversations).toEqual([ + { + id: 1, + messages: [], + status: 'resolved', + }, + ]); + }); + }); }); From faf104c1fefc7a9a4ea606f11b8ead4eebc7b916 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Mon, 2 Aug 2021 16:07:30 +0530 Subject: [PATCH 26/66] fix: Update tweet character count logic (#2709) --- app/javascript/dashboard/api/inbox/message.js | 43 +++++++++++---- .../dashboard/api/specs/inbox/message.spec.js | 32 +++++++++++- .../widgets/conversation/ConversationBox.vue | 4 +- .../widgets/conversation/Message.vue | 1 + .../widgets/conversation/MessagesView.vue | 13 ++--- .../widgets/conversation/ReplyBox.vue | 36 ++++++++++--- .../widgets/conversation/bubble/Actions.vue | 27 ++++++++-- .../i18n/locale/en/conversation.json | 2 +- .../twitter/send_on_twitter_service.rb | 2 + .../twitter/send_on_twitter_service_spec.rb | 52 ++++++++++++++++--- 10 files changed, 174 insertions(+), 38 deletions(-) diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 84e77fb8b..98c250e60 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -2,6 +2,33 @@ /* global axios */ import ApiClient from '../ApiClient'; +export const buildCreatePayload = ({ + message, + isPrivate, + contentAttributes, + echoId, + file, +}) => { + let payload; + if (file) { + payload = new FormData(); + payload.append('attachments[]', file, file.name); + if (message) { + payload.append('content', message); + } + payload.append('private', isPrivate); + payload.append('echo_id', echoId); + } else { + payload = { + content: message, + private: isPrivate, + echo_id: echoId, + content_attributes: contentAttributes, + }; + } + return payload; +}; + class MessageApi extends ApiClient { constructor() { super('conversations', { accountScoped: true }); @@ -15,18 +42,16 @@ class MessageApi extends ApiClient { echo_id: echoId, file, }) { - const formData = new FormData(); - if (file) formData.append('attachments[]', file, file.name); - if (message) formData.append('content', message); - if (contentAttributes) - formData.append('content_attributes', JSON.stringify(contentAttributes)); - - formData.append('private', isPrivate); - formData.append('echo_id', echoId); return axios({ method: 'post', url: `${this.url}/${conversationId}/messages`, - data: formData, + data: buildCreatePayload({ + message, + isPrivate, + contentAttributes, + echoId, + file, + }), }); } diff --git a/app/javascript/dashboard/api/specs/inbox/message.spec.js b/app/javascript/dashboard/api/specs/inbox/message.spec.js index dd4814f23..ca8d313b4 100644 --- a/app/javascript/dashboard/api/specs/inbox/message.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/message.spec.js @@ -1,4 +1,4 @@ -import messageAPI from '../../inbox/message'; +import messageAPI, { buildCreatePayload } from '../../inbox/message'; import ApiClient from '../../ApiClient'; import describeWithAPIMock from '../apiSpecHelper'; @@ -29,4 +29,34 @@ describe('#ConversationAPI', () => { ); }); }); + describe('#buildCreatePayload', () => { + it('builds form payload if file is available', () => { + const formPayload = buildCreatePayload({ + message: 'test content', + echoId: 12, + isPrivate: true, + file: new Blob(['test-content'], { type: 'application/pdf' }), + }); + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('content')).toEqual('test content'); + expect(formPayload.get('echo_id')).toEqual('12'); + expect(formPayload.get('private')).toEqual('true'); + }); + + it('builds object payload if file is not available', () => { + expect( + buildCreatePayload({ + message: 'test content', + isPrivate: false, + echoId: 12, + contentAttributes: { in_reply_to: 12 }, + }) + ).toEqual({ + content: 'test content', + private: false, + echo_id: 12, + content_attributes: { in_reply_to: 12 }, + }); + }); + }); }); diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue index 440f6e1e5..01c21c53f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue @@ -53,9 +53,7 @@ export default { }, }, computed: { - ...mapGetters({ - currentChat: 'getSelectedChat', - }), + ...mapGetters({ currentChat: 'getSelectedChat' }), showContactPanel() { return this.isContactPanelOpen && this.currentChat.id; }, diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index 029ab5e6a..c519f99e9 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -46,6 +46,7 @@ :message-type="data.message_type" :readable-time="readableTime" :source-id="data.source_id" + :inbox-id="data.inbox_id" /> diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 3f8fda58d..9b2c39996 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -33,11 +33,11 @@ - @@ -207,10 +208,10 @@ export default { selectedTweet() { if (this.selectedTweetId) { const { messages = [] } = this.getMessages; - const [selectedMessage = {}] = messages.filter( + const [selectedMessage] = messages.filter( message => message.id === this.selectedTweetId ); - return selectedMessage.content || ''; + return selectedMessage || {}; } return ''; }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 9a2434fbf..ee2929743 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -107,9 +107,13 @@ export default { }, mixins: [clickaway, inboxMixin, uiSettingsMixin, alertMixin], props: { - inReplyTo: { - type: [String, Number], - default: '', + selectedTweet: { + type: [Object, String], + default: () => ({}), + }, + isATweet: { + type: Boolean, + default: false, }, }, data() { @@ -169,11 +173,14 @@ export default { return this.maxLength - this.message.length; }, isReplyButtonDisabled() { - const isMessageEmpty = this.isMessageEmpty; + if (this.isATweet && !this.inReplyTo) { + return true; + } if (this.hasAttachments) return false; + return ( - isMessageEmpty || + this.isMessageEmpty || this.message.length === 0 || this.message.length > this.maxLength ); @@ -198,7 +205,7 @@ export default { } if (this.isATwitterInbox) { if (this.conversationType === 'tweet') { - return MESSAGE_MAX_LENGTH.TWEET; + return MESSAGE_MAX_LENGTH.TWEET - this.replyToUserLength - 2; } } return MESSAGE_MAX_LENGTH.GENERAL; @@ -235,9 +242,22 @@ export default { isOnPrivateNote() { return this.replyType === REPLY_EDITOR_MODES.NOTE; }, + inReplyTo() { + const selectedTweet = this.selectedTweet || {}; + return selectedTweet.id; + }, + replyToUserLength() { + const selectedTweet = this.selectedTweet || {}; + const { + sender: { + additional_attributes: { screen_name: screenName = '' } = {}, + } = {}, + } = selectedTweet; + return screenName ? screenName.length : 0; + }, isMessageEmpty() { - if(!this.message) { - this.message = ''; + if (!this.message) { + return true; } return !this.message.trim().replace(/\n/g, '').length; }, diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue index 8c6954672..59a0a9896 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue @@ -14,13 +14,13 @@ @mouseleave="isHovered = false" /> Date: Tue, 3 Aug 2021 12:13:24 +0530 Subject: [PATCH 27/66] enhancement: Updates icons to show last message type (#2743) --- .../scss/widgets/_conversation-card.scss | 5 ---- .../widgets/conversation/ConversationCard.vue | 24 ++++++++++++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss index 11394f032..f7b96381e 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss @@ -90,11 +90,6 @@ font-size: $font-size-mini; vertical-align: top; } - - .message-from-agent { - color: $color-gray; - font-size: $font-size-mini; - } } .conversation--meta { diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index d6e8528b7..d3152fa3c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -26,7 +26,12 @@ {{ currentContact.name }}

    - + + + {{ parsedLastMessage }} @@ -144,6 +149,18 @@ export default { return messageType === MESSAGE_TYPE.OUTGOING; }, + isMessageAnActivity() { + const lastMessage = this.lastMessageInChat; + const { message_type: messageType } = lastMessage; + return messageType === MESSAGE_TYPE.ACTIVITY; + }, + + isMessagePrivate() { + const lastMessage = this.lastMessageInChat; + const { private: isPrivate } = lastMessage; + return isPrivate; + }, + parsedLastMessage() { const { content_attributes: contentAttributes } = this.lastMessageInChat; const { email: { subject } = {} } = contentAttributes || {}; @@ -230,4 +247,9 @@ export default { font-size: var(--font-size-mini); } } + +.last-message-icon { + color: var(--s-600); + font-size: var(--font-size-mini); +} From 9b01b82cc7fa92484c127482f5d3a2f8bd484729 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 3 Aug 2021 16:13:44 +0530 Subject: [PATCH 28/66] docs: update chatwoot VDP guidelines (#2740) --- README.md | 3 +++ SECURITY.md | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 030b13dcd..35bd2aad0 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,10 @@ Follow this [link](https://www.chatwoot.com/docs/environment-variables) to under Please follow [deployment architecture guide](https://www.chatwoot.com/docs/deployment/architecture) to deploy with Docker or Caprover. --- +#### Security +Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. +--- ### Contributors ✨ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors): diff --git a/SECURITY.md b/SECURITY.md index 6a244bcdc..37e75995e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,31 @@ # Security Policy +Chatwoot is looking forward to working with security researchers across the world to keep Chatwoot and our users safe. If you have found an issue in our systems/applications, please reach out to us. ## Reporting a Vulnerability We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). + This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. -If you have any questions about the process, feel free to reach out to hello@chatwoot.com. +If you have any questions about the process, feel free to reach out to security@chatwoot.com. + + +## Out of scope + +Please do not perform testing against Chatwoot production services. Use a self hosted instance to perform tests. + +We consider the following to be out of scope, though there may be exceptions. + +- Missing HTTP security headers +- Self XSS +- HTTP Host Header XSS without working proof-of-concept +- Incomplete/Missing SPF/DKIM +- Denial of Service attacks +- DNSSEC +- Social Engineering attacks + +If you are not sure about the scope, please create a report. + +## Thanks + +Thank you for keeping Chatwoot and our users safe. 🙇 From 92c14fa87d5f8aba9d44270caecba727d528bc75 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 3 Aug 2021 18:22:50 +0530 Subject: [PATCH 29/66] feat: CSAT response collection public page (#2685) --- .rubocop.yml | 7 +- .../survey/responses_controller.rb | 10 +++ app/javascript/packs/survey.js | 25 +++++++ .../components/Branding.vue | 16 ++-- app/javascript/shared/components/Button.vue | 11 ++- app/javascript/shared/components/TextArea.vue | 74 +++++++++++++++++++ app/javascript/survey/App.vue | 20 +++++ app/javascript/survey/assets/scss/woot.scss | 21 ++++++ app/javascript/survey/components/Rating.vue | 73 ++++++++++++++++++ app/javascript/survey/i18n/index.js | 5 ++ app/javascript/survey/i18n/locale/en.json | 15 ++++ app/javascript/survey/views/Response.vue | 64 ++++++++++++++++ app/javascript/widget/views/Home.vue | 2 +- app/views/survey/responses/show.html.erb | 17 +++++ config/routes.rb | 3 + config/webpack/resolve.js | 1 + .../service/responses_controller_spec.rb | 16 ++++ tailwind.config.js | 1 + 18 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 app/controllers/survey/responses_controller.rb create mode 100644 app/javascript/packs/survey.js rename app/javascript/{widget => shared}/components/Branding.vue (88%) create mode 100644 app/javascript/shared/components/TextArea.vue create mode 100755 app/javascript/survey/App.vue create mode 100755 app/javascript/survey/assets/scss/woot.scss create mode 100644 app/javascript/survey/components/Rating.vue create mode 100644 app/javascript/survey/i18n/index.js create mode 100644 app/javascript/survey/i18n/locale/en.json create mode 100644 app/javascript/survey/views/Response.vue create mode 100644 app/views/survey/responses/show.html.erb create mode 100644 spec/controllers/service/responses_controller_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 00b581c0d..ac0a64665 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -25,7 +25,7 @@ Style/FrozenStringLiteralComment: Style/SymbolArray: Enabled: false Style/OptionalBooleanParameter: - Exclude: + Exclude: - 'app/services/email_templates/db_resolver_service.rb' - 'app/dispatchers/dispatcher.rb' Style/GlobalVars: @@ -57,6 +57,7 @@ Rails/ApplicationController: - 'app/controllers/widgets_controller.rb' - 'app/controllers/platform_controller.rb' - 'app/controllers/public_controller.rb' + - 'app/controllers/survey/responses_controller.rb' Style/ClassAndModuleChildren: EnforcedStyle: compact Exclude: @@ -79,7 +80,7 @@ Style/GuardClause: - 'app/models/message.rb' - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' Metrics/AbcSize: - Exclude: + Exclude: - 'app/controllers/concerns/auth_helper.rb' - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb' @@ -108,7 +109,7 @@ Rails/BulkChangeTable: - 'db/migrate/20191027054756_create_contact_inboxes.rb' - 'db/migrate/20191130164019_add_template_type_to_messages.rb' - 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb' -Rails/UniqueValidationWithoutIndex: +Rails/UniqueValidationWithoutIndex: Exclude: - 'app/models/channel/twitter_profile.rb' - 'app/models/webhook.rb' diff --git a/app/controllers/survey/responses_controller.rb b/app/controllers/survey/responses_controller.rb new file mode 100644 index 000000000..8bbd0fe88 --- /dev/null +++ b/app/controllers/survey/responses_controller.rb @@ -0,0 +1,10 @@ +class Survey::ResponsesController < ActionController::Base + before_action :set_global_config + def show; end + + private + + def set_global_config + @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL') + end +end diff --git a/app/javascript/packs/survey.js b/app/javascript/packs/survey.js new file mode 100644 index 000000000..3c65f8b82 --- /dev/null +++ b/app/javascript/packs/survey.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import Vuelidate from 'vuelidate'; +import VueI18n from 'vue-i18n'; +import App from '../survey/App.vue'; +import i18n from '../survey/i18n'; + +Vue.use(VueI18n); +Vue.use(Vuelidate); + +const i18nConfig = new VueI18n({ + locale: 'en', + messages: i18n, +}); + +// Event Bus +window.bus = new Vue(); + +Vue.config.productionTip = false; + +window.onload = () => { + window.WOOT_SURVEY = new Vue({ + i18n: i18nConfig, + render: h => h(App), + }).$mount('#app'); +}; diff --git a/app/javascript/widget/components/Branding.vue b/app/javascript/shared/components/Branding.vue similarity index 88% rename from app/javascript/widget/components/Branding.vue rename to app/javascript/shared/components/Branding.vue index aacf825d3..6a284a6bd 100644 --- a/app/javascript/widget/components/Branding.vue +++ b/app/javascript/shared/components/Branding.vue @@ -16,21 +16,28 @@ - diff --git a/app/javascript/survey/App.vue b/app/javascript/survey/App.vue new file mode 100755 index 000000000..076ca9fe7 --- /dev/null +++ b/app/javascript/survey/App.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/app/javascript/survey/assets/scss/woot.scss b/app/javascript/survey/assets/scss/woot.scss new file mode 100755 index 000000000..5c7a4a213 --- /dev/null +++ b/app/javascript/survey/assets/scss/woot.scss @@ -0,0 +1,21 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; +@import 'widget/assets/scss/reset'; +@import 'widget/assets/scss/variables'; +@import 'widget/assets/scss/buttons'; +@import 'widget/assets/scss/mixins'; +@import 'widget/assets/scss/forms'; +@import 'shared/assets/fonts/widget_fonts'; + +html, +body { + font-family: $font-family; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + height: 100%; +} + +.woot-survey-wrap { + height: 100%; +} diff --git a/app/javascript/survey/components/Rating.vue b/app/javascript/survey/components/Rating.vue new file mode 100644 index 000000000..22f7d2cb7 --- /dev/null +++ b/app/javascript/survey/components/Rating.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/app/javascript/survey/i18n/index.js b/app/javascript/survey/i18n/index.js new file mode 100644 index 000000000..8475d9f20 --- /dev/null +++ b/app/javascript/survey/i18n/index.js @@ -0,0 +1,5 @@ +import { default as en } from './locale/en.json'; + +export default { + en, +}; diff --git a/app/javascript/survey/i18n/locale/en.json b/app/javascript/survey/i18n/locale/en.json new file mode 100644 index 000000000..72107e5be --- /dev/null +++ b/app/javascript/survey/i18n/locale/en.json @@ -0,0 +1,15 @@ +{ + "SURVEY": { + "DESCRIPTION": "Dear customer 👋 , please take a few moments to complete the feedback about the conversation.", + "RATING": { + "LABEL": "Rate your conversation", + "SUCCESS_MESSAGE": "Thank you for submitting the rating" + }, + "FEEDBACK": { + "LABEL": "Do you have any thoughts you'd like to share?", + "PLACEHOLDER": "Your feedback (optional)", + "BUTTON_TEXT": "Submit feedback" + } + }, + "POWERED_BY": "Powered by Chatwoot" +} diff --git a/app/javascript/survey/views/Response.vue b/app/javascript/survey/views/Response.vue new file mode 100644 index 000000000..ff4d95fff --- /dev/null +++ b/app/javascript/survey/views/Response.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/app/javascript/widget/views/Home.vue b/app/javascript/widget/views/Home.vue index c8d89cbe2..1e46f6a21 100755 --- a/app/javascript/widget/views/Home.vue +++ b/app/javascript/widget/views/Home.vue @@ -69,7 +69,7 @@ + <%= javascript_pack_tag 'survey' %> + <%= stylesheet_pack_tag 'survey' %> + + +

    + <%= yield %> + + diff --git a/config/routes.rb b/config/routes.rb index e118ca0bb..99251c754 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,9 @@ Rails.application.routes.draw do get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents' resource :widget, only: [:show] + namespace :survey do + resources :responses, only: [:show] + end end get '/api', to: 'api#index' diff --git a/config/webpack/resolve.js b/config/webpack/resolve.js index 8ff805de9..ce17c0db4 100644 --- a/config/webpack/resolve.js +++ b/config/webpack/resolve.js @@ -6,6 +6,7 @@ const resolve = { vue$: 'vue/dist/vue.common.js', dashboard: path.resolve('./app/javascript/dashboard'), widget: path.resolve('./app/javascript/widget'), + survey: path.resolve('./app/javascript/survey'), assets: path.resolve('./app/javascript/dashboard/assets'), components: path.resolve('./app/javascript/dashboard/components'), './iconfont.eot': 'vue-easytable/libs/font/iconfont.eot', diff --git a/spec/controllers/service/responses_controller_spec.rb b/spec/controllers/service/responses_controller_spec.rb new file mode 100644 index 000000000..a576a1c19 --- /dev/null +++ b/spec/controllers/service/responses_controller_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +describe '/survey/response', type: :request do + describe 'GET survey/responses/{uuid}' do + it 'renders the page correctly when called' do + conversation = create(:conversation) + get survey_response_url(id: conversation.uuid) + expect(response).to be_successful + end + + it 'returns 404 when called with invalid conversation uuid' do + get survey_response_url(id: '') + expect(response.status).to eq(404) + end + end +end diff --git a/tailwind.config.js b/tailwind.config.js index 9d76f14ee..c4e5aa8c1 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,6 +4,7 @@ module.exports = { purge: [ './app/javascript/widget/**/*.vue', './app/javascript/shared/**/*.vue', + './app/javascript/survey/**/*.vue', ], future: { removeDeprecatedGapUtilities: true, From 7e960b7c72c938483718133b755e23c8359ead35 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 3 Aug 2021 19:38:51 +0530 Subject: [PATCH 30/66] feat: Add support for rich content for message in ongoing campaigns (#2577) --- .../components/widgets/WootWriter/Editor.vue | 22 ++++++++++++++ .../settings/inbox/components/AddCampaign.vue | 29 +++++++++++++++++-- .../inbox/components/CampaignsTable.vue | 20 +++++++++---- .../inbox/components/EditCampaign.vue | 20 +++++++++---- 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 76939bbb7..d00f01667 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -57,6 +57,7 @@ export default { value: { type: String, default: '' }, placeholder: { type: String, default: '' }, isPrivate: { type: Boolean, default: false }, + isFormatMode: { type: Boolean, default: false }, }, data() { return { @@ -280,4 +281,25 @@ export default { padding: 0 var(--space-smaller); } } + +.editor-wrap { + margin-bottom: var(--space-normal); +} + +.message-editor { + border: 1px solid var(--color-border); + border-radius: var(--border-radius-normal); + padding: 0 var(--space-slab); + margin-bottom: 0; +} + +.editor_warning { + border: 1px solid var(--r-400); +} + +.editor-warning__message { + color: var(--r-400); + font-weight: var(--font-weight-normal); + padding: var(--space-smaller) 0 0 0; +} diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/AddCampaign.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/AddCampaign.vue index 5bd4c84e4..498c5c099 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/AddCampaign.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/AddCampaign.vue @@ -16,7 +16,21 @@ @blur="$v.title.$touch" /> -