Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma 08cefb2bb8 do not send notifications for failed messages 2026-01-27 17:06:04 +05:30
45 changed files with 115 additions and 746 deletions
@@ -112,25 +112,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation
@@ -28,7 +28,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts = fetch_contacts_with_has_more(contacts)
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def import
@@ -141,24 +142,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
def fetch_contacts_with_has_more(contacts)
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
# Calculate offset manually to fetch one extra record for has_more check
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
results = filtrate(contacts)
.includes(includes_hash)
.offset(offset)
.limit(RESULTS_PER_PAGE + 1)
.to_a
@has_more = results.size > RESULTS_PER_PAGE
results = results.first(RESULTS_PER_PAGE) if @has_more
@contacts_count = results.size
results
end
def build_contact_inbox
return if params[:inbox_id].blank?
@@ -46,7 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
+1 -3
View File
@@ -180,9 +180,7 @@ class ConversationFinder
def conversations_base_query
@conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox,
{ cached_last_message: { attachments: { file_attachment: [:blob] } } },
:cached_last_non_activity_message
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
end
@@ -5,7 +5,6 @@ import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
const props = defineProps({
searchValue: { type: String, default: '' },
@@ -20,9 +19,6 @@ const props = defineProps({
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
useInfiniteScroll: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
isLoadingMore: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -31,7 +27,6 @@ const emit = defineEmits([
'search',
'applyFilter',
'clearFilters',
'loadMore',
]);
const route = useRoute();
@@ -66,14 +61,6 @@ const updateCurrentPage = page => {
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
const showLoadMore = computed(() => {
return props.useInfiniteScroll && props.hasMore;
});
const showPagination = computed(() => {
return !props.useInfiniteScroll && props.showPaginationFooter;
});
</script>
<template>
@@ -107,14 +94,9 @@ const showPagination = computed(() => {
@open-filter="openFilter"
/>
<slot name="default" />
<ContactsLoadMore
v-if="showLoadMore"
:is-loading="isLoadingMore"
@load-more="emit('loadMore')"
/>
</div>
</main>
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -1,28 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
</script>
<template>
<div class="flex justify-center py-4">
<Button
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
:is-loading="isLoading"
variant="faded"
color="slate"
size="sm"
@click="emit('loadMore')"
/>
</div>
</template>
@@ -303,7 +303,6 @@ const componentToRender = computed(() => {
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_STORY_REPLY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
@@ -1,26 +1,19 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import Icon from 'next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
import { MESSAGE_VARIANTS } from '../constants';
const emit = defineEmits(['error']);
const { t } = useI18n();
const { variant, content, contentAttributes, attachments } =
useMessageContext();
const { variant, content, attachments } = useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
const isStoryReply = computed(() => {
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
});
const hasImgStoryError = ref(false);
const hasVideoStoryError = ref(false);
@@ -45,9 +38,6 @@ const onVideoLoadError = () => {
<template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
</p>
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img
v-if="!hasImgStoryError"
@@ -1,27 +1,9 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
inboxId.value
);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK';
if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
});
</script>
<template>
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
{{ $t(unsupportedMessageKey) }}
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</BaseBubble>
</template>
@@ -52,7 +52,6 @@ export const ATTACHMENT_TYPES = {
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
IG_STORY_REPLY: 'ig_story_reply',
};
export const CONTENT_TYPES = {
@@ -574,8 +574,7 @@
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
},
"LOAD_MORE": "Load more"
}
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
@@ -61,7 +61,6 @@
"UNSUPPORTED_MESSAGE": "This message is unsupported. To view it, please open it on the original platform.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
@@ -273,8 +273,7 @@
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available.",
"INSTAGRAM_STORY_REPLY": "Replied to your story:"
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
},
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map"
@@ -36,9 +36,6 @@ const meta = useMapGetter('contacts/getMeta');
const searchQuery = computed(() => route.query?.search);
const searchValue = ref(searchQuery.value || '');
const pageNumber = computed(() => Number(route.query?.page) || 1);
// For infinite scroll in search, track page internally
const searchPageNumber = ref(1);
const isLoadingMore = ref(false);
const parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-');
@@ -65,8 +62,6 @@ const isFetchingList = computed(
);
const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count);
const hasMore = computed(() => meta.value?.hasMore ?? false);
const isSearchView = computed(() => !!searchQuery.value);
const selectedContactIds = ref([]);
const isBulkActionLoading = ref(false);
@@ -217,11 +212,8 @@ const fetchActiveContacts = async (page = 1) => {
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1, append = false) => {
if (!append) {
clearSelection();
searchPageNumber.value = 1;
}
const searchContacts = debounce(async (value, page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
@@ -235,27 +227,9 @@ const searchContacts = debounce(async (value, page = 1, append = false) => {
await store.dispatch('contacts/search', {
...getCommonFetchParams(page),
search: encodeURIComponent(value),
append,
});
searchPageNumber.value = page;
}, DEBOUNCE_DELAY);
const loadMoreSearchResults = async () => {
if (!hasMore.value || isLoadingMore.value) return;
isLoadingMore.value = true;
const nextPage = searchPageNumber.value + 1;
await store.dispatch('contacts/search', {
...getCommonFetchParams(nextPage),
search: encodeURIComponent(searchValue.value),
append: true,
});
searchPageNumber.value = nextPage;
isLoadingMore.value = false;
};
const fetchContactsBasedOnContext = async page => {
clearSelection();
updatePageParam(page, searchValue.value);
@@ -442,25 +416,21 @@ onMounted(async () => {
:header-title="headerTitle"
:current-page="currentPage"
:total-items="totalItems"
:show-pagination-footer="!isFetchingList && hasContacts && !isSearchView"
:show-pagination-footer="!isFetchingList && hasContacts"
:active-sort="sortState.activeSort"
:active-ordering="sortState.activeOrdering"
:active-segment="activeSegment"
:segments-id="activeSegmentId"
:is-fetching-list="isFetchingList"
:has-applied-filters="hasAppliedFilters"
:use-infinite-scroll="isSearchView"
:has-more="hasMore"
:is-loading-more="isLoadingMore"
@update:current-page="fetchContactsBasedOnContext"
@search="searchContacts"
@update:sort="handleSort"
@apply-filter="fetchSavedOrAppliedFilteredContact"
@clear-filters="fetchContacts"
@load-more="loadMoreSearchResults"
>
<div
v-if="isFetchingList && !(isSearchView && hasContacts)"
v-if="isFetchingList"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
@@ -45,19 +45,14 @@ export const handleContactOperationErrors = error => {
};
export const actions = {
search: async (
{ commit },
{ search, page, sortAttr, label, append = false }
) => {
search: async ({ commit }, { search, page, sortAttr, label }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.search(search, page, sortAttr, label);
if (!append) {
commit(types.CLEAR_CONTACTS);
}
commit(append ? types.APPEND_CONTACTS : types.SET_CONTACTS, payload);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) {
@@ -6,7 +6,6 @@ const state = {
meta: {
count: 0,
currentPage: 1,
hasMore: false,
},
records: {},
uiFlags: {
@@ -15,24 +15,9 @@ export const mutations = {
},
[types.SET_CONTACT_META]: ($state, data) => {
const { count, current_page: currentPage, has_more: hasMore } = data;
const { count, current_page: currentPage } = data;
$state.meta.count = count;
$state.meta.currentPage = currentPage;
if (hasMore !== undefined) {
$state.meta.hasMore = hasMore;
}
},
[types.APPEND_CONTACTS]: ($state, data) => {
data.forEach(contact => {
$state.records[contact.id] = {
...($state.records[contact.id] || {}),
...contact,
};
if (!$state.sortOrder.includes(contact.id)) {
$state.sortOrder.push(contact.id);
}
});
},
[types.SET_CONTACTS]: ($state, data) => {
@@ -139,7 +139,6 @@ export default {
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
SET_CONTACTS: 'SET_CONTACTS',
APPEND_CONTACTS: 'APPEND_CONTACTS',
CLEAR_CONTACTS: 'CLEAR_CONTACTS',
EDIT_CONTACT: 'EDIT_CONTACT',
DELETE_CONTACT: 'DELETE_CONTACT',
@@ -1,11 +0,0 @@
# housekeeping
# remove conversations that do not have a contact_id
# orphan conversations without contact cannot be accessed or used
class Internal::RemoveOrphanConversationsJob < ApplicationJob
queue_as :housekeeping
def perform
Internal::RemoveOrphanConversationsService.new.perform
end
end
@@ -1,22 +0,0 @@
class Migration::BackfillConversationCachedMessageIdsJob < ApplicationJob
queue_as :low
def perform
Conversation.find_in_batches(batch_size: 1000) do |conversations|
conversations.each do |conversation|
updates = {}
last_message = conversation.messages.reorder(created_at: :desc).first
updates[:last_message_id] = last_message&.id
last_incoming = conversation.messages.incoming.reorder(created_at: :desc).first
updates[:last_incoming_message_id] = last_incoming&.id
last_non_activity = conversation.messages.where.not(message_type: :activity).reorder(created_at: :desc).first
updates[:last_non_activity_message_id] = last_non_activity&.id
conversation.update_columns(updates)
end
end
end
end
@@ -1,54 +1,8 @@
class Notification::RemoveOldNotificationJob < ApplicationJob
queue_as :purgable
NOTIFICATION_LIMIT = 300
OLD_NOTIFICATION_THRESHOLD = 1.month
queue_as :low
def perform
remove_old_notifications
trim_user_notifications
end
private
def remove_old_notifications
Notification.where('created_at < ?', OLD_NOTIFICATION_THRESHOLD.ago)
.delete_all
end
def trim_user_notifications
# Find users with more than NOTIFICATION_LIMIT notifications
user_ids_exceeding_limit.each do |user_id|
trim_notifications_for_user(user_id)
end
end
def user_ids_exceeding_limit
Notification.group(:user_id)
.having('COUNT(*) > ?', NOTIFICATION_LIMIT)
.pluck(:user_id)
end
def trim_notifications_for_user(user_id)
# Find the cutoff notification (the 301st when we want to keep top 300)
# Order by created_at DESC, then id DESC for deterministic ordering
cutoff = Notification.where(user_id: user_id)
.order(created_at: :desc, id: :desc)
.offset(NOTIFICATION_LIMIT)
.limit(1)
.pick(:created_at, :id)
return unless cutoff
cutoff_time, cutoff_id = cutoff
# Delete notifications older than cutoff, or same timestamp but lower/equal ID
# Since we order by id DESC, higher IDs are kept (come first), lower IDs deleted
# This avoids race conditions: notifications created after finding the cutoff
# will have timestamps > cutoff_time and won't be incorrectly deleted
Notification.where(user_id: user_id)
.where('created_at < ? OR (created_at = ? AND id <= ?)',
cutoff_time, cutoff_time, cutoff_id)
.delete_all
Notification.where('created_at < ?', 1.month.ago)
.find_each(batch_size: 1000, &:delete)
end
end
+3
View File
@@ -19,6 +19,9 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
# Job to clear notifications which are older than 1 month
Notification::RemoveOldNotificationJob.perform_later
end
end
-3
View File
@@ -105,9 +105,6 @@ class Conversation < ApplicationRecord
belongs_to :contact_inbox
belongs_to :team, optional: true
belongs_to :campaign, optional: true
belongs_to :cached_last_message, class_name: 'Message', optional: true, foreign_key: 'last_message_id'
belongs_to :cached_last_incoming_message, class_name: 'Message', optional: true, foreign_key: 'last_incoming_message_id'
belongs_to :cached_last_non_activity_message, class_name: 'Message', optional: true, foreign_key: 'last_non_activity_message_id'
has_many :mentions, dependent: :destroy_async
has_many :messages, dependent: :destroy_async, autosave: true
+1 -11
View File
@@ -114,7 +114,7 @@ class Message < ApplicationRecord
scope :created_since, ->(datetime) { where('created_at > ?', datetime) }
scope :chat, -> { where.not(message_type: :activity).where(private: false) }
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('created_at desc') }
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('id desc') }
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
scope :voice_calls, -> { where(content_type: :voice_call) }
@@ -133,7 +133,6 @@ class Message < ApplicationRecord
has_many :notifications, as: :primary_actor, dependent: :destroy_async
after_create_commit :execute_after_create_commit_callbacks
after_create_commit :update_conversation_cached_message_ids
after_update_commit :dispatch_update_event
after_commit :reindex_for_search, if: :should_index?, on: [:create, :update]
@@ -423,15 +422,6 @@ class Message < ApplicationRecord
def reindex_for_search
reindex(mode: :async)
end
def update_conversation_cached_message_ids
updates = { last_message_id: id }
updates[:last_incoming_message_id] = id if incoming?
updates[:last_non_activity_message_id] = id unless activity?
# rubocop:disable Rails/SkipsModelValidations
conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
end
end
Message.prepend_mod_with('Message')
@@ -1,34 +0,0 @@
class Internal::RemoveOrphanConversationsService
def initialize(account: nil, days: 1)
@account = account
@days = days
end
def perform
orphan_conversations = build_orphan_conversations_query
total_deleted = 0
Rails.logger.info '[RemoveOrphanConversationsService] Starting removal of orphan conversations'
orphan_conversations.find_in_batches(batch_size: 1000) do |batch|
conversation_ids = batch.map(&:id)
Conversation.where(id: conversation_ids).destroy_all
total_deleted += batch.size
Rails.logger.info "[RemoveOrphanConversationsService] Deleted #{batch.size} orphan conversations (#{total_deleted} total)"
end
Rails.logger.info "[RemoveOrphanConversationsService] Completed. Total deleted: #{total_deleted}"
total_deleted
end
private
def build_orphan_conversations_query
base = @account ? @account.conversations : Conversation.all
base = base.where('conversations.created_at > ?', @days.days.ago)
base = base.left_outer_joins(:contact, :inbox)
# Find conversations whose associated contact or inbox record is missing
base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
end
end
@@ -3,6 +3,7 @@ class Messages::NewMessageNotificationService
def perform
return unless message.notifiable?
return if message.status == 'failed'
notify_conversation_assignee
notify_participating_users
+5 -9
View File
@@ -26,8 +26,8 @@ class Tiktok::AuthClient
end
# https://business-api.tiktok.com/portal/docs?id=1832184159540418
def obtain_short_term_access_token(auth_code) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
endpoint = "#{api_base_url}/tt_user/oauth2/token/"
def obtain_short_term_access_token(auth_code) # rubocop:disable Metrics/MethodLength
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/token/'
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
body = {
client_id: client_id,
@@ -56,7 +56,7 @@ class Tiktok::AuthClient
end
def renew_short_term_access_token(refresh_token) # rubocop:disable Metrics/MethodLength
endpoint = "#{api_base_url}/tt_user/oauth2/refresh_token/"
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/refresh_token/'
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
body = {
client_id: client_id,
@@ -82,7 +82,7 @@ class Tiktok::AuthClient
end
def webhook_callback
endpoint = "#{api_base_url}/business/webhook/list/"
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/webhook/list/'
headers = { Accept: 'application/json' }
params = {
app_id: client_id,
@@ -95,7 +95,7 @@ class Tiktok::AuthClient
end
def update_webhook_callback
endpoint = "#{api_base_url}/business/webhook/update/"
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/webhook/update/'
headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
body = {
app_id: client_id,
@@ -141,9 +141,5 @@ class Tiktok::AuthClient
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
def api_base_url
"https://business-api.tiktok.com/open_api/#{GlobalConfigService.load('TIKTOK_API_VERSION', 'v1.3')}"
end
end
end
+4 -8
View File
@@ -3,7 +3,7 @@ class Tiktok::Client
pattr_initialize [:business_id!, :access_token!]
def business_account_details
endpoint = "#{api_base_url}/business/get/"
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/get/'
headers = { 'Access-Token': access_token }
params = { business_id: business_id, fields: %w[username display_name profile_image].to_s }
response = HTTParty.get(endpoint, query: params, headers: headers)
@@ -17,7 +17,7 @@ class Tiktok::Client
end
def file_download_url(conversation_id, message_id, media_id, media_type = 'IMAGE')
endpoint = "#{api_base_url}/business/message/media/download/"
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/message/media/download/'
headers = { 'Access-Token': access_token, 'Content-Type': 'application/json', Accept: 'application/json' }
body = { business_id: business_id,
conversation_id: conversation_id,
@@ -45,7 +45,7 @@ class Tiktok::Client
def send_message(conversation_id, type, payload, referenced_message_id: nil)
# https://business-api.tiktok.com/portal/docs?id=1832184403754242
endpoint = "#{api_base_url}/business/message/send/"
endpoint ='https://business-api.tiktok.com/open_api/v1.3/business/message/send/'
headers = { 'Access-Token': access_token, 'Content-Type': 'application/json' }
body = {
business_id: business_id,
@@ -70,7 +70,7 @@ class Tiktok::Client
end
def upload_media(file, media_type = 'IMAGE')
endpoint = "#{api_base_url}/business/message/media/upload/"
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/message/media/upload/'
headers = { 'Access-Token': access_token, 'Content-Type': 'multipart/form-data' }
file.open do |temp_file|
@@ -86,10 +86,6 @@ class Tiktok::Client
end
end
def api_base_url
"https://business-api.tiktok.com/open_api/#{GlobalConfigService.load('TIKTOK_API_VERSION', 'v1.3')}"
end
def process_json_response(response, error_prefix)
unless response.success?
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
+2 -2
View File
@@ -26,7 +26,7 @@ module Tiktok::MessagingHelpers
end
def find_conversation(channel, tt_conversation_id)
channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)&.conversations&.first
channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id).conversations.first
end
def create_conversation(channel, contact_inbox, tt_conversation_id)
@@ -59,7 +59,7 @@ module Tiktok::MessagingHelpers
def fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id)
file_download_url = tiktok_client(channel).file_download_url(tt_conversation_id, tt_message_id, tt_image_media_id)
Down.download(file_download_url, headers: { 'x-user' => channel.validated_access_token })
Down.download(file_download_url)
end
def tiktok_client(channel)
+2 -2
View File
@@ -4,9 +4,9 @@ class Tiktok::ReadStatusService
pattr_initialize [:channel!, :content!]
def perform
return if channel.blank? || content.blank? || outbound_event? || conversation.blank?
return if channel.blank? || content.blank? || outbound_event?
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, last_read_timestamp)
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, last_read_timestamp) if conversation.present?
end
def conversation
@@ -1,7 +1,6 @@
json.meta do
json.count @contacts_count
json.current_page @current_page
json.has_more @has_more
end
json.payload do
@@ -27,10 +27,13 @@ json.meta do
end
json.id conversation.display_id
if conversation.cached_last_message.blank?
if conversation.messages.where(account_id: conversation.account_id).last.blank?
json.messages []
else
json.messages [conversation.cached_last_message.push_event_data]
json.messages [
conversation.messages.where(account_id: conversation.account_id)
.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)
]
end
json.account_id conversation.account_id
@@ -51,7 +54,7 @@ json.updated_at conversation.updated_at.to_f
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.cached_last_non_activity_message&.push_event_data
json.last_non_activity_message conversation.messages.where(account_id: conversation.account_id).non_activity_messages.first.try(:push_event_data)
json.last_activity_at conversation.last_activity_at.to_i
json.priority conversation.priority
json.waiting_since conversation.waiting_since.to_i.to_i
-5
View File
@@ -407,11 +407,6 @@
# ------- End of Instagram Channel Related Config ------- #
# ------- TikTok Channel Related Config ------- #
- name: TIKTOK_API_VERSION
display_title: 'TikTok API Version'
description: 'Configure this if you want to use a different TikTok API version. Make sure its prefixed with `v`'
value: 'v1.3'
locked: false
- name: TIKTOK_APP_ID
display_title: 'TikTok App ID'
locked: false
-14
View File
@@ -59,17 +59,3 @@ periodic_assignment_job:
cron: '*/30 * * * *'
class: 'AutoAssignment::PeriodicAssignmentJob'
queue: scheduled_jobs
# executed daily at 2230 UTC
# removes old notifications (>1 month) and trims to 300 per user
remove_old_notification_job:
cron: '30 22 * * *'
class: 'Notification::RemoveOldNotificationJob'
queue: purgable
# executed every 12 hours
# to remove orphan conversations without contact
remove_orphan_conversations_job:
cron: '0 */12 * * *'
class: 'Internal::RemoveOrphanConversationsJob'
queue: housekeeping
@@ -1,11 +0,0 @@
class AddCachedMessageIdsToConversations < ActiveRecord::Migration[7.1]
def change
add_column :conversations, :last_message_id, :bigint
add_column :conversations, :last_incoming_message_id, :bigint
add_column :conversations, :last_non_activity_message_id, :bigint
add_index :conversations, :last_message_id
add_index :conversations, :last_incoming_message_id
add_index :conversations, :last_non_activity_message_id
end
end
+33 -54
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -71,7 +71,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.jsonb "limits", default: {}
t.jsonb "custom_attributes", default: {}
t.integer "status", default: 0
t.integer "contactable_contacts_count", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.index ["status"], name: "index_accounts_on_status"
@@ -590,13 +589,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "contacts_count", default: 0, null: false
t.integer "contacts_count"
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
t.index ["account_id"], name: "index_companies_on_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t|
create_table "contact_inboxes", force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
t.text "source_id", null: false
@@ -604,14 +603,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.datetime "updated_at", null: false
t.boolean "hmac_verified", default: false
t.string "pubsub_token"
t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx"
t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true
t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx"
t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true
t.index ["source_id"], name: "contact_inboxes2_source_id_idx"
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true
t.index ["source_id"], name: "index_contact_inboxes_on_source_id"
end
create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t|
create_table "contacts", id: :serial, force: :cascade do |t|
t.string "name", default: ""
t.string "email"
t.string "phone_number"
@@ -622,27 +621,25 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.string "identifier"
t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil
t.boolean "resolved", default: false, null: false
t.integer "contact_type", default: 0, null: false
t.integer "contact_type", default: 0
t.string "middle_name", default: ""
t.string "last_name", default: ""
t.string "location", default: ""
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.bigint "company_id"
t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved"
t.index ["account_id"], name: "contacts2_account_id_idx"
t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true
t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
end
create_table "conversation_participants", force: :cascade do |t|
@@ -670,7 +667,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.datetime "agent_last_seen_at", precision: nil
t.jsonb "additional_attributes", default: {}
t.bigint "contact_inbox_id"
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.string "identifier"
t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
t.bigint "team_id"
@@ -684,9 +681,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
t.bigint "last_message_id"
t.bigint "last_incoming_message_id"
t.bigint "last_non_activity_message_id"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
@@ -698,9 +692,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id"
t.index ["last_message_id"], name: "index_conversations_on_last_message_id"
t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id"
t.index ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
t.index ["status", "priority"], name: "index_conversations_on_status_and_priority"
@@ -767,6 +758,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.string "regex_pattern"
t.string "regex_cue"
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
end
create_table "custom_filters", force: :cascade do |t|
@@ -949,7 +941,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.integer "visibility", default: 0
t.bigint "created_by_id"
t.bigint "updated_by_id"
t.jsonb "actions", default: "{}", null: false
t.jsonb "actions", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_macros_on_account_id"
@@ -968,7 +960,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.index ["user_id"], name: "index_mentions_on_user_id"
end
create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t|
create_table "messages", id: :serial, force: :cascade do |t|
t.text "content"
t.integer "account_id", null: false
t.integer "inbox_id", null: false
@@ -987,18 +979,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.jsonb "additional_attributes", default: {}
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx"
t.index ["account_id"], name: "messages2_account_id_idx"
t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx"
t.index ["conversation_id"], name: "messages2_conversation_id_idx"
t.index ["created_at"], name: "messages2_created_at_idx"
t.index ["inbox_id"], name: "messages2_inbox_id_idx"
t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx"
t.index ["source_id"], name: "messages2_source_id_idx"
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created"
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
t.index ["created_at"], name: "index_messages_on_created_at"
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
t.index ["source_id"], name: "index_messages_on_source_id"
end
create_table "notes", force: :cascade do |t|
@@ -1056,19 +1048,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) 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", null: false
t.datetime "updated_at", 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
@@ -1251,7 +1230,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
t.text "message_signature"
t.string "otp_secret"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false
t.boolean "otp_required_for_login", default: false
t.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email"
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
@@ -15,13 +15,13 @@ namespace :chatwoot do
days_input = $stdin.gets.strip
days = days_input.empty? ? 7 : days_input.to_i
service = Internal::RemoveOrphanConversationsService.new(account: account, days: days)
# Preview count using the same query logic
# Build a common base relation with identical joins for OR compatibility
base = account
.conversations
.where('conversations.created_at > ?', days.days.ago)
.left_outer_joins(:contact, :inbox)
# Find conversations whose associated contact or inbox record is missing
conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
count = conversations.count
@@ -31,8 +31,8 @@ namespace :chatwoot do
print 'Do you want to delete these conversations? (y/N): '
confirm = $stdin.gets.strip.downcase
if %w[y yes].include?(confirm)
total_deleted = service.perform
puts "#{total_deleted} conversations deleted."
conversations.destroy_all
puts 'Conversations deleted.'
else
puts 'No conversations were deleted.'
end
-42
View File
@@ -1,42 +0,0 @@
# Usage: load 'scripts/benchmark_campaigns.rb'; benchmark_campaigns('website_token')
def benchmark_campaigns(website_token)
web_widget = Channel::WebWidget.find_by(website_token: website_token)
raise "WebWidget not found for token #{website_token}" unless web_widget
inbox = web_widget.inbox
account = inbox.account
puts "Inbox: #{inbox.id} | Account: #{account.id}"
puts "Campaigns feature enabled: #{account.feature_enabled?('campaigns')}"
queries = []
subscription = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, start, finish, _id, payload|
binds = payload[:type_casted_binds] || payload[:binds]&.map(&:value_for_database) || []
queries << { sql: payload[:sql], duration: (finish - start) * 1000, binds: binds }
end
ActiveRecord::Base.uncached do
start = Time.now
@campaigns = inbox.campaigns
.where(enabled: true, account_id: account.id)
.includes(:sender).to_a
@query_time = Time.now - start
end
ActiveSupport::Notifications.unsubscribe(subscription)
puts "\n=== Timing ==="
puts "Query: #{(@query_time * 1000).round(2)}ms"
puts "Queries: #{queries.size}"
puts "Campaigns found: #{@campaigns.size}"
puts "\n=== All Queries ==="
queries.each_with_index do |q, i|
puts "\n#{i + 1}. #{q[:duration].round(2)}ms"
puts " #{q[:sql][0..200]}"
puts " Binds: #{q[:binds].inspect[0..150]}" if q[:binds]&.any?
end
nil
end
-72
View File
@@ -1,72 +0,0 @@
# Usage: load 'scripts/benchmark_contacts.rb'; benchmark_contacts(account_id, user_id)
# With action: benchmark_contacts(account_id, user_id, action: 'search', q: 'john')
def benchmark_contacts(account_id, user_id, action: 'index', **opts)
account = Account.find(account_id)
user = User.find(user_id)
Current.account = account
Current.user = user
puts "Account: #{account_id} | User: #{user_id} | Action: #{action}"
queries = []
subscription = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, start, finish, _id, payload|
binds = payload[:type_casted_binds] || payload[:binds]&.map(&:value_for_database) || []
queries << { sql: payload[:sql], duration: (finish - start) * 1000, binds: binds }
end
ActiveRecord::Base.uncached do
start = Time.now
case action
when 'index'
contacts = account.contacts.resolved_contacts
@contacts = contacts
.includes(avatar_attachment: [:blob], contact_inboxes: { inbox: :channel })
.page(opts[:page] || 1)
.per(15).to_a
@count = contacts.count
when 'search'
q = opts[:q] || ''
contacts = account.contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{q.strip}%"
)
@contacts = contacts
.includes(avatar_attachment: [:blob], contact_inboxes: { inbox: :channel })
.page(opts[:page] || 1)
.per(15).to_a
@count = contacts.count
when 'filter'
# Requires payload param - simplified version
@contacts = account.contacts.page(1).per(15).to_a
@count = account.contacts.count
end
@query_time = Time.now - start
end
ActiveSupport::Notifications.unsubscribe(subscription)
puts "\n=== Timing ==="
puts "Query: #{(@query_time * 1000).round(2)}ms"
puts "Queries: #{queries.size}"
puts "Contacts found: #{@contacts&.size}"
puts "Total count: #{@count}"
puts "\n=== Top 10 Slowest Queries ==="
queries.select { |q| q[:duration] }.sort_by { |q| -q[:duration] }.first(10).each_with_index do |q, i|
puts "\n#{i + 1}. #{q[:duration].round(2)}ms"
puts " #{q[:sql][0..200]}"
puts " Binds: #{q[:binds].inspect[0..150]}" if q[:binds]&.any?
end
puts "\n=== All Queries (grouped by pattern) ==="
queries.group_by { |q| q[:sql][0..100] }.sort_by { |_, qs| -qs.size }.each do |sql, qs|
puts " #{qs.size}x #{qs.sum { |q| q[:duration] }.round(2)}ms - #{sql[0..80]}..."
end
nil
end
-92
View File
@@ -1,92 +0,0 @@
# Usage: load 'scripts/benchmark_conversations.rb'; benchmark_conversations(91698, 89557)
# With options: benchmark_conversations(91698, 89557, assignee_type: 'all', status: 'open', team_id: 123)
def benchmark_conversations(account_id, user_id, assignee_type: 'me', status: 'open', team_id: nil)
account = Account.find(account_id)
user = User.find(user_id)
Current.account = account
Current.user = user
params = { status: status, assignee_type: assignee_type, page: 1, sort_by: 'last_activity_at_desc' }
params[:team_id] = team_id if team_id
queries = []
subscription = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, start, finish, _id, payload|
binds = payload[:type_casted_binds] || payload[:binds]&.map(&:value_for_database) || []
bt = caller.select { |l| l.include?('chatwoot') }.first(5)
queries << { sql: payload[:sql], duration: (finish - start) * 1000, binds: binds, backtrace: bt }
end
# Disable ActiveRecord query cache
ActiveRecord::Base.uncached do
start = Time.now
finder = ConversationFinder.new(user, params)
result = finder.perform
conversations = result[:conversations]
@finder_time = Time.now - start
@conversations = conversations
start = Time.now
conversations.each do |conversation|
ApplicationController.renderer.render(
partial: 'api/v1/conversations/partials/conversation',
locals: { conversation: conversation },
formats: [:json]
)
end
@render_time = Time.now - start
end
ActiveSupport::Notifications.unsubscribe(subscription)
finder_time = @finder_time
render_time = @render_time
conversations = @conversations
puts "\n=== Timing ==="
puts "Finder: #{(finder_time * 1000).round(1)}ms"
puts "Render: #{(render_time * 1000).round(1)}ms"
puts "Total: #{((finder_time + render_time) * 1000).round(1)}ms"
puts "Queries: #{queries.size}"
puts "\n=== Top 10 Slowest Queries ==="
queries.select { |q| q[:duration] }.sort_by { |q| -q[:duration] }.first(10).each_with_index do |q, i|
puts "\n#{i + 1}. #{q[:duration].round(2)}ms"
puts " #{q[:sql][0..200]}"
puts " Binds: #{q[:binds].inspect[0..200]}" if q[:binds]&.any?
puts " Source: #{q[:backtrace]&.first}" if q[:backtrace]&.any?
end
# Group queries by conversation_id
conv_ids = conversations.map(&:id)
conv_queries = Hash.new { |h, k| h[k] = [] }
queries.each do |q|
next unless q[:binds]&.any?
conv_id = q[:binds].find { |b| conv_ids.include?(b) }
conv_queries[conv_id] << q if conv_id
end
puts "\n=== Queries Per Conversation ==="
conv_queries.sort_by { |_, qs| -qs.sum { |q| q[:duration] } }.first(5).each do |conv_id, qs|
total_ms = qs.sum { |q| q[:duration] }.round(2)
puts "\nConversation #{conv_id}: #{qs.size} queries, #{total_ms}ms total"
qs.group_by { |q| q[:sql][0..80] }.each do |sql, grouped|
puts " #{grouped.size}x #{grouped.sum { |q| q[:duration] }.round(2)}ms - #{sql[0..60]}..."
end
end
puts "\n=== Summary ==="
puts "Conversations: #{conversations.size}"
puts "Avg queries/conversation: #{(conv_queries.values.sum(&:size).to_f / conversations.size).round(1)}"
tied_queries = conv_queries.values.flatten
untied = queries - tied_queries
puts "Queries not tied to conversation: #{untied.size}"
puts "\n=== Untied Queries (grouped by pattern) ==="
untied.group_by { |q| q[:sql][0..100] }.sort_by { |_, qs| -qs.size }.first(15).each do |sql, qs|
puts " #{qs.size}x #{qs.sum { |q| q[:duration] }.round(2)}ms - #{sql[0..80]}..."
end
nil
end
@@ -103,10 +103,23 @@ describe Messages::Instagram::MessageBuilder do
it 'creates message with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
story_url = messaging['message']['reply_to']['story']['url']
story_source_id = messaging['message']['mid']
stub_request(:get, story_url)
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(
status: 200,
body: {
story: {
mention: {
id: 'chatwoot-app-user-id-1'
}
},
from: {
username: instagram_inbox.channel.instagram_id
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
described_class.new(messaging, instagram_inbox).perform
@@ -115,9 +128,6 @@ describe Messages::Instagram::MessageBuilder do
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:image_type]).to eq('ig_story_reply')
expect(message.attachments.first.file_type).to eq('ig_story')
expect(message.attachments.first.external_url).to eq(story_url)
end
it 'creates message with reply to mid' do
@@ -104,7 +104,6 @@ describe Messages::Instagram::Messenger::MessageBuilder do
it 'creates message with for reply with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
story_url = messaging['message']['reply_to']['story']['url']
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
@@ -115,8 +114,6 @@ describe Messages::Instagram::Messenger::MessageBuilder do
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
stub_request(:get, story_url)
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
@@ -126,10 +123,7 @@ describe Messages::Instagram::Messenger::MessageBuilder do
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_messenger_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:story_url]).to eq(story_url)
expect(message.content_attributes[:image_type]).to eq('ig_story_reply')
expect(message.attachments.first.file_type).to eq('ig_story')
expect(message.attachments.first.external_url).to eq(story_url)
expect(message.content_attributes[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
end
it 'creates message with for reply with mid' do
@@ -369,50 +369,6 @@ RSpec.describe 'Contacts API', type: :request do
expect(response.body).to include(contact_special.identifier)
expect(response.body).not_to include(contact_normal.identifier)
end
it 'returns has_more as false when results fit in one page' do
get "/api/v1/accounts/#{account.id}/contacts/search",
params: { q: contact2.email },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['meta']['has_more']).to be(false)
expect(response_body['meta']['count']).to eq(1)
end
it 'returns has_more as true when there are more results' do
# Create 16 contacts (more than RESULTS_PER_PAGE which is 15)
create_list(:contact, 16, account: account, name: 'searchable_contact')
get "/api/v1/accounts/#{account.id}/contacts/search",
params: { q: 'searchable_contact' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['meta']['has_more']).to be(true)
expect(response_body['meta']['count']).to eq(15)
expect(response_body['payload'].length).to eq(15)
end
it 'returns has_more as false on the last page' do
# Create 16 contacts
create_list(:contact, 16, account: account, name: 'searchable_contact')
get "/api/v1/accounts/#{account.id}/contacts/search",
params: { q: 'searchable_contact', page: 2 },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['meta']['has_more']).to be(false)
expect(response_body['meta']['count']).to eq(1)
expect(response_body['payload'].length).to eq(1)
end
end
end
@@ -1,65 +1,23 @@
require 'rails_helper'
RSpec.describe Notification::RemoveOldNotificationJob do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account) }
let(:user) { create(:user) }
let(:conversation) { create(:conversation) }
it 'enqueues the job' do
expect do
described_class.perform_later
end.to have_enqueued_job(described_class)
.on_queue('purgable')
.on_queue('low')
end
describe 'removing old notifications' do
it 'removes notifications older than 1 month' do
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation,
created_at: 2.months.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation,
created_at: 1.month.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation,
created_at: 1.day.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation,
created_at: 1.hour.ago)
it 'removes old notifications which are older than 1 month' do
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 2.months.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.month.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.day.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.hour.ago)
described_class.perform_now
expect(Notification.count).to eq(2)
end
end
describe 'trimming user notifications' do
it 'does not delete notifications when user has fewer than 300' do
create_list(:notification, 50, user: user, account: account, primary_actor: conversation)
expect { described_class.perform_now }.not_to(change(Notification, :count))
end
it 'trims to 300 notifications per user keeping the most recent' do
old_notifications = create_list(:notification, 50, user: user, account: account, primary_actor: conversation,
created_at: 2.days.ago)
recent_notifications = create_list(:notification, 300, user: user, account: account, primary_actor: conversation,
created_at: 1.hour.ago)
described_class.perform_now
expect(Notification.where(user_id: user.id).count).to eq(300)
expect(Notification.where(id: old_notifications.map(&:id))).to be_empty
expect(Notification.where(id: recent_notifications.map(&:id)).count).to eq(300)
end
end
describe 'combined functionality' do
it 'removes old notifications and trims user notifications in one job' do
# User with old and excess notifications
create_list(:notification, 100, user: user, account: account, primary_actor: conversation, created_at: 2.months.ago)
create_list(:notification, 250, user: user, account: account, primary_actor: conversation, created_at: 1.day.ago)
described_class.perform_now
# All old notifications removed, remaining trimmed to 300
expect(Notification.where(user_id: user.id).count).to eq(250)
expect(Notification.where('created_at < ?', 1.month.ago)).to be_empty
end
described_class.perform_now
expect(Notification.count).to eq(2)
end
end
@@ -30,6 +30,11 @@ RSpec.describe TriggerScheduledItemsJob do
described_class.perform_now
end
it 'triggers Notification::RemoveOldNotificationJob' do
expect(Notification::RemoveOldNotificationJob).to receive(:perform_later).once
described_class.perform_now
end
context 'when unexecuted Scheduled campaign jobs' do
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }