From 16282f6a66741dd62dfa56fa4e50173272032c36 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Fri, 29 Mar 2024 20:27:21 +1100 Subject: [PATCH 01/34] feat: Add push/email notification support for SLA (#9140) * feat: update SLA evaluation logic * Update enterprise/app/services/sla/evaluate_applied_sla_service.rb Co-authored-by: Muhsin Keloth * chore: refactor spec to bring down expecations in a single block * chore: fix process_account_applied_sla spec * chore: add spec to test multiple nrt misses * feat: persist sla notifications * feat: revert persist sla notifications * feat: add SLA push/email notification support * chore: refactor sla_status to include active_with_misses * chore: add support for sla push/email notifications * chore: refactor * chore: add liquid templates * chore: add spec for liquid templates * chore: add spec for sla email notifications * chore: add spec for SlaPolicyDrop * chore: refactor to ee namespace * chore: set enterprise test type to mailer * feat: enable sla notification settings only if SLA enabled * chore: refactor * chore: fix spec --------- Co-authored-by: Muhsin Keloth --- .../dashboard/i18n/locale/en/settings.json | 10 +- .../settings/profile/NotificationSettings.vue | 105 ++++++++++++++++++ .../conversation_notifications_mailer.rb | 5 +- app/models/application_record.rb | 8 +- app/models/notification.rb | 4 +- .../sla_missed_first_response.liquid | 10 ++ .../sla_missed_next_response.liquid | 10 ++ .../sla_missed_resolution.liquid | 10 ++ enterprise/app/drops/sla_policy_drop.rb | 9 ++ .../conversation_notifications_mailer.rb | 32 ++++++ .../models/enterprise/application_record.rb | 5 + spec/enterprise/drops/sla_policy_drop_spec.rb | 15 +++ .../conversation_notifications_mailer_spec.rb | 54 +++++++++ 13 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_first_response.liquid create mode 100644 app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_next_response.liquid create mode 100644 app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_resolution.liquid create mode 100644 enterprise/app/drops/sla_policy_drop.rb create mode 100644 enterprise/app/mailers/enterprise/agent_notifications/conversation_notifications_mailer.rb create mode 100644 enterprise/app/models/enterprise/application_record.rb create mode 100644 spec/enterprise/drops/sla_policy_drop_spec.rb create mode 100644 spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 9a4bde2c8..d9834c545 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -83,7 +83,10 @@ "CONVERSATION_CREATION": "Send email notifications when a new conversation is created", "CONVERSATION_MENTION": "Send email notifications when you are mentioned in a conversation", "ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation", - "PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation" + "PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation", + "SLA_MISSED_FIRST_RESPONSE": "Send email notifications when a conversation misses first response SLA", + "SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA", + "SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA" }, "API": { "UPDATE_SUCCESS": "Your notification preferences are updated successfully", @@ -98,7 +101,10 @@ "ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation", "PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in a participating conversation", "HAS_ENABLED_PUSH": "You have enabled push for this browser.", - "REQUEST_PUSH": "Enable push notifications" + "REQUEST_PUSH": "Enable push notifications", + "SLA_MISSED_FIRST_RESPONSE": "Send push notifications when a conversation misses first response SLA", + "SLA_MISSED_NEXT_RESPONSE": "Send push notifications when a conversation misses next response SLA", + "SLA_MISSED_RESOLUTION": "Send push notifications when a conversation misses resolution SLA" }, "PROFILE_IMAGE": { "LABEL": "Profile Image" diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationSettings.vue index 7214d0deb..cdbc40ec4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationSettings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationSettings.vue @@ -236,6 +236,54 @@ }} +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
@@ -367,6 +466,7 @@ import { requestPushPermissions, verifyServiceWorkerExistence, } from '../../../../helper/pushHelper'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; export default { mixins: [alertMixin, configMixin, uiSettingsMixin], @@ -393,13 +493,18 @@ export default { }, computed: { ...mapGetters({ + accountId: 'getCurrentAccountId', emailFlags: 'userNotificationSettings/getSelectedEmailFlags', pushFlags: 'userNotificationSettings/getSelectedPushFlags', uiSettings: 'getUISettings', + isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', }), hasPushAPISupport() { return !!('Notification' in window); }, + isSLAEnabled() { + return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SLA); + }, }, watch: { emailFlags(value) { diff --git a/app/mailers/agent_notifications/conversation_notifications_mailer.rb b/app/mailers/agent_notifications/conversation_notifications_mailer.rb index 874449adf..bc498d43b 100644 --- a/app/mailers/agent_notifications/conversation_notifications_mailer.rb +++ b/app/mailers/agent_notifications/conversation_notifications_mailer.rb @@ -61,7 +61,10 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer user: @agent, conversation: @conversation, inbox: @conversation.inbox, - message: @message + message: @message, + sla_policy: @sla_policy }) end end + +AgentNotifications::ConversationNotificationsMailer.include_mod_with('AgentNotifications::ConversationNotificationsMailer') diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 117950e35..64fc8cebf 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -5,11 +5,13 @@ class ApplicationRecord < ActiveRecord::Base before_validation :validates_column_content_length # the models that exposed in email templates through liquid - DROPPABLES = %w[Account Channel Conversation Inbox User Message].freeze + def droppables + %w[Account Channel Conversation Inbox User Message] + end # ModelDrop class should exist in app/drops def to_drop - return unless DROPPABLES.include?(self.class.name) + return unless droppables.include?(self.class.name) "#{self.class.name}Drop".constantize.new(self) end @@ -47,3 +49,5 @@ class ApplicationRecord < ActiveRecord::Base end end end + +ApplicationRecord.include_mod_with('Enterprise::ApplicationRecord') diff --git a/app/models/notification.rb b/app/models/notification.rb index c71bdbb62..b5834225a 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -118,11 +118,11 @@ class Notification < ApplicationRecord def push_message_body case notification_type - when 'conversation_creation' + when 'conversation_creation', 'sla_missed_first_response' message_body(conversation.messages.first) when 'assigned_conversation_new_message', 'participating_conversation_new_message', 'conversation_mention' message_body(secondary_actor) - when 'conversation_assignment' + when 'conversation_assignment', 'sla_missed_next_response', 'sla_missed_resolution' message_body(conversation.messages.incoming.last) else '' diff --git a/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_first_response.liquid b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_first_response.liquid new file mode 100644 index 000000000..d7988ad5f --- /dev/null +++ b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_first_response.liquid @@ -0,0 +1,10 @@ +

Hi {{user.available_name}},

+ +

+ Conversation #{{conversation.display_id}} in {{ inbox.name }} + has missed the SLA for first response under policy {{ sla_policy.name }}. +

+ +

+Please address immediately. +

diff --git a/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_next_response.liquid b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_next_response.liquid new file mode 100644 index 000000000..d7bf8d445 --- /dev/null +++ b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_next_response.liquid @@ -0,0 +1,10 @@ +

Hi {{user.available_name}},

+ +

+ Conversation #{{conversation.display_id}} in {{ inbox.name }} + has missed the SLA for next response under policy {{ sla_policy.name }}.. +

+ +

+Please address immediately. +

diff --git a/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_resolution.liquid b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_resolution.liquid new file mode 100644 index 000000000..efd24913e --- /dev/null +++ b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_resolution.liquid @@ -0,0 +1,10 @@ +

Hi {{user.available_name}},

+ +

+ Conversation #{{conversation.display_id}} in {{ inbox.name }} + has missed the SLA for resolution time under policy {{ sla_policy.name }}. +

+ +

+Please address immediately. +

diff --git a/enterprise/app/drops/sla_policy_drop.rb b/enterprise/app/drops/sla_policy_drop.rb new file mode 100644 index 000000000..ea9fbe34d --- /dev/null +++ b/enterprise/app/drops/sla_policy_drop.rb @@ -0,0 +1,9 @@ +class SlaPolicyDrop < BaseDrop + def name + @obj.try(:name) + end + + def description + @obj.try(:description) + end +end diff --git a/enterprise/app/mailers/enterprise/agent_notifications/conversation_notifications_mailer.rb b/enterprise/app/mailers/enterprise/agent_notifications/conversation_notifications_mailer.rb new file mode 100644 index 000000000..df71beb10 --- /dev/null +++ b/enterprise/app/mailers/enterprise/agent_notifications/conversation_notifications_mailer.rb @@ -0,0 +1,32 @@ +module Enterprise::AgentNotifications::ConversationNotificationsMailer + def sla_missed_first_response(conversation, agent, sla_policy) + return unless smtp_config_set_or_development? + + @agent = agent + @conversation = conversation + @sla_policy = sla_policy + subject = "Conversation [ID - #{@conversation.display_id}] missed SLA for first response" + @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) + send_mail_with_liquid(to: @agent.email, subject: subject) and return + end + + def sla_missed_next_response(conversation, agent, sla_policy) + return unless smtp_config_set_or_development? + + @agent = agent + @conversation = conversation + @sla_policy = sla_policy + @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) + send_mail_with_liquid(to: @agent.email, subject: "Conversation [ID - #{@conversation.display_id}] missed SLA for next response") and return + end + + def sla_missed_resolution(conversation, agent, sla_policy) + return unless smtp_config_set_or_development? + + @agent = agent + @conversation = conversation + @sla_policy = sla_policy + @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) + send_mail_with_liquid(to: @agent.email, subject: "Conversation [ID - #{@conversation.display_id}] missed SLA for resolution time") and return + end +end diff --git a/enterprise/app/models/enterprise/application_record.rb b/enterprise/app/models/enterprise/application_record.rb new file mode 100644 index 000000000..a05f60767 --- /dev/null +++ b/enterprise/app/models/enterprise/application_record.rb @@ -0,0 +1,5 @@ +module Enterprise::ApplicationRecord + def droppables + super + %w[SlaPolicy] + end +end diff --git a/spec/enterprise/drops/sla_policy_drop_spec.rb b/spec/enterprise/drops/sla_policy_drop_spec.rb new file mode 100644 index 000000000..c1be13c70 --- /dev/null +++ b/spec/enterprise/drops/sla_policy_drop_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe SlaPolicyDrop do + subject(:sla_policy_drop) { described_class.new(sla_policy) } + + let!(:sla_policy) { create(:sla_policy) } + + it 'returns name' do + expect(sla_policy_drop.name).to eq sla_policy.name + end + + it 'returns description' do + expect(sla_policy_drop.description).to eq sla_policy.description + end +end diff --git a/spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb b/spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb new file mode 100644 index 000000000..e5e2b14da --- /dev/null +++ b/spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +# rails helper is using infer filetype to detect rspec type +# so we need to include type: :mailer to make this test work in enterprise namespace +RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :mailer do + let(:class_instance) { described_class.new } + let!(:account) { create(:account) } + let(:agent) { create(:user, email: 'agent1@example.com', account: account) } + let(:conversation) { create(:conversation, assignee: agent, account: account) } + + before do + allow(described_class).to receive(:new).and_return(class_instance) + allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) + end + + describe 'sla_missed_first_response' do + let(:sla_policy) { create(:sla_policy, account: account) } + let(:mail) { described_class.with(account: account).sla_missed_first_response(conversation, agent, sla_policy).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for first response") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([agent.email]) + end + end + + describe 'sla_missed_next_response' do + let(:sla_policy) { create(:sla_policy, account: account) } + let(:mail) { described_class.with(account: account).sla_missed_next_response(conversation, agent, sla_policy).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for next response") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([agent.email]) + end + end + + describe 'sla_missed_resolution' do + let(:sla_policy) { create(:sla_policy, account: account) } + let(:mail) { described_class.with(account: account).sla_missed_resolution(conversation, agent, sla_policy).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for resolution time") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([agent.email]) + end + end +end From 4e28481f274d4a17b5b765444e30595ff2d77a4f Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 1 Apr 2024 23:30:07 +0530 Subject: [PATCH 02/34] feat: Conversation API to return applied_sla and sla_events (#9174) * chore: Add sla_events to push_event_data * chore: Return SLA details in the API * chore: feature lock sla push event data * Update _conversation.json.jbuilder * chore: rubocop fixes --- app/controllers/application_controller.rb | 1 + app/finders/conversation_finder.rb | 9 +++-- app/models/conversation.rb | 2 +- .../conversations/event_data_presenter.rb | 1 + .../partials/_conversation.json.jbuilder | 5 +++ .../api/v1/models/_conversation.json.jbuilder | 2 ++ .../enterprise_accounts_controller.rb | 6 ---- .../application_controller_concern.rb | 12 +++++++ .../finders/enterprise/conversation_finder.rb | 5 +++ enterprise/app/models/applied_sla.rb | 17 ++++++++++ .../conversation.rb} | 5 +-- enterprise/app/models/sla_event.rb | 11 +++++- .../conversations/event_data_presenter.rb | 12 +++++++ .../api/v1/models/_applied_sla.json.jbuilder | 11 ++++++ .../partials/_conversation.json.jbuilder | 10 ++++++ .../accounts/conversations_controller_spec.rb | 34 +++++++++++++++++++ spec/enterprise/models/applied_sla_spec.rb | 21 ++++++++++++ spec/enterprise/models/sla_event_spec.rb | 15 ++++++++ .../event_data_presenter_spec.rb | 29 ++++++++++++++++ .../event_data_presenter_spec.rb | 3 +- 20 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb create mode 100644 enterprise/app/finders/enterprise/conversation_finder.rb rename enterprise/app/models/enterprise/{enterprise_conversation_concern.rb => concerns/conversation.rb} (88%) create mode 100644 enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb create mode 100644 enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder create mode 100644 enterprise/app/views/enterprise/api/v1/conversations/partials/_conversation.json.jbuilder create mode 100644 spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb create mode 100644 spec/enterprise/presenters/conversations/event_data_presenter_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d2960a699..2f389049d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -25,3 +25,4 @@ class ApplicationController < ActionController::Base } end end +ApplicationController.include_mod_with('Concerns::ApplicationControllerConcern') diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index f3c85be7a..44592c201 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -163,10 +163,14 @@ class ConversationFinder params[:page] || 1 end - def conversations - @conversations = @conversations.includes( + def conversations_base_query + @conversations.includes( :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox ) + end + + def conversations + @conversations = conversations_base_query sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc'] @conversations = @conversations.send(sort_by, sort_order) @@ -178,3 +182,4 @@ class ConversationFinder end end end +ConversationFinder.prepend_mod_with('ConversationFinder') diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 513c993da..550c09df1 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -312,5 +312,5 @@ class Conversation < ApplicationRecord end end -Conversation.include_mod_with('EnterpriseConversationConcern') +Conversation.include_mod_with('Concerns::Conversation') Conversation.include_mod_with('SentimentAnalysisHelper') diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb index f739554b3..67c7dc1dd 100644 --- a/app/presenters/conversations/event_data_presenter.rb +++ b/app/presenters/conversations/event_data_presenter.rb @@ -45,3 +45,4 @@ class Conversations::EventDataPresenter < SimpleDelegator } end end +Conversations::EventDataPresenter.prepend_mod_with('Conversations::EventDataPresenter') diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 2e90e073a..a9a580e26 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -1,3 +1,7 @@ +# TODO: Move this into models jbuilder +# Currently the file there is used only for search endpoint. +# Everywhere else we use conversation builder in partials folder + json.meta do json.sender do json.partial! 'api/v1/models/contact', formats: [:json], resource: conversation.contact @@ -48,3 +52,4 @@ 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 +json.partial! 'enterprise/api/v1/conversations/partials/conversation', conversation: conversation if ChatwootApp.enterprise? diff --git a/app/views/api/v1/models/_conversation.json.jbuilder b/app/views/api/v1/models/_conversation.json.jbuilder index 3fa54bcd9..68dfd2c40 100644 --- a/app/views/api/v1/models/_conversation.json.jbuilder +++ b/app/views/api/v1/models/_conversation.json.jbuilder @@ -1,3 +1,5 @@ +# This file is used to render conversation data search API response. + json.id conversation.display_id json.uuid conversation.uuid json.created_at conversation.created_at.to_i diff --git a/enterprise/app/controllers/api/v1/accounts/enterprise_accounts_controller.rb b/enterprise/app/controllers/api/v1/accounts/enterprise_accounts_controller.rb index f3028721c..32182d798 100644 --- a/enterprise/app/controllers/api/v1/accounts/enterprise_accounts_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/enterprise_accounts_controller.rb @@ -1,8 +1,2 @@ class Api::V1::Accounts::EnterpriseAccountsController < Api::V1::Accounts::BaseController - before_action :prepend_view_paths - - # Prepend the view path to the enterprise/app/views won't be available by default - def prepend_view_paths - prepend_view_path 'enterprise/app/views/' - end end diff --git a/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb b/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb new file mode 100644 index 000000000..a44a0eceb --- /dev/null +++ b/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb @@ -0,0 +1,12 @@ +module Enterprise::Concerns::ApplicationControllerConcern + extend ActiveSupport::Concern + + included do + before_action :prepend_view_paths + end + + # Prepend the view path to the enterprise/app/views won't be available by default + def prepend_view_paths + prepend_view_path 'enterprise/app/views/' + end +end diff --git a/enterprise/app/finders/enterprise/conversation_finder.rb b/enterprise/app/finders/enterprise/conversation_finder.rb new file mode 100644 index 000000000..24e6b493d --- /dev/null +++ b/enterprise/app/finders/enterprise/conversation_finder.rb @@ -0,0 +1,5 @@ +module Enterprise::ConversationFinder + def conversations_base_query + current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super + end +end diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb index 111b78e84..8207b2cc0 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -40,6 +40,23 @@ class AppliedSla < ApplicationRecord end } scope :missed, -> { where(sla_status: :missed) } + + def push_event_data + { + id: id, + sla_id: sla_policy_id, + sla_status: sla_status, + created_at: created_at.to_i, + updated_at: updated_at.to_i, + sla_description: sla_policy.description, + sla_name: sla_policy.name, + sla_first_response_time_threshold: sla_policy.first_response_time_threshold, + sla_next_response_time_threshold: sla_policy.next_response_time_threshold, + sla_only_during_business_hours: sla_policy.only_during_business_hours, + sla_resolution_time_threshold: sla_policy.resolution_time_threshold + } + end + private def ensure_account_id diff --git a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb b/enterprise/app/models/enterprise/concerns/conversation.rb similarity index 88% rename from enterprise/app/models/enterprise/enterprise_conversation_concern.rb rename to enterprise/app/models/enterprise/concerns/conversation.rb index 721f069dc..f03d643f4 100644 --- a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb +++ b/enterprise/app/models/enterprise/concerns/conversation.rb @@ -1,9 +1,10 @@ -module Enterprise::EnterpriseConversationConcern +module Enterprise::Concerns::Conversation extend ActiveSupport::Concern included do belongs_to :sla_policy, optional: true - has_one :applied_sla, dependent: :destroy + has_one :applied_sla, dependent: :destroy_async + has_many :sla_events, dependent: :destroy_async before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? } around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? } end diff --git a/enterprise/app/models/sla_event.rb b/enterprise/app/models/sla_event.rb index 59068684e..1ba18f01a 100644 --- a/enterprise/app/models/sla_event.rb +++ b/enterprise/app/models/sla_event.rb @@ -31,9 +31,18 @@ class SlaEvent < ApplicationRecord enum event_type: { frt: 0, nrt: 1, rt: 2 } before_validation :ensure_applied_sla_id, :ensure_account_id, :ensure_inbox_id, :ensure_sla_policy_id - after_create_commit :create_notifications + def push_event_data + { + id: id, + event_type: event_type, + meta: meta, + created_at: created_at.to_i, + updated_at: updated_at.to_i + } + end + private def ensure_applied_sla_id diff --git a/enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb b/enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb new file mode 100644 index 000000000..686b05dc2 --- /dev/null +++ b/enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb @@ -0,0 +1,12 @@ +module Enterprise::Conversations::EventDataPresenter + def push_data + if account.feature_enabled?('sla') + super.merge( + applied_sla: applied_sla&.push_event_data, + sla_events: sla_events.map(&:push_event_data) + ) + else + super + end + end +end diff --git a/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder b/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder new file mode 100644 index 000000000..e7f1c49fc --- /dev/null +++ b/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder @@ -0,0 +1,11 @@ +json.id resource.id +json.sla_id resource.sla_policy_id +json.sla_status resource.sla_status +json.created_at resource.created_at.to_i +json.updated_at resource.updated_at.to_i +json.sla_description resource.sla_policy.description +json.sla_name resource.sla_policy.name +json.sla_first_response_time_threshold resource.sla_policy.first_response_time_threshold +json.sla_next_response_time_threshold resource.sla_policy.next_response_time_threshold +json.sla_only_during_business_hours resource.sla_policy.only_during_business_hours +json.sla_resolution_time_threshold resource.sla_policy.resolution_time_threshold diff --git a/enterprise/app/views/enterprise/api/v1/conversations/partials/_conversation.json.jbuilder b/enterprise/app/views/enterprise/api/v1/conversations/partials/_conversation.json.jbuilder new file mode 100644 index 000000000..5a390a68b --- /dev/null +++ b/enterprise/app/views/enterprise/api/v1/conversations/partials/_conversation.json.jbuilder @@ -0,0 +1,10 @@ +if conversation.account.feature_enabled?('sla') + json.applied_sla do + json.partial! 'api/v1/models/applied_sla', formats: [:json], resource: conversation.applied_sla if conversation.applied_sla.present? + end + json.sla_events do + json.array! conversation.sla_events do |sla_event| + json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event + end + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb new file mode 100644 index 000000000..4d5269cde --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe 'Conversations API', type: :request do + let(:account) { create(:account) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + describe 'GET /api/v1/accounts/{account.id}/conversations/:id' do + it 'returns SLA data for the conversation if the feature is enabled' do + account.enable_features!('sla') + conversation = create(:conversation, account: account) + applied_sla = create(:applied_sla, conversation: conversation) + sla_event = create(:sla_event, conversation: conversation, applied_sla: applied_sla) + + get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['applied_sla']['id']).to eq(applied_sla.id) + expect(response.parsed_body['sla_events'].first['id']).to eq(sla_event.id) + end + + it 'does not return SLA data for the conversation if the feature is disabled' do + account.disable_features!('sla') + conversation = create(:conversation, account: account) + create(:applied_sla, conversation: conversation) + create(:sla_event, conversation: conversation) + + get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.keys).not_to include('applied_sla') + expect(response.parsed_body.keys).not_to include('sla_events') + end + end +end diff --git a/spec/enterprise/models/applied_sla_spec.rb b/spec/enterprise/models/applied_sla_spec.rb index af73395a6..a1433f6a2 100644 --- a/spec/enterprise/models/applied_sla_spec.rb +++ b/spec/enterprise/models/applied_sla_spec.rb @@ -7,6 +7,27 @@ RSpec.describe AppliedSla, type: :model do it { is_expected.to belong_to(:conversation) } end + describe 'push_event_data' do + it 'returns the correct hash' do + applied_sla = create(:applied_sla) + expect(applied_sla.push_event_data).to eq( + { + id: applied_sla.id, + sla_id: applied_sla.sla_policy_id, + sla_status: applied_sla.sla_status, + created_at: applied_sla.created_at.to_i, + updated_at: applied_sla.updated_at.to_i, + sla_description: applied_sla.sla_policy.description, + sla_name: applied_sla.sla_policy.name, + sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold, + sla_next_response_time_threshold: applied_sla.sla_policy.next_response_time_threshold, + sla_only_during_business_hours: applied_sla.sla_policy.only_during_business_hours, + sla_resolution_time_threshold: applied_sla.sla_policy.resolution_time_threshold + } + ) + end + end + describe 'validates_factory' do it 'creates valid applied sla policy object' do applied_sla = create(:applied_sla) diff --git a/spec/enterprise/models/sla_event_spec.rb b/spec/enterprise/models/sla_event_spec.rb index 3c44d3961..8a839d626 100644 --- a/spec/enterprise/models/sla_event_spec.rb +++ b/spec/enterprise/models/sla_event_spec.rb @@ -9,6 +9,21 @@ RSpec.describe SlaEvent, type: :model do it { is_expected.to belong_to(:inbox) } end + describe 'push_event_data' do + it 'returns the correct hash' do + sla_event = create(:sla_event) + expect(sla_event.push_event_data).to eq( + { + id: sla_event.id, + event_type: 'frt', + meta: sla_event.meta, + created_at: sla_event.created_at.to_i, + updated_at: sla_event.updated_at.to_i + } + ) + end + end + describe 'validates_factory' do it 'creates valid sla event object' do sla_event = create(:sla_event) diff --git a/spec/enterprise/presenters/conversations/event_data_presenter_spec.rb b/spec/enterprise/presenters/conversations/event_data_presenter_spec.rb new file mode 100644 index 000000000..f9897c96d --- /dev/null +++ b/spec/enterprise/presenters/conversations/event_data_presenter_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Conversations::EventDataPresenter do + let!(:presenter) { described_class.new(conversation) } + let!(:conversation) { create(:conversation) } + let!(:applied_sla) { create(:applied_sla, conversation: conversation) } + let!(:sla_event) { create(:sla_event, conversation: conversation, applied_sla: applied_sla) } + + describe '#push_data' do + it 'returns push event payload with applied sla & sla events if the feature is enabled' do + conversation.account.enable_features!('sla') + + expect(presenter.push_data).to include( + { + applied_sla: applied_sla.push_event_data, + sla_events: [sla_event.push_event_data] + } + ) + end + + it 'returns push event payload without applied sla & sla events if the feature is disabled' do + conversation.account.disable_features!('sla') + + expect(presenter.push_data).not_to include(:applied_sla, :sla_events) + end + end +end diff --git a/spec/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb index 557d2b95c..f6a400b96 100644 --- a/spec/presenters/conversations/event_data_presenter_spec.rb +++ b/spec/presenters/conversations/event_data_presenter_spec.rb @@ -38,7 +38,8 @@ RSpec.describe Conversations::EventDataPresenter do end it 'returns push event payload' do - expect(presenter.push_data).to eq(expected_data) + # the exceptions are the values that would be added in enterprise edition. + expect(presenter.push_data.except(:applied_sla, :sla_events)).to include(expected_data) end end end From 631598b6b6a2262fab552c5e12ca4c5782f2f5d7 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 2 Apr 2024 15:04:10 +0530 Subject: [PATCH 03/34] chore: Fix twilio inbox create transaction rollback (#9181) chore: Fix twilio create transaction --- .../channels/twilio_channels_controller.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index d2d51baef..ebf8e49dd 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -2,13 +2,9 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: before_action :authorize_request def create - ActiveRecord::Base.transaction do - authenticate_twilio - build_inbox - setup_webhooks if @twilio_channel.sms? - rescue StandardError => e - render_could_not_create_error(e.message) - end + process_create + rescue StandardError => e + render_could_not_create_error(e.message) end private @@ -17,6 +13,14 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: authorize ::Inbox end + def process_create + ActiveRecord::Base.transaction do + authenticate_twilio + build_inbox + setup_webhooks if @twilio_channel.sms? + end + end + def authenticate_twilio client = if permitted_params[:api_key_sid].present? Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid]) From fc25f4344874faab2b97c7ce201d33aa47c2f97a Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 3 Apr 2024 03:38:19 +0530 Subject: [PATCH 04/34] feat: Add SLA reports overview component (#9167) --- .../dashboard/i18n/locale/en/report.json | 13 ++++++ .../reports/components/SLA/SLAMetricCard.vue | 46 +++++++++++++++++++ .../reports/components/SLA/SLAMetrics.vue | 40 ++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAMetricCard.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAMetrics.vue diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 56ca21773..c41e8b8a1 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -505,5 +505,18 @@ "THURSDAY": "Thursday", "FRIDAY": "Friday", "SATURDAY": "Saturday" + }, + "SLA_REPORTS": { + "HEADER": "SLA Reports", + "METRICS": { + "HIT_RATE": { + "LABEL": "Hit Rate", + "TOOLTIP": "Percentage of SLAs created were completed successfully" + }, + "NO_OF_BREACHES": { + "LABEL": "Number of Breaches", + "TOOLTIP": "The total SLA breaches in a certain period." + } + } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAMetricCard.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAMetricCard.vue new file mode 100644 index 000000000..78e068d6c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAMetricCard.vue @@ -0,0 +1,46 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAMetrics.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAMetrics.vue new file mode 100644 index 000000000..2abc1c601 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAMetrics.vue @@ -0,0 +1,40 @@ + + + From 727fa677352f805f516bd2e68bf10e45dc875752 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 3 Apr 2024 12:53:31 +0530 Subject: [PATCH 05/34] feat: SLA reports store (#9185) - Added sla reports actions, getters and mutations. --- app/javascript/dashboard/api/slaReports.js | 72 ++++++++++++++ .../dashboard/api/specs/slaReports.spec.js | 98 ++++++++++++++++++ app/javascript/dashboard/store/index.js | 2 + .../dashboard/store/modules/SLAReports.js | 99 +++++++++++++++++++ .../modules/specs/slaReports/actions.spec.js | 55 +++++++++++ .../modules/specs/slaReports/fixtures.js | 52 ++++++++++ .../modules/specs/slaReports/getters.spec.js | 24 +++++ .../specs/slaReports/mutations.spec.js | 49 +++++++++ .../dashboard/store/mutation-types.js | 6 ++ 9 files changed, 457 insertions(+) create mode 100644 app/javascript/dashboard/api/slaReports.js create mode 100644 app/javascript/dashboard/api/specs/slaReports.spec.js create mode 100644 app/javascript/dashboard/store/modules/SLAReports.js create mode 100644 app/javascript/dashboard/store/modules/specs/slaReports/actions.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/slaReports/fixtures.js create mode 100644 app/javascript/dashboard/store/modules/specs/slaReports/getters.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/slaReports/mutations.spec.js diff --git a/app/javascript/dashboard/api/slaReports.js b/app/javascript/dashboard/api/slaReports.js new file mode 100644 index 000000000..c187c58a3 --- /dev/null +++ b/app/javascript/dashboard/api/slaReports.js @@ -0,0 +1,72 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SLAReportsAPI extends ApiClient { + constructor() { + super('applied_slas', { accountScoped: true }); + } + + get({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + page, + } = {}) { + return axios.get(this.url, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + page, + }, + }); + } + + download({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + } = {}) { + return axios.get(`${this.url}/download`, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + }, + }); + } + + getMetrics({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + } = {}) { + return axios.get(`${this.url}/metrics`, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + }, + }); + } +} + +export default new SLAReportsAPI(); diff --git a/app/javascript/dashboard/api/specs/slaReports.spec.js b/app/javascript/dashboard/api/specs/slaReports.spec.js new file mode 100644 index 000000000..51ac8bfe4 --- /dev/null +++ b/app/javascript/dashboard/api/specs/slaReports.spec.js @@ -0,0 +1,98 @@ +import SLAReportsAPI from '../slaReports'; +import ApiClient from '../ApiClient'; + +describe('#SLAReports API', () => { + it('creates correct instance', () => { + expect(SLAReportsAPI).toBeInstanceOf(ApiClient); + expect(SLAReportsAPI.apiVersion).toBe('/api/v1'); + expect(SLAReportsAPI).toHaveProperty('get'); + expect(SLAReportsAPI).toHaveProperty('getMetrics'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get', () => { + SLAReportsAPI.get({ + page: 1, + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', { + params: { + page: 1, + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + }, + }); + }); + it('#getMetrics', () => { + SLAReportsAPI.getMetrics({ + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/applied_slas/metrics', + { + params: { + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + }, + } + ); + }); + it('#download', () => { + SLAReportsAPI.download({ + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/applied_slas/download', + { + params: { + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + }, + } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 91b84ddd1..2f8a9c1e5 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -44,6 +44,7 @@ import teams from './modules/teams'; import userNotificationSettings from './modules/userNotificationSettings'; import webhooks from './modules/webhooks'; import draftMessages from './modules/draftMessages'; +import SLAReports from './modules/SLAReports'; import LogRocket from 'logrocket'; import createPlugin from 'logrocket-vuex'; @@ -111,6 +112,7 @@ export default new Vuex.Store({ webhooks, draftMessages, sla, + slaReports: SLAReports, }, plugins, }); diff --git a/app/javascript/dashboard/store/modules/SLAReports.js b/app/javascript/dashboard/store/modules/SLAReports.js new file mode 100644 index 000000000..44c11fd0c --- /dev/null +++ b/app/javascript/dashboard/store/modules/SLAReports.js @@ -0,0 +1,99 @@ +import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import types from '../mutation-types'; +import SLAReportsAPI from '../../api/slaReports'; + +export const state = { + records: [], + metrics: { + numberOfSLABreaches: 0, + hitRate: '0%', + }, + uiFlags: { + isFetching: false, + isFetchingMetrics: false, + }, + meta: { + count: 0, + currentPage: 1, + }, +}; + +export const getters = { + getAll(_state) { + return _state.records; + }, + getMeta(_state) { + return _state.meta; + }, + getMetrics(_state) { + return _state.metrics; + }, + getUIFlags(_state) { + return _state.uiFlags; + }, +}; + +export const actions = { + get: async function getResponses({ commit }, params) { + commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetching: true }); + try { + const response = await SLAReportsAPI.get(params); + const { payload, meta } = response.data; + + commit(types.SET_SLA_REPORTS, payload); + commit(types.SET_SLA_REPORTS_META, meta); + } catch (error) { + throw new Error(error); + } finally { + commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetching: false }); + } + }, + getMetrics: async function getMetrics({ commit }, params) { + commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: true }); + try { + const response = await SLAReportsAPI.getMetrics(params); + commit(types.SET_SLA_REPORTS_METRICS, response.data); + } catch (error) { + // Ignore error + } finally { + commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: false }); + } + }, +}; + +export const mutations = { + [types.SET_SLA_REPORTS_UI_FLAG](_state, data) { + _state.uiFlags = { + ..._state.uiFlags, + ...data, + }; + }, + + [types.SET_SLA_REPORTS]: MutationHelpers.set, + [types.SET_SLA_REPORTS_METRICS]( + _state, + { number_of_sla_breaches: numberOfSLABreaches, hit_rate: hitRate } + ) { + _state.metrics = { + numberOfSLABreaches, + hitRate, + }; + }, + [types.SET_SLA_REPORTS_META]( + _state, + { total_applied_slas: totalAppliedSLAs, current_page: currentPage } + ) { + _state.meta = { + count: totalAppliedSLAs, + currentPage, + }; + }, +}; + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +}; diff --git a/app/javascript/dashboard/store/modules/specs/slaReports/actions.spec.js b/app/javascript/dashboard/store/modules/specs/slaReports/actions.spec.js new file mode 100644 index 000000000..fb350c937 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/slaReports/actions.spec.js @@ -0,0 +1,55 @@ +import axios from 'axios'; +import { actions } from '../../SLAReports'; +import appliedSlas from './fixtures'; +import types from '../../../mutation-types'; + +const commit = jest.fn(); +global.axios = axios; +jest.mock('axios'); + +describe('#actions', () => { + describe('#get', () => { + it('sends correct actions if API is success', async () => { + axios.get.mockResolvedValue({ + data: { payload: appliedSlas, meta: { count: 1 } }, + }); + await actions.get({ commit }, {}); + expect(commit.mock.calls).toEqual([ + [types.SET_SLA_REPORTS_UI_FLAG, { isFetching: true }], + [types.SET_SLA_REPORTS, appliedSlas], + [types.SET_SLA_REPORTS_META, { count: 1 }], + [types.SET_SLA_REPORTS_UI_FLAG, { isFetching: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Incorrect header' }); + await expect(actions.get({ commit }, { teamId: 1 })).rejects.toThrow( + Error + ); + expect(commit.mock.calls).toEqual([ + [types.SET_SLA_REPORTS_UI_FLAG, { isFetching: true }], + [types.SET_SLA_REPORTS_UI_FLAG, { isFetching: false }], + ]); + }); + }); + + describe('#getMetrics', () => { + it('sends correct actions if API is success', async () => { + axios.get.mockResolvedValue({ data: { metrics: { count: 1 } } }); + await actions.getMetrics({ commit }, {}); + expect(commit.mock.calls).toEqual([ + [types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: true }], + [types.SET_SLA_REPORTS_METRICS, { metrics: { count: 1 } }], + [types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Incorrect header' }); + await actions.getMetrics({ commit }, { teamId: 1 }); + expect(commit.mock.calls).toEqual([ + [types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: true }], + [types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: false }], + ]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/slaReports/fixtures.js b/app/javascript/dashboard/store/modules/specs/slaReports/fixtures.js new file mode 100644 index 000000000..799ad74a7 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/slaReports/fixtures.js @@ -0,0 +1,52 @@ +export default [ + { + id: 23, + sla_policy_id: 7, + conversation_id: 152, + sla_status: 'active_with_misses', + created_at: '2024-03-31T07:50:53.518Z', + updated_at: '2024-03-31T07:55:06.451Z', + conversation: { + id: 152, + uuid: '2f9a988d-418f-47d9-b4dc-c441f28da7c2', + account_id: 1, + }, + sla_events: [ + { + id: 14, + event_type: 'frt', + meta: {}, + updated_at: 1711871706, + created_at: 1711871706, + }, + { + id: 15, + event_type: 'rt', + meta: {}, + updated_at: 1711871706, + created_at: 1711871706, + }, + ], + }, + { + id: 24, + sla_policy_id: 7, + conversation_id: 153, + sla_status: 'active_with_misses', + created_at: '2024-03-31T07:57:49.659Z', + updated_at: '2024-03-31T08:00:31.627Z', + conversation: { + id: 153, + uuid: 'd5d97961-4341-469e-accf-f13f25a14c3c', + }, + sla_events: [ + { + id: 16, + event_type: 'rt', + meta: {}, + updated_at: 1711872031, + created_at: 1711872031, + }, + ], + }, +]; diff --git a/app/javascript/dashboard/store/modules/specs/slaReports/getters.spec.js b/app/javascript/dashboard/store/modules/specs/slaReports/getters.spec.js new file mode 100644 index 000000000..6eddb3f60 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/slaReports/getters.spec.js @@ -0,0 +1,24 @@ +import { getters } from '../../SLAReports'; +import appliedSlas from './fixtures'; + +describe('#getters', () => { + it('getAppliedSlas', () => { + const state = { + records: [appliedSlas[0]], + }; + expect(getters.getAll(state)).toEqual([appliedSlas[0]]); + }); + + it('getUIFlags', () => { + const state = { + uiFlags: { + isFetching: false, + isFetchingMetrics: false, + }, + }; + expect(getters.getUIFlags(state)).toEqual({ + isFetching: false, + isFetchingMetrics: false, + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/slaReports/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/slaReports/mutations.spec.js new file mode 100644 index 000000000..04e171889 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/slaReports/mutations.spec.js @@ -0,0 +1,49 @@ +import { mutations } from '../../SLAReports'; +import appliedSlas from './fixtures'; +import types from '../../../mutation-types'; + +describe('#mutations', () => { + describe('#SET_SLA_REPORTS', () => { + it('Adds sla reports', () => { + const state = { records: {} }; + mutations[types.SET_SLA_REPORTS](state, appliedSlas); + expect(state.records).toEqual(appliedSlas); + }); + }); + + describe('#SET_SLA_REPORTS_UI_FLAG', () => { + it('set ui flags', () => { + const state = { uiFlags: {} }; + mutations[types.SET_SLA_REPORTS_UI_FLAG](state, { isFetching: true }); + expect(state.uiFlags).toEqual({ isFetching: true }); + }); + }); + + describe('#SET_SLA_REPORTS_METRICS', () => { + it('set metrics', () => { + const state = { metrics: {} }; + mutations[types.SET_SLA_REPORTS_METRICS](state, { + number_of_sla_breaches: 1, + hit_rate: '100%', + }); + expect(state.metrics).toEqual({ + numberOfSLABreaches: 1, + hitRate: '100%', + }); + }); + }); + + describe('#SET_SLA_REPORTS_META', () => { + it('set meta', () => { + const state = { meta: {} }; + mutations[types.SET_SLA_REPORTS_META](state, { + total_applied_slas: 1, + current_page: 1, + }); + expect(state.meta).toEqual({ + count: 1, + currentPage: 1, + }); + }); + }); +}); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 6f7d36cf7..c21fb44e2 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -309,4 +309,10 @@ export default { ADD_SLA: 'ADD_SLA', EDIT_SLA: 'EDIT_SLA', DELETE_SLA: 'DELETE_SLA', + + // SLA Reports + SET_SLA_REPORTS_UI_FLAG: 'SET_SLA_REPORTS_UI_FLAG', + SET_SLA_REPORTS: 'SET_SLA_REPORTS', + SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS', + SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META', }; From 6b7a707fef6007e268d16e2c5e117b45ab5e4eb2 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 3 Apr 2024 20:22:46 +0530 Subject: [PATCH 06/34] chore: Security upgrade omniauth-google-oauth2 from 1.1.1 to 1.1.2 (#9173) fix: Gemfile & Gemfile.lock to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-RUBY-RACK-1061917 Co-authored-by: snyk-bot --- Gemfile | 2 +- Gemfile.lock | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/Gemfile b/Gemfile index 59bfc5d7f..7f44af03d 100644 --- a/Gemfile +++ b/Gemfile @@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1' # need for google auth gem 'omniauth', '>= 2.1.2' -gem 'omniauth-google-oauth2' +gem 'omniauth-google-oauth2', '>= 1.1.2' gem 'omniauth-rails_csrf_protection', '~> 1.0' ## Gems for reponse bot diff --git a/Gemfile.lock b/Gemfile.lock index 41cb0f20b..0f1595a72 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -237,9 +237,8 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.7.4) - faraday-net_http (>= 2.0, < 3.1) - ruby2_keywords (>= 0.0.4) + faraday (2.9.0) + faraday-net_http (>= 2.0, < 3.2) faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) faraday-mashify (0.1.1) @@ -247,7 +246,8 @@ GEM hashie faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.0.2) + faraday-net_http (3.1.0) + net-http faraday-net_http_persistent (2.1.0) faraday (~> 2.5) net-http-persistent (~> 4.0) @@ -394,7 +394,8 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) - jwt (2.7.0) + jwt (2.8.1) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -470,6 +471,8 @@ GEM multipart-post (2.3.0) neighbor (0.2.3) activerecord (>= 5.2) + net-http (0.4.1) + uri net-http-persistent (4.0.2) connection_pool (~> 2.2) net-imap (0.4.9) @@ -515,11 +518,11 @@ GEM hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-google-oauth2 (1.1.1) + omniauth-google-oauth2 (1.1.2) jwt (>= 2.0) - oauth2 (~> 2.0.6) + oauth2 (~> 2.0) omniauth (~> 2.0) - omniauth-oauth2 (~> 1.8.0) + omniauth-oauth2 (~> 1.8) omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) @@ -559,7 +562,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.7.3) - rack (2.2.8.1) + rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.4.0) @@ -568,7 +571,8 @@ GEM rack (>= 2.0.0) rack-mini-profiler (3.2.0) rack (>= 1.2.0) - rack-protection (3.1.0) + rack-protection (3.2.0) + base64 (>= 0.1.0) rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.6) rack @@ -790,11 +794,12 @@ GEM unf_ext (0.0.8.2) unicode-display_width (2.4.2) uniform_notifier (1.16.0) + uri (0.13.0) uri_template (0.7.0) valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) - version_gem (1.1.3) + version_gem (1.1.4) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -907,7 +912,7 @@ DEPENDENCIES newrelic-sidekiq-metrics (>= 1.6.2) newrelic_rpm omniauth (>= 2.1.2) - omniauth-google-oauth2 + omniauth-google-oauth2 (>= 1.1.2) omniauth-oauth2 omniauth-rails_csrf_protection (~> 1.0) pg From e21d7552d3805d9d451e1ce9a365cb44193571b4 Mon Sep 17 00:00:00 2001 From: Jaideep Guntupalli <63718527+JaideepGuntupalli@users.noreply.github.com> Date: Wed, 3 Apr 2024 21:33:23 +0530 Subject: [PATCH 07/34] feat: extending lock to single conversation to meta inbox (#9104) This change introduces the ability to lock conversations to a single thread for Instagram and facebook messages within the Meta inbox, mirroring existing functionality in WhatsApp and SMS inboxes. Co-authored-by: Shivam Mishra --- .../messages/facebook/message_builder.rb | 18 +++- .../messages/instagram/message_builder.rb | 25 ++++- .../dashboard/settings/inbox/Settings.vue | 4 +- .../messages/facebook/message_builder_spec.rb | 86 +++++++++++++++++ .../instagram/message_builder_spec.rb | 92 +++++++++++++++++++ .../incoming_fb_text_message.rb | 15 +++ 6 files changed, 235 insertions(+), 5 deletions(-) diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index fec298bce..2c55922f6 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -53,7 +53,23 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end def conversation - @conversation ||= Conversation.find_by(conversation_params) || build_conversation + @conversation ||= set_conversation_based_on_inbox_config + end + + def set_conversation_based_on_inbox_config + if @inbox.lock_to_single_conversation + Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation + else + find_or_build_for_multiple_conversations + end + end + + def find_or_build_for_multiple_conversations + # If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved + last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first + return build_conversation if last_conversation.nil? + + last_conversation end def build_conversation diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index e9debb767..7f1b9cab2 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -69,9 +69,28 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder end def conversation - @conversation ||= Conversation.where(conversation_params).find_by( - "additional_attributes ->> 'type' = 'instagram_direct_message'" - ) || build_conversation + @conversation ||= set_conversation_based_on_inbox_config + end + + def instagram_direct_message_conversation + Conversation.where(conversation_params) + .where("additional_attributes ->> 'type' = 'instagram_direct_message'") + end + + def set_conversation_based_on_inbox_config + if @inbox.lock_to_single_conversation + instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation + else + find_or_build_for_multiple_conversations + end + end + + def find_or_build_for_multiple_conversations + last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first + + return build_conversation if last_conversation.nil? + + last_conversation end def message_content diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 87b6e6724..8823e593f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -589,7 +589,9 @@ export default { return this.inbox.name; }, canLocktoSingleConversation() { - return this.isASmsInbox || this.isAWhatsAppChannel; + return ( + this.isASmsInbox || this.isAWhatsAppChannel || this.isAFacebookInbox + ); }, inboxNameLabel() { if (this.isAWebWidgetInbox) { diff --git a/spec/builders/messages/facebook/message_builder_spec.rb b/spec/builders/messages/facebook/message_builder_spec.rb index 8219adaa7..4b94c9be4 100644 --- a/spec/builders/messages/facebook/message_builder_spec.rb +++ b/spec/builders/messages/facebook/message_builder_spec.rb @@ -58,5 +58,91 @@ describe Messages::Facebook::MessageBuilder do expect(facebook_channel.inbox.reload.contacts.count).to eq(1) expect(contact.name).to eq(default_name) end + + context 'when lock to single conversation' do + subject(:mocked_message_builder) do + described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform + end + + let!(:mocked_message_object) { build(:mocked_message_text, sender_id: contact_inbox.source_id).to_json } + let!(:mocked_incoming_fb_text_message) { Integrations::Facebook::MessageParser.new(mocked_message_object) } + let(:contact) { create(:contact, name: 'Jane Dae') } + let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: facebook_channel.inbox.id) } + + context 'when lock to single conversation is disabled' do + before do + facebook_channel.inbox.update!(lock_to_single_conversation: false) + stub_request(:get, /graph.facebook.com/) + end + + it 'creates a new conversation if existing conversation is not present' do + inital_count = Conversation.count + + mocked_message_builder + + facebook_channel.inbox.reload + + expect(facebook_channel.inbox.conversations.count).to eq(1) + expect(Conversation.count).to eq(inital_count + 1) + end + + it 'will not create a new conversation if last conversation is not resolved' do + existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id, + contact_id: contact.id, contact_inbox_id: contact_inbox.id, + status: :open) + + mocked_message_builder + + facebook_channel.inbox.reload + + expect(facebook_channel.inbox.conversations.last.id).to eq(existing_conversation.id) + end + + it 'creates a new conversation if last conversation is resolved' do + existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id, + contact_id: contact.id, contact_inbox_id: contact_inbox.id, status: :resolved) + + inital_count = Conversation.count + + mocked_message_builder + + facebook_channel.inbox.reload + + expect(facebook_channel.inbox.conversations.last.id).not_to eq(existing_conversation.id) + expect(Conversation.count).to eq(inital_count + 1) + end + end + + context 'when lock to single conversation is enabled' do + before do + facebook_channel.inbox.update!(lock_to_single_conversation: true) + stub_request(:get, /graph.facebook.com/) + end + + it 'creates a new conversation if existing conversation is not present' do + inital_count = Conversation.count + mocked_message_builder + + facebook_channel.inbox.reload + + expect(facebook_channel.inbox.conversations.count).to eq(1) + expect(Conversation.count).to eq(inital_count + 1) + end + + it 'reopens last conversation if last conversation exists' do + existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id, + contact_id: contact.id, contact_inbox_id: contact_inbox.id) + + inital_count = Conversation.count + + mocked_message_builder + + facebook_channel.inbox.reload + + expect(facebook_channel.inbox.conversations.last.id).to eq(existing_conversation.id) + expect(Conversation.count).to eq(inital_count) + end + end + end end end diff --git a/spec/builders/messages/instagram/message_builder_spec.rb b/spec/builders/messages/instagram/message_builder_spec.rb index 618b8bdd6..3db393dab 100644 --- a/spec/builders/messages/instagram/message_builder_spec.rb +++ b/spec/builders/messages/instagram/message_builder_spec.rb @@ -183,4 +183,96 @@ describe Messages::Instagram::MessageBuilder do expect(contact.name).to eq('Jane Dae') end end + + context 'when lock to single conversation is disabled' do + before do + instagram_inbox.update!(lock_to_single_conversation: false) + stub_request(:get, /graph.facebook.com/) + end + + it 'creates a new conversation if existing conversation is not present' do + inital_count = Conversation.count + message = dm_params[:entry][0]['messaging'][0] + contact_inbox + + described_class.new(message, instagram_inbox).perform + + instagram_inbox.reload + contact_inbox.reload + + expect(instagram_inbox.conversations.count).to eq(1) + expect(Conversation.count).to eq(inital_count + 1) + end + + it 'will not create a new conversation if last conversation is not resolved' do + existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :open, + additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }) + + message = dm_params[:entry][0]['messaging'][0] + contact_inbox + + described_class.new(message, instagram_inbox).perform + + instagram_inbox.reload + contact_inbox.reload + + expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id) + end + + it 'creates a new conversation if last conversation is resolved' do + existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved, + additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }) + + inital_count = Conversation.count + message = dm_params[:entry][0]['messaging'][0] + contact_inbox + + described_class.new(message, instagram_inbox).perform + + instagram_inbox.reload + contact_inbox.reload + + expect(instagram_inbox.conversations.last.id).not_to eq(existing_conversation.id) + expect(Conversation.count).to eq(inital_count + 1) + end + end + + context 'when lock to single conversation is enabled' do + before do + instagram_inbox.update!(lock_to_single_conversation: true) + stub_request(:get, /graph.facebook.com/) + end + + it 'creates a new conversation if existing conversation is not present' do + inital_count = Conversation.count + message = dm_params[:entry][0]['messaging'][0] + contact_inbox + + described_class.new(message, instagram_inbox).perform + + instagram_inbox.reload + contact_inbox.reload + + expect(instagram_inbox.conversations.count).to eq(1) + expect(Conversation.count).to eq(inital_count + 1) + end + + it 'reopens last conversation if last conversation is resolved' do + existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved, + additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }) + + inital_count = Conversation.count + + message = dm_params[:entry][0]['messaging'][0] + contact_inbox + + described_class.new(message, instagram_inbox).perform + + instagram_inbox.reload + contact_inbox.reload + + expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id) + expect(Conversation.count).to eq(inital_count) + end + end end diff --git a/spec/factories/facebook_message/incoming_fb_text_message.rb b/spec/factories/facebook_message/incoming_fb_text_message.rb index dc75a912c..261e49261 100644 --- a/spec/factories/facebook_message/incoming_fb_text_message.rb +++ b/spec/factories/facebook_message/incoming_fb_text_message.rb @@ -11,6 +11,21 @@ FactoryBot.define do initialize_with { attributes } end + factory :mocked_message_text, class: Hash do + transient do + sender_id { '3383290475046708' } + end + + initialize_with do + { messaging: { sender: { id: sender_id }, + recipient: { id: '117172741761305' }, + message: { mid: 'm_KXGKDUpO6xbVdAmZFBVpzU1AhKVJdAIUnUH4cwkvb_K3iZsWhowDRyJ_DcowEpJjncaBwdCIoRrixvCbbO1PcA', + text: 'facebook message' } } } + end + + # initialize_with { attributes } + end + factory :message_deliveries, class: Hash do messaging do { sender: { id: '3383290475046708' }, From e49ef773d810e3443b8fc8df35015cde268fc7bd Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 4 Apr 2024 15:46:46 +0530 Subject: [PATCH 08/34] feat: UI to show the SLA threshold in chat screen (#9146) - UI will show the breach in the conversation list. - UI will show the breach in the conversation header. Fixes: https://linear.app/chatwoot/issue/CW-3146/update-the-ui-to-show-the-breach-in-the-conversation-list Fixes: https://linear.app/chatwoot/issue/CW-3144/ui-update-to-show-the-breachgoing-to-breach --- .../dashboard/components/ChatList.vue | 38 ++--- .../dashboard/components/ui/Label.vue | 4 +- .../widgets/conversation/ConversationCard.vue | 11 +- .../conversation/ConversationHeader.vue | 22 ++- .../conversation/components/SLACardLabel.vue | 122 +++++++++----- .../components/SLAPopoverCard.vue | 45 ++++++ .../conversationCardComponents/CardLabels.vue | 35 ++-- .../widgets/conversation/helpers/SLAHelper.js | 117 ++++++++++++++ .../helpers/specs/SLAHelper.spec.js | 150 ++++++++++++++++++ .../i18n/locale/en/conversation.json | 8 +- .../mixins/conversation/labelMixin.js | 1 + .../store/modules/specs/sla/actions.spec.js | 78 +++++++++ .../store/modules/specs/sla/fixtures.js | 95 +++++++++++ .../store/modules/specs/sla/getters.spec.js | 26 +++ .../store/modules/specs/sla/mutations.spec.js | 34 ++++ .../FluentIcon/dashboard-icons.json | 2 + app/models/concerns/push_data_helper.rb | 15 ++ app/models/conversation.rb | 28 ++-- enterprise/app/models/applied_sla.rb | 12 ++ .../app/models/enterprise/conversation.rb | 5 + .../conversations/event_data_presenter.rb | 3 +- 21 files changed, 745 insertions(+), 106 deletions(-) create mode 100644 app/javascript/dashboard/components/widgets/conversation/components/SLAPopoverCard.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/helpers/SLAHelper.js create mode 100644 app/javascript/dashboard/components/widgets/conversation/helpers/specs/SLAHelper.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/sla/actions.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/sla/fixtures.js create mode 100644 app/javascript/dashboard/store/modules/specs/sla/getters.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/sla/mutations.spec.js create mode 100644 app/models/concerns/push_data_helper.rb create mode 100644 enterprise/app/models/enterprise/conversation.rb diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index a75f0c12b..26c057f98 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -1,14 +1,14 @@