Merge branch 'develop' into feat/new-auth-screens
This commit is contained in:
commit
b155f64fe2
54 changed files with 657 additions and 175 deletions
|
@ -60,9 +60,10 @@ MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
|
|||
|
||||
#SMTP domain key is set up for HELO checking
|
||||
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
|
||||
SMTP_ADDRESS=mailhog
|
||||
# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix)
|
||||
SMTP_ADDRESS=
|
||||
SMTP_PORT=1025
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
|
|
@ -16,7 +16,6 @@ Metrics/ClassLength:
|
|||
- 'app/models/message.rb'
|
||||
- 'app/builders/messages/facebook/message_builder.rb'
|
||||
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
|
||||
- 'app/controllers/api/v1/accounts/conversations_controller.rb'
|
||||
- 'app/listeners/action_cable_listener.rb'
|
||||
- 'app/models/conversation.rb'
|
||||
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
|
||||
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?
|
||||
end
|
||||
end
|
||||
|
@ -99,8 +99,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
end
|
||||
|
||||
def set_conversation_status
|
||||
status = params[:status] == 'bot' ? 'pending' : params[:status]
|
||||
@conversation.status = 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 = 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]
|
||||
end
|
||||
|
||||
|
@ -152,26 +154,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
).perform
|
||||
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
|
||||
@conversation_finder ||= ConversationFinder.new(Current.user, params)
|
||||
end
|
||||
|
|
|
@ -113,7 +113,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
|
||||
def inbox_attributes
|
||||
[: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
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
|
|
|
@ -56,7 +56,6 @@ class ConversationFinder
|
|||
filter_by_team if @team
|
||||
filter_by_labels if params[:labels]
|
||||
filter_by_query if params[:q]
|
||||
filter_by_reply_status
|
||||
end
|
||||
|
||||
def set_inboxes
|
||||
|
@ -76,12 +75,9 @@ class ConversationFinder
|
|||
end
|
||||
|
||||
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)
|
||||
end
|
||||
filter_by_conversation_type if params[:conversation_type]
|
||||
@conversations
|
||||
end
|
||||
|
||||
def filter_by_assignee_type
|
||||
|
@ -96,8 +92,15 @@ class ConversationFinder
|
|||
@conversations
|
||||
end
|
||||
|
||||
def filter_by_reply_status
|
||||
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended'
|
||||
def filter_by_conversation_type
|
||||
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
|
||||
|
||||
def filter_by_query
|
||||
|
|
|
@ -335,10 +335,8 @@ export default {
|
|||
status: this.activeStatus,
|
||||
page: this.currentPage + 1,
|
||||
labels: this.label ? [this.label] : undefined,
|
||||
teamId: this.teamId ? this.teamId : undefined,
|
||||
conversationType: this.conversationType
|
||||
? this.conversationType
|
||||
: undefined,
|
||||
teamId: this.teamId || undefined,
|
||||
conversationType: this.conversationType || undefined,
|
||||
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
|
||||
};
|
||||
},
|
||||
|
@ -355,6 +353,9 @@ export default {
|
|||
if (this.conversationType === 'mention') {
|
||||
return this.$t('CHAT_LIST.MENTION_HEADING');
|
||||
}
|
||||
if (this.conversationType === 'unattended') {
|
||||
return this.$t('CHAT_LIST.UNATTENDED_HEADING');
|
||||
}
|
||||
if (this.hasActiveFolders) {
|
||||
return this.activeFolder.name;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ const conversations = accountId => ({
|
|||
'conversation_through_mentions',
|
||||
'folder_conversations',
|
||||
'conversations_through_folders',
|
||||
'conversation_unattended',
|
||||
'conversation_through_unattended',
|
||||
],
|
||||
menuItems: [
|
||||
{
|
||||
|
@ -33,6 +35,13 @@ const conversations = accountId => ({
|
|||
toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
|
||||
toStateName: 'conversation_mentions',
|
||||
},
|
||||
{
|
||||
icon: 'mail-unread',
|
||||
label: 'UNATTENDED_CONVERSATIONS',
|
||||
key: 'conversation_unattended',
|
||||
toState: frontendURL(`accounts/${accountId}/unattended/conversations`),
|
||||
toStateName: 'conversation_unattended',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
@ -233,7 +233,9 @@ export default {
|
|||
node
|
||||
);
|
||||
this.state = this.editorView.state.apply(tr);
|
||||
return this.emitOnChange();
|
||||
this.emitOnChange();
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
insertCannedResponse(cannedItem) {
|
||||
|
@ -241,22 +243,26 @@ export default {
|
|||
return null;
|
||||
}
|
||||
|
||||
const tr = this.editorView.state.tr.insertText(
|
||||
cannedItem,
|
||||
this.range.from,
|
||||
this.range.to
|
||||
let from = this.range.from - 1;
|
||||
let node = addMentionsToMarkdownParser(defaultMarkdownParser).parse(
|
||||
cannedItem
|
||||
);
|
||||
|
||||
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.emitOnChange();
|
||||
|
||||
// Hacky fix for #5501
|
||||
this.state = createState(
|
||||
this.contentFromEditor,
|
||||
this.placeholder,
|
||||
this.plugins
|
||||
);
|
||||
this.editorView.updateState(this.state);
|
||||
this.focusEditorInputField();
|
||||
tr.scrollIntoView();
|
||||
return false;
|
||||
},
|
||||
|
||||
|
|
|
@ -59,10 +59,12 @@
|
|||
:story-sender="storySender"
|
||||
:story-id="storyId"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:is-email="isEmailContentType"
|
||||
:is-private="data.private"
|
||||
:message-type="data.message_type"
|
||||
:message-status="status"
|
||||
:readable-time="readableTime"
|
||||
:source-id="data.source_id"
|
||||
:inbox-id="data.inbox_id"
|
||||
|
@ -157,6 +159,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAWhatsAppChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasInstagramStory: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
@ -231,6 +237,9 @@ export default {
|
|||
sender() {
|
||||
return this.data.sender || {};
|
||||
},
|
||||
status() {
|
||||
return this.data.status;
|
||||
},
|
||||
storySender() {
|
||||
return this.contentAttributes.story_sender || null;
|
||||
},
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
class="message--read ph-no-capture"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:has-user-read-message="
|
||||
hasUserReadMessage(message.created_at, getLastSeenAt)
|
||||
|
@ -60,6 +61,7 @@
|
|||
class="message--unread ph-no-capture"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:has-user-read-message="
|
||||
hasUserReadMessage(message.created_at, getLastSeenAt)
|
||||
|
|
|
@ -3,20 +3,30 @@
|
|||
<span class="time" :class="{ delivered: messageRead }">{{
|
||||
readableTime
|
||||
}}</span>
|
||||
<span v-if="showSentIndicator" class="time">
|
||||
<span v-if="showReadIndicator" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
|
||||
icon="checkmark"
|
||||
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
|
||||
icon="checkmark-double"
|
||||
class="action--icon read-tick read-indicator"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="showDeliveredIndicator" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-if="messageRead"
|
||||
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
|
||||
v-tooltip.top-start="$t('CHAT_LIST.DELIVERED')"
|
||||
icon="checkmark-double"
|
||||
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
|
||||
v-if="isEmail"
|
||||
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
|
||||
|
@ -74,7 +84,7 @@
|
|||
</template>
|
||||
|
||||
<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 inboxMixin from 'shared/mixins/inboxMixin';
|
||||
|
||||
|
@ -117,6 +127,10 @@ export default {
|
|||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
messageStatus: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
sourceId: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
@ -144,6 +158,15 @@ export default {
|
|||
isOutgoing() {
|
||||
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() {
|
||||
const { additional_attributes: additionalAttributes = {} } =
|
||||
this.sender || {};
|
||||
|
@ -168,7 +191,23 @@ export default {
|
|||
return (
|
||||
this.isOutgoing &&
|
||||
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 {
|
||||
.message-text--metadata {
|
||||
align-items: center;
|
||||
.time {
|
||||
color: var(--w-100);
|
||||
}
|
||||
|
||||
.action--icon {
|
||||
color: var(--white);
|
||||
&.read-tick {
|
||||
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 {
|
||||
|
@ -296,4 +339,10 @@ export default {
|
|||
.delivered-icon {
|
||||
margin-left: -var(--space-normal);
|
||||
}
|
||||
|
||||
.read-indicator-wrap {
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -56,6 +56,8 @@ export const conversationUrl = ({
|
|||
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
|
||||
} else if (conversationType === 'mention') {
|
||||
url = `accounts/${accountId}/mentions/conversations/${id}`;
|
||||
} else if (conversationType === 'unattended') {
|
||||
url = `accounts/${accountId}/unattended/conversations/${id}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
},
|
||||
"TAB_HEADING": "Conversations",
|
||||
"MENTION_HEADING": "Mentions",
|
||||
"UNATTENDED_HEADING": "Unattended",
|
||||
"SEARCH": {
|
||||
"INPUT": "Search for People, Chats, Saved Replies .."
|
||||
},
|
||||
|
@ -56,6 +57,8 @@
|
|||
"REPLY_TO_TWEET": "Reply to this tweet",
|
||||
"LINK_TO_STORY": "Go to instagram story",
|
||||
"SENT": "Sent successfully",
|
||||
"READ": "Read successfully",
|
||||
"DELIVERED": "Delivered successfully",
|
||||
"NO_MESSAGES": "No Messages",
|
||||
"NO_CONTENT": "No content available",
|
||||
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
|
||||
|
|
|
@ -388,6 +388,10 @@
|
|||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"ENABLE_HMAC": {
|
||||
"LABEL": "Enable"
|
||||
}
|
||||
|
@ -441,6 +445,8 @@
|
|||
"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_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_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.",
|
||||
|
|
|
@ -177,6 +177,7 @@
|
|||
"CONVERSATIONS": "Conversations",
|
||||
"ALL_CONVERSATIONS": "All Conversations",
|
||||
"MENTIONED_CONVERSATIONS": "Mentions",
|
||||
"UNATTENDED_CONVERSATIONS": "Unattended",
|
||||
"REPORTS": "Reports",
|
||||
"SETTINGS": "Settings",
|
||||
"CONTACTS": "Contacts",
|
||||
|
|
|
@ -121,5 +121,25 @@ export default {
|
|||
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',
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -258,6 +258,28 @@
|
|||
</p>
|
||||
</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">
|
||||
{{ $t('INBOX_MGMT.FEATURES.LABEL') }}
|
||||
</label>
|
||||
|
@ -380,6 +402,7 @@ export default {
|
|||
greetingMessage: '',
|
||||
emailCollectEnabled: false,
|
||||
csatSurveyEnabled: false,
|
||||
locktoSingleConversation: false,
|
||||
allowMessagesAfterResolved: true,
|
||||
continuityViaEmail: true,
|
||||
selectedInboxName: '',
|
||||
|
@ -496,6 +519,9 @@ export default {
|
|||
}
|
||||
return this.inbox.name;
|
||||
},
|
||||
canLocktoSingleConversation() {
|
||||
return this.isASmsInbox || this.isAWhatsAppChannel;
|
||||
},
|
||||
inboxNameLabel() {
|
||||
if (this.isAWebWidgetInbox) {
|
||||
return this.$t('INBOX_MGMT.ADD.WEBSITE_NAME.LABEL');
|
||||
|
@ -567,6 +593,7 @@ export default {
|
|||
this.channelWelcomeTagline = this.inbox.welcome_tagline;
|
||||
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
|
||||
this.replyTime = this.inbox.reply_time;
|
||||
this.locktoSingleConversation = this.inbox.lock_to_single_conversation;
|
||||
});
|
||||
},
|
||||
async updateInbox() {
|
||||
|
@ -579,6 +606,7 @@ export default {
|
|||
allow_messages_after_resolved: this.allowMessagesAfterResolved,
|
||||
greeting_enabled: this.greetingEnabled,
|
||||
greeting_message: this.greetingMessage || '',
|
||||
lock_to_single_conversation: this.locktoSingleConversation,
|
||||
channel: {
|
||||
widget_color: this.inbox.widget_color,
|
||||
website_url: this.channelWebsiteUrl,
|
||||
|
|
|
@ -89,7 +89,19 @@ export const mutations = {
|
|||
},
|
||||
[types.default.ADD_CONTACT_CONVERSATION]: ($state, { id, data }) => {
|
||||
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) => {
|
||||
Vue.delete($state.records, id);
|
||||
|
|
|
@ -7,6 +7,7 @@ import { createPendingMessage } from 'dashboard/helper/commons';
|
|||
import {
|
||||
buildConversationList,
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
} from './helpers/actionHelpers';
|
||||
import messageReadActions from './actions/messageReadActions';
|
||||
// actions
|
||||
|
@ -230,6 +231,7 @@ const actions = {
|
|||
if (
|
||||
!hasAppliedFilters &&
|
||||
!isOnMentionsView(rootState) &&
|
||||
!isOnUnattendedView(rootState) &&
|
||||
isMatchingInboxFilter
|
||||
) {
|
||||
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) {
|
||||
const {
|
||||
meta: { sender },
|
||||
|
|
|
@ -5,33 +5,54 @@ export const findPendingMessageIndex = (chat, message) => {
|
|||
);
|
||||
};
|
||||
|
||||
const filterByStatus = (chatStatus, filterStatus) =>
|
||||
export const filterByStatus = (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) => {
|
||||
const { inboxId, status, labels = [], teamId } = filters;
|
||||
const { inboxId, status, labels = [], teamId, conversationType } = filters;
|
||||
const {
|
||||
status: chatStatus,
|
||||
inbox_id: chatInboxId,
|
||||
labels: chatLabels = [],
|
||||
meta = {},
|
||||
first_reply_created_at: firstReplyOn,
|
||||
} = conversation;
|
||||
const team = meta.team || {};
|
||||
const { id: chatTeamId } = team;
|
||||
|
||||
let shouldFilter = filterByStatus(chatStatus, status);
|
||||
if (inboxId) {
|
||||
const filterByInbox = Number(inboxId) === chatInboxId;
|
||||
shouldFilter = shouldFilter && filterByInbox;
|
||||
}
|
||||
if (teamId) {
|
||||
const filterByTeam = Number(teamId) === chatTeamId;
|
||||
shouldFilter = shouldFilter && filterByTeam;
|
||||
}
|
||||
if (labels.length) {
|
||||
const filterByLabels = labels.every(label => chatLabels.includes(label));
|
||||
shouldFilter = shouldFilter && filterByLabels;
|
||||
}
|
||||
shouldFilter = filterByInbox(shouldFilter, inboxId, chatInboxId);
|
||||
shouldFilter = filterByTeam(shouldFilter, teamId, chatTeamId);
|
||||
shouldFilter = filterByLabel(shouldFilter, labels, chatLabels);
|
||||
shouldFilter = filterByUnattended(
|
||||
shouldFilter,
|
||||
conversationType,
|
||||
firstReplyOn
|
||||
);
|
||||
|
||||
return shouldFilter;
|
||||
};
|
||||
|
|
|
@ -22,6 +22,14 @@ export const isOnMentionsView = ({ route: { name: 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 = (
|
||||
context,
|
||||
requestPayload,
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import {
|
||||
findPendingMessageIndex,
|
||||
applyPageFilters,
|
||||
filterByInbox,
|
||||
filterByTeam,
|
||||
filterByLabel,
|
||||
filterByUnattended,
|
||||
} from '../../conversations/helpers';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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-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-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",
|
||||
"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",
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
export const MESSAGE_STATUS = {
|
||||
FAILED: 'failed',
|
||||
SENT: 'sent',
|
||||
DELIVERED: 'delivered',
|
||||
READ: 'read',
|
||||
PROGRESS: 'progress',
|
||||
};
|
||||
|
||||
|
|
37
app/jobs/agents/destroy_job.rb
Normal file
37
app/jobs/agents/destroy_job.rb
Normal 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
|
|
@ -36,9 +36,9 @@ class ReportingEventListener < BaseListener
|
|||
event_start_time: conversation.created_at,
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_columns(first_reply_created_at: message.created_at)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
conversation.update(first_reply_created_at: message.created_at)
|
||||
|
||||
reporting_event.save!
|
||||
end
|
||||
end
|
||||
|
|
|
@ -46,7 +46,7 @@ class AccountUser < ApplicationRecord
|
|||
end
|
||||
|
||||
def remove_user_from_account
|
||||
::Agents::DestroyService.new(account: account, user: user).perform
|
||||
::Agents::DestroyJob.perform_later(account, user)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -73,7 +73,8 @@ module ActivityMessageHandler
|
|||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
|
|
@ -219,7 +219,7 @@ class Conversation < ApplicationRecord
|
|||
|
||||
def notify_conversation_updation
|
||||
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)
|
||||
end
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
# enable_email_collect :boolean default(TRUE)
|
||||
# greeting_enabled :boolean default(FALSE)
|
||||
# greeting_message :string
|
||||
# lock_to_single_conversation :boolean default(FALSE), not null
|
||||
# name :string not null
|
||||
# out_of_office_message :string
|
||||
# timezone :string default("UTC")
|
||||
|
|
|
@ -65,8 +65,9 @@ class Message < ApplicationRecord
|
|||
# [:in_reply_to] : Used to reply to a particular tweet in threads
|
||||
# [: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_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,
|
||||
: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
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
|||
custom_attributes: custom_attributes,
|
||||
snoozed_until: snoozed_until,
|
||||
unread_count: unread_incoming_messages.count,
|
||||
first_reply_created_at: first_reply_created_at,
|
||||
**push_timestamps
|
||||
}
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -7,11 +7,38 @@ class Whatsapp::IncomingMessageBaseService
|
|||
def perform
|
||||
processed_params
|
||||
|
||||
perform_statuses
|
||||
|
||||
set_contact
|
||||
return unless @contact
|
||||
|
||||
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?
|
||||
|
||||
@message = @conversation.messages.build(
|
||||
|
@ -27,8 +54,6 @@ class Whatsapp::IncomingMessageBaseService
|
|||
@message.save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def processed_params
|
||||
@processed_params ||= params
|
||||
end
|
||||
|
|
|
@ -69,17 +69,18 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
|
|||
def send_attachment_message(phone_number, message)
|
||||
attachment = message.attachments.first
|
||||
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(
|
||||
"#{api_base_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
'to' => phone_number,
|
||||
'type' => type,
|
||||
type.to_s => {
|
||||
'link': attachment_url,
|
||||
'caption': message.content
|
||||
}
|
||||
type.to_s => type_content
|
||||
}.to_json
|
||||
)
|
||||
|
||||
|
|
|
@ -69,11 +69,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
|||
def send_attachment_message(phone_number, message)
|
||||
attachment = message.attachments.first
|
||||
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
|
||||
attachment_url = attachment.download_url
|
||||
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(
|
||||
"#{phone_id_path}/messages",
|
||||
headers: api_headers,
|
||||
|
|
|
@ -38,5 +38,6 @@ json.muted conversation.muted?
|
|||
json.snoozed_until conversation.snoozed_until
|
||||
json.status conversation.status
|
||||
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.last_non_activity_message conversation.messages.non_activity_messages.first.try(:push_event_data)
|
||||
|
|
|
@ -15,12 +15,13 @@ json.working_hours resource.weekly_schedule
|
|||
json.timezone resource.timezone
|
||||
json.callback_webhook_url resource.callback_webhook_url
|
||||
json.allow_messages_after_resolved resource.allow_messages_after_resolved
|
||||
|
||||
json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter?
|
||||
json.lock_to_single_conversation resource.lock_to_single_conversation
|
||||
|
||||
## Channel specific settings
|
||||
## TODO : Clean up and move the attributes into channel sub section
|
||||
|
||||
json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter?
|
||||
|
||||
## WebWidget Attributes
|
||||
json.widget_color resource.channel.try(:widget_color)
|
||||
json.website_url resource.channel.try(:website_url)
|
||||
|
|
|
@ -5,6 +5,7 @@ json.echo_id message.echo_id if message.echo_id
|
|||
json.conversation_id message.conversation.display_id
|
||||
json.message_type message.message_type_before_type_cast
|
||||
json.content_type message.content_type
|
||||
json.status message.status
|
||||
json.content_attributes message.content_attributes
|
||||
json.created_at message.created_at.to_i
|
||||
json.private message.private
|
||||
|
|
|
@ -30,6 +30,9 @@ Rails.application.configure do
|
|||
# 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']
|
||||
|
||||
# Use sendmail if using postfix for email
|
||||
config.action_mailer.delivery_method = :sendmail if ENV['SMTP_ADDRESS'].blank?
|
||||
|
||||
#########################################
|
||||
# Configuration Related to Action MailBox
|
||||
#########################################
|
||||
|
|
|
@ -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 "allow_messages_after_resolved", default: true
|
||||
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"
|
||||
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
|
|
@ -26,7 +26,9 @@ describe ::NotificationBuilder do
|
|||
end
|
||||
|
||||
it 'will not throw error if notification setting is not present' do
|
||||
perform_enqueued_jobs do
|
||||
user.account_users.destroy_all
|
||||
end
|
||||
expect(
|
||||
described_class.new(
|
||||
notification_type: 'conversation_creation',
|
||||
|
|
|
@ -5,10 +5,11 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
|||
let(:account) { create(:account) }
|
||||
let(:agent_1) { create(:user, account: account, role: :agent) }
|
||||
let(:agent_2) { create(:user, account: account, role: :agent) }
|
||||
let(:team_1) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
create(:conversation, account_id: account.id, status: :open)
|
||||
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, team_id: team_1.id)
|
||||
create(:conversation, account_id: account.id, status: :open)
|
||||
create(:conversation, account_id: account.id, status: :open)
|
||||
end
|
||||
|
@ -55,6 +56,44 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
|||
expect(Conversation.first.assignee_id).to be_nil
|
||||
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
|
||||
params = { type: 'Conversation', fields: { assignee_id: agent_1.id }, ids: Conversation.first(3).pluck(:display_id) }
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ RSpec.describe 'Conversations API', type: :request do
|
|||
|
||||
get "/api/v1/accounts/#{account.id}/conversations",
|
||||
headers: agent_1.create_new_auth_token,
|
||||
params: { reply_status: 'unattended' },
|
||||
params: { conversation_type: 'unattended' },
|
||||
as: :json
|
||||
|
||||
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
|
||||
# Added for backwards compatibility for bot status
|
||||
it 'creates a conversation as pending if status is specified as bot' do
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
post "/api/v1/accounts/#{account.id}/conversations",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { source_id: contact_inbox.source_id, status: 'bot' },
|
||||
as: :json
|
||||
# remove this in subsequent release
|
||||
# it 'creates a conversation as pending if status is specified as bot' do
|
||||
# allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
# post "/api/v1/accounts/#{account.id}/conversations",
|
||||
# headers: agent.create_new_auth_token,
|
||||
# params: { source_id: contact_inbox.source_id, status: 'bot' },
|
||||
# as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(response_data[:status]).to eq('pending')
|
||||
end
|
||||
# expect(response).to have_http_status(:success)
|
||||
# response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
# expect(response_data[:status]).to eq('pending')
|
||||
# end
|
||||
|
||||
it 'creates a new conversation with message when message is passed' do
|
||||
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
|
||||
# Added for backwards compatibility for bot status
|
||||
it 'toggles the conversation status to pending status when parameter bot is passed' do
|
||||
expect(conversation.status).to eq('open')
|
||||
# remove in next release
|
||||
# it 'toggles the conversation status to pending status when parameter bot is passed' do
|
||||
# expect(conversation.status).to eq('open')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { status: 'bot' },
|
||||
as: :json
|
||||
# post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
# headers: agent.create_new_auth_token,
|
||||
# params: { status: 'bot' },
|
||||
# as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(conversation.reload.status).to eq('pending')
|
||||
end
|
||||
# expect(response).to have_http_status(:success)
|
||||
# expect(conversation.reload.status).to eq('pending')
|
||||
# end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -136,5 +136,15 @@ describe ::ConversationFinder do
|
|||
expect(result[:conversations].length).to be 25
|
||||
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
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe Agents::DestroyService do
|
||||
let(:account) { create(:account) }
|
||||
RSpec.describe Agents::DestroyJob, type: :job do
|
||||
subject(:job) { described_class.perform_later(account, user) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:team1) { create(:team, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
|
@ -12,9 +14,16 @@ describe Agents::DestroyService do
|
|||
create(:conversation, account: account, assignee: user, inbox: inbox)
|
||||
end
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.with(account, user)
|
||||
.on_queue('default')
|
||||
end
|
||||
|
||||
describe '#perform' 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
|
||||
expect(user.teams.length).to eq 0
|
||||
expect(user.inboxes.length).to eq 0
|
|
@ -3,13 +3,10 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe User do
|
||||
let!(:account_user) { create(:account_user) }
|
||||
let(:agent_destroy_service) { double }
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
before do
|
||||
allow(Agents::DestroyService).to receive(:new).and_return(agent_destroy_service)
|
||||
allow(agent_destroy_service).to receive(:perform).and_return(agent_destroy_service)
|
||||
end
|
||||
let!(:account_user) { create(:account_user) }
|
||||
let!(:inbox) { create(:inbox, account: account_user.account) }
|
||||
|
||||
describe 'notification_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
|
||||
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
|
||||
account = account_user.account
|
||||
|
||||
expect(user.assigned_conversations.count).to eq(1)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
account_user.destroy!
|
||||
expect(Agents::DestroyService).to have_received(:new).with({
|
||||
user: user, account: account
|
||||
})
|
||||
end
|
||||
|
||||
expect(user.assigned_conversations.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -455,6 +455,7 @@ RSpec.describe Conversation, type: :model do
|
|||
channel: 'Channel::WebWidget',
|
||||
snoozed_until: conversation.snoozed_until,
|
||||
custom_attributes: conversation.custom_attributes,
|
||||
first_reply_created_at: nil,
|
||||
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
|
||||
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
|
||||
unread_count: 0
|
||||
|
|
|
@ -27,6 +27,7 @@ RSpec.describe Conversations::EventDataPresenter do
|
|||
timestamp: conversation.last_activity_at.to_i,
|
||||
snoozed_until: conversation.snoozed_until,
|
||||
custom_attributes: conversation.custom_attributes,
|
||||
first_reply_created_at: nil,
|
||||
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
|
||||
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
|
||||
unread_count: 0
|
||||
|
|
|
@ -70,6 +70,46 @@ describe Whatsapp::IncomingMessageService do
|
|||
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
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
params = {
|
||||
|
|
|
@ -28,7 +28,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||
expect(service.send_message('+123456789', message)).to eq 'message_id'
|
||||
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.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({
|
||||
messaging_product: 'whatsapp',
|
||||
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)
|
||||
|
|
Loading…
Reference in a new issue