From 3dae3ff3ad75baab8ecafda49e1c556e14144acc Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 14 Mar 2024 17:22:32 +0530 Subject: [PATCH 001/105] feat: Conversation update API for sla_policy_id (#8973) - Add an endpoint for updating conversation attributes (priority / sla_policy_id ) - Swagger spec - minor chores around the conversation API/spec Fixes: https://linear.app/chatwoot/issue/CW-2100/feat-backend-api-to-update-the-sla-of-a-conversation --- .../v1/accounts/conversations_controller.rb | 11 ++++ .../concerns/access_token_auth_helper.rb | 2 +- .../conversations/update.json.jbuilder | 1 + .../partials/_conversation.json.jbuilder | 1 + config/routes.rb | 2 +- .../v1/accounts/conversations_controller.rb | 5 ++ enterprise/app/models/applied_sla.rb | 7 ++ .../enterprise_conversation_concern.rb | 30 +++++++++ enterprise/app/models/sla_policy.rb | 1 + .../app/services/enterprise/action_service.rb | 11 ---- .../accounts/conversations_controller_spec.rb | 64 ++++++++++++++----- .../accounts/conversations_controller_spec.rb | 41 ++++++++++++ spec/enterprise/models/conversation_spec.rb | 62 ++++++++++++++---- .../sla/evaluate_applied_sla_service_spec.rb | 19 ++++-- .../paths/application/conversation/update.yml | 29 +++++++++ swagger/paths/index.yml | 2 + swagger/swagger.json | 58 +++++++++++++++++ 17 files changed, 301 insertions(+), 45 deletions(-) create mode 100644 app/views/api/v1/accounts/conversations/update.json.jbuilder create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb create mode 100644 spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb create mode 100644 swagger/paths/application/conversation/update.yml diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 281ff95de..d0d8f6d5b 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -36,6 +36,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end end + def update + @conversation.update!(permitted_update_params) + end + def filter result = ::Conversations::FilterService.new(params.permit!, current_user).perform @conversations = result[:conversations] @@ -110,6 +114,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro private + def permitted_update_params + # TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute + params.permit(:priority) + end + def update_last_seen_on_conversation(last_seen_at, update_assignee) # rubocop:disable Rails/SkipsModelValidations @conversation.update_column(:agent_last_seen_at, last_seen_at) @@ -176,3 +185,5 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro @conversation.assignee_id? && Current.user == @conversation.assignee end end + +Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController') diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index cd760f7ce..2ee9f9854 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -1,6 +1,6 @@ module AccessTokenAuthHelper BOT_ACCESSIBLE_ENDPOINTS = { - 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create], + 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update], 'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/assignments' => ['create'] }.freeze diff --git a/app/views/api/v1/accounts/conversations/update.json.jbuilder b/app/views/api/v1/accounts/conversations/update.json.jbuilder new file mode 100644 index 000000000..c273dd3c6 --- /dev/null +++ b/app/views/api/v1/accounts/conversations/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/conversations/partials/conversation', formats: [:json], conversation: @conversation diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index cef56fed4..2e90e073a 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -47,3 +47,4 @@ json.last_non_activity_message conversation.messages.where(account_id: conversat json.last_activity_at conversation.last_activity_at.to_i json.priority conversation.priority json.waiting_since conversation.waiting_since.to_i.to_i +json.sla_policy_id conversation.sla_policy_id diff --git a/config/routes.rb b/config/routes.rb index 90317c7b2..cfa14c854 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,7 +78,7 @@ Rails.application.routes.draw do namespace :channels do resource :twilio_channel, only: [:create] end - resources :conversations, only: [:index, :create, :show] do + resources :conversations, only: [:index, :create, :show, :update] do collection do get :meta get :search diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb new file mode 100644 index 000000000..be8dfbddf --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb @@ -0,0 +1,5 @@ +module Enterprise::Api::V1::Accounts::ConversationsController + def permitted_update_params + super.merge(params.permit(:sla_policy_id)) + end +end diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb index cb5ca508d..48fd852e4 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -23,6 +23,13 @@ class AppliedSla < ApplicationRecord belongs_to :conversation validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] } + before_validation :ensure_account_id enum sla_status: { active: 0, hit: 1, missed: 2 } + + private + + def ensure_account_id + self.account_id ||= sla_policy&.account_id + end end diff --git a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb b/enterprise/app/models/enterprise/enterprise_conversation_concern.rb index fc39a61a9..721f069dc 100644 --- a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb +++ b/enterprise/app/models/enterprise/enterprise_conversation_concern.rb @@ -3,5 +3,35 @@ module Enterprise::EnterpriseConversationConcern included do belongs_to :sla_policy, optional: true + has_one :applied_sla, dependent: :destroy + before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? } + around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? } + end + + private + + def validate_sla_policy + # TODO: remove these validations once we figure out how to deal with these cases + if sla_policy_id.nil? && changes[:sla_policy_id].first.present? + errors.add(:sla_policy, 'cannot remove sla policy from conversation') + return + end + + if changes[:sla_policy_id].first.present? + errors.add(:sla_policy, 'conversation already has a different sla') + return + end + + errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id + end + + # handling inside a transaction to ensure applied sla record is also created + def ensure_applied_sla_is_created + ActiveRecord::Base.transaction do + yield + create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank? + end + rescue ActiveRecord::RecordInvalid + raise ActiveRecord::Rollback end end diff --git a/enterprise/app/models/sla_policy.rb b/enterprise/app/models/sla_policy.rb index 647db2cfc..f53f00ed5 100644 --- a/enterprise/app/models/sla_policy.rb +++ b/enterprise/app/models/sla_policy.rb @@ -22,6 +22,7 @@ class SlaPolicy < ApplicationRecord validates :name, presence: true has_many :conversations, dependent: :nullify + has_many :applied_slas, dependent: :destroy def push_event_data { diff --git a/enterprise/app/services/enterprise/action_service.rb b/enterprise/app/services/enterprise/action_service.rb index 1e4165b09..f0c3bbf9f 100644 --- a/enterprise/app/services/enterprise/action_service.rb +++ b/enterprise/app/services/enterprise/action_service.rb @@ -8,16 +8,5 @@ module Enterprise::ActionService Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}" @conversation.update!(sla_policy_id: sla_policy.id) - create_applied_sla(sla_policy) - end - - def create_applied_sla(sla_policy) - Rails.logger.info "SLA:: Creating Applied SLA for conversation: #{@conversation.id}" - AppliedSla.create!( - account_id: @conversation.account_id, - sla_policy_id: sla_policy.id, - conversation_id: @conversation.id, - sla_status: 'active' - ) end end diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 72e6603f7..70f5b82ee 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -210,6 +210,55 @@ RSpec.describe 'Conversations API', type: :request do end end + describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do + let(:conversation) { create(:conversation, account: account) } + let(:params) { { priority: 'high' } } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'does not update the conversation if you do not have access to it' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'updates the conversation if you are an administrator' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high') + end + + it 'updates the conversation if you are an agent with access to inbox' do + create(:inbox_member, user: agent, inbox: conversation.inbox) + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high') + end + end + end + describe 'POST /api/v1/accounts/{account.id}/conversations' do let(:contact) { create(:contact, account: account) } let(:inbox) { create(:inbox, account: account) } @@ -411,21 +460,6 @@ RSpec.describe 'Conversations API', type: :request do 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 - # remove in next release - # 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('pending') - # end end context 'when it is an authenticated bot' do diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb new file mode 100644 index 000000000..fb028b76f --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise Conversations API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do + let(:conversation) { create(:conversation, account: account) } + let(:sla_policy) { create(:sla_policy, account: account) } + let(:params) { { sla_policy_id: sla_policy.id } } + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + + before do + create(:inbox_member, user: agent, inbox: conversation.inbox) + end + + it 'updates the conversation if you are an agent with access to inbox' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:sla_policy_id]).to eq(sla_policy.id) + end + + it 'throws error if conversation already has a different sla' do + conversation.update(sla_policy: create(:sla_policy, account: account)) + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body, symbolize_names: true)[:message]).to eq('Sla policy conversation already has a different sla') + end + end + end +end diff --git a/spec/enterprise/models/conversation_spec.rb b/spec/enterprise/models/conversation_spec.rb index bb3ced5b3..e9fe811ca 100644 --- a/spec/enterprise/models/conversation_spec.rb +++ b/spec/enterprise/models/conversation_spec.rb @@ -7,10 +7,10 @@ RSpec.describe Conversation, type: :model do describe 'SLA policy updates' do let!(:conversation) { create(:conversation) } - let!(:sla_policy) { create(:sla_policy) } + let!(:sla_policy) { create(:sla_policy, account: conversation.account) } it 'generates an activity message when the SLA policy is updated' do - conversation.update(sla_policy_id: sla_policy.id) + conversation.update!(sla_policy_id: sla_policy.id) perform_enqueued_jobs @@ -21,18 +21,19 @@ RSpec.describe Conversation, type: :model do expect(activity_message.content).to include('added SLA policy') end - it 'generates an activity message when the SLA policy is removed' do - conversation.update(sla_policy_id: sla_policy.id) - conversation.update(sla_policy_id: nil) + # TODO: Reenable this when we let the SLA policy be removed from a conversation + # it 'generates an activity message when the SLA policy is removed' do + # conversation.update!(sla_policy_id: sla_policy.id) + # conversation.update!(sla_policy_id: nil) - perform_enqueued_jobs + # perform_enqueued_jobs - activity_message = conversation.messages.where(message_type: 'activity').last + # activity_message = conversation.messages.where(message_type: 'activity').last - expect(activity_message).not_to be_nil - expect(activity_message.message_type).to eq('activity') - expect(activity_message.content).to include('removed SLA policy') - end + # expect(activity_message).not_to be_nil + # expect(activity_message.message_type).to eq('activity') + # expect(activity_message.content).to include('removed SLA policy') + # end end describe 'conversation sentiments' do @@ -64,4 +65,43 @@ RSpec.describe Conversation, type: :model do expect(sentiments[:label]).to eq('positive') end end + + describe 'sla_policy' do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:sla_policy) { create(:sla_policy, account: account) } + let(:different_account_sla_policy) { create(:sla_policy) } + + context 'when sla_policy is getting updated' do + it 'throws error if sla policy belongs to different account' do + conversation.sla_policy = different_account_sla_policy + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to include('sla policy account mismatch') + end + + it 'creates applied sla record if sla policy is present' do + conversation.sla_policy = sla_policy + conversation.save! + expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id) + end + end + + context 'when conversation already has a different sla' do + before do + conversation.update(sla_policy: create(:sla_policy, account: account)) + end + + it 'throws error if trying to assing a different sla' do + conversation.sla_policy = sla_policy + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to eq(['conversation already has a different sla']) + end + + it 'throws error if trying to set sla to nil' do + conversation.sla_policy = nil + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to eq(['cannot remove sla policy from conversation']) + end + end + end end diff --git a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb index 17c09cb0e..12cb59d35 100644 --- a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb +++ b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb @@ -5,14 +5,21 @@ RSpec.describe Sla::EvaluateAppliedSlaService do let!(:user_1) { create(:user, account: account) } let!(:user_2) { create(:user, account: account) } let!(:admin) { create(:user, account: account, role: :administrator) } - let!(:conversation) { create(:conversation, created_at: 6.hours.ago, assignee: user_1, account: account) } + let!(:sla_policy) do - create(:sla_policy, account: conversation.account, - first_response_time_threshold: nil, - next_response_time_threshold: nil, - resolution_time_threshold: nil) + create(:sla_policy, + account: account, + first_response_time_threshold: nil, + next_response_time_threshold: nil, + resolution_time_threshold: nil) end - let!(:applied_sla) { create(:applied_sla, conversation: conversation, sla_policy: sla_policy, sla_status: 'active') } + let!(:conversation) do + create(:conversation, + created_at: 6.hours.ago, assignee: user_1, + account: sla_policy.account, + sla_policy: sla_policy) + end + let!(:applied_sla) { conversation.applied_sla } describe '#perform - SLA misses' do context 'when first response SLA is missed' do diff --git a/swagger/paths/application/conversation/update.yml b/swagger/paths/application/conversation/update.yml new file mode 100644 index 000000000..3add02635 --- /dev/null +++ b/swagger/paths/application/conversation/update.yml @@ -0,0 +1,29 @@ +tags: + - Conversations +operationId: update-conversation +summary: Update Conversation +description: Update Conversation Attributes +security: + - userApiKey: [] + - agentBotApiKey: [] +parameters: + - name: data + in: body + required: true + schema: + type: object + properties: + priority: + type: string + enum: ["urgent", "high", "medium", "low", "none"] + description: "The priority of the conversation" + sla_policy_id: + type: number + description: "The ID of the SLA policy (Available only in Enterprise edition)" +responses: + 200: + description: Success + 404: + description: Conversation not found + 401: + description: Unauthorized diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 2c6dd3f87..6df22d06e 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -339,6 +339,8 @@ - $ref: '#/parameters/conversation_id' get: $ref: ./application/conversation/show.yml + patch: + $ref: ./application/conversation/update.yml /api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status: parameters: - $ref: '#/parameters/account_id' diff --git a/swagger/swagger.json b/swagger/swagger.json index ec88a224b..d5f94f730 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -3319,6 +3319,64 @@ "description": "Access denied" } } + }, + "patch": { + "tags": [ + "Conversations" + ], + "operationId": "update-conversation", + "summary": "Update Conversation", + "description": "Update Conversation Attributes", + "security": [ + { + "userApiKey": [ + + ] + }, + { + "agentBotApiKey": [ + + ] + } + ], + "parameters": [ + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "priority": { + "type": "string", + "enum": [ + "urgent", + "high", + "medium", + "low", + "none" + ], + "description": "The priority of the conversation" + }, + "sla_policy_id": { + "type": "number", + "description": "The ID of the SLA policy (Available only in Enterprise edition)" + } + } + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Conversation not found" + }, + "401": { + "description": "Unauthorized" + } + } } }, "/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status": { From 586552013ef5556816abf141744c1de49146309f Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Fri, 15 Mar 2024 10:55:40 +0530 Subject: [PATCH 002/105] feat: Update the `contact_type` when creating or updating the contact (#9107) * feat: Update location and country code when the contact create/update * feat: Update the location and country_code when creating or updating the contact. * chore: improve comments * feat: Update the contact_type when the contact created/updated * chore: add more specs * chore: code cleanups * chore: code cleanups * Update contact_spec.rb * Update inbox.rb * Update sync_attributes_spec.rb * chore: build fixes * chore: check visitor type before update * chore: review fixes --- app/models/contact.rb | 9 ++-- app/services/contacts/sync_attributes.rb | 37 +++++++++++++++ spec/models/contact_spec.rb | 19 ++++++++ .../services/contacts/sync_attributes_spec.rb | 46 +++++++++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 app/services/contacts/sync_attributes.rb create mode 100644 spec/services/contacts/sync_attributes_spec.rb diff --git a/app/models/contact.rb b/app/models/contact.rb index 1f3d2fb90..a60e9f4d2 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -61,7 +61,7 @@ class Contact < ApplicationRecord after_create_commit :dispatch_create_event, :ip_lookup after_update_commit :dispatch_update_event after_destroy_commit :dispatch_destroy_event - before_save :update_contact_location_and_country_code + before_save :sync_contact_attributes enum contact_type: { visitor: 0, lead: 1, customer: 2 } @@ -207,11 +207,8 @@ class Contact < ApplicationRecord self.custom_attributes = {} if custom_attributes.blank? end - def update_contact_location_and_country_code - # TODO: Ensure that location and country_code are updated from additional_attributes. - # We will remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app. - self.location = additional_attributes['city'] - self.country_code = additional_attributes['country'] + def sync_contact_attributes + ::Contacts::SyncAttributes.new(self).perform end def dispatch_create_event diff --git a/app/services/contacts/sync_attributes.rb b/app/services/contacts/sync_attributes.rb new file mode 100644 index 000000000..bc10def66 --- /dev/null +++ b/app/services/contacts/sync_attributes.rb @@ -0,0 +1,37 @@ +class Contacts::SyncAttributes + attr_reader :contact + + def initialize(contact) + @contact = contact + end + + def perform + update_contact_location_and_country_code + set_contact_type + end + + private + + def update_contact_location_and_country_code + # Ensure that location and country_code are updated from additional_attributes. + # TODO: Remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app. + @contact.location = @contact.additional_attributes['city'] + @contact.country_code = @contact.additional_attributes['country'] + end + + def set_contact_type + # If the contact is already a lead or customer then do not change the contact type + return unless @contact.contact_type == 'visitor' + # If the contact has an email or phone number or social details( facebook_user_id, instagram_user_id, etc) then it is a lead + # If contact is from external channel like facebook, instagram, whatsapp, etc then it is a lead + return unless @contact.email.present? || @contact.phone_number.present? || social_details_present? + + @contact.contact_type = 'lead' + end + + def social_details_present? + @contact.additional_attributes.keys.any? do |key| + key.start_with?('social_') && @contact.additional_attributes[key].present? + end + end +end diff --git a/spec/models/contact_spec.rb b/spec/models/contact_spec.rb index ff186466a..2ca65fa4e 100644 --- a/spec/models/contact_spec.rb +++ b/spec/models/contact_spec.rb @@ -22,11 +22,13 @@ RSpec.describe Contact do it 'sets email to lowercase' do contact = create(:contact, email: 'Test@test.com') expect(contact.email).to eq('test@test.com') + expect(contact.contact_type).to eq('lead') end it 'sets email to nil when empty string' do contact = create(:contact, email: '') expect(contact.email).to be_nil + expect(contact.contact_type).to eq('visitor') end it 'sets custom_attributes to {} when nil' do @@ -83,4 +85,21 @@ RSpec.describe Contact do expect(contact.country_code).to eq 'US' end end + + context 'when a contact is created' do + it 'has contact type "visitor" by default' do + contact = create(:contact) + expect(contact.contact_type).to eq 'visitor' + end + + it 'has contact type "lead" when email is present' do + contact = create(:contact, email: 'test@test.com') + expect(contact.contact_type).to eq 'lead' + end + + it 'has contact type "lead" when contacted through a social channel' do + contact = create(:contact, additional_attributes: { social_facebook_user_id: '123' }) + expect(contact.contact_type).to eq 'lead' + end + end end diff --git a/spec/services/contacts/sync_attributes_spec.rb b/spec/services/contacts/sync_attributes_spec.rb new file mode 100644 index 000000000..447bcd740 --- /dev/null +++ b/spec/services/contacts/sync_attributes_spec.rb @@ -0,0 +1,46 @@ +# spec/services/contacts/sync_attributes_spec.rb + +require 'rails_helper' + +RSpec.describe Contacts::SyncAttributes do + describe '#perform' do + let(:contact) { create(:contact, additional_attributes: { 'city' => 'New York', 'country' => 'US' }) } + + context 'when contact has neither email/phone number nor social details' do + it 'does not change contact type' do + described_class.new(contact).perform + expect(contact.reload.contact_type).to eq('visitor') + end + end + + context 'when contact has email or phone number' do + it 'sets contact type to lead' do + contact.email = 'test@test.com' + contact.save + described_class.new(contact).perform + + expect(contact.reload.contact_type).to eq('lead') + end + end + + context 'when contact has social details' do + it 'sets contact type to lead' do + contact.additional_attributes['social_facebook_user_id'] = '123456789' + contact.save + described_class.new(contact).perform + + expect(contact.reload.contact_type).to eq('lead') + end + end + + context 'when location and country code are updated from additional attributes' do + it 'updates location and country code' do + described_class.new(contact).perform + + # Expect location and country code to be updated + expect(contact.reload.location).to eq('New York') + expect(contact.reload.country_code).to eq('US') + end + end + end +end From 476077ab846e046b48fc183625137af2e48ba158 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 15 Mar 2024 11:23:43 +0530 Subject: [PATCH 003/105] fix: Update location component to avoid overflow, handle location title from Telegram payload (#9113) Co-authored-by: Pranav --- .../widgets/conversation/bubble/Location.vue | 90 ++++++++----------- .../telegram/incoming_message_service.rb | 11 +++ .../telegram/incoming_message_service_spec.rb | 24 +++++ 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue index 3b8fcbaa0..e72233b88 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue @@ -1,17 +1,45 @@ + + - - - - diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index d18994a00..a36231634 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -130,6 +130,7 @@ class Telegram::IncomingMessageService @message.attachments.new( account_id: @message.account_id, file_type: :location, + fallback_title: location_fallback_title, coordinates_lat: location['latitude'], coordinates_long: location['longitude'] ) @@ -139,6 +140,16 @@ class Telegram::IncomingMessageService @file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence end + def location_fallback_title + return '' if venue.blank? + + venue[:title] || '' + end + + def venue + @venue ||= params.dig(:message, :venue).presence + end + def location @location ||= params.dig(:message, :location).presence end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 795202894..3cade6343 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -255,6 +255,30 @@ describe Telegram::IncomingMessageService do expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location') end + + it 'creates appropriate conversations, message and contacts if venue is present' do + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'location': { + 'latitude': 37.7893768, + 'longitude': -122.3895553 + }, + venue: { + title: 'San Francisco' + } + }.merge(message_params) + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + + attachment = telegram_channel.inbox.messages.first.attachments.first + expect(attachment.file_type).to eq('location') + expect(attachment.coordinates_lat).to eq(37.7893768) + expect(attachment.coordinates_long).to eq(-122.3895553) + expect(attachment.fallback_title).to eq('San Francisco') + end end context 'when valid callback_query params' do From 89d0b2cb6eb4f65410b2e6ef225ac74685864e7f Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 15 Mar 2024 11:34:14 +0530 Subject: [PATCH 004/105] feat: Add the bot performance reports UI (#9036) Co-authored-by: Pranav Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- app/javascript/dashboard/api/reports.js | 18 +++ .../dashboard/api/specs/reports.spec.js | 34 ++++++ .../layout/config/sidebarItems/reports.js | 10 ++ app/javascript/dashboard/featureFlags.js | 1 + .../dashboard/i18n/locale/en/report.json | 76 +++++++++++-- .../dashboard/i18n/locale/en/settings.json | 1 + .../dashboard/mixins/reportMixin.js | 10 +- .../mixins/specs/reportMixin.spec.js | 31 +++++ .../mixins/specs/reportMixinFixtures.js | 8 ++ .../dashboard/settings/reports/BotReports.vue | 106 ++++++++++++++++++ .../settings/reports/ReportContainer.vue | 2 +- .../reports/components/BotMetrics.vue | 68 +++++++++++ .../components/ChartElements/ChartStats.vue | 6 +- .../reports/components/CsatMetrics.vue | 3 + .../reports/components/ReportMetricCard.vue | 2 +- .../dashboard/settings/reports/constants.js | 2 + .../settings/reports/reports.routes.js | 18 +++ .../dashboard/store/modules/reports.js | 31 +++++ .../dashboard/store/mutation-types.js | 1 + 19 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/BotMetrics.vue diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 987b69701..52fa7f444 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -84,6 +84,24 @@ class ReportsAPI extends ApiClient { params: { since, until, business_hours: businessHours }, }); } + + getBotMetrics({ from, to } = {}) { + return axios.get(`${this.url}/bot_metrics`, { + params: { since: from, until: to }, + }); + } + + getBotSummary({ from, to, groupBy, businessHours } = {}) { + return axios.get(`${this.url}/bot_summary`, { + params: { + since: from, + until: to, + type: 'account', + group_by: groupBy, + business_hours: businessHours, + }, + }); + } } export default new ReportsAPI(); diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index 7822dad8f..05d4a152c 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -111,6 +111,40 @@ describe('#Reports API', () => { }); }); + it('#getBotMetrics', () => { + reportsAPI.getBotMetrics({ from: 1621103400, to: 1621621800 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/bot_metrics', + { + params: { + since: 1621103400, + until: 1621621800, + }, + } + ); + }); + + it('#getBotSummary', () => { + reportsAPI.getBotSummary({ + from: 1621103400, + to: 1621621800, + groupBy: 'date', + businessHours: true, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/bot_summary', + { + params: { + since: 1621103400, + until: 1621621800, + type: 'account', + group_by: 'date', + business_hours: true, + }, + } + ); + }); + it('#getConversationMetric', () => { reportsAPI.getConversationMetric('account'); expect(axiosMock.get).toHaveBeenCalledWith( diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js index 967ee44ed..551256c74 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; const reports = accountId => ({ @@ -6,6 +7,7 @@ const reports = accountId => ({ 'account_overview_reports', 'conversation_reports', 'csat_reports', + 'bot_reports', 'agent_reports', 'label_reports', 'inbox_reports', @@ -33,6 +35,14 @@ const reports = accountId => ({ toState: frontendURL(`accounts/${accountId}/reports/csat`), toStateName: 'csat_reports', }, + { + icon: 'bot', + label: 'REPORTS_BOT', + hasSubMenu: false, + featureFlag: FEATURE_FLAGS.RESPONSE_BOT, + toState: frontendURL(`accounts/${accountId}/reports/bot`), + toStateName: 'bot_reports', + }, { icon: 'people', label: 'REPORTS_AGENT', diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 2936d22ea..f2fd3e757 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -19,4 +19,5 @@ export const FEATURE_FLAGS = { INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply', INBOX_VIEW: 'inbox_view', SLA: 'sla', + RESPONSE_BOT: 'response_bot', }; diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index a6d476bc9..56ca21773 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -35,6 +35,14 @@ "NAME": "Resolution Count", "DESC": "( Total )" }, + "BOT_RESOLUTION_COUNT": { + "NAME": "Resolution Count", + "DESC": "( Total )" + }, + "BOT_HANDOFF_COUNT": { + "NAME": "Handoff Count", + "DESC": "( Total )" + }, "REPLY_TIME": { "NAME": "Customer waiting time", "TOOLTIP_TEXT": "Waiting time is %{metricValue} (based on %{conversationCount} replies)" @@ -86,20 +94,49 @@ "MONTH": "Month", "YEAR": "Year" }, - "GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }], + "GROUP_BY_DAY_OPTIONS": [ + { + "id": 1, + "groupBy": "Day" + } + ], "GROUP_BY_WEEK_OPTIONS": [ - { "id": 1, "groupBy": "Day" }, - { "id": 2, "groupBy": "Week" } + { + "id": 1, + "groupBy": "Day" + }, + { + "id": 2, + "groupBy": "Week" + } ], "GROUP_BY_MONTH_OPTIONS": [ - { "id": 1, "groupBy": "Day" }, - { "id": 2, "groupBy": "Week" }, - { "id": 3, "groupBy": "Month" } + { + "id": 1, + "groupBy": "Day" + }, + { + "id": 2, + "groupBy": "Week" + }, + { + "id": 3, + "groupBy": "Month" + } ], "GROUP_BY_YEAR_OPTIONS": [ - { "id": 2, "groupBy": "Week" }, - { "id": 3, "groupBy": "Month" }, - { "id": 4, "groupBy": "Year" } + { + "id": 2, + "groupBy": "Week" + }, + { + "id": 3, + "groupBy": "Month" + }, + { + "id": 4, + "groupBy": "Year" + } ], "BUSINESS_HOURS": "Business Hours" }, @@ -404,6 +441,27 @@ } } }, + "BOT_REPORTS": { + "HEADER": "Bot Reports", + "METRIC": { + "TOTAL_CONVERSATIONS": { + "LABEL": "No. of Conversations", + "TOOLTIP": "Total number of conversations handled by the bot" + }, + "TOTAL_RESPONSES": { + "LABEL": "Total Responses", + "TOOLTIP": "Total number of responses sent by the bot" + }, + "RESOLUTION_RATE": { + "LABEL": "Resolution Rate", + "TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100" + }, + "HANDOFF_RATE": { + "LABEL": "Handoff Rate", + "TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100" + } + } + }, "OVERVIEW_REPORTS": { "HEADER": "Overview", "LIVE": "Live", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 63725f949..9a4bde2c8 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -234,6 +234,7 @@ "CAMPAIGNS": "Campaigns", "ONGOING": "Ongoing", "ONE_OFF": "One off", + "REPORTS_BOT": "Bot", "REPORTS_AGENT": "Agents", "REPORTS_LABEL": "Labels", "REPORTS_INBOX": "Inbox", diff --git a/app/javascript/dashboard/mixins/reportMixin.js b/app/javascript/dashboard/mixins/reportMixin.js index 2b8a5f87d..d57af1ad2 100644 --- a/app/javascript/dashboard/mixins/reportMixin.js +++ b/app/javascript/dashboard/mixins/reportMixin.js @@ -2,11 +2,19 @@ import { mapGetters } from 'vuex'; import { formatTime } from '@chatwoot/utils'; export default { + props: { + accountSummaryKey: { + type: String, + default: 'getAccountSummary', + }, + }, computed: { ...mapGetters({ - accountSummary: 'getAccountSummary', accountReport: 'getAccountReports', }), + accountSummary() { + return this.$store.getters[this.accountSummaryKey]; + }, }, methods: { calculateTrend(key) { diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js index d981de4e9..c0bc1e15f 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js @@ -11,11 +11,42 @@ describe('reportMixin', () => { beforeEach(() => { getters = { getAccountSummary: () => reportFixtures.summary, + getBotSummary: () => reportFixtures.botSummary, getAccountReports: () => reportFixtures.report, }; store = new Vuex.Store({ getters }); }); + it('display the metric for account', async () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + }; + const wrapper = shallowMount(Component, { store, localVue }); + await wrapper.setProps({ + accountSummaryKey: 'getAccountSummary', + }); + expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000'); + expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual( + '3 Min 18 Sec' + ); + }); + + it('display the metric for bot', async () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + }; + const wrapper = shallowMount(Component, { store, localVue }); + await wrapper.setProps({ + accountSummaryKey: 'getBotSummary', + }); + expect(wrapper.vm.displayMetric('bot_resolutions_count')).toEqual('10'); + expect(wrapper.vm.displayMetric('bot_handoffs_count')).toEqual('20'); + }); + it('display the metric', () => { const Component = { render() {}, diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js index ab6b6fecf..591bf7c1f 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js +++ b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js @@ -15,6 +15,14 @@ export default { }, resolutions_count: 3, }, + botSummary: { + bot_resolutions_count: 10, + bot_handoffs_count: 20, + previous: { + bot_resolutions_count: 8, + bot_handoffs_count: 5, + }, + }, report: { data: [ { value: '0.00', timestamp: 1647541800, count: 0 }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue new file mode 100644 index 000000000..a75051aaf --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue @@ -0,0 +1,106 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue index c601b4277..273f814b7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue @@ -7,7 +7,7 @@ :key="metric.KEY" class="p-4 rounded-md mb-3" > - +
+import { ref, watch, onMounted } from 'vue'; +import ReportMetricCard from './ReportMetricCard.vue'; +import ReportsAPI from 'dashboard/api/reports'; + +const props = defineProps({ + filters: { + type: Object, + required: true, + }, +}); + +const conversationCount = ref('0'); +const messageCount = ref('0'); +const resolutionRate = ref('0'); +const handoffRate = ref('0'); + +const formatToPercent = value => { + return value ? `${value}%` : '--'; +}; + +const fetchMetrics = () => { + if (!props.filters.to || !props.filters.from) { + return; + } + ReportsAPI.getBotMetrics(props.filters).then(response => { + conversationCount.value = response.data.conversation_count.toLocaleString(); + messageCount.value = response.data.message_count.toLocaleString(); + resolutionRate.value = response.data.resolution_rate.toString(); + handoffRate.value = response.data.handoff_rate.toString(); + }); +}; + +watch(() => props.filters, fetchMetrics, { deep: true }); + +onMounted(fetchMetrics); + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue index 94fe1359d..4faf4de2b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue @@ -1,6 +1,8 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue new file mode 100644 index 000000000..a888b5093 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue new file mode 100644 index 000000000..8b3236836 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue @@ -0,0 +1,23 @@ + + diff --git a/app/javascript/shared/components/EmojiOrIcon.vue b/app/javascript/shared/components/EmojiOrIcon.vue index 5cde463fe..2a93dc06f 100644 --- a/app/javascript/shared/components/EmojiOrIcon.vue +++ b/app/javascript/shared/components/EmojiOrIcon.vue @@ -4,6 +4,7 @@ v-else-if="showIcon" :size="iconSize" :icon="icon" + class="flex-shrink-0" :class="className" /> diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index e1235bf51..efff6fddf 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -9,6 +9,7 @@ "arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z", "arrow-download-outline": "M18.25 20.5a.75.75 0 1 1 0 1.5l-13 .004a.75.75 0 1 1 0-1.5l13-.004ZM11.648 2.012l.102-.007a.75.75 0 0 1 .743.648l.007.102-.001 13.685 3.722-3.72a.75.75 0 0 1 .976-.073l.085.073a.75.75 0 0 1 .072.976l-.073.084-4.997 4.997a.75.75 0 0 1-.976.073l-.085-.073-5.003-4.996a.75.75 0 0 1 .976-1.134l.084.072 3.719 3.714L11 2.755a.75.75 0 0 1 .648-.743l.102-.007-.102.007Z", "arrow-expand-outline": "M7.669 14.923a1 1 0 0 1 1.414 1.414l-2.668 2.667H8a1 1 0 0 1 .993.884l.007.116a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 1 1 2 0v1.587l2.669-2.668Zm8.336 6.081a1 1 0 1 1 0-2h1.583l-2.665-2.667a1 1 0 0 1-.083-1.32l.083-.094a1 1 0 0 1 1.414 0l2.668 2.67v-1.589a1 1 0 0 1 .883-.993l.117-.007a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4ZM8 3a1 1 0 0 1 0 2H6.417l2.665 2.668a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.414 0L5 6.412V8a1 1 0 0 1-.883.993L4 9a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4Zm12.005 0a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V6.412l-2.668 2.67a1 1 0 0 1-1.32.083l-.094-.083a1 1 0 0 1 0-1.414L17.589 5h-1.584a1 1 0 0 1-.993-.883L15.005 4a1 1 0 0 1 1-1h4Z", + "arrow-outwards-outline": "m16 8.4l-8.875 8.9q-.3.3-.713.3t-.712-.3q-.3-.3-.3-.713t.3-.712L14.6 7H7q-.425 0-.713-.288T6 6q0-.425.288-.713T7 5h10q.425 0 .713.288T18 6v10q0 .425-.288.713T17 17q-.425 0-.713-.288T16 16V8.4Z", "arrow-redo-outline": "M19.25 2a.75.75 0 0 0-.743.648l-.007.102v5.69l-4.574-4.56a6.41 6.41 0 0 0-8.878-.179l-.186.18a6.41 6.41 0 0 0 0 9.063l8.845 8.84a.75.75 0 0 0 1.06-1.062l-8.845-8.838a4.91 4.91 0 0 1 6.766-7.112l.178.17L17.438 9.5H11.75a.75.75 0 0 0-.743.648L11 10.25c0 .38.282.694.648.743l.102.007h7.5a.75.75 0 0 0 .743-.648L20 10.25v-7.5a.75.75 0 0 0-.75-.75Z", "arrow-right-import-outline": "M21.25 4.5a.75.75 0 0 1 .743.648L22 5.25v13.004a.75.75 0 0 1-1.493.102l-.007-.102V5.25a.75.75 0 0 1 .75-.75Zm-8.603 1.804l.072-.084a.75.75 0 0 1 .977-.073l.084.073l4.997 4.997a.75.75 0 0 1 .073.976l-.073.085l-4.997 5.003a.75.75 0 0 1-1.133-.976l.072-.084l3.711-3.717H2.75a.75.75 0 0 1-.743-.647L2 11.755a.75.75 0 0 1 .648-.743l.102-.007l13.693-.001l-3.724-3.724a.75.75 0 0 1-.072-.976l.072-.084l-.072.084Z", "arrow-reply-outline": "M9.277 16.221a.75.75 0 0 1-1.061 1.06l-4.997-5.003a.75.75 0 0 1 0-1.06L8.217 6.22a.75.75 0 0 1 1.061 1.06L5.557 11h7.842c1.595 0 2.81.242 3.889.764l.246.126a6.203 6.203 0 0 1 2.576 2.576c.61 1.14.89 2.418.89 4.135a.75.75 0 0 1-1.5 0c0-1.484-.228-2.52-.713-3.428a4.702 4.702 0 0 0-1.96-1.96c-.838-.448-1.786-.676-3.094-.709L13.4 12.5H5.562l3.715 3.721Z", @@ -166,6 +167,7 @@ "person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z", "person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z", "play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z", + "plus-sign-outline": "M12 19q-.425 0-.713-.288T11 18v-5H6q-.425 0-.713-.288T5 12q0-.425.288-.713T6 11h5V6q0-.425.288-.713T12 5q.425 0 .713.288T13 6v5h5q.425 0 .713.288T19 12q0 .425-.288.713T18 13h-5v5q0 .425-.288.713T12 19Z", "power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z", "quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z", "repeat-outline": "m14.712 2.289l-.087-.078a1 1 0 0 0-1.327.078l-.078.087a.999.999 0 0 0 .078 1.326l1.299 1.297H8.999l-.24.004A6.997 6.997 0 0 0 2 11.993a6.94 6.94 0 0 0 1.189 3.899a.999.999 0 0 0 1.626-1.163l-.135-.218A4.997 4.997 0 0 1 9 6.998h5.595l-1.297 1.297l-.078.087a.999.999 0 0 0 1.492 1.326l3.006-3.003l.077-.087a.999.999 0 0 0-.078-1.326l-3.005-3.003Zm6.075 5.771A.999.999 0 0 0 19 8.677c0 .209.064.402.172.561a4.997 4.997 0 0 1-4.17 7.75H9.414l1.294-1.29l.083-.096a1 1 0 0 0-.006-1.23l-.077-.088l-.095-.084a1.001 1.001 0 0 0-1.232.006l-.088.078l-3.005 3.003l-.083.095a1 1 0 0 0 .006 1.231l.077.087l3.005 3.003l.095.084a1 1 0 0 0 1.397-1.41l-.077-.087l-1.304-1.303H15l.24-.003a6.997 6.997 0 0 0 5.546-10.927v.003Z", From 762a39330a678d309fb00e32bb7088d290c3ead7 Mon Sep 17 00:00:00 2001 From: Ryan Kon Date: Thu, 21 Mar 2024 06:14:04 -0700 Subject: [PATCH 015/105] fix: use safe nav when downcasing email in from_email (#9139) Use safe nav when downcasing email in from_email --- app/models/contact.rb | 2 +- app/models/user.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/contact.rb b/app/models/contact.rb index a60e9f4d2..95ee69c75 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -169,7 +169,7 @@ class Contact < ApplicationRecord end def self.from_email(email) - find_by(email: email.downcase) + find_by(email: email&.downcase) end private diff --git a/app/models/user.rb b/app/models/user.rb index 3fdaf7f25..faadb3271 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -157,7 +157,7 @@ class User < ApplicationRecord end def self.from_email(email) - find_by(email: email.downcase) + find_by(email: email&.downcase) end private From c51492c6747f06f373e59feec9d167e284c366d8 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 21 Mar 2024 19:30:11 +0530 Subject: [PATCH 016/105] feat: SLA List Item component (#9135) - Base settings list and list item components. - SLA list item component. Fixes: https://linear.app/chatwoot/issue/CW-3126/create-a-sla-list-item-component-with-the-new-design Co-authored-by: Shivam Mishra Co-authored-by: Pranav --- .../dashboard/assets/scss/_layout.scss | 5 +- .../dashboard/i18n/locale/en/sla.json | 14 +++- .../dashboard/settings/SettingsLayout.vue | 6 ++ .../dashboard/settings/SettingsWrapper.vue | 2 +- .../components/BaseSettingsHeader.vue | 8 ++- .../components/BaseSettingsListItem.vue | 53 +++++++++++++++ .../sla/components/SLABusinessHoursLabel.vue | 39 +++++++++++ .../settings/sla/components/SLAListItem.vue | 64 +++++++++++++++++++ .../sla/components/SLAResponseTime.vue | 36 +++++++++++ .../FluentIcon/dashboard-icons.json | 3 + tailwind.config.js | 5 ++ 11 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsListItem.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/sla/components/SLABusinessHoursLabel.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue diff --git a/app/javascript/dashboard/assets/scss/_layout.scss b/app/javascript/dashboard/assets/scss/_layout.scss index ea40c1f3a..54a03c403 100644 --- a/app/javascript/dashboard/assets/scss/_layout.scss +++ b/app/javascript/dashboard/assets/scss/_layout.scss @@ -1,11 +1,10 @@ // scss-lint:disable SpaceAfterPropertyColon -// @import 'shared/assets/fonts/inter'; - +@import 'shared/assets/fonts/inter'; +// Inter, html, body { font-family: 'PlusJakarta', - Inter, -apple-system, system-ui, BlinkMacSystemFont, diff --git a/app/javascript/dashboard/i18n/locale/en/sla.json b/app/javascript/dashboard/i18n/locale/en/sla.json index d2a4f1d2a..dcf8d2dca 100644 --- a/app/javascript/dashboard/i18n/locale/en/sla.json +++ b/app/javascript/dashboard/i18n/locale/en/sla.json @@ -19,7 +19,19 @@ "NRT", "RT", "Business Hours" - ] + ], + "BUSINESS_HOURS_ON": "Business hours on", + "BUSINESS_HOURS_OFF": "Business hours off", + "RESPONSE_TYPES": { + "FRT": "First response time threshold", + "NRT": "Next response time threshold", + "RT": "Resolution time threshold", + "SHORT_HAND": { + "FRT": "FRT", + "NRT": "NRT", + "RT": "RT" + } + } }, "FORM": { "NAME": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue new file mode 100644 index 000000000..6dd3c306b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue @@ -0,0 +1,6 @@ + diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue index 293a093af..3f5bf16f9 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue @@ -9,7 +9,7 @@ defineProps({