Compare commits
19 commits
develop
...
feat-searc
Author | SHA1 | Date | |
---|---|---|---|
|
228167823c | ||
|
c618064b13 | ||
|
d23b4a45ad | ||
|
4af9d00d6f | ||
|
e8bd066f93 | ||
|
4dc691e29c | ||
|
6c07ab8bb6 | ||
|
a98bffbe2b | ||
|
4b674a167a | ||
|
bd4b051460 | ||
|
963980d13b | ||
|
c498668345 | ||
|
cb388d93e6 | ||
|
7ba0666cb1 | ||
|
75b7f8f0c4 | ||
|
9b8f0e0152 | ||
|
92ea452680 | ||
|
65de030244 | ||
|
54eab87806 |
57 changed files with 1716 additions and 144 deletions
10
Gemfile.lock
10
Gemfile.lock
|
@ -427,14 +427,14 @@ GEM
|
|||
netrc (0.11.0)
|
||||
newrelic_rpm (8.9.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.9)
|
||||
nokogiri (1.13.10)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.9-arm64-darwin)
|
||||
nokogiri (1.13.10-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.9-x86_64-darwin)
|
||||
nokogiri (1.13.10-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.9-x86_64-linux)
|
||||
nokogiri (1.13.10-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (0.5.10)
|
||||
orm_adapter (0.5.0)
|
||||
|
@ -459,7 +459,7 @@ GEM
|
|||
pundit (2.2.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.6.0)
|
||||
racc (1.6.1)
|
||||
rack (2.2.4)
|
||||
rack-attack (6.6.1)
|
||||
rack (>= 1.0, < 3)
|
||||
|
|
|
@ -46,6 +46,7 @@ class Messages::Messenger::MessageBuilder
|
|||
end
|
||||
|
||||
def update_attachment_file_type(attachment)
|
||||
return if @message.reload.attachments.blank?
|
||||
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
|
||||
|
||||
attachment.file_type = file_type(attachment.file&.content_type)
|
||||
|
@ -62,6 +63,7 @@ class Messages::Messenger::MessageBuilder
|
|||
story_sender = result['from']['username']
|
||||
message.content_attributes[:story_sender] = story_sender
|
||||
message.content_attributes[:story_id] = story_id
|
||||
message.content_attributes[:image_type] = 'story_mention'
|
||||
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
|
||||
message.save!
|
||||
end
|
||||
|
@ -74,6 +76,7 @@ class Messages::Messenger::MessageBuilder
|
|||
raise
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
Rails.logger.error e
|
||||
{}
|
||||
|
|
|
@ -2,8 +2,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
include Events::Types
|
||||
include DateRangeHelper
|
||||
|
||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
||||
before_action :inbox, :contact, :contact_inbox, only: [:create]
|
||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter, :text_search]
|
||||
before_action :inbox, :contact, :contact_inbox, :text_search, only: [:create]
|
||||
|
||||
def index
|
||||
result = conversation_finder.perform
|
||||
|
@ -11,6 +11,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
@conversations_count = result[:count]
|
||||
end
|
||||
|
||||
def text_search
|
||||
@result = TextSearch.new(Current.user, params).perform
|
||||
end
|
||||
|
||||
def meta
|
||||
result = conversation_finder.perform
|
||||
@conversations_count = result[:count]
|
||||
|
|
|
@ -24,7 +24,6 @@ class DashboardController < ActionController::Base
|
|||
'API_CHANNEL_NAME',
|
||||
'API_CHANNEL_THUMBNAIL',
|
||||
'ANALYTICS_TOKEN',
|
||||
'ANALYTICS_HOST',
|
||||
'DIRECT_UPLOADS_ENABLED',
|
||||
'HCAPTCHA_SITE_KEY',
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
|
|
36
app/finders/text_search.rb
Normal file
36
app/finders/text_search.rb
Normal file
|
@ -0,0 +1,36 @@
|
|||
class TextSearch
|
||||
attr_reader :current_user, :current_account, :params
|
||||
|
||||
DEFAULT_STATUS = 'open'.freeze
|
||||
|
||||
def initialize(current_user, params)
|
||||
@current_account = current_user.account || Current.account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def perform
|
||||
{
|
||||
messages: filter_messages,
|
||||
conversations: filter_conversations,
|
||||
contacts: filter_contacts
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_conversations
|
||||
conversation_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account,
|
||||
searchable_type: 'Conversation').pluck(:searchable_id)
|
||||
@conversations = Conversation.where(id: conversation_ids)
|
||||
end
|
||||
|
||||
def filter_messages
|
||||
message_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account, searchable_type: 'Message').pluck(:searchable_id)
|
||||
@messages = Message.where(id: message_ids)
|
||||
end
|
||||
|
||||
def filter_contacts
|
||||
contact_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account, searchable_type: 'Contact').pluck(:searchable_id)
|
||||
@contacts = Contact.where(id: contact_ids)
|
||||
end
|
||||
end
|
|
@ -45,6 +45,12 @@ class ConversationApi extends ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
textSearch(query) {
|
||||
return axios.post(`${this.url}/text_search`, null, {
|
||||
params: { query },
|
||||
});
|
||||
}
|
||||
|
||||
toggleStatus({ conversationId, status, snoozedUntil = null }) {
|
||||
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
|
||||
status,
|
||||
|
|
|
@ -47,6 +47,9 @@ import {
|
|||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
|
||||
const createState = (content, placeholder, plugins = []) => {
|
||||
return EditorState.create({
|
||||
|
@ -268,6 +271,7 @@ export default {
|
|||
);
|
||||
this.state = this.editorView.state.apply(tr);
|
||||
this.emitOnChange();
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS);
|
||||
|
||||
return false;
|
||||
},
|
||||
|
@ -297,6 +301,7 @@ export default {
|
|||
this.emitOnChange();
|
||||
|
||||
tr.scrollIntoView();
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
return false;
|
||||
},
|
||||
|
||||
|
|
|
@ -161,6 +161,9 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
|
|||
import { trimContent, debounce } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
|
||||
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
|
||||
|
||||
|
@ -698,6 +701,7 @@ export default {
|
|||
},
|
||||
replaceText(message) {
|
||||
setTimeout(() => {
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
this.message = message;
|
||||
}, 100);
|
||||
},
|
||||
|
|
|
@ -54,19 +54,6 @@
|
|||
size="16"
|
||||
/>
|
||||
</button>
|
||||
<a
|
||||
v-if="hasInstagramStory && (isIncoming || isOutgoing) && linkToStory"
|
||||
:href="linkToStory"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.LINK_TO_STORY')"
|
||||
icon="open"
|
||||
class="action--icon cursor-pointer"
|
||||
size="16"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
|
||||
:href="linkToTweet"
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
export const EXECUTED_A_MACRO = 'Executed a macro';
|
||||
export const SENT_MESSAGE = 'Sent a message';
|
||||
export const SENT_PRIVATE_NOTE = 'Sent a private note';
|
||||
export const INSERTED_A_CANNED_RESPONSE = 'Inserted a canned response';
|
||||
export const USED_MENTIONS = 'Used mentions';
|
||||
export const MERGED_CONTACTS = 'Used merge contact option';
|
||||
export const ADDED_TO_CANNED_RESPONSE = 'Used added to canned response option';
|
||||
export const ADDED_A_CUSTOM_ATTRIBUTE = 'Added a custom attribute';
|
||||
export const ADDED_AN_INBOX = 'Added an inbox';
|
67
app/javascript/dashboard/helper/AnalyticsHelper/index.js
Normal file
67
app/javascript/dashboard/helper/AnalyticsHelper/index.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { AnalyticsBrowser } from '@june-so/analytics-next';
|
||||
|
||||
class AnalyticsHelper {
|
||||
constructor({ token: analyticsToken } = {}) {
|
||||
this.analyticsToken = analyticsToken;
|
||||
this.analytics = null;
|
||||
this.user = {};
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.analyticsToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
let [analytics] = await AnalyticsBrowser.load({
|
||||
writeKey: this.analyticsToken,
|
||||
});
|
||||
this.analytics = analytics;
|
||||
}
|
||||
|
||||
identify(user) {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
this.user = user;
|
||||
this.analytics.identify(this.user.email, {
|
||||
userId: this.user.id,
|
||||
email: this.user.email,
|
||||
name: this.user.name,
|
||||
avatar: this.user.avatar_url,
|
||||
});
|
||||
|
||||
const { accounts, account_id: accountId } = this.user;
|
||||
const [currentAccount] = accounts.filter(
|
||||
account => account.id === accountId
|
||||
);
|
||||
if (currentAccount) {
|
||||
this.analytics.group(currentAccount.id, this.user.id, {
|
||||
name: currentAccount.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
track(eventName, properties = {}) {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.track({
|
||||
userId: this.user.id,
|
||||
event: eventName,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
|
||||
page(params) {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.page(params);
|
||||
}
|
||||
}
|
||||
|
||||
export * as ANALYTICS_EVENTS from './events';
|
||||
|
||||
export default new AnalyticsHelper(window.analyticsConfig);
|
|
@ -17,13 +17,22 @@ const formatArray = params => {
|
|||
return params;
|
||||
};
|
||||
|
||||
const generatePayloadForObject = item => {
|
||||
if (item.action_params.id) {
|
||||
item.action_params = [item.action_params.id];
|
||||
} else {
|
||||
item.action_params = [item.action_params];
|
||||
}
|
||||
return item.action_params;
|
||||
};
|
||||
|
||||
const generatePayload = data => {
|
||||
const actions = JSON.parse(JSON.stringify(data));
|
||||
let payload = actions.map(item => {
|
||||
if (Array.isArray(item.action_params)) {
|
||||
item.action_params = formatArray(item.action_params);
|
||||
} else if (typeof item.action_params === 'object') {
|
||||
item.action_params = [item.action_params.id];
|
||||
item.action_params = generatePayloadForObject(item);
|
||||
} else if (!item.action_params) {
|
||||
item.action_params = [];
|
||||
} else {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import posthog from 'posthog-js';
|
||||
import AnalyticsHelper from './AnalyticsHelper';
|
||||
|
||||
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
|
||||
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
|
||||
|
@ -8,16 +8,9 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
|
|||
|
||||
export const initializeAnalyticsEvents = () => {
|
||||
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
|
||||
if (window.analyticsConfig) {
|
||||
posthog.identify(user.id, { name: user.name, email: user.email });
|
||||
}
|
||||
});
|
||||
|
||||
window.bus.$on(ANALYTICS_RESET, () => {
|
||||
if (window.analyticsConfig) {
|
||||
posthog.reset();
|
||||
}
|
||||
AnalyticsHelper.identify(user);
|
||||
});
|
||||
window.bus.$on(ANALYTICS_RESET, () => {});
|
||||
};
|
||||
|
||||
export const initializeChatwootEvents = () => {
|
||||
|
|
|
@ -97,6 +97,7 @@
|
|||
"SEARCH_PLACEHOLDER": "Search or jump to",
|
||||
"SECTIONS": {
|
||||
"GENERAL": "General",
|
||||
"CHATWOOT": "Search Chatwoot",
|
||||
"REPORTS": "Reports",
|
||||
"CONVERSATION": "Conversation",
|
||||
"CHANGE_ASSIGNEE": "Change Assignee",
|
||||
|
@ -107,6 +108,7 @@
|
|||
},
|
||||
"COMMANDS": {
|
||||
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
|
||||
"SEARCH_EVEYTHING": "Search everything",
|
||||
"GO_TO_CONTACTS_DASHBOARD": "Go to Contacts Dashboard",
|
||||
"GO_TO_REPORTS_OVERVIEW": "Go to Reports Overview",
|
||||
"GO_TO_CONVERSATION_REPORTS": "Go to Conversation Reports",
|
||||
|
|
|
@ -24,6 +24,9 @@ import MergeContact from 'dashboard/modules/contact/components/MergeContact';
|
|||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
import { mapGetters } from 'vuex';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../helper/AnalyticsHelper';
|
||||
|
||||
export default {
|
||||
components: { MergeContact },
|
||||
|
@ -72,6 +75,7 @@ export default {
|
|||
}
|
||||
},
|
||||
async onMergeContacts(childContactId) {
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.MERGED_CONTACTS);
|
||||
try {
|
||||
await this.$store.dispatch('contacts/merge', {
|
||||
childId: childContactId,
|
||||
|
|
|
@ -72,6 +72,9 @@ import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned
|
|||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -127,6 +130,7 @@ export default {
|
|||
this.$emit('toggle', false);
|
||||
},
|
||||
showCannedResponseModal() {
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.ADDED_TO_CANNED_RESPONSE);
|
||||
this.isCannedResponseModalOpen = true;
|
||||
},
|
||||
},
|
||||
|
|
|
@ -26,3 +26,4 @@ export const ICON_LABELS = `<svg role="img" class="ninja-icon ninja-icon--fluent
|
|||
export const ICON_ACCOUNT_SETTINGS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.75 3h6.5a.75.75 0 0 1 .743.648L16 3.75V7h1.75A3.25 3.25 0 0 1 21 10.25v6.5A3.25 3.25 0 0 1 17.75 20H6.25A3.25 3.25 0 0 1 3 16.75v-6.5A3.25 3.25 0 0 1 6.25 7H8V3.75a.75.75 0 0 1 .648-.743L8.75 3h6.5-6.5Zm9 5.5H6.25a1.75 1.75 0 0 0-1.75 1.75v6.5c0 .966.784 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-6.5a1.75 1.75 0 0 0-1.75-1.75Zm-3.25-4h-5V7h5V4.5Z" fill="currentColor"/></svg>`;
|
||||
export const ICON_INBOXES = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="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 3h11.5-11.5ZM4.5 14.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-.188H4.5v3.25-3.25Zm13.25-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.5Z" fill="currentColor"/></svg>`;
|
||||
export const ICON_APPS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m18.492 2.33 3.179 3.179a2.25 2.25 0 0 1 0 3.182l-2.584 2.584A2.25 2.25 0 0 1 21 13.5v5.25A2.25 2.25 0 0 1 18.75 21H5.25A2.25 2.25 0 0 1 3 18.75V5.25A2.25 2.25 0 0 1 5.25 3h5.25a2.25 2.25 0 0 1 2.225 1.915L15.31 2.33a2.25 2.25 0 0 1 3.182 0ZM4.5 18.75c0 .414.336.75.75.75l5.999-.001.001-6.75H4.5v6Zm8.249.749h6.001a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75h-6.001v6.75Zm-2.249-15H5.25a.75.75 0 0 0-.75.75v6h6.75v-6a.75.75 0 0 0-.75-.75Zm2.25 4.81v1.94h1.94l-1.94-1.94Zm3.62-5.918-3.178 3.178a.75.75 0 0 0 0 1.061l3.179 3.179a.75.75 0 0 0 1.06 0l3.18-3.179a.75.75 0 0 0 0-1.06l-3.18-3.18a.75.75 0 0 0-1.06 0Z" fill="currentColor"/></svg>`;
|
||||
export const SEARCH = `<svg class="ninja-icon ninja-icon--fluent" width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M10 2.75a7.25 7.25 0 0 1 5.63 11.819l4.9 4.9a.75.75 0 0 1-.976 1.134l-.084-.073l-4.901-4.9A7.25 7.25 0 1 1 10 2.75Zm0 1.5a5.75 5.75 0 1 0 0 11.5a5.75 5.75 0 0 0 0-11.5Z"/></svg>`;
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
hideBreadcrumbs
|
||||
:placeholder="placeholder"
|
||||
@selected="setCommandbarData"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
@ -17,6 +18,7 @@ import agentMixin from 'dashboard/mixins/agentMixin';
|
|||
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
|
||||
import conversationTeamMixin from 'dashboard/mixins/conversation/teamMixin';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
|
@ -53,8 +55,28 @@ export default {
|
|||
this.setCommandbarData();
|
||||
},
|
||||
methods: {
|
||||
setCommandbarData() {
|
||||
this.$refs.ninjakeys.data = this.hotKeys;
|
||||
setCommandbarData(e) {
|
||||
if (e && e.detail.action.id && e.detail.action.title) {
|
||||
const action = e.detail.action;
|
||||
if (action.type === 'contact') {
|
||||
this.$refs.ninjakeys.close();
|
||||
this.$router.push(
|
||||
frontendURL(`accounts/${this.accountId}/contacts/${action.key}`)
|
||||
);
|
||||
} else if (action.type === 'message') {
|
||||
this.$refs.ninjakeys.close();
|
||||
this.$router.push(
|
||||
frontendURL(
|
||||
`accounts/${this.accountId}/conversations/${action.key}`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.$refs.ninjakeys.data = this.hotKeys;
|
||||
}
|
||||
},
|
||||
onChange(ninjaKeyInstance) {
|
||||
// console.log(ninjaKeyInstance);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -20,6 +20,41 @@ import { mapGetters } from 'vuex';
|
|||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
|
||||
const GO_TO_COMMANDS = [
|
||||
{
|
||||
id: 'search_everything',
|
||||
title: 'COMMAND_BAR.COMMANDS.SEARCH_EVEYTHING',
|
||||
section: 'COMMAND_BAR.SECTIONS.CHATWOOT',
|
||||
icon: ICON_CONVERSATION_DASHBOARD,
|
||||
builder: searchKey => {
|
||||
return new Promise(resolve => {
|
||||
fetch(`/api/v1/accounts/1/conversations/text_search?q=${searchKey}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
api_access_token: '',
|
||||
},
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const contacts = data.payload.contacts.map(result => ({
|
||||
id: result.id,
|
||||
title: result.name,
|
||||
type: 'contact',
|
||||
}));
|
||||
const messages = data.payload.messages.map(result => ({
|
||||
id: result.conversation_id,
|
||||
title: result.content,
|
||||
type: 'message',
|
||||
}));
|
||||
const flattened = [...contacts, ...messages];
|
||||
return resolve(flattened);
|
||||
})
|
||||
.catch(error => {
|
||||
resolve([]);
|
||||
});
|
||||
});
|
||||
},
|
||||
role: ['agent'],
|
||||
},
|
||||
{
|
||||
id: 'goto_conversation_dashboard',
|
||||
title: 'COMMAND_BAR.COMMANDS.GO_TO_CONVERSATION_DASHBOARD',
|
||||
|
@ -180,18 +215,25 @@ export default {
|
|||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!this.isAdmin) {
|
||||
commands = commands.filter(command => command.role.includes('agent'));
|
||||
}
|
||||
|
||||
return commands.map(command => ({
|
||||
id: command.id,
|
||||
section: this.$t(command.section),
|
||||
title: this.$t(command.title),
|
||||
icon: command.icon,
|
||||
handler: () => this.openRoute(command.path(this.accountId)),
|
||||
}));
|
||||
return commands.map(command => {
|
||||
const hotKey = {
|
||||
id: command.id,
|
||||
section: this.$t(command.section),
|
||||
title: this.$t(command.title),
|
||||
icon: command.icon,
|
||||
};
|
||||
if (command.builder) {
|
||||
hotKey.builder = command.builder;
|
||||
} else {
|
||||
hotKey.handler = () => {
|
||||
this.openRoute(command.path(this.accountId));
|
||||
};
|
||||
}
|
||||
return hotKey;
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -35,6 +35,9 @@
|
|||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import MacroPreview from './MacroPreview';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../../helper/AnalyticsHelper';
|
||||
export default {
|
||||
components: {
|
||||
MacroPreview,
|
||||
|
@ -64,6 +67,7 @@ export default {
|
|||
macroId: macro.id,
|
||||
conversationIds: [this.conversationId],
|
||||
});
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.EXECUTED_A_MACRO);
|
||||
this.showAlert(this.$t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY'));
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('MACROS.ERROR'));
|
||||
|
|
|
@ -171,11 +171,12 @@
|
|||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { required, url, minLength } from 'vuelidate/lib/validators';
|
||||
import { required } from 'vuelidate/lib/validators';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import campaignMixin from 'shared/mixins/campaignMixin';
|
||||
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
|
||||
import { URLPattern } from 'urlpattern-polyfill';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -221,8 +222,23 @@ export default {
|
|||
},
|
||||
endPoint: {
|
||||
required,
|
||||
minLength: minLength(7),
|
||||
url,
|
||||
shouldBeAValidURLPattern(value) {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
new URLPattern(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
shouldStartWithHTTP(value) {
|
||||
if (value) {
|
||||
return (
|
||||
value.startsWith('https://') || value.startsWith('http://')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
timeOnPage: {
|
||||
required,
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
<label :class="{ error: $v.selectedInbox.$error }">
|
||||
{{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }}
|
||||
<select v-model="selectedInbox" @change="onChangeInbox($event)">
|
||||
<option v-for="item in inboxes" :key="item.name" :value="item.id">
|
||||
<option v-for="item in inboxes" :key="item.id" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
@ -111,10 +111,12 @@
|
|||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { required, url, minLength } from 'vuelidate/lib/validators';
|
||||
import { required } from 'vuelidate/lib/validators';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import campaignMixin from 'shared/mixins/campaignMixin';
|
||||
import { URLPattern } from 'urlpattern-polyfill';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootMessageEditor,
|
||||
|
@ -152,8 +154,21 @@ export default {
|
|||
},
|
||||
endPoint: {
|
||||
required,
|
||||
minLength: minLength(7),
|
||||
url,
|
||||
shouldBeAValidURLPattern(value) {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
new URLPattern(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
shouldStartWithHTTP(value) {
|
||||
if (value) {
|
||||
return value.startsWith('https://') || value.startsWith('http://');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
timeOnPage: {
|
||||
required,
|
||||
|
|
|
@ -7,6 +7,7 @@ import dashboard from './dashboard/dashboard.routes';
|
|||
import login from './login/login.routes';
|
||||
import store from '../store';
|
||||
import { validateLoggedInRoutes } from '../helper/routeHelpers';
|
||||
import AnalyticsHelper from '../helper/AnalyticsHelper';
|
||||
|
||||
const routes = [...login.routes, ...dashboard.routes, ...authRoute.routes];
|
||||
|
||||
|
@ -117,6 +118,11 @@ export const validateRouteAccess = (to, from, next, { getters }) => {
|
|||
export const initalizeRouter = () => {
|
||||
const userAuthentication = store.dispatch('setUser');
|
||||
router.beforeEach((to, from, next) => {
|
||||
AnalyticsHelper.page(to.name || '', {
|
||||
path: to.path,
|
||||
name: to.name,
|
||||
});
|
||||
|
||||
if (validateSSOLoginParams(to)) {
|
||||
clearBrowserSessionCookies();
|
||||
next();
|
||||
|
|
|
@ -10,6 +10,9 @@ import {
|
|||
isOnUnattendedView,
|
||||
} from './helpers/actionHelpers';
|
||||
import messageReadActions from './actions/messageReadActions';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
// actions
|
||||
const actions = {
|
||||
getConversation: async ({ commit }, conversationId) => {
|
||||
|
@ -171,6 +174,11 @@ const actions = {
|
|||
status: MESSAGE_STATUS.PROGRESS,
|
||||
});
|
||||
const response = await MessageApi.create(pendingMessage);
|
||||
AnalyticsHelper.track(
|
||||
pendingMessage.private
|
||||
? ANALYTICS_EVENTS.SENT_PRIVATE_NOTE
|
||||
: ANALYTICS_EVENTS.SENT_MESSAGE
|
||||
);
|
||||
commit(types.ADD_MESSAGE, {
|
||||
...response.data,
|
||||
status: MESSAGE_STATUS.SENT,
|
||||
|
|
|
@ -6,6 +6,9 @@ import WebChannel from '../../api/channel/webChannel';
|
|||
import FBChannel from '../../api/channel/fbChannel';
|
||||
import TwilioChannel from '../../api/channel/twilioChannel';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../helper/AnalyticsHelper';
|
||||
|
||||
const buildInboxData = inboxParams => {
|
||||
const formData = new FormData();
|
||||
|
@ -117,6 +120,12 @@ export const getters = {
|
|||
},
|
||||
};
|
||||
|
||||
const sendAnalyticsEvent = channelType => {
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.ADDED_AN_INBOX, {
|
||||
channelType,
|
||||
});
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
|
||||
|
@ -134,6 +143,8 @@ export const actions = {
|
|||
const response = await WebChannel.create(params);
|
||||
commit(types.default.ADD_INBOXES, response.data);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
const { channel = {} } = params;
|
||||
sendAnalyticsEvent(channel.type);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
|
@ -146,6 +157,7 @@ export const actions = {
|
|||
const response = await WebChannel.create(buildInboxData(params));
|
||||
commit(types.default.ADD_INBOXES, response.data);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
sendAnalyticsEvent('website');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
|
@ -158,6 +170,7 @@ export const actions = {
|
|||
const response = await TwilioChannel.create(params);
|
||||
commit(types.default.ADD_INBOXES, response.data);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
sendAnalyticsEvent('twilio');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
|
@ -170,6 +183,7 @@ export const actions = {
|
|||
const response = await FBChannel.create(params);
|
||||
commit(types.default.ADD_INBOXES, response.data);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
sendAnalyticsEvent('facebook');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
|
|
|
@ -1,8 +1,3 @@
|
|||
/* eslint no-console: 0 */
|
||||
/* eslint-env browser */
|
||||
/* eslint-disable no-new */
|
||||
/* Vue Core */
|
||||
|
||||
import Vue from 'vue';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import VueRouter from 'vue-router';
|
||||
|
@ -32,7 +27,6 @@ import constants from '../dashboard/constants';
|
|||
import * as Sentry from '@sentry/vue';
|
||||
import 'vue-easytable/libs/theme-default/index.css';
|
||||
import { Integrations } from '@sentry/tracing';
|
||||
import posthog from 'posthog-js';
|
||||
import {
|
||||
initializeAnalyticsEvents,
|
||||
initializeChatwootEvents,
|
||||
|
@ -40,6 +34,7 @@ import {
|
|||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
|
||||
import AnalyticsHelper from '../dashboard/helper/AnalyticsHelper';
|
||||
|
||||
Vue.config.env = process.env;
|
||||
|
||||
|
@ -51,12 +46,6 @@ if (window.errorLoggingConfig) {
|
|||
});
|
||||
}
|
||||
|
||||
if (window.analyticsConfig) {
|
||||
posthog.init(window.analyticsConfig.token, {
|
||||
api_host: window.analyticsConfig.host,
|
||||
});
|
||||
}
|
||||
|
||||
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||
Vue.use(VueRouter);
|
||||
Vue.use(VueI18n);
|
||||
|
@ -90,6 +79,7 @@ window.WootConstants = constants;
|
|||
window.axios = createAxios(axios);
|
||||
window.bus = new Vue();
|
||||
initializeChatwootEvents();
|
||||
AnalyticsHelper.init();
|
||||
initializeAnalyticsEvents();
|
||||
initalizeRouter();
|
||||
|
||||
|
|
|
@ -14,6 +14,12 @@
|
|||
<div v-if="error" class="text-red-400 mt-2 text-xs font-medium">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-if="!error && helpText"
|
||||
class="text-red-400 mt-2 text-xs font-medium"
|
||||
>
|
||||
{{ helpText }}
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -41,6 +47,10 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
helpText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
labelClass() {
|
||||
|
|
|
@ -1,5 +1,19 @@
|
|||
export const stripTrailingSlash = ({ URL }) => {
|
||||
return URL.replace(/\/$/, '');
|
||||
import { URLPattern } from 'urlpattern-polyfill';
|
||||
|
||||
export const isPatternMatchingWithURL = (urlPattern, url) => {
|
||||
let updatedUrlPattern = urlPattern;
|
||||
const locationObj = new URL(url);
|
||||
|
||||
if (updatedUrlPattern.endsWith('/')) {
|
||||
updatedUrlPattern = updatedUrlPattern.slice(0, -1) + '*\\?*\\#*';
|
||||
}
|
||||
|
||||
if (locationObj.pathname.endsWith('/')) {
|
||||
locationObj.pathname = locationObj.pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
const pattern = new URLPattern(updatedUrlPattern);
|
||||
return pattern.test(locationObj.toString());
|
||||
};
|
||||
|
||||
// Format all campaigns
|
||||
|
@ -22,10 +36,7 @@ export const filterCampaigns = ({
|
|||
isInBusinessHours,
|
||||
}) => {
|
||||
return campaigns.filter(campaign => {
|
||||
const hasMatchingURL =
|
||||
stripTrailingSlash({ URL: campaign.url }) ===
|
||||
stripTrailingSlash({ URL: currentURL });
|
||||
if (!hasMatchingURL) {
|
||||
if (!isPatternMatchingWithURL(campaign.url, currentURL)) {
|
||||
return false;
|
||||
}
|
||||
if (campaign.triggerOnlyDuringBusinessHours) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
stripTrailingSlash,
|
||||
formatCampaigns,
|
||||
filterCampaigns,
|
||||
isPatternMatchingWithURL,
|
||||
} from '../campaignHelper';
|
||||
import campaigns from './campaignFixtures';
|
||||
|
||||
|
@ -9,11 +9,35 @@ global.chatwootWebChannel = {
|
|||
workingHoursEnabled: false,
|
||||
};
|
||||
describe('#Campaigns Helper', () => {
|
||||
describe('stripTrailingSlash', () => {
|
||||
it('should return striped trailing slash if url with trailing slash is passed', () => {
|
||||
describe('#isPatternMatchingWithURL', () => {
|
||||
it('returns correct value if a valid URL is passed', () => {
|
||||
expect(
|
||||
stripTrailingSlash({ URL: 'https://www.chatwoot.com/pricing/' })
|
||||
).toBe('https://www.chatwoot.com/pricing');
|
||||
isPatternMatchingWithURL(
|
||||
'https://chatwoot.com/pricing*',
|
||||
'https://chatwoot.com/pricing/'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isPatternMatchingWithURL(
|
||||
'https://*.chatwoot.com/pricing/',
|
||||
'https://app.chatwoot.com/pricing/'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isPatternMatchingWithURL(
|
||||
'https://{*.}?chatwoot.com/pricing?test=true',
|
||||
'https://app.chatwoot.com/pricing/?test=true'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isPatternMatchingWithURL(
|
||||
'https://{*.}?chatwoot.com/pricing*\\?*',
|
||||
'https://chatwoot.com/pricing/?test=true'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -70,12 +70,15 @@ class Attachment < ApplicationRecord
|
|||
private
|
||||
|
||||
def file_metadata
|
||||
{
|
||||
metadata = {
|
||||
extension: extension,
|
||||
data_url: file_url,
|
||||
thumb_url: thumb_url,
|
||||
file_size: file.byte_size
|
||||
}
|
||||
|
||||
metadata[:data_url] = metadata[:thumb_url] = external_url if message.instagram_story_mention?
|
||||
metadata
|
||||
end
|
||||
|
||||
def location_metadata
|
||||
|
|
|
@ -86,7 +86,8 @@ class Campaign < ApplicationRecord
|
|||
def validate_url
|
||||
return unless trigger_rules['url']
|
||||
|
||||
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !url_valid?(trigger_rules['url'])
|
||||
use_http_protocol = trigger_rules['url'].starts_with?('http://') || trigger_rules['url'].starts_with?('https://')
|
||||
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !use_http_protocol
|
||||
end
|
||||
|
||||
def prevent_completed_campaign_from_update
|
||||
|
|
|
@ -63,4 +63,21 @@ class Channel::FacebookPage < ApplicationRecord
|
|||
Rails.logger.debug { "Rescued: #{e.inspect}" }
|
||||
true
|
||||
end
|
||||
|
||||
# TODO: We will be removing this code after instagram_manage_insights is implemented
|
||||
def fetch_instagram_story_link(message)
|
||||
k = Koala::Facebook::API.new(page_access_token)
|
||||
result = k.get_object(message.source_id, fields: %w[story]) || {}
|
||||
story_link = result['story']['mention']['link']
|
||||
# If the story is expired then it raises the ClientError and if the story is deleted with valid story-id it responses with nil
|
||||
delete_instagram_story(message) if story_link.blank?
|
||||
story_link
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
delete_instagram_story(message)
|
||||
end
|
||||
|
||||
def delete_instagram_story(message)
|
||||
message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'), content_attributes: {})
|
||||
message.attachments.destroy_all
|
||||
end
|
||||
end
|
||||
|
|
|
@ -16,4 +16,8 @@ module MessageFilterHelpers
|
|||
def email_reply_summarizable?
|
||||
incoming? || outgoing? || input_csat?
|
||||
end
|
||||
|
||||
def instagram_story_mention?
|
||||
inbox.instagram? && try(:content_attributes)[:image_type] == 'story_mention'
|
||||
end
|
||||
end
|
||||
|
|
48
app/models/concerns/multi_searchable_helpers.rb
Normal file
48
app/models/concerns/multi_searchable_helpers.rb
Normal file
|
@ -0,0 +1,48 @@
|
|||
module MultiSearchableHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
PgSearch.multisearch_options = {
|
||||
using: {
|
||||
tsearch: {
|
||||
prefix: true,
|
||||
any_word: true,
|
||||
normalization: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def update_contact_search_document
|
||||
return if contact_pg_search_record.present?
|
||||
|
||||
initialize_contact_pg_search_record.update!(
|
||||
content: "#{contact.id} #{contact.email} #{contact.name} #{contact.phone_number}",
|
||||
conversation_id: id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def contact_pg_search_record
|
||||
contacts_pg_search_records.find_by(conversation_id: id)
|
||||
end
|
||||
|
||||
def initialize_contact_pg_search_record
|
||||
record = contacts_pg_search_records.find_by(conversation_id: nil)
|
||||
|
||||
return record if record.present?
|
||||
|
||||
PgSearch::Document.new(
|
||||
searchable_type: 'Contact',
|
||||
searchable_id: contact_id,
|
||||
account_id: account_id
|
||||
)
|
||||
end
|
||||
|
||||
def contacts_pg_search_records
|
||||
PgSearch::Document.where(
|
||||
searchable_type: 'Contact',
|
||||
searchable_id: contact_id,
|
||||
account_id: account_id
|
||||
)
|
||||
end
|
||||
end
|
|
@ -26,6 +26,13 @@ class Contact < ApplicationRecord
|
|||
include Avatarable
|
||||
include AvailabilityStatusable
|
||||
include Labelable
|
||||
include PgSearch::Model
|
||||
include MultiSearchableHelpers
|
||||
|
||||
multisearchable(
|
||||
against: [:id, :email, :name, :phone_number],
|
||||
additional_attributes: ->(contact) { { conversation_id: nil, account_id: contact.account_id } }
|
||||
)
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },
|
||||
|
@ -140,6 +147,24 @@ class Contact < ApplicationRecord
|
|||
email_format
|
||||
end
|
||||
|
||||
def self.rebuild_pg_search_documents
|
||||
return super unless name == 'Contact'
|
||||
|
||||
connection.execute <<~SQL.squish
|
||||
INSERT INTO pg_search_documents (searchable_type, searchable_id, content, account_id, conversation_id, created_at, updated_at)
|
||||
SELECT 'Contact' AS searchable_type,
|
||||
contacts.id AS searchable_id,
|
||||
CONCAT_WS(' ', contacts.email, contacts.name, contacts.phone_number, contacts.id, contacts.account_id) AS content,
|
||||
contacts.account_id::int AS account_id,
|
||||
conversations.id AS conversation_id,
|
||||
now() AS created_at,
|
||||
now() AS updated_at
|
||||
FROM contacts
|
||||
LEFT OUTER JOIN conversations
|
||||
ON conversations.contact_id = contacts.id
|
||||
SQL
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ip_lookup
|
||||
|
|
|
@ -48,7 +48,13 @@ class Conversation < ApplicationRecord
|
|||
include ActivityMessageHandler
|
||||
include UrlHelper
|
||||
include SortHandler
|
||||
include PgSearch::Model
|
||||
include MultiSearchableHelpers
|
||||
|
||||
multisearchable(
|
||||
against: [:display_id],
|
||||
additional_attributes: ->(conversation) { { conversation_id: conversation.id, account_id: conversation.account_id } }
|
||||
)
|
||||
validates :account_id, presence: true
|
||||
validates :inbox_id, presence: true
|
||||
before_validation :validate_additional_attributes
|
||||
|
@ -93,6 +99,7 @@ class Conversation < ApplicationRecord
|
|||
|
||||
after_update_commit :execute_after_update_commit_callbacks
|
||||
after_create_commit :notify_conversation_creation
|
||||
after_create_commit :update_contact_search_document, if: :contact_id?
|
||||
after_commit :set_display_id, unless: :display_id?
|
||||
|
||||
delegate :auto_resolve_duration, to: :account
|
||||
|
|
|
@ -78,6 +78,10 @@ class Inbox < ApplicationRecord
|
|||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
facebook? && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
|
|
@ -33,6 +33,14 @@
|
|||
class Message < ApplicationRecord
|
||||
include MessageFilterHelpers
|
||||
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
||||
include PgSearch::Model
|
||||
include MultiSearchableHelpers
|
||||
|
||||
multisearchable(
|
||||
against: [:content],
|
||||
if: :allowed_message_types?,
|
||||
additional_attributes: ->(message) { { conversation_id: message.conversation_id, account_id: message.account_id } }
|
||||
)
|
||||
|
||||
before_validation :ensure_content_type
|
||||
|
||||
|
@ -107,10 +115,20 @@ class Message < ApplicationRecord
|
|||
conversation: { assignee_id: conversation.assignee_id }
|
||||
)
|
||||
data.merge!(echo_id: echo_id) if echo_id.present?
|
||||
validate_instagram_story if instagram_story_mention?
|
||||
data.merge!(attachments: attachments.map(&:push_event_data)) if attachments.present?
|
||||
merge_sender_attributes(data)
|
||||
end
|
||||
|
||||
# TODO: We will be removing this code after instagram_manage_insights is implemented
|
||||
# Better logic is to listen to webhook and remove stories proactively rather than trying
|
||||
# a fetch every time a message is returned
|
||||
def validate_instagram_story
|
||||
inbox.channel.fetch_instagram_story_link(self)
|
||||
# we want to reload the message in case the story has expired and data got removed
|
||||
reload
|
||||
end
|
||||
|
||||
def merge_sender_attributes(data)
|
||||
data.merge!(sender: sender.push_event_data) if sender && !sender.is_a?(AgentBot)
|
||||
data.merge!(sender: sender.push_event_data(inbox)) if sender.is_a?(AgentBot)
|
||||
|
@ -264,4 +282,8 @@ class Message < ApplicationRecord
|
|||
conversation.update_columns(last_activity_at: created_at)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def allowed_message_types?
|
||||
incoming? || outgoing?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,8 +13,10 @@
|
|||
# display_name :string
|
||||
# email :string
|
||||
# encrypted_password :string default(""), not null
|
||||
# failed_attempts :integer
|
||||
# last_sign_in_at :datetime
|
||||
# last_sign_in_ip :string
|
||||
# locked_at :datetime
|
||||
# message_signature :text
|
||||
# name :string not null
|
||||
# provider :string default("email"), not null
|
||||
|
@ -28,6 +30,7 @@
|
|||
# ui_settings :jsonb
|
||||
# uid :string default(""), not null
|
||||
# unconfirmed_email :string
|
||||
# unlock_token :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
|
|
@ -13,8 +13,10 @@
|
|||
# display_name :string
|
||||
# email :string
|
||||
# encrypted_password :string default(""), not null
|
||||
# failed_attempts :integer
|
||||
# last_sign_in_at :datetime
|
||||
# last_sign_in_ip :string
|
||||
# locked_at :datetime
|
||||
# message_signature :text
|
||||
# name :string not null
|
||||
# provider :string default("email"), not null
|
||||
|
@ -28,6 +30,7 @@
|
|||
# ui_settings :jsonb
|
||||
# uid :string default(""), not null
|
||||
# unconfirmed_email :string
|
||||
# unlock_token :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
json.payload do
|
||||
json.conversations do
|
||||
json.array! @result[:conversations] do |conversation|
|
||||
json.partial! 'api/v1/models/conversation', formats: [:json], conversation: conversation
|
||||
end
|
||||
end
|
||||
|
||||
json.contacts do
|
||||
json.array! @result[:contacts] do |contact|
|
||||
json.partial! 'api/v1/models/contact', formats: [:json], resource: contact
|
||||
end
|
||||
end
|
||||
|
||||
json.messages do
|
||||
json.array! @result[:messages] do |message|
|
||||
json.partial! 'api/v1/models/message', formats: [:json], message: message
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,7 +2,7 @@ json.id message.id
|
|||
json.content message.content
|
||||
json.inbox_id message.inbox_id
|
||||
json.echo_id message.echo_id if message.echo_id
|
||||
json.conversation_id message.conversation.display_id
|
||||
json.conversation_id message.conversation.try(:display_id)
|
||||
json.message_type message.message_type_before_type_cast
|
||||
json.content_type message.content_type
|
||||
json.status message.status
|
||||
|
|
|
@ -51,11 +51,10 @@
|
|||
}
|
||||
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '')%>'
|
||||
</script>
|
||||
<% if @global_config['ANALYTICS_TOKEN'].present? && @global_config['ANALYTICS_HOST'].present? %>
|
||||
<% if @global_config['ANALYTICS_TOKEN'].present? %>
|
||||
<script>
|
||||
window.analyticsConfig = {
|
||||
token: '<%= @global_config['ANALYTICS_TOKEN'] %>',
|
||||
host: '<%= @global_config['ANALYTICS_HOST'] %>',
|
||||
}
|
||||
</script>
|
||||
<% end %>
|
||||
|
|
|
@ -42,8 +42,6 @@
|
|||
value:
|
||||
- name: ANALYTICS_TOKEN
|
||||
value:
|
||||
- name: ANALYTICS_HOST
|
||||
value:
|
||||
- name: DIRECT_UPLOADS_ENABLED
|
||||
value: false
|
||||
locked: false
|
||||
|
|
|
@ -71,6 +71,7 @@ Rails.application.routes.draw do
|
|||
get :meta
|
||||
get :search
|
||||
post :filter
|
||||
get :text_search
|
||||
end
|
||||
scope module: :conversations do
|
||||
resources :messages, only: [:index, :create, :destroy]
|
||||
|
|
21
db/migrate/20221205081737_create_pg_search_documents.rb
Normal file
21
db/migrate/20221205081737_create_pg_search_documents.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
class CreatePgSearchDocuments < ActiveRecord::Migration[6.1]
|
||||
def up
|
||||
say_with_time('Creating table for pg_search multisearch') do
|
||||
create_table :pg_search_documents do |t|
|
||||
t.text :content
|
||||
t.bigint 'conversation_id'
|
||||
t.bigint 'account_id'
|
||||
t.belongs_to :searchable, polymorphic: true, index: true
|
||||
t.timestamps null: false
|
||||
end
|
||||
add_index :pg_search_documents, :account_id
|
||||
add_index :pg_search_documents, :conversation_id
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
say_with_time('Dropping table for pg_search multisearch') do
|
||||
drop_table :pg_search_documents
|
||||
end
|
||||
end
|
||||
end
|
11
db/migrate/20221212061802_enable_multi_searchable.rb
Normal file
11
db/migrate/20221212061802_enable_multi_searchable.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class EnableMultiSearchable < ActiveRecord::Migration[6.1]
|
||||
def up
|
||||
Contact.rebuild_pg_search_documents
|
||||
PgSearch::Multisearch.rebuild(Conversation)
|
||||
PgSearch::Multisearch.rebuild(Message)
|
||||
end
|
||||
|
||||
def down
|
||||
PgSearch::Document.delete_all
|
||||
end
|
||||
end
|
20
db/schema.rb
20
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2022_11_16_000514) do
|
||||
ActiveRecord::Schema.define(version: 2022_12_12_061802) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
|
@ -399,7 +399,7 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
|
|||
t.datetime "agent_last_seen_at"
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.bigint "contact_inbox_id"
|
||||
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
|
||||
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
|
||||
t.string "identifier"
|
||||
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
|
||||
t.bigint "team_id"
|
||||
|
@ -674,6 +674,19 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
|
|||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||
end
|
||||
|
||||
create_table "pg_search_documents", force: :cascade do |t|
|
||||
t.text "content"
|
||||
t.bigint "conversation_id"
|
||||
t.bigint "account_id"
|
||||
t.string "searchable_type"
|
||||
t.bigint "searchable_id"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.index ["account_id"], name: "index_pg_search_documents_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_pg_search_documents_on_conversation_id"
|
||||
t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable"
|
||||
end
|
||||
|
||||
create_table "platform_app_permissibles", force: :cascade do |t|
|
||||
t.bigint "platform_app_id", null: false
|
||||
t.string "permissible_type", null: false
|
||||
|
@ -836,6 +849,9 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
|
|||
t.jsonb "custom_attributes", default: {}
|
||||
t.string "type"
|
||||
t.text "message_signature"
|
||||
t.datetime "locked_at"
|
||||
t.integer "failed_attempts"
|
||||
t.string "unlock_token"
|
||||
t.index ["email"], name: "index_users_on_email"
|
||||
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517",
|
||||
"@chatwoot/utils": "^0.0.10",
|
||||
"@hcaptcha/vue-hcaptcha": "^0.3.2",
|
||||
"@june-so/analytics-next": "^1.36.5",
|
||||
"@rails/actioncable": "6.1.3",
|
||||
"@rails/ujs": "^7.0.3-1",
|
||||
"@rails/webpacker": "5.3.0",
|
||||
|
@ -44,9 +45,8 @@
|
|||
"js-cookie": "^2.2.1",
|
||||
"marked": "4.0.10",
|
||||
"md5": "^2.3.0",
|
||||
"ninja-keys": "^1.1.9",
|
||||
"ninja-keys": "github:fayazara/ninja-keys",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"posthog-js": "^1.13.7",
|
||||
"prosemirror-markdown": "1.5.1",
|
||||
"prosemirror-state": "1.3.4",
|
||||
"prosemirror-view": "1.18.4",
|
||||
|
@ -55,6 +55,7 @@
|
|||
"tailwindcss": "^1.9.6",
|
||||
"turbolinks": "^5.2.0",
|
||||
"url-loader": "^2.0.0",
|
||||
"urlpattern-polyfill": "^6.0.2",
|
||||
"v-tooltip": "~2.1.3",
|
||||
"videojs-record": "^4.5.0",
|
||||
"vue": "2.6.12",
|
||||
|
|
|
@ -68,7 +68,7 @@ describe ::Messages::Instagram::MessageBuilder do
|
|||
|
||||
expect(contact.name).to eq('Jane Dae')
|
||||
expect(message.content).to eq('This story is no longer available.')
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'does not create message for unsupported file type' do
|
||||
|
|
|
@ -6,5 +6,9 @@ FactoryBot.define do
|
|||
user_access_token { SecureRandom.uuid }
|
||||
page_id { SecureRandom.uuid }
|
||||
account
|
||||
|
||||
before :create do |_channel|
|
||||
WebMock::API.stub_request(:post, 'https://graph.facebook.com/v3.2/me/subscribed_apps')
|
||||
end
|
||||
end
|
||||
end
|
|
@ -8,6 +8,18 @@ FactoryBot.define do
|
|||
content_type { 'text' }
|
||||
account { create(:account) }
|
||||
|
||||
trait :instagram_story_mention do
|
||||
content_attributes { { image_type: 'story_mention' } }
|
||||
after(:build) do |message|
|
||||
unless message.inbox.instagram?
|
||||
message.inbox = create(:inbox, account: message.account,
|
||||
channel: create(:channel_instagram_fb_page, account: message.account, instagram_id: 'instagram-123'))
|
||||
end
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image, external_url: 'https://www.example.com/test.jpeg')
|
||||
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
|
||||
end
|
||||
end
|
||||
|
||||
after(:build) do |message|
|
||||
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
|
||||
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)
|
||||
|
|
51
spec/finders/text_search_spec.rb
Normal file
51
spec/finders/text_search_spec.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe ::TextSearch do
|
||||
subject(:text_search) { described_class.new(user_1, params) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user_1) { create(:user, account: account) }
|
||||
let!(:user_2) { create(:user, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: user_1, inbox: inbox)
|
||||
create(:inbox_member, user: user_2, inbox: inbox)
|
||||
|
||||
create(:contact, name: '1223', account_id: account.id)
|
||||
create(:contact, name: 'Potter', account_id: account.id)
|
||||
contact_2 = create(:contact, name: 'Harry Potter', account_id: account.id)
|
||||
conversation_1 = create(:conversation, account: account, inbox: inbox, assignee: user_1, display_id: 121)
|
||||
conversation_2 = create(:conversation, account: account, inbox: inbox, assignee: user_1, display_id: 122)
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, status: 'resolved', display_id: 13, contact_id: contact_2.id)
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_2, display_id: 14)
|
||||
create(:conversation, account: account, inbox: inbox, display_id: 15)
|
||||
Current.account = account
|
||||
|
||||
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'Ask Lisa')
|
||||
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'message_12')
|
||||
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'message_13')
|
||||
|
||||
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'Pottery Barn order')
|
||||
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'message_22')
|
||||
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'message_23')
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with text search' do
|
||||
it 'filter conversations by number' do
|
||||
params = { q: '12' }
|
||||
result = described_class.new(user_1, params).perform
|
||||
expect(result[:conversations].length).to be 2
|
||||
expect(result[:contacts].length).to be 1
|
||||
end
|
||||
|
||||
it 'filter conversations by string' do
|
||||
params = { q: 'pot' }
|
||||
result = described_class.new(user_1, params).perform
|
||||
expect(result[:messages].length).to be 1
|
||||
expect(result[:contacts].length).to be 2
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -113,6 +113,9 @@ describe Webhooks::InstagramEventsJob do
|
|||
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
expect(instagram_inbox.messages.last.attachments.count).to be 1
|
||||
|
||||
attachment = instagram_inbox.messages.last.attachments.last
|
||||
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
|
||||
end
|
||||
|
||||
it 'creates does not create contact or messages' do
|
||||
|
|
|
@ -9,4 +9,23 @@ RSpec.describe Attachment, type: :model do
|
|||
expect(attachment.download_url).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'push_event_data for instagram story mentions' do
|
||||
let(:instagram_message) { create(:message, :instagram_story_mention) }
|
||||
|
||||
before do
|
||||
# stubbing the request to facebook api during the message creation
|
||||
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
|
||||
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
|
||||
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
|
||||
id: 'instagram-message-id-1234'
|
||||
}.to_json, headers: {})
|
||||
end
|
||||
|
||||
it 'returns external url as data and thumb urls' do
|
||||
external_url = instagram_message.attachments.first.external_url
|
||||
expect(instagram_message.attachments.first.push_event_data[:data_url]).to eq external_url
|
||||
expect(instagram_message.attachments.first.push_event_data[:thumb_url]).to eq external_url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -180,4 +180,34 @@ RSpec.describe Message, type: :model do
|
|||
expect(message.email_notifiable_message?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when facebook channel with unavailable story link' do
|
||||
let(:instagram_message) { create(:message, :instagram_story_mention) }
|
||||
|
||||
before do
|
||||
# stubbing the request to facebook api during the message creation
|
||||
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
|
||||
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
|
||||
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
|
||||
id: 'instagram-message-id-1234'
|
||||
}.to_json, headers: {})
|
||||
end
|
||||
|
||||
it 'deletes the attachment for deleted stories' do
|
||||
expect(instagram_message.attachments.count).to eq 1
|
||||
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 404)
|
||||
instagram_message.push_event_data
|
||||
expect(instagram_message.reload.attachments.count).to eq 0
|
||||
end
|
||||
|
||||
it 'deletes the attachment for expired stories' do
|
||||
expect(instagram_message.attachments.count).to eq 1
|
||||
# for expired stories, the link will be empty
|
||||
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
|
||||
story: { mention: { link: '', id: '17920786367196703' } }
|
||||
}.to_json, headers: {})
|
||||
instagram_message.push_event_data
|
||||
expect(instagram_message.reload.attachments.count).to eq 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue