Compare commits

..
Author SHA1 Message Date
Pranav e21f4f1dfe cache this 2026-01-28 12:12:10 -08:00
Pranav e419cd5a07 Update created_at here 2026-01-28 11:12:20 -08:00
Muhsin KelothandGitHub 6cd1b37981 feat: Tiktok API version configurable via Super Admin (#13381)
Extracted hardcoded TikTok API version (`v1.3`) into a configurable
`TIKTOK_API_VERSION` setting, consistent with how Instagram and WhatsApp
handle API versions.

Fixes
https://linear.app/chatwoot/issue/CW-6408/tiktok-api-version-configurable-via-super-admin
2026-01-28 19:46:48 +04:00
Muhsin KelothandGitHub 3b612e2b20 chore: Add unsupported message for Tiktok (#13380)
This PR adds the unsupported messages for tiktok.
Fixes
https://linear.app/chatwoot/issue/CW-6407/add-support-for-unsupported-message
2026-01-28 19:34:11 +04:00
d166ae73bc feat: add cron job to remove orphan conversations (#13335)
## Description

This PR includes cron job to delete the orphans

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces a scheduled cleanup for conversations missing `contact` or
`inbox`.
> 
> - Adds `Internal::RemoveOrphanConversationsService` to batch-delete
orphan conversations (scoped by optional `account`, within a
configurable `days` window) with progress logging
> - New `Internal::RemoveOrphanConversationsJob` that invokes the
service; scheduled via `config/schedule.yml` to run every 12 hours on
`housekeeping` queue
> - Refactors rake task `chatwoot:ops:cleanup_orphan_conversations` to
use the service and report `total_deleted` after confirmation
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
59a24715cc59f048d08db3f588cde6fa036f3166. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 19:25:20 +05:30
Muhsin KelothandGitHub aaeea6c9bf feat: Display story replies with attachment and context label (#13356)
Fixes https://github.com/chatwoot/chatwoot/issues/13354
Fixes
https://linear.app/chatwoot/issue/CW-6394/story-responses-are-not-being-shown-in-the-ui
When someone replies to your Instagram story, agents in Chatwoot only
see the reply text with no story image and no indication that it was a
story reply. This makes it impossible to understand what the customer is
responding to the message looks like a random text with no context. For
example, if a customer replies "Love this!" to your story, the agent
just sees "Love this!" with no way to know which story triggered the
conversation. This PR fixes the issue by storing the story attachment
and adding a context label.

<img width="1408" height="2052" alt="CleanShot 2026-01-27 at 19 19
38@2x"
src="https://github.com/user-attachments/assets/341afea9-98e3-4e47-b2fa-ef77fe32851f"
/>
2026-01-28 16:47:04 +04:00
b870a48734 perf: limit the number of notifications per user to 300 (#13234)
## Linear issue


https://linear.app/chatwoot/issue/CW-6289/limit-the-number-of-notifications-per-user-to-300

## Description

Limits the number of notifications per user to 300 by introducing an
async trim job that runs after each notification creation. This prevents
unbounded notification growth that was causing DB CPU spikes.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- Added unit tests for TrimUserNotificationsJob

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Implements a dedicated purge job to control notification volume and
scheduling.
> 
> - Introduces `Notification::RemoveOldNotificationJob` (queue:
`purgable`) to delete notifications older than 1 month and trim each
user to the 300 most recent (deterministic by `created_at DESC, id
DESC`)
> - Adds daily cron (`remove_old_notification_job` at 22:30 UTC, queue
`purgable`) in `config/schedule.yml`
> - Removes ad-hoc triggering of the purge from
`TriggerScheduledItemsJob`
> - Adds/updates specs covering enqueue queue, old-notification
deletion, per-user trimming, and combined behavior
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9ea2b48e36df96cd15d4119d1dd7dcf5250695de. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-28 17:35:13 +05:30
68f0da7351 fix: Attachments download authentication issue in Tiktok (#13151)
Fixes
https://linear.app/chatwoot/issue/CW-6357/ensure-authentication-when-fetching-attachments
Update the attachment download method to include the access token in the
request headers, ensuring proper authentication when fetching
attachments.

https://business-api.tiktok.com/portal/docs?id=1832184455450626

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 11:50:14 +04:00
40c622ed95 fix: Tiktok nil conversation handling in ReadStatusService (#13152)
Fixes
https://linear.app/chatwoot/issue/CW-6358/handling-of-nil-conversation-in-the-readstatusservice
Improve handling of nil conversation in the `ReadStatusService` to
prevent potential errors. Ensure that the conversation is checked before
performing updates to message status. This change fixes the below error.



```
NoMethodError: undefined method 'conversations' for nil (NoMethodError)

    channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id).conversations.first
                                                                        ^^^^^^^^^^^^^^
    from app/services/tiktok/messaging_helpers.rb:29:in 'Tiktok::MessagingHelpers#find_conversation'
    from app/services/tiktok/read_status_service.rb:13:in 'Tiktok::ReadStatusService#conversation'
    from app/services/tiktok/read_status_service.rb:9:in 'Tiktok::ReadStatusService#perform'
    from app/jobs/webhooks/tiktok_events_job.rb:67:in 'Webhooks::TiktokEventsJob#im_mark_read_msg'
    from app/jobs/webhooks/tiktok_events_job.rb:31:in 'Webhooks::TiktokEventsJob#process_event'
    from app/jobs/webhooks/tiktok_events_job.rb:15:in 'block in Webhooks::TiktokEventsJob#perform'
    from app/jobs/mutex_application_job.rb:23:in 'MutexApplicationJob#with_lock'
    from app/jobs/webhooks/tiktok_events_job.rb:14:in 'Webhooks::TiktokEventsJob#perform'
    from activejob (7.1.5.2) lib/active_job/execution.rb:68:in 'block in ActiveJob::Execution#_perform_job'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:121:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from i18n (1.14.7) lib/i18n.rb:353:in 'I18n::Base#with_locale'
    from activejob (7.1.5.2) lib/active_job/translation.rb:9:in 'block (2 levels) in <module:Translation>'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'BasicObject#instance_exec'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from activesupport (7.1.5.2) lib/active_support/core_ext/time/zones.rb:65:in 'Time.use_zone'
    from activejob (7.1.5.2) lib/active_job/timezones.rb:9:in 'block (2 levels) in <module:Timezones>'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'BasicObject#instance_exec'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:141:in 'ActiveSupport::Callbacks#run_callbacks'
    from activejob (7.1.5.2) lib/active_job/execution.rb:67:in 'ActiveJob::Execution#_perform_job
```

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 10:41:19 +04:00
PranavandGitHub 7cddba2b08 feat: Add infinite scroll to contacts search page (#13376)
## Summary
- Add `has_more` to contacts search API response to enable infinite
scroll without expensive count queries
- Set `count` to the number of items in the current page instead of
total count
- Implement "Load more" button for contacts search results
- Keep existing contacts visible while loading additional pages

## Changes

### Backend
- Add `fetch_contacts_with_has_more` method that fetches N+1 records to
determine if more pages exist
- Return `has_more` in search endpoint meta response
- Set `count` to current page size instead of total count

### Frontend
- Add `APPEND_CONTACTS` mutation for appending contacts without clearing
existing ones
- Update search action to support `append` parameter
- Add `ContactsLoadMore` component with loading state
- Update `ContactsListLayout` to support infinite scroll mode
- Update `ContactsIndex` to use infinite scroll for search view
2026-01-27 18:55:19 -08:00
45 changed files with 746 additions and 115 deletions
@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank? return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes) @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 end
def build_conversation 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', 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%" search: "%#{params[:q].strip}%"
) )
@contacts = fetch_contacts(contacts) @contacts = fetch_contacts_with_has_more(contacts)
@contacts_count = @contacts.total_count
end end
def import def import
@@ -142,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE) .per(RESULTS_PER_PAGE)
end 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 def build_contact_inbox
return if params[:inbox_id].blank? return if params[:inbox_id].blank?
@@ -46,7 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_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], '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], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], '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], 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
+3 -1
View File
@@ -180,7 +180,9 @@ class ConversationFinder
def conversations_base_query def conversations_base_query
@conversations.includes( @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 end
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue'; import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue'; import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
const props = defineProps({ const props = defineProps({
searchValue: { type: String, default: '' }, searchValue: { type: String, default: '' },
@@ -19,6 +20,9 @@ const props = defineProps({
segmentsId: { type: [String, Number], default: 0 }, segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false }, hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { 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([ const emit = defineEmits([
@@ -27,6 +31,7 @@ const emit = defineEmits([
'search', 'search',
'applyFilter', 'applyFilter',
'clearFilters', 'clearFilters',
'loadMore',
]); ]);
const route = useRoute(); const route = useRoute();
@@ -61,6 +66,14 @@ const updateCurrentPage = page => {
const openFilter = () => { const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters(); contactListHeaderWrapper.value?.onToggleFilters();
}; };
const showLoadMore = computed(() => {
return props.useInfiniteScroll && props.hasMore;
});
const showPagination = computed(() => {
return !props.useInfiniteScroll && props.showPaginationFooter;
});
</script> </script>
<template> <template>
@@ -94,9 +107,14 @@ const openFilter = () => {
@open-filter="openFilter" @open-filter="openFilter"
/> />
<slot name="default" /> <slot name="default" />
<ContactsLoadMore
v-if="showLoadMore"
:is-loading="isLoadingMore"
@load-more="emit('loadMore')"
/>
</div> </div>
</main> </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 <PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING" current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage" :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 = [ const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION, ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY, ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_STORY_REPLY,
ATTACHMENT_TYPES.IG_POST, ATTACHMENT_TYPES.IG_POST,
]; ];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) { if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
@@ -1,19 +1,26 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue'; import BaseBubble from 'next/message/bubbles/Base.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../constants'; import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
const emit = defineEmits(['error']); const emit = defineEmits(['error']);
const { variant, content, attachments } = useMessageContext(); const { t } = useI18n();
const { variant, content, contentAttributes, attachments } =
useMessageContext();
const attachment = computed(() => { const attachment = computed(() => {
return attachments.value[0]; return attachments.value[0];
}); });
const isStoryReply = computed(() => {
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
});
const hasImgStoryError = ref(false); const hasImgStoryError = ref(false);
const hasVideoStoryError = ref(false); const hasVideoStoryError = ref(false);
@@ -38,6 +45,9 @@ const onVideoLoadError = () => {
<template> <template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story"> <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" /> <div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img <img
v-if="!hasImgStoryError" v-if="!hasImgStoryError"
@@ -1,9 +1,27 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
import BaseBubble from './Base.vue'; 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> </script>
<template> <template>
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported"> <BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }} {{ $t(unsupportedMessageKey) }}
</BaseBubble> </BaseBubble>
</template> </template>
@@ -52,6 +52,7 @@ export const ATTACHMENT_TYPES = {
EMBED: 'embed', EMBED: 'embed',
IG_POST: 'ig_post', IG_POST: 'ig_post',
IG_STORY: 'ig_story', IG_STORY: 'ig_story',
IG_STORY_REPLY: 'ig_story_reply',
}; };
export const CONTENT_TYPES = { export const CONTENT_TYPES = {
@@ -574,7 +574,8 @@
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍", "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋", "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙" "ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
} },
"LOAD_MORE": "Load more"
}, },
"CONTACTS_BULK_ACTIONS": { "CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels", "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": "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_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_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", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response", "NO_RESPONSE": "No response",
@@ -273,7 +273,8 @@
"FILE_BUBBLE": { "FILE_BUBBLE": {
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
"UPLOADING": "Uploading...", "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": { "LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map" "SEE_ON_MAP": "See on map"
@@ -36,6 +36,9 @@ const meta = useMapGetter('contacts/getMeta');
const searchQuery = computed(() => route.query?.search); const searchQuery = computed(() => route.query?.search);
const searchValue = ref(searchQuery.value || ''); const searchValue = ref(searchQuery.value || '');
const pageNumber = computed(() => Number(route.query?.page) || 1); 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 parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-'); const hasDescending = sortString.startsWith('-');
@@ -62,6 +65,8 @@ const isFetchingList = computed(
); );
const currentPage = computed(() => Number(meta.value?.currentPage)); const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count); const totalItems = computed(() => meta.value?.count);
const hasMore = computed(() => meta.value?.hasMore ?? false);
const isSearchView = computed(() => !!searchQuery.value);
const selectedContactIds = ref([]); const selectedContactIds = ref([]);
const isBulkActionLoading = ref(false); const isBulkActionLoading = ref(false);
@@ -212,8 +217,11 @@ const fetchActiveContacts = async (page = 1) => {
updatePageParam(page); updatePageParam(page);
}; };
const searchContacts = debounce(async (value, page = 1) => { const searchContacts = debounce(async (value, page = 1, append = false) => {
clearSelection(); if (!append) {
clearSelection();
searchPageNumber.value = 1;
}
await store.dispatch('contacts/clearContactFilters'); await store.dispatch('contacts/clearContactFilters');
searchValue.value = value; searchValue.value = value;
@@ -227,9 +235,27 @@ const searchContacts = debounce(async (value, page = 1) => {
await store.dispatch('contacts/search', { await store.dispatch('contacts/search', {
...getCommonFetchParams(page), ...getCommonFetchParams(page),
search: encodeURIComponent(value), search: encodeURIComponent(value),
append,
}); });
searchPageNumber.value = page;
}, DEBOUNCE_DELAY); }, 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 => { const fetchContactsBasedOnContext = async page => {
clearSelection(); clearSelection();
updatePageParam(page, searchValue.value); updatePageParam(page, searchValue.value);
@@ -416,21 +442,25 @@ onMounted(async () => {
:header-title="headerTitle" :header-title="headerTitle"
:current-page="currentPage" :current-page="currentPage"
:total-items="totalItems" :total-items="totalItems"
:show-pagination-footer="!isFetchingList && hasContacts" :show-pagination-footer="!isFetchingList && hasContacts && !isSearchView"
:active-sort="sortState.activeSort" :active-sort="sortState.activeSort"
:active-ordering="sortState.activeOrdering" :active-ordering="sortState.activeOrdering"
:active-segment="activeSegment" :active-segment="activeSegment"
:segments-id="activeSegmentId" :segments-id="activeSegmentId"
:is-fetching-list="isFetchingList" :is-fetching-list="isFetchingList"
:has-applied-filters="hasAppliedFilters" :has-applied-filters="hasAppliedFilters"
:use-infinite-scroll="isSearchView"
:has-more="hasMore"
:is-loading-more="isLoadingMore"
@update:current-page="fetchContactsBasedOnContext" @update:current-page="fetchContactsBasedOnContext"
@search="searchContacts" @search="searchContacts"
@update:sort="handleSort" @update:sort="handleSort"
@apply-filter="fetchSavedOrAppliedFilteredContact" @apply-filter="fetchSavedOrAppliedFilteredContact"
@clear-filters="fetchContacts" @clear-filters="fetchContacts"
@load-more="loadMoreSearchResults"
> >
<div <div
v-if="isFetchingList" v-if="isFetchingList && !(isSearchView && hasContacts)"
class="flex items-center justify-center py-10 text-n-slate-11" class="flex items-center justify-center py-10 text-n-slate-11"
> >
<Spinner /> <Spinner />
@@ -45,14 +45,19 @@ export const handleContactOperationErrors = error => {
}; };
export const actions = { 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 }); commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try { try {
const { const {
data: { payload, meta }, data: { payload, meta },
} = await ContactAPI.search(search, page, sortAttr, label); } = await ContactAPI.search(search, page, sortAttr, label);
commit(types.CLEAR_CONTACTS); if (!append) {
commit(types.SET_CONTACTS, payload); commit(types.CLEAR_CONTACTS);
}
commit(append ? types.APPEND_CONTACTS : types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta); commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false }); commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) { } catch (error) {
@@ -6,6 +6,7 @@ const state = {
meta: { meta: {
count: 0, count: 0,
currentPage: 1, currentPage: 1,
hasMore: false,
}, },
records: {}, records: {},
uiFlags: { uiFlags: {
@@ -15,9 +15,24 @@ export const mutations = {
}, },
[types.SET_CONTACT_META]: ($state, data) => { [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.count = count;
$state.meta.currentPage = currentPage; $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) => { [types.SET_CONTACTS]: ($state, data) => {
@@ -139,6 +139,7 @@ export default {
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG', SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM', SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
SET_CONTACTS: 'SET_CONTACTS', SET_CONTACTS: 'SET_CONTACTS',
APPEND_CONTACTS: 'APPEND_CONTACTS',
CLEAR_CONTACTS: 'CLEAR_CONTACTS', CLEAR_CONTACTS: 'CLEAR_CONTACTS',
EDIT_CONTACT: 'EDIT_CONTACT', EDIT_CONTACT: 'EDIT_CONTACT',
DELETE_CONTACT: 'DELETE_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 class Notification::RemoveOldNotificationJob < ApplicationJob
queue_as :low queue_as :purgable
NOTIFICATION_LIMIT = 300
OLD_NOTIFICATION_THRESHOLD = 1.month
def perform def perform
Notification.where('created_at < ?', 1.month.ago) remove_old_notifications
.find_each(batch_size: 1000, &:delete) 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
end end
-3
View File
@@ -19,9 +19,6 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates # Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
# Job to clear notifications which are older than 1 month
Notification::RemoveOldNotificationJob.perform_later
end end
end end
+3
View File
@@ -105,6 +105,9 @@ class Conversation < ApplicationRecord
belongs_to :contact_inbox belongs_to :contact_inbox
belongs_to :team, optional: true belongs_to :team, optional: true
belongs_to :campaign, 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 :mentions, dependent: :destroy_async
has_many :messages, dependent: :destroy_async, autosave: true has_many :messages, dependent: :destroy_async, autosave: true
+11 -1
View File
@@ -114,7 +114,7 @@ class Message < ApplicationRecord
scope :created_since, ->(datetime) { where('created_at > ?', datetime) } scope :created_since, ->(datetime) { where('created_at > ?', datetime) }
scope :chat, -> { where.not(message_type: :activity).where(private: false) } 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 :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
scope :voice_calls, -> { where(content_type: :voice_call) } scope :voice_calls, -> { where(content_type: :voice_call) }
@@ -133,6 +133,7 @@ class Message < ApplicationRecord
has_many :notifications, as: :primary_actor, dependent: :destroy_async has_many :notifications, as: :primary_actor, dependent: :destroy_async
after_create_commit :execute_after_create_commit_callbacks after_create_commit :execute_after_create_commit_callbacks
after_create_commit :update_conversation_cached_message_ids
after_update_commit :dispatch_update_event after_update_commit :dispatch_update_event
after_commit :reindex_for_search, if: :should_index?, on: [:create, :update] after_commit :reindex_for_search, if: :should_index?, on: [:create, :update]
@@ -422,6 +423,15 @@ class Message < ApplicationRecord
def reindex_for_search def reindex_for_search
reindex(mode: :async) reindex(mode: :async)
end 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 end
Message.prepend_mod_with('Message') 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
@@ -3,7 +3,6 @@ class Messages::NewMessageNotificationService
def perform def perform
return unless message.notifiable? return unless message.notifiable?
return if message.status == 'failed'
notify_conversation_assignee notify_conversation_assignee
notify_participating_users notify_participating_users
+9 -5
View File
@@ -26,8 +26,8 @@ class Tiktok::AuthClient
end end
# https://business-api.tiktok.com/portal/docs?id=1832184159540418 # https://business-api.tiktok.com/portal/docs?id=1832184159540418
def obtain_short_term_access_token(auth_code) # rubocop:disable Metrics/MethodLength def obtain_short_term_access_token(auth_code) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/token/' endpoint = "#{api_base_url}/tt_user/oauth2/token/"
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
body = { body = {
client_id: client_id, client_id: client_id,
@@ -56,7 +56,7 @@ class Tiktok::AuthClient
end end
def renew_short_term_access_token(refresh_token) # rubocop:disable Metrics/MethodLength 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' } headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
body = { body = {
client_id: client_id, client_id: client_id,
@@ -82,7 +82,7 @@ class Tiktok::AuthClient
end end
def webhook_callback 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' } headers = { Accept: 'application/json' }
params = { params = {
app_id: client_id, app_id: client_id,
@@ -95,7 +95,7 @@ class Tiktok::AuthClient
end end
def update_webhook_callback 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' } headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
body = { body = {
app_id: client_id, app_id: client_id,
@@ -141,5 +141,9 @@ class Tiktok::AuthClient
def base_url def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
def api_base_url
"https://business-api.tiktok.com/open_api/#{GlobalConfigService.load('TIKTOK_API_VERSION', 'v1.3')}"
end
end end
end end
+8 -4
View File
@@ -3,7 +3,7 @@ class Tiktok::Client
pattr_initialize [:business_id!, :access_token!] pattr_initialize [:business_id!, :access_token!]
def business_account_details 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 } headers = { 'Access-Token': access_token }
params = { business_id: business_id, fields: %w[username display_name profile_image].to_s } params = { business_id: business_id, fields: %w[username display_name profile_image].to_s }
response = HTTParty.get(endpoint, query: params, headers: headers) response = HTTParty.get(endpoint, query: params, headers: headers)
@@ -17,7 +17,7 @@ class Tiktok::Client
end end
def file_download_url(conversation_id, message_id, media_id, media_type = 'IMAGE') 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' } headers = { 'Access-Token': access_token, 'Content-Type': 'application/json', Accept: 'application/json' }
body = { business_id: business_id, body = { business_id: business_id,
conversation_id: conversation_id, conversation_id: conversation_id,
@@ -45,7 +45,7 @@ class Tiktok::Client
def send_message(conversation_id, type, payload, referenced_message_id: nil) def send_message(conversation_id, type, payload, referenced_message_id: nil)
# https://business-api.tiktok.com/portal/docs?id=1832184403754242 # 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' } headers = { 'Access-Token': access_token, 'Content-Type': 'application/json' }
body = { body = {
business_id: business_id, business_id: business_id,
@@ -70,7 +70,7 @@ class Tiktok::Client
end end
def upload_media(file, media_type = 'IMAGE') 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' } headers = { 'Access-Token': access_token, 'Content-Type': 'multipart/form-data' }
file.open do |temp_file| file.open do |temp_file|
@@ -86,6 +86,10 @@ class Tiktok::Client
end end
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) def process_json_response(response, error_prefix)
unless response.success? unless response.success?
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}" Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
+2 -2
View File
@@ -26,7 +26,7 @@ module Tiktok::MessagingHelpers
end end
def find_conversation(channel, tt_conversation_id) 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 end
def create_conversation(channel, contact_inbox, tt_conversation_id) 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) 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) 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 end
def tiktok_client(channel) def tiktok_client(channel)
+2 -2
View File
@@ -4,9 +4,9 @@ class Tiktok::ReadStatusService
pattr_initialize [:channel!, :content!] pattr_initialize [:channel!, :content!]
def perform 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 end
def conversation def conversation
@@ -1,6 +1,7 @@
json.meta do json.meta do
json.count @contacts_count json.count @contacts_count
json.current_page @current_page json.current_page @current_page
json.has_more @has_more
end end
json.payload do json.payload do
@@ -27,13 +27,10 @@ json.meta do
end end
json.id conversation.display_id json.id conversation.display_id
if conversation.messages.where(account_id: conversation.account_id).last.blank? if conversation.cached_last_message.blank?
json.messages [] json.messages []
else else
json.messages [ json.messages [conversation.cached_last_message.push_event_data]
conversation.messages.where(account_id: conversation.account_id)
.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)
]
end end
json.account_id conversation.account_id 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.timestamp conversation.last_activity_at.to_i
json.first_reply_created_at conversation.first_reply_created_at.to_i json.first_reply_created_at conversation.first_reply_created_at.to_i
json.unread_count conversation.unread_incoming_messages.count json.unread_count conversation.unread_incoming_messages.count
json.last_non_activity_message conversation.messages.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.last_activity_at conversation.last_activity_at.to_i
json.priority conversation.priority json.priority conversation.priority
json.waiting_since conversation.waiting_since.to_i.to_i json.waiting_since conversation.waiting_since.to_i.to_i
+5
View File
@@ -407,6 +407,11 @@
# ------- End of Instagram Channel Related Config ------- # # ------- End of Instagram Channel Related Config ------- #
# ------- TikTok 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 - name: TIKTOK_APP_ID
display_title: 'TikTok App ID' display_title: 'TikTok App ID'
locked: false locked: false
+14
View File
@@ -59,3 +59,17 @@ periodic_assignment_job:
cron: '*/30 * * * *' cron: '*/30 * * * *'
class: 'AutoAssignment::PeriodicAssignmentJob' class: 'AutoAssignment::PeriodicAssignmentJob'
queue: scheduled_jobs 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
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" 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 "limits", default: {}
t.jsonb "custom_attributes", default: {} t.jsonb "custom_attributes", default: {}
t.integer "status", default: 0 t.integer "status", default: 0
t.integer "contactable_contacts_count", default: 0
t.jsonb "internal_attributes", default: {}, null: false t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {} t.jsonb "settings", default: {}
t.index ["status"], name: "index_accounts_on_status" 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.bigint "account_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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", "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 ["account_id"], name: "index_companies_on_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id" t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end 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 "contact_id"
t.bigint "inbox_id" t.bigint "inbox_id"
t.text "source_id", null: false 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.datetime "updated_at", null: false
t.boolean "hmac_verified", default: false t.boolean "hmac_verified", default: false
t.string "pubsub_token" t.string "pubsub_token"
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id" t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx"
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id" t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx"
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true
t.index ["source_id"], name: "index_contact_inboxes_on_source_id" t.index ["source_id"], name: "contact_inboxes2_source_id_idx"
end 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 "name", default: ""
t.string "email" t.string "email"
t.string "phone_number" t.string "phone_number"
@@ -621,25 +622,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
t.string "identifier" t.string "identifier"
t.jsonb "custom_attributes", default: {} t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil 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 "middle_name", default: ""
t.string "last_name", default: "" t.string "last_name", default: ""
t.string "location", default: "" t.string "location", default: ""
t.string "country_code", default: "" t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false t.boolean "blocked", default: false, null: false
t.bigint "company_id" 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", "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", "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", "resolved"], name: "index_contacts_on_account_id_and_resolved"
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"], 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 ["blocked"], name: "index_contacts_on_blocked"
t.index ["company_id"], name: "index_contacts_on_company_id" 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 ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", 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 ["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: "index_contacts_on_phone_number_and_account_id" t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx"
end end
create_table "conversation_participants", force: :cascade do |t| 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.datetime "agent_last_seen_at", precision: nil
t.jsonb "additional_attributes", default: {} t.jsonb "additional_attributes", default: {}
t.bigint "contact_inbox_id" 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.string "identifier"
t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
t.bigint "team_id" t.bigint "team_id"
@@ -681,6 +684,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
t.datetime "waiting_since" t.datetime "waiting_since"
t.text "cached_label_list" t.text "cached_label_list"
t.bigint "assignee_agent_bot_id" 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", "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", "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" 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 ["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 ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
t.index ["inbox_id"], name: "index_conversations_on_inbox_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 ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id" t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
t.index ["status", "priority"], name: "index_conversations_on_status_and_priority" 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_pattern"
t.string "regex_cue" t.string "regex_cue"
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id" 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 end
create_table "custom_filters", force: :cascade do |t| 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.integer "visibility", default: 0
t.bigint "created_by_id" t.bigint "created_by_id"
t.bigint "updated_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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_macros_on_account_id" 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" t.index ["user_id"], name: "index_mentions_on_user_id"
end 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.text "content"
t.integer "account_id", null: false t.integer "account_id", null: false
t.integer "inbox_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.jsonb "additional_attributes", default: {}
t.text "processed_message_content" t.text "processed_message_content"
t.jsonb "sentiment", default: {} 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", "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", "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", "inbox_id"], name: "messages2_account_id_inbox_id_idx"
t.index ["account_id"], name: "index_messages_on_account_id" t.index ["account_id"], name: "messages2_account_id_idx"
t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin t.index ["content"], name: "messages2_content_idx", 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", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx"
t.index ["conversation_id"], name: "index_messages_on_conversation_id" t.index ["conversation_id"], name: "messages2_conversation_id_idx"
t.index ["created_at"], name: "index_messages_on_created_at" t.index ["created_at"], name: "messages2_created_at_idx"
t.index ["inbox_id"], name: "index_messages_on_inbox_id" t.index ["inbox_id"], name: "messages2_inbox_id_idx"
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id" t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx"
t.index ["source_id"], name: "index_messages_on_source_id" t.index ["source_id"], name: "messages2_source_id_idx"
end end
create_table "notes", force: :cascade do |t| 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" t.index ["user_id"], name: "index_notifications_on_user_id"
end 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| create_table "platform_app_permissibles", force: :cascade do |t|
t.bigint "platform_app_id", null: false t.bigint "platform_app_id", null: false
t.string "permissible_type", 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.text "message_signature"
t.string "otp_secret" t.string "otp_secret"
t.integer "consumed_timestep" 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.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email" t.index ["email"], name: "index_users_on_email"
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login" 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_input = $stdin.gets.strip
days = days_input.empty? ? 7 : days_input.to_i 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 base = account
.conversations .conversations
.where('conversations.created_at > ?', days.days.ago) .where('conversations.created_at > ?', days.days.ago)
.left_outer_joins(:contact, :inbox) .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 })) conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
count = conversations.count count = conversations.count
@@ -31,8 +31,8 @@ namespace :chatwoot do
print 'Do you want to delete these conversations? (y/N): ' print 'Do you want to delete these conversations? (y/N): '
confirm = $stdin.gets.strip.downcase confirm = $stdin.gets.strip.downcase
if %w[y yes].include?(confirm) if %w[y yes].include?(confirm)
conversations.destroy_all total_deleted = service.perform
puts 'Conversations deleted.' puts "#{total_deleted} conversations deleted."
else else
puts 'No conversations were deleted.' puts 'No conversations were deleted.'
end end
+42
View File
@@ -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
+72
View File
@@ -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
+92
View File
@@ -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 it 'creates message with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0] messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox) 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}\?.*}) stub_request(:get, story_url)
.to_return( .to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
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 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).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_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[: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 end
it 'creates message with reply to mid' do 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 it 'creates message with for reply with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0] messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id'] 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(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return( 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' profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access }.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) create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform 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).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_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_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 end
it 'creates message with for reply with mid' do 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).to include(contact_special.identifier)
expect(response.body).not_to include(contact_normal.identifier) expect(response.body).not_to include(contact_normal.identifier)
end 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
end end
@@ -1,23 +1,65 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Notification::RemoveOldNotificationJob do RSpec.describe Notification::RemoveOldNotificationJob do
let(:user) { create(:user) } let(:account) { create(:account) }
let(:conversation) { create(:conversation) } let(:user) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account) }
it 'enqueues the job' do it 'enqueues the job' do
expect do expect do
described_class.perform_later described_class.perform_later
end.to have_enqueued_job(described_class) end.to have_enqueued_job(described_class)
.on_queue('low') .on_queue('purgable')
end end
it 'removes old notifications which are older than 1 month' do describe 'removing old notifications' do
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 2.months.ago) it 'removes notifications older than 1 month' do
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,
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.day.ago) created_at: 2.months.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.hour.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 described_class.perform_now
expect(Notification.count).to eq(2) 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
end end
@@ -30,11 +30,6 @@ RSpec.describe TriggerScheduledItemsJob do
described_class.perform_now described_class.perform_now
end 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 context 'when unexecuted Scheduled campaign jobs' do
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) } let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) } let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }