Merge branch 'develop' into feat/new-auth-screens

This commit is contained in:
Pranav Raj S 2022-11-29 17:21:11 -08:00 committed by GitHub
commit b155f64fe2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 657 additions and 175 deletions

View file

@ -60,9 +60,10 @@ MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
#SMTP domain key is set up for HELO checking #SMTP domain key is set up for HELO checking
SMTP_DOMAIN=chatwoot.com SMTP_DOMAIN=chatwoot.com
# the default value is set "mailhog" and is used by docker-compose for development environments, # Set the value to "mailhog" if using docker-compose for development environments,
# Set the value as "localhost" or your SMTP address in other environments # Set the value as "localhost" or your SMTP address in other environments
SMTP_ADDRESS=mailhog # If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix)
SMTP_ADDRESS=
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=

View file

@ -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:

View 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

View file

@ -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

View file

@ -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 = [])

View file

@ -56,7 +56,6 @@ class ConversationFinder
filter_by_team if @team filter_by_team if @team
filter_by_labels if params[:labels] filter_by_labels if params[:labels]
filter_by_query if params[:q] filter_by_query if params[:q]
filter_by_reply_status
end end
def set_inboxes def set_inboxes
@ -76,12 +75,9 @@ class ConversationFinder
end end
def find_all_conversations def find_all_conversations
if params[:conversation_type] == 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = current_account.conversations.where(id: conversation_ids)
else
@conversations = current_account.conversations.where(inbox_id: @inbox_ids) @conversations = current_account.conversations.where(inbox_id: @inbox_ids)
end filter_by_conversation_type if params[:conversation_type]
@conversations
end end
def filter_by_assignee_type def filter_by_assignee_type
@ -96,8 +92,15 @@ class ConversationFinder
@conversations @conversations
end end
def filter_by_reply_status def filter_by_conversation_type
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended' case @params[:conversation_type]
when 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil)
end
@conversations
end end
def filter_by_query def filter_by_query

View file

@ -335,10 +335,8 @@ export default {
status: this.activeStatus, status: this.activeStatus,
page: this.currentPage + 1, page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined, labels: this.label ? [this.label] : undefined,
teamId: this.teamId ? this.teamId : undefined, teamId: this.teamId || undefined,
conversationType: this.conversationType conversationType: this.conversationType || undefined,
? this.conversationType
: undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined, folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
}; };
}, },
@ -355,6 +353,9 @@ export default {
if (this.conversationType === 'mention') { if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING'); return this.$t('CHAT_LIST.MENTION_HEADING');
} }
if (this.conversationType === 'unattended') {
return this.$t('CHAT_LIST.UNATTENDED_HEADING');
}
if (this.hasActiveFolders) { if (this.hasActiveFolders) {
return this.activeFolder.name; return this.activeFolder.name;
} }

View file

@ -16,6 +16,8 @@ const conversations = accountId => ({
'conversation_through_mentions', 'conversation_through_mentions',
'folder_conversations', 'folder_conversations',
'conversations_through_folders', 'conversations_through_folders',
'conversation_unattended',
'conversation_through_unattended',
], ],
menuItems: [ menuItems: [
{ {
@ -33,6 +35,13 @@ const conversations = accountId => ({
toState: frontendURL(`accounts/${accountId}/mentions/conversations`), toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
toStateName: 'conversation_mentions', toStateName: 'conversation_mentions',
}, },
{
icon: 'mail-unread',
label: 'UNATTENDED_CONVERSATIONS',
key: 'conversation_unattended',
toState: frontendURL(`accounts/${accountId}/unattended/conversations`),
toStateName: 'conversation_unattended',
},
], ],
}); });

View file

@ -233,7 +233,9 @@ export default {
node node
); );
this.state = this.editorView.state.apply(tr); this.state = this.editorView.state.apply(tr);
return this.emitOnChange(); this.emitOnChange();
return false;
}, },
insertCannedResponse(cannedItem) { insertCannedResponse(cannedItem) {
@ -241,22 +243,26 @@ export default {
return null; return null;
} }
const tr = this.editorView.state.tr.insertText( let from = this.range.from - 1;
cannedItem, let node = addMentionsToMarkdownParser(defaultMarkdownParser).parse(
this.range.from, cannedItem
this.range.to
); );
if (node.childCount === 1) {
node = this.editorView.state.schema.text(cannedItem);
from = this.range.from;
}
const tr = this.editorView.state.tr.replaceWith(
from,
this.range.to,
node
);
this.state = this.editorView.state.apply(tr); this.state = this.editorView.state.apply(tr);
this.emitOnChange(); this.emitOnChange();
// Hacky fix for #5501 tr.scrollIntoView();
this.state = createState(
this.contentFromEditor,
this.placeholder,
this.plugins
);
this.editorView.updateState(this.state);
this.focusEditorInputField();
return false; return false;
}, },

View file

@ -59,10 +59,12 @@
:story-sender="storySender" :story-sender="storySender"
:story-id="storyId" :story-id="storyId"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:is-email="isEmailContentType" :is-email="isEmailContentType"
:is-private="data.private" :is-private="data.private"
:message-type="data.message_type" :message-type="data.message_type"
:message-status="status"
:readable-time="readableTime" :readable-time="readableTime"
:source-id="data.source_id" :source-id="data.source_id"
:inbox-id="data.inbox_id" :inbox-id="data.inbox_id"
@ -157,6 +159,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isAWhatsAppChannel: {
type: Boolean,
default: false,
},
hasInstagramStory: { hasInstagramStory: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -231,6 +237,9 @@ export default {
sender() { sender() {
return this.data.sender || {}; return this.data.sender || {};
}, },
status() {
return this.data.status;
},
storySender() { storySender() {
return this.contentAttributes.story_sender || null; return this.contentAttributes.story_sender || null;
}, },

View file

@ -38,6 +38,7 @@
class="message--read ph-no-capture" class="message--read ph-no-capture"
:data="message" :data="message"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:has-user-read-message=" :has-user-read-message="
hasUserReadMessage(message.created_at, getLastSeenAt) hasUserReadMessage(message.created_at, getLastSeenAt)
@ -60,6 +61,7 @@
class="message--unread ph-no-capture" class="message--unread ph-no-capture"
:data="message" :data="message"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:has-user-read-message=" :has-user-read-message="
hasUserReadMessage(message.created_at, getLastSeenAt) hasUserReadMessage(message.created_at, getLastSeenAt)

View file

@ -3,20 +3,30 @@
<span class="time" :class="{ delivered: messageRead }">{{ <span class="time" :class="{ delivered: messageRead }">{{
readableTime readableTime
}}</span> }}</span>
<span v-if="showSentIndicator" class="time"> <span v-if="showReadIndicator" class="read-indicator-wrap">
<fluent-icon <fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.SENT')" v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
icon="checkmark" icon="checkmark-double"
class="action--icon read-tick read-indicator"
size="14" size="14"
/> />
</span> </span>
<span v-if="showDeliveredIndicator" class="read-indicator-wrap">
<fluent-icon <fluent-icon
v-if="messageRead" v-tooltip.top-start="$t('CHAT_LIST.DELIVERED')"
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
icon="checkmark-double" icon="checkmark-double"
class="action--icon read-tick" class="action--icon read-tick"
size="12" size="14"
/> />
</span>
<span v-if="showSentIndicator" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
icon="checkmark"
class="action--icon read-tick"
size="14"
/>
</span>
<fluent-icon <fluent-icon
v-if="isEmail" v-if="isEmail"
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')" v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
@ -74,7 +84,7 @@
</template> </template>
<script> <script>
import { MESSAGE_TYPE } from 'shared/constants/messages'; import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
@ -117,6 +127,10 @@ export default {
type: Number, type: Number,
default: 1, default: 1,
}, },
messageStatus: {
type: String,
default: '',
},
sourceId: { sourceId: {
type: String, type: String,
default: '', default: '',
@ -144,6 +158,15 @@ export default {
isOutgoing() { isOutgoing() {
return MESSAGE_TYPE.OUTGOING === this.messageType; return MESSAGE_TYPE.OUTGOING === this.messageType;
}, },
isDelivered() {
return MESSAGE_STATUS.DELIVERED === this.messageStatus;
},
isRead() {
return MESSAGE_STATUS.READ === this.messageStatus;
},
isSent() {
return MESSAGE_STATUS.SENT === this.messageStatus;
},
screenName() { screenName() {
const { additional_attributes: additionalAttributes = {} } = const { additional_attributes: additionalAttributes = {} } =
this.sender || {}; this.sender || {};
@ -168,7 +191,23 @@ export default {
return ( return (
this.isOutgoing && this.isOutgoing &&
this.sourceId && this.sourceId &&
(this.isAnEmailChannel || this.isAWhatsAppChannel) (this.isAnEmailChannel || (this.isAWhatsAppChannel && this.isSent))
);
},
showDeliveredIndicator() {
return (
this.isOutgoing &&
this.sourceId &&
this.isAWhatsAppChannel &&
this.isDelivered
);
},
showReadIndicator() {
return (
this.isOutgoing &&
this.sourceId &&
this.isAWhatsAppChannel &&
this.isRead
); );
}, },
}, },
@ -185,16 +224,20 @@ export default {
.right { .right {
.message-text--metadata { .message-text--metadata {
align-items: center;
.time { .time {
color: var(--w-100); color: var(--w-100);
} }
.action--icon { .action--icon {
color: var(--white);
&.read-tick { &.read-tick {
color: var(--v-100); color: var(--v-100);
margin-top: calc(var(--space-micro) + var(--space-micro) / 2);
} }
color: var(--white);
&.read-indicator {
color: var(--g-300);
}
} }
.lock--icon--private { .lock--icon--private {
@ -296,4 +339,10 @@ export default {
.delivered-icon { .delivered-icon {
margin-left: -var(--space-normal); margin-left: -var(--space-normal);
} }
.read-indicator-wrap {
line-height: 1;
display: flex;
align-items: center;
}
</style> </style>

View file

@ -56,6 +56,8 @@ export const conversationUrl = ({
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`; url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
} else if (conversationType === 'mention') { } else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations/${id}`; url = `accounts/${accountId}/mentions/conversations/${id}`;
} else if (conversationType === 'unattended') {
url = `accounts/${accountId}/unattended/conversations/${id}`;
} }
return url; return url;
}; };

View file

@ -8,6 +8,7 @@
}, },
"TAB_HEADING": "Conversations", "TAB_HEADING": "Conversations",
"MENTION_HEADING": "Mentions", "MENTION_HEADING": "Mentions",
"UNATTENDED_HEADING": "Unattended",
"SEARCH": { "SEARCH": {
"INPUT": "Search for People, Chats, Saved Replies .." "INPUT": "Search for People, Chats, Saved Replies .."
}, },
@ -56,6 +57,8 @@
"REPLY_TO_TWEET": "Reply to this tweet", "REPLY_TO_TWEET": "Reply to this tweet",
"LINK_TO_STORY": "Go to instagram story", "LINK_TO_STORY": "Go to instagram story",
"SENT": "Sent successfully", "SENT": "Sent successfully",
"READ": "Read successfully",
"DELIVERED": "Delivered successfully",
"NO_MESSAGES": "No Messages", "NO_MESSAGES": "No Messages",
"NO_CONTENT": "No content available", "NO_CONTENT": "No content available",
"HIDE_QUOTED_TEXT": "Hide Quoted Text", "HIDE_QUOTED_TEXT": "Hide Quoted Text",

View file

@ -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.",

View file

@ -177,6 +177,7 @@
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"ALL_CONVERSATIONS": "All Conversations", "ALL_CONVERSATIONS": "All Conversations",
"MENTIONED_CONVERSATIONS": "Mentions", "MENTIONED_CONVERSATIONS": "Mentions",
"UNATTENDED_CONVERSATIONS": "Unattended",
"REPORTS": "Reports", "REPORTS": "Reports",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",

View file

@ -121,5 +121,25 @@ export default {
conversationType: 'mention', conversationType: 'mention',
}), }),
}, },
{
path: frontendURL('accounts/:accountId/unattended/conversations'),
name: 'conversation_unattended',
roles: ['administrator', 'agent'],
component: ConversationView,
props: () => ({ conversationType: 'unattended' }),
},
{
path: frontendURL(
'accounts/:accountId/unattended/conversations/:conversationId'
),
name: 'conversation_through_unattended',
roles: ['administrator', 'agent'],
component: ConversationView,
props: route => ({
conversationId: route.params.conversationId,
conversationType: 'unattended',
}),
},
], ],
}; };

View file

@ -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,

View file

@ -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);

View file

@ -7,6 +7,7 @@ import { createPendingMessage } from 'dashboard/helper/commons';
import { import {
buildConversationList, buildConversationList,
isOnMentionsView, isOnMentionsView,
isOnUnattendedView,
} from './helpers/actionHelpers'; } from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions'; import messageReadActions from './actions/messageReadActions';
// actions // actions
@ -230,6 +231,7 @@ const actions = {
if ( if (
!hasAppliedFilters && !hasAppliedFilters &&
!isOnMentionsView(rootState) && !isOnMentionsView(rootState) &&
!isOnUnattendedView(rootState) &&
isMatchingInboxFilter isMatchingInboxFilter
) { ) {
commit(types.ADD_CONVERSATION, conversation); commit(types.ADD_CONVERSATION, conversation);
@ -243,6 +245,12 @@ const actions = {
} }
}, },
addUnattended({ dispatch, rootState }, conversation) {
if (isOnUnattendedView(rootState)) {
dispatch('updateConversation', conversation);
}
},
updateConversation({ commit, dispatch }, conversation) { updateConversation({ commit, dispatch }, conversation) {
const { const {
meta: { sender }, meta: { sender },

View file

@ -5,33 +5,54 @@ export const findPendingMessageIndex = (chat, message) => {
); );
}; };
const filterByStatus = (chatStatus, filterStatus) => export const filterByStatus = (chatStatus, filterStatus) =>
filterStatus === 'all' ? true : chatStatus === filterStatus; filterStatus === 'all' ? true : chatStatus === filterStatus;
export const filterByInbox = (shouldFilter, inboxId, chatInboxId) => {
const isOnInbox = Number(inboxId) === chatInboxId;
return inboxId ? isOnInbox && shouldFilter : shouldFilter;
};
export const filterByTeam = (shouldFilter, teamId, chatTeamId) => {
const isOnTeam = Number(teamId) === chatTeamId;
return teamId ? isOnTeam && shouldFilter : shouldFilter;
};
export const filterByLabel = (shouldFilter, labels, chatLabels) => {
const isOnLabel = labels.every(label => chatLabels.includes(label));
return labels.length ? isOnLabel && shouldFilter : shouldFilter;
};
export const filterByUnattended = (
shouldFilter,
conversationType,
firstReplyOn
) => {
return conversationType === 'unattended'
? !firstReplyOn && shouldFilter
: shouldFilter;
};
export const applyPageFilters = (conversation, filters) => { export const applyPageFilters = (conversation, filters) => {
const { inboxId, status, labels = [], teamId } = filters; const { inboxId, status, labels = [], teamId, conversationType } = filters;
const { const {
status: chatStatus, status: chatStatus,
inbox_id: chatInboxId, inbox_id: chatInboxId,
labels: chatLabels = [], labels: chatLabels = [],
meta = {}, meta = {},
first_reply_created_at: firstReplyOn,
} = conversation; } = conversation;
const team = meta.team || {}; const team = meta.team || {};
const { id: chatTeamId } = team; const { id: chatTeamId } = team;
let shouldFilter = filterByStatus(chatStatus, status); let shouldFilter = filterByStatus(chatStatus, status);
if (inboxId) { shouldFilter = filterByInbox(shouldFilter, inboxId, chatInboxId);
const filterByInbox = Number(inboxId) === chatInboxId; shouldFilter = filterByTeam(shouldFilter, teamId, chatTeamId);
shouldFilter = shouldFilter && filterByInbox; shouldFilter = filterByLabel(shouldFilter, labels, chatLabels);
} shouldFilter = filterByUnattended(
if (teamId) { shouldFilter,
const filterByTeam = Number(teamId) === chatTeamId; conversationType,
shouldFilter = shouldFilter && filterByTeam; firstReplyOn
} );
if (labels.length) {
const filterByLabels = labels.every(label => chatLabels.includes(label));
shouldFilter = shouldFilter && filterByLabels;
}
return shouldFilter; return shouldFilter;
}; };

View file

@ -22,6 +22,14 @@ export const isOnMentionsView = ({ route: { name: routeName } }) => {
return MENTION_ROUTES.includes(routeName); return MENTION_ROUTES.includes(routeName);
}; };
export const isOnUnattendedView = ({ route: { name: routeName } }) => {
const UNATTENDED_ROUTES = [
'conversation_unattended',
'conversation_through_unattended',
];
return UNATTENDED_ROUTES.includes(routeName);
};
export const buildConversationList = ( export const buildConversationList = (
context, context,
requestPayload, requestPayload,

View file

@ -1,6 +1,10 @@
import { import {
findPendingMessageIndex, findPendingMessageIndex,
applyPageFilters, applyPageFilters,
filterByInbox,
filterByTeam,
filterByLabel,
filterByUnattended,
} from '../../conversations/helpers'; } from '../../conversations/helpers';
const conversationList = [ const conversationList = [
@ -119,3 +123,52 @@ describe('#applyPageFilters', () => {
}); });
}); });
}); });
describe('#filterByInbox', () => {
it('returns true if conversation has inbox filter active', () => {
const inboxId = '1';
const chatInboxId = 1;
expect(filterByInbox(true, inboxId, chatInboxId)).toEqual(true);
});
it('returns false if inbox filter is not active', () => {
const inboxId = '1';
const chatInboxId = 13;
expect(filterByInbox(true, inboxId, chatInboxId)).toEqual(false);
});
});
describe('#filterByTeam', () => {
it('returns true if conversation has team and team filter is active', () => {
const [teamId, chatTeamId] = ['1', 1];
expect(filterByTeam(true, teamId, chatTeamId)).toEqual(true);
});
it('returns false if team filter is not active', () => {
const [teamId, chatTeamId] = ['1', 12];
expect(filterByTeam(true, teamId, chatTeamId)).toEqual(false);
});
});
describe('#filterByLabel', () => {
it('returns true if conversation has labels and labels filter is active', () => {
const labels = ['dev', 'cs'];
const chatLabels = ['dev', 'cs', 'sales'];
expect(filterByLabel(true, labels, chatLabels)).toEqual(true);
});
it('returns false if conversation has not all labels', () => {
const labels = ['dev', 'cs', 'sales'];
const chatLabels = ['cs', 'sales'];
expect(filterByLabel(true, labels, chatLabels)).toEqual(false);
});
});
describe('#filterByUnattended', () => {
it('returns true if conversation type is unattended and has no first reply', () => {
expect(filterByUnattended(true, 'unattended', undefined)).toEqual(true);
});
it('returns false if conversation type is not unattended and has no first reply', () => {
expect(filterByUnattended(false, 'mentions', undefined)).toEqual(false);
});
it('returns true if conversation type is unattended and has first reply', () => {
expect(filterByUnattended(true, 'mentions', 123)).toEqual(true);
});
});

View file

@ -96,6 +96,7 @@
"lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z", "lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z",
"lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z", "lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z",
"mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z", "mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z",
"mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z",
"mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z", "mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z",
"map-outline": "m9.203 4 .047-.002.046.001a.73.73 0 0 1 .067.007l.016.004c.086.014.17.044.252.092l.051.034 5.07 3.565L19.82 4.14a.75.75 0 0 1 1.174.51l.007.104v10.632a.75.75 0 0 1-.238.548l-.08.066-5.5 3.866a.744.744 0 0 1-.828.023L9.25 16.297l-5.07 3.565a.75.75 0 0 1-1.174-.51l-.007-.104V8.616a.75.75 0 0 1 .238-.548l.08-.066 5.5-3.866a.762.762 0 0 1 .2-.101l.122-.028.064-.008Zm10.298 2.197-4 2.812v8.799l4-2.812v-8.8ZM8.5 6.193l-4 2.812v8.8l4-2.813V6.193Zm1.502 0v8.8l4 2.811V9.005l-4-2.812Z", "map-outline": "m9.203 4 .047-.002.046.001a.73.73 0 0 1 .067.007l.016.004c.086.014.17.044.252.092l.051.034 5.07 3.565L19.82 4.14a.75.75 0 0 1 1.174.51l.007.104v10.632a.75.75 0 0 1-.238.548l-.08.066-5.5 3.866a.744.744 0 0 1-.828.023L9.25 16.297l-5.07 3.565a.75.75 0 0 1-1.174-.51l-.007-.104V8.616a.75.75 0 0 1 .238-.548l.08-.066 5.5-3.866a.762.762 0 0 1 .2-.101l.122-.028.064-.008Zm10.298 2.197-4 2.812v8.799l4-2.812v-8.8ZM8.5 6.193l-4 2.812v8.8l4-2.813V6.193Zm1.502 0v8.8l4 2.811V9.005l-4-2.812Z",
"megaphone-outline": "M21.907 5.622c.062.208.093.424.093.641V17.74a2.25 2.25 0 0 1-2.891 2.156l-5.514-1.64a4.002 4.002 0 0 1-7.59-1.556L6 16.5l-.001-.5-2.39-.711A2.25 2.25 0 0 1 2 13.131V10.87a2.25 2.25 0 0 1 1.61-2.156l15.5-4.606a2.25 2.25 0 0 1 2.797 1.515ZM7.499 16.445l.001.054a2.5 2.5 0 0 0 4.624 1.321l-4.625-1.375Zm12.037-10.9-15.5 4.605a.75.75 0 0 0-.536.72v2.261a.75.75 0 0 0 .536.72l15.5 4.607a.75.75 0 0 0 .964-.72V6.264a.75.75 0 0 0-.964-.719Z", "megaphone-outline": "M21.907 5.622c.062.208.093.424.093.641V17.74a2.25 2.25 0 0 1-2.891 2.156l-5.514-1.64a4.002 4.002 0 0 1-7.59-1.556L6 16.5l-.001-.5-2.39-.711A2.25 2.25 0 0 1 2 13.131V10.87a2.25 2.25 0 0 1 1.61-2.156l15.5-4.606a2.25 2.25 0 0 1 2.797 1.515ZM7.499 16.445l.001.054a2.5 2.5 0 0 0 4.624 1.321l-4.625-1.375Zm12.037-10.9-15.5 4.605a.75.75 0 0 0-.536.72v2.261a.75.75 0 0 0 .536.72l15.5 4.607a.75.75 0 0 0 .964-.72V6.264a.75.75 0 0 0-.964-.719Z",

View file

@ -1,6 +1,8 @@
export const MESSAGE_STATUS = { export const MESSAGE_STATUS = {
FAILED: 'failed', FAILED: 'failed',
SENT: 'sent', SENT: 'sent',
DELIVERED: 'delivered',
READ: 'read',
PROGRESS: 'progress', PROGRESS: 'progress',
}; };

View file

@ -0,0 +1,37 @@
class Agents::DestroyJob < ApplicationJob
queue_as :default
def perform(account, user)
ActiveRecord::Base.transaction do
destroy_notification_setting(account, user)
remove_user_from_teams(account, user)
remove_user_from_inboxes(account, user)
unassign_conversations(account, user)
end
end
private
def remove_user_from_inboxes(account, user)
inboxes = account.inboxes.all
inbox_members = user.inbox_members.where(inbox_id: inboxes.pluck(:id))
inbox_members.destroy_all
end
def remove_user_from_teams(account, user)
teams = account.teams.all
team_members = user.team_members.where(team_id: teams.pluck(:id))
team_members.destroy_all
end
def destroy_notification_setting(account, user)
setting = user.notification_settings.find_by(account_id: account.id)
setting&.destroy!
end
def unassign_conversations(account, user)
# rubocop:disable Rails/SkipsModelValidations
user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil)
# rubocop:enable Rails/SkipsModelValidations
end
end

View file

@ -36,9 +36,9 @@ class ReportingEventListener < BaseListener
event_start_time: conversation.created_at, event_start_time: conversation.created_at,
event_end_time: message.created_at event_end_time: message.created_at
) )
# rubocop:disable Rails/SkipsModelValidations
conversation.update_columns(first_reply_created_at: message.created_at) conversation.update(first_reply_created_at: message.created_at)
# rubocop:enable Rails/SkipsModelValidations
reporting_event.save! reporting_event.save!
end end
end end

View file

@ -46,7 +46,7 @@ class AccountUser < ApplicationRecord
end end
def remove_user_from_account def remove_user_from_account
::Agents::DestroyService.new(account: account, user: user).perform ::Agents::DestroyJob.perform_later(account, user)
end end
private private

View file

@ -73,7 +73,8 @@ module ActivityMessageHandler
end end
def generate_team_change_activity_key def generate_team_change_activity_key
key = team_id ? 'assigned' : 'removed' team = Team.find_by(id: team_id)
key = team.present? ? 'assigned' : 'removed'
key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee
key key
end end

View file

@ -219,7 +219,7 @@ class Conversation < ApplicationRecord
def notify_conversation_updation def notify_conversation_updation
return unless previous_changes.keys.present? && (previous_changes.keys & %w[team_id assignee_id status snoozed_until return unless previous_changes.keys.present? && (previous_changes.keys & %w[team_id assignee_id status snoozed_until
custom_attributes label_list]).present? custom_attributes label_list first_reply_created_at]).present?
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes) dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
end end

View file

@ -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")

View file

@ -65,8 +65,9 @@ class Message < ApplicationRecord
# [:in_reply_to] : Used to reply to a particular tweet in threads # [:in_reply_to] : Used to reply to a particular tweet in threads
# [:deleted] : Used to denote whether the message was deleted by the agent # [:deleted] : Used to denote whether the message was deleted by the agent
# [:external_created_at] : Can specify if the message was created at a different timestamp externally # [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted, store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id], coder: JSON :external_created_at, :story_sender, :story_id, :external_error], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id

View file

@ -14,6 +14,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
custom_attributes: custom_attributes, custom_attributes: custom_attributes,
snoozed_until: snoozed_until, snoozed_until: snoozed_until,
unread_count: unread_incoming_messages.count, unread_count: unread_incoming_messages.count,
first_reply_created_at: first_reply_created_at,
**push_timestamps **push_timestamps
} }
end end

View file

@ -1,35 +0,0 @@
class Agents::DestroyService
pattr_initialize [:account!, :user!]
def perform
ActiveRecord::Base.transaction do
destroy_notification_setting
remove_user_from_teams
remove_user_from_inboxes
unassign_conversations
end
end
private
def remove_user_from_inboxes
inboxes = account.inboxes.all
inbox_members = user.inbox_members.where(inbox_id: inboxes.pluck(:id))
inbox_members.destroy_all
end
def remove_user_from_teams
teams = account.teams.all
team_members = user.team_members.where(team_id: teams.pluck(:id))
team_members.destroy_all
end
def destroy_notification_setting
setting = user.notification_settings.find_by(account_id: account.id)
setting&.destroy!
end
def unassign_conversations
user.assigned_conversations.where(account: account).find_each(&:update_assignee)
end
end

View file

@ -7,11 +7,38 @@ class Whatsapp::IncomingMessageBaseService
def perform def perform
processed_params processed_params
perform_statuses
set_contact set_contact
return unless @contact return unless @contact
set_conversation set_conversation
perform_messages
end
private
def perform_statuses
return if @processed_params[:statuses].blank?
status = @processed_params[:statuses].first
@message = Message.find_by(source_id: status[:id])
return unless @message
update_message_with_status(@message, status)
end
def update_message_with_status(message, status)
message.status = status[:status]
if status[:status] == 'failed' && status[:errors].present?
error = status[:errors]&.first
message.external_error = "#{error[:code]}: #{error[:title]}"
end
message.save!
end
def perform_messages
return if @processed_params[:messages].blank? || unprocessable_message_type? return if @processed_params[:messages].blank? || unprocessable_message_type?
@message = @conversation.messages.build( @message = @conversation.messages.build(
@ -27,8 +54,6 @@ class Whatsapp::IncomingMessageBaseService
@message.save! @message.save!
end end
private
def processed_params def processed_params
@processed_params ||= params @processed_params ||= params
end end

View file

@ -69,17 +69,18 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
def send_attachment_message(phone_number, message) def send_attachment_message(phone_number, message)
attachment = message.attachments.first attachment = message.attachments.first
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document' type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
attachment_url = attachment.download_url type_content = {
'link': attachment.download_url
}
type_content['caption'] = message.content unless %w[audio sticker].include?(type)
type_content['filename'] = attachment.file.filename if type == 'document'
response = HTTParty.post( response = HTTParty.post(
"#{api_base_path}/messages", "#{api_base_path}/messages",
headers: api_headers, headers: api_headers,
body: { body: {
'to' => phone_number, 'to' => phone_number,
'type' => type, 'type' => type,
type.to_s => { type.to_s => type_content
'link': attachment_url,
'caption': message.content
}
}.to_json }.to_json
) )

View file

@ -69,11 +69,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def send_attachment_message(phone_number, message) def send_attachment_message(phone_number, message)
attachment = message.attachments.first attachment = message.attachments.first
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document' type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
attachment_url = attachment.download_url
type_content = { type_content = {
'link': attachment_url 'link': attachment.download_url
} }
type_content['caption'] = message.content if type != 'audio' type_content['caption'] = message.content unless %w[audio sticker].include?(type)
type_content['filename'] = attachment.file.filename if type == 'document'
response = HTTParty.post( response = HTTParty.post(
"#{phone_id_path}/messages", "#{phone_id_path}/messages",
headers: api_headers, headers: api_headers,

View file

@ -38,5 +38,6 @@ json.muted conversation.muted?
json.snoozed_until conversation.snoozed_until json.snoozed_until conversation.snoozed_until
json.status conversation.status json.status conversation.status
json.timestamp conversation.last_activity_at.to_i json.timestamp conversation.last_activity_at.to_i
json.first_reply_created_at conversation.first_reply_created_at.to_i
json.unread_count conversation.unread_incoming_messages.count json.unread_count conversation.unread_incoming_messages.count
json.last_non_activity_message conversation.messages.non_activity_messages.first.try(:push_event_data) json.last_non_activity_message conversation.messages.non_activity_messages.first.try(:push_event_data)

View file

@ -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)

View file

@ -5,6 +5,7 @@ json.echo_id message.echo_id if message.echo_id
json.conversation_id message.conversation.display_id json.conversation_id message.conversation.display_id
json.message_type message.message_type_before_type_cast json.message_type message.message_type_before_type_cast
json.content_type message.content_type json.content_type message.content_type
json.status message.status
json.content_attributes message.content_attributes json.content_attributes message.content_attributes
json.created_at message.created_at.to_i json.created_at message.created_at.to_i
json.private message.private json.private message.private

View file

@ -30,6 +30,9 @@ Rails.application.configure do
# You can use letter opener for your local development by setting the environment variable # You can use letter opener for your local development by setting the environment variable
config.action_mailer.delivery_method = :letter_opener if Rails.env.development? && ENV['LETTER_OPENER'] config.action_mailer.delivery_method = :letter_opener if Rails.env.development? && ENV['LETTER_OPENER']
# Use sendmail if using postfix for email
config.action_mailer.delivery_method = :sendmail if ENV['SMTP_ADDRESS'].blank?
######################################### #########################################
# Configuration Related to Action MailBox # Configuration Related to Action MailBox
######################################### #########################################

View file

@ -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

View file

@ -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

View 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

View file

@ -26,7 +26,9 @@ describe ::NotificationBuilder do
end end
it 'will not throw error if notification setting is not present' do it 'will not throw error if notification setting is not present' do
perform_enqueued_jobs do
user.account_users.destroy_all user.account_users.destroy_all
end
expect( expect(
described_class.new( described_class.new(
notification_type: 'conversation_creation', notification_type: 'conversation_creation',

View file

@ -5,10 +5,11 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:agent_1) { create(:user, account: account, role: :agent) } let(:agent_1) { create(:user, account: account, role: :agent) }
let(:agent_2) { create(:user, account: account, role: :agent) } let(:agent_2) { create(:user, account: account, role: :agent) }
let(:team_1) { create(:team, account: account) }
before do before do
create(:conversation, account_id: account.id, status: :open) create(:conversation, account_id: account.id, status: :open, team_id: team_1.id)
create(:conversation, account_id: account.id, status: :open) create(:conversation, account_id: account.id, status: :open, team_id: team_1.id)
create(:conversation, account_id: account.id, status: :open) create(:conversation, account_id: account.id, status: :open)
create(:conversation, account_id: account.id, status: :open) create(:conversation, account_id: account.id, status: :open)
end end
@ -55,6 +56,44 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
expect(Conversation.first.assignee_id).to be_nil expect(Conversation.first.assignee_id).to be_nil
end end
it 'Bulk update conversation team id to none' do
params = { type: 'Conversation', fields: { team_id: 0 }, ids: Conversation.first(1).pluck(:display_id) }
expect(Conversation.first.team).not_to be_nil
perform_enqueued_jobs do
post "/api/v1/accounts/#{account.id}/bulk_actions",
headers: agent.create_new_auth_token,
params: params
expect(response).to have_http_status(:success)
end
expect(Conversation.first.team).to be_nil
last_activity_message = Conversation.first.messages.activity.last
expect(last_activity_message.content).to eq("Unassigned from #{team_1.name} by #{agent.name}")
end
it 'Bulk update conversation team id to team' do
params = { type: 'Conversation', fields: { team_id: team_1.id }, ids: Conversation.last(2).pluck(:display_id) }
expect(Conversation.last.team_id).to be_nil
perform_enqueued_jobs do
post "/api/v1/accounts/#{account.id}/bulk_actions",
headers: agent.create_new_auth_token,
params: params
expect(response).to have_http_status(:success)
end
expect(Conversation.last.team).to eq(team_1)
last_activity_message = Conversation.last.messages.activity.last
expect(last_activity_message.content).to eq("Assigned to #{team_1.name} by #{agent.name}")
end
it 'Bulk update conversation assignee id' do it 'Bulk update conversation assignee id' do
params = { type: 'Conversation', fields: { assignee_id: agent_1.id }, ids: Conversation.first(3).pluck(:display_id) } params = { type: 'Conversation', fields: { assignee_id: agent_1.id }, ids: Conversation.first(3).pluck(:display_id) }

View file

@ -53,7 +53,7 @@ RSpec.describe 'Conversations API', type: :request do
get "/api/v1/accounts/#{account.id}/conversations", get "/api/v1/accounts/#{account.id}/conversations",
headers: agent_1.create_new_auth_token, headers: agent_1.create_new_auth_token,
params: { reply_status: 'unattended' }, params: { conversation_type: 'unattended' },
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
@ -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

View file

@ -136,5 +136,15 @@ describe ::ConversationFinder do
expect(result[:conversations].length).to be 25 expect(result[:conversations].length).to be 25
end end
end end
context 'with unattended' do
let(:params) { { status: 'open', assignee_type: 'me', conversation_type: 'unattended' } }
it 'returns unattended conversations' do
create_list(:conversation, 25, account: account, inbox: inbox, assignee: user_1)
result = conversation_finder.perform
expect(result[:conversations].length).to be 25
end
end
end end
end end

View file

@ -1,7 +1,9 @@
require 'rails_helper' require 'rails_helper'
describe Agents::DestroyService do RSpec.describe Agents::DestroyJob, type: :job do
let(:account) { create(:account) } subject(:job) { described_class.perform_later(account, user) }
let!(:account) { create(:account) }
let(:user) { create(:user, account: account) } let(:user) { create(:user, account: account) }
let(:team1) { create(:team, account: account) } let(:team1) { create(:team, account: account) }
let!(:inbox) { create(:inbox, account: account) } let!(:inbox) { create(:inbox, account: account) }
@ -12,9 +14,16 @@ describe Agents::DestroyService do
create(:conversation, account: account, assignee: user, inbox: inbox) create(:conversation, account: account, assignee: user, inbox: inbox)
end end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(account, user)
.on_queue('default')
end
describe '#perform' do describe '#perform' do
it 'remove inboxes, teams, and conversations when removed from account' do it 'remove inboxes, teams, and conversations when removed from account' do
described_class.new(account: account, user: user).perform described_class.perform_now(account, user)
user.reload user.reload
expect(user.teams.length).to eq 0 expect(user.teams.length).to eq 0
expect(user.inboxes.length).to eq 0 expect(user.inboxes.length).to eq 0

View file

@ -3,13 +3,10 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe User do RSpec.describe User do
let!(:account_user) { create(:account_user) } include ActiveJob::TestHelper
let(:agent_destroy_service) { double }
before do let!(:account_user) { create(:account_user) }
allow(Agents::DestroyService).to receive(:new).and_return(agent_destroy_service) let!(:inbox) { create(:inbox, account: account_user.account) }
allow(agent_destroy_service).to receive(:perform).and_return(agent_destroy_service)
end
describe 'notification_settings' do describe 'notification_settings' do
it 'gets created with the right default settings' do it 'gets created with the right default settings' do
@ -22,12 +19,16 @@ RSpec.describe User do
describe 'destroy call agent::destroy service' do describe 'destroy call agent::destroy service' do
it 'gets created with the right default settings' do it 'gets created with the right default settings' do
create(:conversation, account: account_user.account, assignee: account_user.user, inbox: inbox)
user = account_user.user user = account_user.user
account = account_user.account
expect(user.assigned_conversations.count).to eq(1)
perform_enqueued_jobs do
account_user.destroy! account_user.destroy!
expect(Agents::DestroyService).to have_received(:new).with({ end
user: user, account: account
}) expect(user.assigned_conversations.count).to eq(0)
end end
end end
end end

View file

@ -455,6 +455,7 @@ RSpec.describe Conversation, type: :model do
channel: 'Channel::WebWidget', channel: 'Channel::WebWidget',
snoozed_until: conversation.snoozed_until, snoozed_until: conversation.snoozed_until,
custom_attributes: conversation.custom_attributes, custom_attributes: conversation.custom_attributes,
first_reply_created_at: nil,
contact_last_seen_at: conversation.contact_last_seen_at.to_i, contact_last_seen_at: conversation.contact_last_seen_at.to_i,
agent_last_seen_at: conversation.agent_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i,
unread_count: 0 unread_count: 0

View file

@ -27,6 +27,7 @@ RSpec.describe Conversations::EventDataPresenter do
timestamp: conversation.last_activity_at.to_i, timestamp: conversation.last_activity_at.to_i,
snoozed_until: conversation.snoozed_until, snoozed_until: conversation.snoozed_until,
custom_attributes: conversation.custom_attributes, custom_attributes: conversation.custom_attributes,
first_reply_created_at: nil,
contact_last_seen_at: conversation.contact_last_seen_at.to_i, contact_last_seen_at: conversation.contact_last_seen_at.to_i,
agent_last_seen_at: conversation.agent_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i,
unread_count: 0 unread_count: 0

View file

@ -70,6 +70,46 @@ describe Whatsapp::IncomingMessageService do
end end
end end
context 'when valid status params' do
let(:from) { '2423423243' }
let(:contact_inbox) { create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: from) }
let(:params) do
{
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => from }],
'messages' => [{ 'from' => from, 'id' => from, 'text' => { 'body' => 'Test' },
'timestamp' => '1633034394', 'type' => 'text' }]
}.with_indifferent_access
end
before do
create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
end
it 'update status message to read' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'read' }]
}.with_indifferent_access
message = Message.find_by!(source_id: from)
expect(message.status).to eq('sent')
described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform
expect(message.reload.status).to eq('read')
end
it 'update status message to failed' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'failed',
'errors' => [{ 'code': 123, 'title': 'abc' }] }]
}.with_indifferent_access
message = Message.find_by!(source_id: from)
expect(message.status).to eq('sent')
described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform
expect(message.reload.status).to eq('failed')
expect(message.external_error).to eq('123: abc')
end
end
context 'when valid interactive message params' do context 'when valid interactive message params' do
it 'creates appropriate conversations, message and contacts' do it 'creates appropriate conversations, message and contacts' do
params = { params = {

View file

@ -28,7 +28,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
expect(service.send_message('+123456789', message)).to eq 'message_id' expect(service.send_message('+123456789', message)).to eq 'message_id'
end end
it 'calls message endpoints for attachment message messages' do it 'calls message endpoints for image attachment message messages' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image) attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png') attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
@ -37,7 +37,27 @@ describe Whatsapp::Providers::WhatsappCloudService do
body: hash_including({ body: hash_including({
messaging_product: 'whatsapp', messaging_product: 'whatsapp',
to: '+123456789', to: '+123456789',
type: 'image' type: 'image',
image: WebMock::API.hash_including({ caption: message.content, link: anything })
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for document attachment message messages' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/sample.pdf')), filename: 'sample.pdf', content_type: 'application/pdf')
# ref: https://github.com/bblimke/webmock/issues/900
# reason for Webmock::API.hash_including
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'document',
document: WebMock::API.hash_including({ filename: 'sample.pdf', caption: message.content, link: anything })
}) })
) )
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)