Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21f4f1dfe | ||
|
|
e419cd5a07 | ||
|
|
6cd1b37981 | ||
|
|
3b612e2b20 | ||
|
|
d166ae73bc | ||
|
|
aaeea6c9bf | ||
|
|
b870a48734 | ||
|
|
68f0da7351 | ||
|
|
40c622ed95 | ||
|
|
7cddba2b08 |
@@ -112,6 +112,25 @@ 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,8 +28,7 @@ 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(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
@contacts = fetch_contacts_with_has_more(contacts)
|
||||
end
|
||||
|
||||
def import
|
||||
@@ -142,6 +141,24 @@ 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' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
|
||||
'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],
|
||||
|
||||
@@ -180,7 +180,9 @@ class ConversationFinder
|
||||
|
||||
def conversations_base_query
|
||||
@conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
|
||||
: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
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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: '' },
|
||||
@@ -19,6 +20,9 @@ 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([
|
||||
@@ -27,6 +31,7 @@ const emit = defineEmits([
|
||||
'search',
|
||||
'applyFilter',
|
||||
'clearFilters',
|
||||
'loadMore',
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
@@ -61,6 +66,14 @@ 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>
|
||||
@@ -94,9 +107,14 @@ const openFilter = () => {
|
||||
@open-filter="openFilter"
|
||||
/>
|
||||
<slot name="default" />
|
||||
<ContactsLoadMore
|
||||
v-if="showLoadMore"
|
||||
:is-loading="isLoadingMore"
|
||||
@load-more="emit('loadMore')"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<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,6 +303,7 @@ 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,19 +1,26 @@
|
||||
<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 } from '../constants';
|
||||
import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const { variant, content, attachments } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const { variant, content, contentAttributes, 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);
|
||||
|
||||
@@ -38,6 +45,9 @@ 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,9 +1,27 @@
|
||||
<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('CONVERSATION.UNSUPPORTED_MESSAGE') }}
|
||||
{{ $t(unsupportedMessageKey) }}
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -52,6 +52,7 @@ 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,7 +574,8 @@
|
||||
"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,6 +61,7 @@
|
||||
"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,7 +273,8 @@
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading...",
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available.",
|
||||
"INSTAGRAM_STORY_REPLY": "Replied to your story:"
|
||||
},
|
||||
"LOCATION_BUBBLE": {
|
||||
"SEE_ON_MAP": "See on map"
|
||||
|
||||
@@ -36,6 +36,9 @@ 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('-');
|
||||
@@ -62,6 +65,8 @@ 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);
|
||||
@@ -212,8 +217,11 @@ const fetchActiveContacts = async (page = 1) => {
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const searchContacts = debounce(async (value, page = 1) => {
|
||||
clearSelection();
|
||||
const searchContacts = debounce(async (value, page = 1, append = false) => {
|
||||
if (!append) {
|
||||
clearSelection();
|
||||
searchPageNumber.value = 1;
|
||||
}
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
searchValue.value = value;
|
||||
|
||||
@@ -227,9 +235,27 @@ const searchContacts = debounce(async (value, page = 1) => {
|
||||
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);
|
||||
@@ -416,21 +442,25 @@ onMounted(async () => {
|
||||
:header-title="headerTitle"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:show-pagination-footer="!isFetchingList && hasContacts"
|
||||
:show-pagination-footer="!isFetchingList && hasContacts && !isSearchView"
|
||||
: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"
|
||||
v-if="isFetchingList && !(isSearchView && hasContacts)"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
|
||||
@@ -45,14 +45,19 @@ export const handleContactOperationErrors = error => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
search: async ({ commit }, { search, page, sortAttr, label }) => {
|
||||
search: async (
|
||||
{ commit },
|
||||
{ search, page, sortAttr, label, append = false }
|
||||
) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await ContactAPI.search(search, page, sortAttr, label);
|
||||
commit(types.CLEAR_CONTACTS);
|
||||
commit(types.SET_CONTACTS, payload);
|
||||
if (!append) {
|
||||
commit(types.CLEAR_CONTACTS);
|
||||
}
|
||||
commit(append ? types.APPEND_CONTACTS : types.SET_CONTACTS, payload);
|
||||
commit(types.SET_CONTACT_META, meta);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const state = {
|
||||
meta: {
|
||||
count: 0,
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
records: {},
|
||||
uiFlags: {
|
||||
|
||||
@@ -15,9 +15,24 @@ export const mutations = {
|
||||
},
|
||||
|
||||
[types.SET_CONTACT_META]: ($state, data) => {
|
||||
const { count, current_page: currentPage } = data;
|
||||
const { count, current_page: currentPage, has_more: hasMore } = 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,6 +139,7 @@ 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',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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
|
||||
@@ -0,0 +1,22 @@
|
||||
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,8 +1,54 @@
|
||||
class Notification::RemoveOldNotificationJob < ApplicationJob
|
||||
queue_as :low
|
||||
queue_as :purgable
|
||||
|
||||
NOTIFICATION_LIMIT = 300
|
||||
OLD_NOTIFICATION_THRESHOLD = 1.month
|
||||
|
||||
def perform
|
||||
Notification.where('created_at < ?', 1.month.ago)
|
||||
.find_each(batch_size: 1000, &:delete)
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,9 +19,6 @@ 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
|
||||
|
||||
|
||||
@@ -105,6 +105,9 @@ 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
|
||||
|
||||
+11
-1
@@ -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('id desc') }
|
||||
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('created_at desc') }
|
||||
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
|
||||
scope :voice_calls, -> { where(content_type: :voice_call) }
|
||||
|
||||
@@ -133,6 +133,7 @@ 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]
|
||||
@@ -422,6 +423,15 @@ 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')
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
@@ -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
|
||||
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/token/'
|
||||
def obtain_short_term_access_token(auth_code) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
||||
endpoint = "#{api_base_url}/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 = 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/refresh_token/'
|
||||
endpoint = "#{api_base_url}/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 = 'https://business-api.tiktok.com/open_api/v1.3/business/webhook/list/'
|
||||
endpoint = "#{api_base_url}/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 = 'https://business-api.tiktok.com/open_api/v1.3/business/webhook/update/'
|
||||
endpoint = "#{api_base_url}/business/webhook/update/"
|
||||
headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
|
||||
body = {
|
||||
app_id: client_id,
|
||||
@@ -141,5 +141,9 @@ 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
|
||||
|
||||
@@ -3,7 +3,7 @@ class Tiktok::Client
|
||||
pattr_initialize [:business_id!, :access_token!]
|
||||
|
||||
def business_account_details
|
||||
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/get/'
|
||||
endpoint = "#{api_base_url}/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 = 'https://business-api.tiktok.com/open_api/v1.3/business/message/media/download/'
|
||||
endpoint = "#{api_base_url}/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 ='https://business-api.tiktok.com/open_api/v1.3/business/message/send/'
|
||||
endpoint = "#{api_base_url}/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 = 'https://business-api.tiktok.com/open_api/v1.3/business/message/media/upload/'
|
||||
endpoint = "#{api_base_url}/business/message/media/upload/"
|
||||
headers = { 'Access-Token': access_token, 'Content-Type': 'multipart/form-data' }
|
||||
|
||||
file.open do |temp_file|
|
||||
@@ -86,6 +86,10 @@ 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}"
|
||||
|
||||
@@ -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)
|
||||
Down.download(file_download_url, headers: { 'x-user' => channel.validated_access_token })
|
||||
end
|
||||
|
||||
def tiktok_client(channel)
|
||||
|
||||
@@ -4,9 +4,9 @@ class Tiktok::ReadStatusService
|
||||
pattr_initialize [:channel!, :content!]
|
||||
|
||||
def perform
|
||||
return if channel.blank? || content.blank? || outbound_event?
|
||||
return if channel.blank? || content.blank? || outbound_event? || conversation.blank?
|
||||
|
||||
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, last_read_timestamp) if conversation.present?
|
||||
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, last_read_timestamp)
|
||||
end
|
||||
|
||||
def conversation
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
json.meta do
|
||||
json.count @contacts_count
|
||||
json.current_page @current_page
|
||||
json.has_more @has_more
|
||||
end
|
||||
|
||||
json.payload do
|
||||
|
||||
@@ -27,13 +27,10 @@ json.meta do
|
||||
end
|
||||
|
||||
json.id conversation.display_id
|
||||
if conversation.messages.where(account_id: conversation.account_id).last.blank?
|
||||
if conversation.cached_last_message.blank?
|
||||
json.messages []
|
||||
else
|
||||
json.messages [
|
||||
conversation.messages.where(account_id: conversation.account_id)
|
||||
.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)
|
||||
]
|
||||
json.messages [conversation.cached_last_message.push_event_data]
|
||||
end
|
||||
|
||||
json.account_id conversation.account_id
|
||||
@@ -54,7 +51,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.messages.where(account_id: conversation.account_id).non_activity_messages.first.try(:push_event_data)
|
||||
json.last_non_activity_message conversation.cached_last_non_activity_message&.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
|
||||
|
||||
@@ -407,6 +407,11 @@
|
||||
# ------- 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
|
||||
|
||||
@@ -59,3 +59,17 @@ 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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
+54
-33
@@ -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_20_121402) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -71,6 +71,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) 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"
|
||||
@@ -589,13 +590,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "contacts_count"
|
||||
t.integer "contacts_count", default: 0, null: false
|
||||
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", force: :cascade do |t|
|
||||
create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.bigint "contact_id"
|
||||
t.bigint "inbox_id"
|
||||
t.text "source_id", null: false
|
||||
@@ -603,14 +604,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "hmac_verified", default: false
|
||||
t.string "pubsub_token"
|
||||
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"
|
||||
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"
|
||||
end
|
||||
|
||||
create_table "contacts", id: :serial, force: :cascade do |t|
|
||||
create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.string "name", default: ""
|
||||
t.string "email"
|
||||
t.string "phone_number"
|
||||
@@ -621,25 +622,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
t.string "identifier"
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.datetime "last_activity_at", precision: nil
|
||||
t.integer "contact_type", default: 0
|
||||
t.boolean "resolved", default: false, null: false
|
||||
t.integer "contact_type", default: 0, null: false
|
||||
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: "index_contacts_on_lower_email_account_id"
|
||||
t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx"
|
||||
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
|
||||
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", "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", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
|
||||
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 ["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 ["blocked"], name: "index_contacts_on_blocked"
|
||||
t.index ["company_id"], name: "index_contacts_on_company_id"
|
||||
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"
|
||||
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"
|
||||
end
|
||||
|
||||
create_table "conversation_participants", force: :cascade do |t|
|
||||
@@ -667,7 +670,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
t.datetime "agent_last_seen_at", precision: nil
|
||||
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", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
|
||||
t.bigint "team_id"
|
||||
@@ -681,6 +684,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) 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"
|
||||
@@ -692,6 +698,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) 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"
|
||||
@@ -758,7 +767,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) 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|
|
||||
@@ -941,7 +949,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) 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"
|
||||
@@ -960,7 +968,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
t.index ["user_id"], name: "index_mentions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "messages", id: :serial, force: :cascade do |t|
|
||||
create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.text "content"
|
||||
t.integer "account_id", null: false
|
||||
t.integer "inbox_id", null: false
|
||||
@@ -979,18 +987,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.text "processed_message_content"
|
||||
t.jsonb "sentiment", default: {}
|
||||
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
|
||||
t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", 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: "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"
|
||||
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"
|
||||
end
|
||||
|
||||
create_table "notes", force: :cascade do |t|
|
||||
@@ -1048,6 +1056,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) 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
|
||||
@@ -1230,7 +1251,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
||||
t.text "message_signature"
|
||||
t.string "otp_secret"
|
||||
t.integer "consumed_timestep"
|
||||
t.boolean "otp_required_for_login", default: false
|
||||
t.boolean "otp_required_for_login", default: false, null: 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
|
||||
|
||||
# Build a common base relation with identical joins for OR compatibility
|
||||
service = Internal::RemoveOrphanConversationsService.new(account: account, days: days)
|
||||
|
||||
# Preview count using the same query logic
|
||||
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)
|
||||
conversations.destroy_all
|
||||
puts 'Conversations deleted.'
|
||||
total_deleted = service.perform
|
||||
puts "#{total_deleted} conversations deleted."
|
||||
else
|
||||
puts 'No conversations were deleted.'
|
||||
end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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,23 +103,10 @@ 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_source_id = messaging['message']['mid']
|
||||
story_url = messaging['message']['reply_to']['story']['url']
|
||||
|
||||
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' }
|
||||
)
|
||||
stub_request(:get, story_url)
|
||||
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
|
||||
|
||||
described_class.new(messaging, instagram_inbox).perform
|
||||
|
||||
@@ -128,6 +115,9 @@ 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,6 +104,7 @@ 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(
|
||||
@@ -114,6 +115,8 @@ 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
|
||||
@@ -123,7 +126,10 @@ 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('https://chatwoot-assets.local/sample.png')
|
||||
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)
|
||||
end
|
||||
|
||||
it 'creates message with for reply with mid' do
|
||||
|
||||
@@ -369,6 +369,50 @@ 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,23 +1,65 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Notification::RemoveOldNotificationJob do
|
||||
let(:user) { create(:user) }
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect do
|
||||
described_class.perform_later
|
||||
end.to have_enqueued_job(described_class)
|
||||
.on_queue('low')
|
||||
.on_queue('purgable')
|
||||
end
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
described_class.perform_now
|
||||
expect(Notification.count).to eq(2)
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,11 +30,6 @@ 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) }
|
||||
|
||||
Reference in New Issue
Block a user