feat: Ability to lock to single conversation (#5881)
Adds the ability to lock conversation to a single thread for Whatsapp and Sms Inboxes when using outbound messages. demo: https://www.loom.com/share/c9e1e563c8914837a4139dfdd2503fef fixes: #4975 Co-authored-by: Nithin David <1277421+nithindavid@users.noreply.github.com>
This commit is contained in:
parent
8813c77907
commit
b05d06a28a
13 changed files with 171 additions and 47 deletions
|
@ -16,7 +16,6 @@ Metrics/ClassLength:
|
||||||
- 'app/models/message.rb'
|
- 'app/models/message.rb'
|
||||||
- 'app/builders/messages/facebook/message_builder.rb'
|
- 'app/builders/messages/facebook/message_builder.rb'
|
||||||
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
|
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
|
||||||
- 'app/controllers/api/v1/accounts/conversations_controller.rb'
|
|
||||||
- 'app/listeners/action_cable_listener.rb'
|
- 'app/listeners/action_cable_listener.rb'
|
||||||
- 'app/models/conversation.rb'
|
- 'app/models/conversation.rb'
|
||||||
RSpec/ExampleLength:
|
RSpec/ExampleLength:
|
||||||
|
|
40
app/builders/conversation_builder.rb
Normal file
40
app/builders/conversation_builder.rb
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
class ConversationBuilder
|
||||||
|
pattr_initialize [:params!, :contact_inbox!]
|
||||||
|
|
||||||
|
def perform
|
||||||
|
look_up_exising_conversation || create_new_conversation
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def look_up_exising_conversation
|
||||||
|
return unless @contact_inbox.inbox.lock_to_single_conversation?
|
||||||
|
|
||||||
|
@contact_inbox.conversations.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_new_conversation
|
||||||
|
::Conversation.create!(conversation_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_params
|
||||||
|
additional_attributes = params[:additional_attributes]&.permit! || {}
|
||||||
|
custom_attributes = params[:custom_attributes]&.permit! || {}
|
||||||
|
status = params[:status].present? ? { status: params[:status] } : {}
|
||||||
|
|
||||||
|
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
|
||||||
|
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
|
||||||
|
# status = { status: 'pending' } if status[:status] == 'bot'
|
||||||
|
{
|
||||||
|
account_id: @contact_inbox.inbox.account_id,
|
||||||
|
inbox_id: @contact_inbox.inbox_id,
|
||||||
|
contact_id: @contact_inbox.contact_id,
|
||||||
|
contact_inbox_id: @contact_inbox.id,
|
||||||
|
additional_attributes: additional_attributes,
|
||||||
|
custom_attributes: custom_attributes,
|
||||||
|
snoozed_until: params[:snoozed_until],
|
||||||
|
assignee_id: params[:assignee_id],
|
||||||
|
team_id: params[:team_id]
|
||||||
|
}.merge(status)
|
||||||
|
end
|
||||||
|
end
|
|
@ -24,7 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||||
|
|
||||||
def create
|
def create
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@conversation = ::Conversation.create!(conversation_params)
|
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
|
||||||
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
|
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -99,8 +99,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_conversation_status
|
def set_conversation_status
|
||||||
status = params[:status] == 'bot' ? 'pending' : params[:status]
|
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
|
||||||
@conversation.status = status
|
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
|
||||||
|
# status = params[:status] == 'bot' ? 'pending' : params[:status]
|
||||||
|
@conversation.status = params[:status]
|
||||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -152,26 +154,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||||
).perform
|
).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_params
|
|
||||||
additional_attributes = params[:additional_attributes]&.permit! || {}
|
|
||||||
custom_attributes = params[:custom_attributes]&.permit! || {}
|
|
||||||
status = params[:status].present? ? { status: params[:status] } : {}
|
|
||||||
|
|
||||||
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
|
|
||||||
status = { status: 'pending' } if status[:status] == 'bot'
|
|
||||||
{
|
|
||||||
account_id: Current.account.id,
|
|
||||||
inbox_id: @contact_inbox.inbox_id,
|
|
||||||
contact_id: @contact_inbox.contact_id,
|
|
||||||
contact_inbox_id: @contact_inbox.id,
|
|
||||||
additional_attributes: additional_attributes,
|
|
||||||
custom_attributes: custom_attributes,
|
|
||||||
snoozed_until: params[:snoozed_until],
|
|
||||||
assignee_id: params[:assignee_id],
|
|
||||||
team_id: params[:team_id]
|
|
||||||
}.merge(status)
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation_finder
|
def conversation_finder
|
||||||
@conversation_finder ||= ConversationFinder.new(Current.user, params)
|
@conversation_finder ||= ConversationFinder.new(Current.user, params)
|
||||||
end
|
end
|
||||||
|
|
|
@ -113,7 +113,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||||
|
|
||||||
def inbox_attributes
|
def inbox_attributes
|
||||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved]
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||||
|
:lock_to_single_conversation]
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
|
|
|
@ -388,6 +388,10 @@
|
||||||
"ENABLED": "Enabled",
|
"ENABLED": "Enabled",
|
||||||
"DISABLED": "Disabled"
|
"DISABLED": "Disabled"
|
||||||
},
|
},
|
||||||
|
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||||
|
"ENABLED": "Enabled",
|
||||||
|
"DISABLED": "Disabled"
|
||||||
|
},
|
||||||
"ENABLE_HMAC": {
|
"ENABLE_HMAC": {
|
||||||
"LABEL": "Enable"
|
"LABEL": "Enable"
|
||||||
}
|
}
|
||||||
|
@ -441,6 +445,8 @@
|
||||||
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
|
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
|
||||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||||
|
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
|
||||||
|
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
|
||||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||||
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
|
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
|
||||||
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
|
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
|
||||||
|
|
|
@ -258,6 +258,28 @@
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="canLocktoSingleConversation"
|
||||||
|
class="medium-9 columns settings-item"
|
||||||
|
>
|
||||||
|
{{ $t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION') }}
|
||||||
|
<select v-model="locktoSingleConversation">
|
||||||
|
<option :value="true">
|
||||||
|
{{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED') }}
|
||||||
|
</option>
|
||||||
|
<option :value="false">
|
||||||
|
{{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="help-text">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label v-if="isAWebWidgetInbox">
|
<label v-if="isAWebWidgetInbox">
|
||||||
{{ $t('INBOX_MGMT.FEATURES.LABEL') }}
|
{{ $t('INBOX_MGMT.FEATURES.LABEL') }}
|
||||||
</label>
|
</label>
|
||||||
|
@ -380,6 +402,7 @@ export default {
|
||||||
greetingMessage: '',
|
greetingMessage: '',
|
||||||
emailCollectEnabled: false,
|
emailCollectEnabled: false,
|
||||||
csatSurveyEnabled: false,
|
csatSurveyEnabled: false,
|
||||||
|
locktoSingleConversation: false,
|
||||||
allowMessagesAfterResolved: true,
|
allowMessagesAfterResolved: true,
|
||||||
continuityViaEmail: true,
|
continuityViaEmail: true,
|
||||||
selectedInboxName: '',
|
selectedInboxName: '',
|
||||||
|
@ -496,6 +519,9 @@ export default {
|
||||||
}
|
}
|
||||||
return this.inbox.name;
|
return this.inbox.name;
|
||||||
},
|
},
|
||||||
|
canLocktoSingleConversation() {
|
||||||
|
return this.isASmsInbox || this.isAWhatsAppChannel;
|
||||||
|
},
|
||||||
inboxNameLabel() {
|
inboxNameLabel() {
|
||||||
if (this.isAWebWidgetInbox) {
|
if (this.isAWebWidgetInbox) {
|
||||||
return this.$t('INBOX_MGMT.ADD.WEBSITE_NAME.LABEL');
|
return this.$t('INBOX_MGMT.ADD.WEBSITE_NAME.LABEL');
|
||||||
|
@ -567,6 +593,7 @@ export default {
|
||||||
this.channelWelcomeTagline = this.inbox.welcome_tagline;
|
this.channelWelcomeTagline = this.inbox.welcome_tagline;
|
||||||
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
|
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
|
||||||
this.replyTime = this.inbox.reply_time;
|
this.replyTime = this.inbox.reply_time;
|
||||||
|
this.locktoSingleConversation = this.inbox.lock_to_single_conversation;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async updateInbox() {
|
async updateInbox() {
|
||||||
|
@ -579,6 +606,7 @@ export default {
|
||||||
allow_messages_after_resolved: this.allowMessagesAfterResolved,
|
allow_messages_after_resolved: this.allowMessagesAfterResolved,
|
||||||
greeting_enabled: this.greetingEnabled,
|
greeting_enabled: this.greetingEnabled,
|
||||||
greeting_message: this.greetingMessage || '',
|
greeting_message: this.greetingMessage || '',
|
||||||
|
lock_to_single_conversation: this.locktoSingleConversation,
|
||||||
channel: {
|
channel: {
|
||||||
widget_color: this.inbox.widget_color,
|
widget_color: this.inbox.widget_color,
|
||||||
website_url: this.channelWebsiteUrl,
|
website_url: this.channelWebsiteUrl,
|
||||||
|
|
|
@ -89,7 +89,19 @@ export const mutations = {
|
||||||
},
|
},
|
||||||
[types.default.ADD_CONTACT_CONVERSATION]: ($state, { id, data }) => {
|
[types.default.ADD_CONTACT_CONVERSATION]: ($state, { id, data }) => {
|
||||||
const conversations = $state.records[id] || [];
|
const conversations = $state.records[id] || [];
|
||||||
Vue.set($state.records, id, [...conversations, data]);
|
|
||||||
|
const updatedConversations = [...conversations];
|
||||||
|
const index = conversations.findIndex(
|
||||||
|
conversation => conversation.id === data.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
updatedConversations[index] = { ...conversations[index], ...data };
|
||||||
|
} else {
|
||||||
|
updatedConversations.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.set($state.records, id, updatedConversations);
|
||||||
},
|
},
|
||||||
[types.default.DELETE_CONTACT_CONVERSATION]: ($state, id) => {
|
[types.default.DELETE_CONTACT_CONVERSATION]: ($state, id) => {
|
||||||
Vue.delete($state.records, id);
|
Vue.delete($state.records, id);
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
# enable_email_collect :boolean default(TRUE)
|
# enable_email_collect :boolean default(TRUE)
|
||||||
# greeting_enabled :boolean default(FALSE)
|
# greeting_enabled :boolean default(FALSE)
|
||||||
# greeting_message :string
|
# greeting_message :string
|
||||||
|
# lock_to_single_conversation :boolean default(FALSE), not null
|
||||||
# name :string not null
|
# name :string not null
|
||||||
# out_of_office_message :string
|
# out_of_office_message :string
|
||||||
# timezone :string default("UTC")
|
# timezone :string default("UTC")
|
||||||
|
|
|
@ -15,12 +15,13 @@ json.working_hours resource.weekly_schedule
|
||||||
json.timezone resource.timezone
|
json.timezone resource.timezone
|
||||||
json.callback_webhook_url resource.callback_webhook_url
|
json.callback_webhook_url resource.callback_webhook_url
|
||||||
json.allow_messages_after_resolved resource.allow_messages_after_resolved
|
json.allow_messages_after_resolved resource.allow_messages_after_resolved
|
||||||
|
json.lock_to_single_conversation resource.lock_to_single_conversation
|
||||||
json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter?
|
|
||||||
|
|
||||||
## Channel specific settings
|
## Channel specific settings
|
||||||
## TODO : Clean up and move the attributes into channel sub section
|
## TODO : Clean up and move the attributes into channel sub section
|
||||||
|
|
||||||
|
json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter?
|
||||||
|
|
||||||
## WebWidget Attributes
|
## WebWidget Attributes
|
||||||
json.widget_color resource.channel.try(:widget_color)
|
json.widget_color resource.channel.try(:widget_color)
|
||||||
json.website_url resource.channel.try(:website_url)
|
json.website_url resource.channel.try(:website_url)
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddLockConversationToSingleThread < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :inboxes, :lock_to_single_conversation, :boolean, null: false, default: false
|
||||||
|
end
|
||||||
|
end
|
|
@ -535,6 +535,7 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
|
||||||
t.boolean "csat_survey_enabled", default: false
|
t.boolean "csat_survey_enabled", default: false
|
||||||
t.boolean "allow_messages_after_resolved", default: true
|
t.boolean "allow_messages_after_resolved", default: true
|
||||||
t.jsonb "auto_assignment_config", default: {}
|
t.jsonb "auto_assignment_config", default: {}
|
||||||
|
t.boolean "lock_to_single_conversation", default: false, null: false
|
||||||
t.index ["account_id"], name: "index_inboxes_on_account_id"
|
t.index ["account_id"], name: "index_inboxes_on_account_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
46
spec/builders/conversation_builder_spec.rb
Normal file
46
spec/builders/conversation_builder_spec.rb
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe ::ConversationBuilder do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let!(:sms_channel) { create(:channel_sms, account: account) }
|
||||||
|
let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: account) }
|
||||||
|
let(:contact) { create(:contact, account: account) }
|
||||||
|
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: sms_inbox) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
it 'creates conversation' do
|
||||||
|
conversation = described_class.new(
|
||||||
|
contact_inbox: contact_inbox,
|
||||||
|
params: {}
|
||||||
|
).perform
|
||||||
|
|
||||||
|
expect(conversation.contact_inbox_id).to eq(contact_inbox.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when lock_to_single_conversation is true for inbox' do
|
||||||
|
before do
|
||||||
|
sms_inbox.update!(lock_to_single_conversation: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates conversation when existing conversation is not present' do
|
||||||
|
conversation = described_class.new(
|
||||||
|
contact_inbox: contact_inbox,
|
||||||
|
params: {}
|
||||||
|
).perform
|
||||||
|
|
||||||
|
expect(conversation.contact_inbox_id).to eq(contact_inbox.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns last from existing conversations when existing conversation is not present' do
|
||||||
|
create(:conversation, contact_inbox: contact_inbox)
|
||||||
|
existing_conversation = create(:conversation, contact_inbox: contact_inbox)
|
||||||
|
conversation = described_class.new(
|
||||||
|
contact_inbox: contact_inbox,
|
||||||
|
params: {}
|
||||||
|
).perform
|
||||||
|
|
||||||
|
expect(conversation.id).to eq(existing_conversation.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -265,17 +265,18 @@ RSpec.describe 'Conversations API', type: :request do
|
||||||
|
|
||||||
# TODO: remove this spec when we remove the condition check in controller
|
# TODO: remove this spec when we remove the condition check in controller
|
||||||
# Added for backwards compatibility for bot status
|
# Added for backwards compatibility for bot status
|
||||||
it 'creates a conversation as pending if status is specified as bot' do
|
# remove this in subsequent release
|
||||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
# it 'creates a conversation as pending if status is specified as bot' do
|
||||||
post "/api/v1/accounts/#{account.id}/conversations",
|
# allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||||
headers: agent.create_new_auth_token,
|
# post "/api/v1/accounts/#{account.id}/conversations",
|
||||||
params: { source_id: contact_inbox.source_id, status: 'bot' },
|
# headers: agent.create_new_auth_token,
|
||||||
as: :json
|
# params: { source_id: contact_inbox.source_id, status: 'bot' },
|
||||||
|
# as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
# expect(response).to have_http_status(:success)
|
||||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
# response_data = JSON.parse(response.body, symbolize_names: true)
|
||||||
expect(response_data[:status]).to eq('pending')
|
# expect(response_data[:status]).to eq('pending')
|
||||||
end
|
# end
|
||||||
|
|
||||||
it 'creates a new conversation with message when message is passed' do
|
it 'creates a new conversation with message when message is passed' do
|
||||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||||
|
@ -408,17 +409,18 @@ RSpec.describe 'Conversations API', type: :request do
|
||||||
|
|
||||||
# TODO: remove this spec when we remove the condition check in controller
|
# TODO: remove this spec when we remove the condition check in controller
|
||||||
# Added for backwards compatibility for bot status
|
# Added for backwards compatibility for bot status
|
||||||
it 'toggles the conversation status to pending status when parameter bot is passed' do
|
# remove in next release
|
||||||
expect(conversation.status).to eq('open')
|
# 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",
|
# post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||||
headers: agent.create_new_auth_token,
|
# headers: agent.create_new_auth_token,
|
||||||
params: { status: 'bot' },
|
# params: { status: 'bot' },
|
||||||
as: :json
|
# as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
# expect(response).to have_http_status(:success)
|
||||||
expect(conversation.reload.status).to eq('pending')
|
# expect(conversation.reload.status).to eq('pending')
|
||||||
end
|
# end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue