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
44 changed files with 746 additions and 114 deletions
@@ -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],
+3 -1
View File
@@ -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
-3
View File
@@ -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
+3
View File
@@ -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
View File
@@ -114,7 +114,7 @@ class Message < ApplicationRecord
scope :created_since, ->(datetime) { where('created_at > ?', datetime) }
scope :chat, -> { where.not(message_type: :activity).where(private: false) }
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('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
+9 -5
View File
@@ -26,8 +26,8 @@ class Tiktok::AuthClient
end
# https://business-api.tiktok.com/portal/docs?id=1832184159540418
def obtain_short_term_access_token(auth_code) # rubocop:disable Metrics/MethodLength
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
+8 -4
View File
@@ -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}"
+2 -2
View File
@@ -26,7 +26,7 @@ module Tiktok::MessagingHelpers
end
def find_conversation(channel, tt_conversation_id)
channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id).conversations.first
channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)&.conversations&.first
end
def create_conversation(channel, contact_inbox, tt_conversation_id)
@@ -59,7 +59,7 @@ module Tiktok::MessagingHelpers
def fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id)
file_download_url = tiktok_client(channel).file_download_url(tt_conversation_id, tt_message_id, tt_image_media_id)
Down.download(file_download_url)
Down.download(file_download_url, headers: { 'x-user' => channel.validated_access_token })
end
def tiktok_client(channel)
+2 -2
View File
@@ -4,9 +4,9 @@ class Tiktok::ReadStatusService
pattr_initialize [:channel!, :content!]
def perform
return if channel.blank? || content.blank? || outbound_event?
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
+5
View File
@@ -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
+14
View File
@@ -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
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_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
+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
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) }