-
-
-
- {{ thHeader }}
-
- |
-
-
+
+
+
+
+ |
+
+ {{ thHeader }}
+
+ |
+
+
+
+
+
+
{
icon: 'i-woot-voice',
});
+ channels.push({
+ key: 'whatsapp_call',
+ title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.TITLE'),
+ description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.DESCRIPTION'),
+ icon: 'i-woot-whatsapp',
+ });
+
return channels;
});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/PreChatForm/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/PreChatForm/Settings.vue
index 7f6ac7fe1..626971081 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/PreChatForm/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/PreChatForm/Settings.vue
@@ -156,10 +156,8 @@ onMounted(() => {
.message-editor {
@apply px-3;
- ::v-deep {
- .ProseMirror-menubar {
- @apply rounded-tl-[4px];
- }
+ :deep(.ProseMirror-menubar) {
+ @apply rounded-tl-[4px];
}
}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index a26eb0e18..9e5206bb3 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -22,6 +22,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
+import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -48,6 +49,7 @@ export default {
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
+ WhatsappCallingPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -249,6 +251,22 @@ export default {
];
}
+ if (
+ this.isAWhatsAppCloudChannel &&
+ this.isFeatureEnabledonAccount(
+ this.accountId,
+ FEATURE_FLAGS.CHANNEL_VOICE
+ )
+ ) {
+ visibleToAllChannelTabs = [
+ ...visibleToAllChannelTabs,
+ {
+ key: 'calls-configuration',
+ name: this.$t('INBOX_MGMT.TABS.CALLS'),
+ },
+ ];
+ }
+
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -1262,6 +1280,12 @@ export default {
>
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
new file mode 100644
index 000000000..c27cd7d1f
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
index cf5c1310e..668e0709a 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
+import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
@@ -16,6 +17,13 @@ import {
isValidBusinessData,
} from './whatsapp/utils';
+const props = defineProps({
+ enableCallingOnComplete: {
+ type: Boolean,
+ default: false,
+ },
+});
+
const store = useStore();
const router = useRouter();
const { t } = useI18n();
@@ -65,11 +73,27 @@ const handleSignupCancellation = () => {
isAuthenticating.value = false;
};
-const handleSignupSuccess = inboxData => {
- isProcessing.value = false;
- isAuthenticating.value = false;
+const enableCallingForInbox = async inboxId => {
+ try {
+ await InboxesAPI.enableWhatsappCalling(inboxId);
+ } catch (_) {
+ useAlert(
+ t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CALLING_ENABLE_FAILED')
+ );
+ }
+};
+const handleSignupSuccess = async inboxData => {
if (inboxData && inboxData.id) {
+ if (props.enableCallingOnComplete) {
+ isProcessing.value = true;
+ processingMessage.value = t(
+ 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
+ );
+ await enableCallingForInbox(inboxData.id);
+ }
+ isProcessing.value = false;
+ isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
@@ -79,6 +103,8 @@ const handleSignupSuccess = inboxData => {
},
});
} else {
+ isProcessing.value = false;
+ isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
index a5c16fa57..e49d98423 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
@@ -1,6 +1,5 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/SingleIntegrationHooks.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/SingleIntegrationHooks.vue
index 405656542..14fec4996 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/SingleIntegrationHooks.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/SingleIntegrationHooks.vue
@@ -1,5 +1,4 @@
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
index ed674e3bc..75eb8a2f8 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
@@ -13,7 +13,6 @@ import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
-import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
@@ -22,6 +21,7 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
+import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -36,7 +36,7 @@ export default {
UserProfilePicture,
Policy,
UserBasicDetails,
- HotKeyCard,
+ RadioCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
@@ -268,26 +268,27 @@ export default {
-
+
![]()
+
-import { computed, ref, watch, defineModel } from 'vue';
+import { computed, ref, watch } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import addMonths from 'date-fns/addMonths';
diff --git a/app/javascript/dashboard/routes/dashboard/upgrade/UpgradePage.vue b/app/javascript/dashboard/routes/dashboard/upgrade/UpgradePage.vue
index a650aac37..fd55213ea 100644
--- a/app/javascript/dashboard/routes/dashboard/upgrade/UpgradePage.vue
+++ b/app/javascript/dashboard/routes/dashboard/upgrade/UpgradePage.vue
@@ -1,5 +1,5 @@
+
+ {{ kbd }}
+
diff --git a/app/javascript/portal/components/SearchSuggestions.vue b/app/javascript/portal/components/SearchSuggestions.vue
index 282275547..71853848c 100644
--- a/app/javascript/portal/components/SearchSuggestions.vue
+++ b/app/javascript/portal/components/SearchSuggestions.vue
@@ -29,7 +29,7 @@ export default {
setup(props) {
const selectedIndex = ref(-1);
const portalSearchSuggestionsRef = ref(null);
- const { highlightContent } = useMessageFormatter();
+ const { highlightContent, getPlainText } = useMessageFormatter();
const adjustScroll = () => {
nextTick(() => {
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
@@ -37,9 +37,7 @@ export default {
};
const isSearchItemActive = index => {
- return index === selectedIndex.value
- ? 'bg-slate-25 dark:bg-slate-800'
- : 'bg-white dark:bg-slate-900';
+ return index === selectedIndex.value ? 'bg-n-portal-soft' : '';
};
useKeyboardNavigableList({
@@ -53,6 +51,7 @@ export default {
portalSearchSuggestionsRef,
isSearchItemActive,
highlightContent,
+ getPlainText,
};
},
@@ -70,7 +69,7 @@ export default {
return this.highlightContent(
content,
this.searchTerm,
- 'bg-slate-100 dark:bg-slate-700 font-semibold text-slate-600 dark:text-slate-200'
+ 'bg-n-portal-soft text-n-portal font-semibold rounded-sm px-1'
);
},
},
@@ -80,46 +79,46 @@ export default {
-
+
{{ loadingPlaceholder }}
-
+
{{ emptyPlaceholder }}
diff --git a/app/javascript/portal/components/SidebarThemeToggle.vue b/app/javascript/portal/components/SidebarThemeToggle.vue
new file mode 100644
index 000000000..0cfd5fc87
--- /dev/null
+++ b/app/javascript/portal/components/SidebarThemeToggle.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/portal/components/TableOfContents.vue b/app/javascript/portal/components/TableOfContents.vue
index 3cb140018..fb2aa21b7 100644
--- a/app/javascript/portal/components/TableOfContents.vue
+++ b/app/javascript/portal/components/TableOfContents.vue
@@ -79,13 +79,13 @@ export default {
},
elementBorderStyles(el) {
if (this.isElementActive(el)) {
- return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
+ return 'border-n-portal transition-colors duration-200';
}
return 'border-slate-100 dark:border-slate-800';
},
elementTextStyles(el) {
if (this.isElementActive(el)) {
- return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
+ return 'text-n-portal transition-colors duration-200';
}
return 'text-slate-700 dark:text-slate-100';
},
@@ -113,7 +113,7 @@ export default {
{{ element.title }}
diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js
index 18a54c58e..d654f9054 100644
--- a/app/javascript/portal/portalHelpers.js
+++ b/app/javascript/portal/portalHelpers.js
@@ -7,6 +7,7 @@ import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
+import SidebarThemeToggle from './components/SidebarThemeToggle.vue';
import { initializeTheme } from './portalThemeHelper.js';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
@@ -78,18 +79,23 @@ export const InitializationHelpers = {
},
initializeSearch: () => {
- const isSearchContainerAvailable = document.querySelector('#search-wrap');
- if (isSearchContainerAvailable) {
+ ['#search-wrap', '#search-wrap-hero'].forEach(selector => {
+ const mountPoint = document.querySelector(selector);
+ if (!mountPoint) return;
+ const size = mountPoint.dataset.size || 'default';
+ const showKbd = !!mountPoint.dataset.kbd;
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { PublicArticleSearch },
- template: '',
+ data() {
+ return { size, showKbd };
+ },
+ template: '',
});
-
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);
- app.mount('#search-wrap');
- }
+ app.mount(selector);
+ });
},
initializeTableOfContents: () => {
@@ -109,6 +115,31 @@ export const InitializationHelpers = {
}
},
+ initializeSidebarThemeToggle: () => {
+ const mountPoint = document.querySelector('#sidebar-theme-toggle');
+ if (mountPoint) {
+ // eslint-disable-next-line vue/one-component-per-file
+ const app = createApp({
+ components: { SidebarThemeToggle },
+ template: '',
+ });
+ app.directive('on-clickaway', onClickaway);
+ app.mount('#sidebar-theme-toggle');
+ }
+ },
+
+ initializeDetailsClickAway: () => {
+ document.addEventListener('click', event => {
+ document
+ .querySelectorAll('details[data-close-on-clickaway][open]')
+ .forEach(details => {
+ if (!details.contains(event.target)) {
+ details.removeAttribute('open');
+ }
+ });
+ });
+ },
+
appendPlainParamToURLs: () => {
[...document.getElementsByTagName('a')].forEach(aTagElement => {
if (aTagElement.href && aTagElement.href.includes('/hc/')) {
@@ -143,6 +174,8 @@ export const InitializationHelpers = {
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
+ InitializationHelpers.initializeSidebarThemeToggle();
+ InitializationHelpers.initializeDetailsClickAway();
}
},
diff --git a/app/javascript/portal/specs/SearchSuggestions.spec.js b/app/javascript/portal/specs/SearchSuggestions.spec.js
index 6352c1725..96475dc0b 100644
--- a/app/javascript/portal/specs/SearchSuggestions.spec.js
+++ b/app/javascript/portal/specs/SearchSuggestions.spec.js
@@ -9,6 +9,7 @@ vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
vi.mock('shared/composables/useMessageFormatter', () => ({
useMessageFormatter: () => ({
highlightContent: content => content,
+ getPlainText: content => content,
}),
}));
diff --git a/app/javascript/shared/components/CustomerSatisfaction.vue b/app/javascript/shared/components/CustomerSatisfaction.vue
index 799ec772c..de0647069 100644
--- a/app/javascript/shared/components/CustomerSatisfaction.vue
+++ b/app/javascript/shared/components/CustomerSatisfaction.vue
@@ -4,6 +4,7 @@ import Spinner from 'shared/components/Spinner.vue';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import StarRating from 'shared/components/StarRating.vue';
+import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
@@ -30,6 +31,10 @@ export default {
default: '',
},
},
+ setup() {
+ const { formatMessage } = useMessageFormatter();
+ return { formatMessage };
+ },
data() {
return {
email: '',
@@ -61,6 +66,9 @@ export default {
? this.$t('CSAT.SUBMITTED_TITLE')
: this.message || this.$t('CSAT.TITLE');
},
+ formattedTitle() {
+ return this.formatMessage(this.title, false);
+ },
isEmojiType() {
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
},
@@ -128,9 +136,10 @@ export default {
class="customer-satisfaction w-full bg-n-background dark:bg-n-solid-3 shadow-[0_0.25rem_6px_rgba(50,50,93,0.08),0_1px_3px_rgba(0,0,0,0.05)] ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] rounded-lg inline-block leading-[1.5] mt-1 border-t-2 border-t-n-brand border-solid"
:style="{ borderColor: widgetColor }"
>
-
- {{ title }}
-
+
diff --git a/app/views/public/api/v1/portals/_uncategorized-block.html.erb b/app/views/public/api/v1/portals/_uncategorized-block.html.erb
index 430be617b..fe28bca5f 100644
--- a/app/views/public/api/v1/portals/_uncategorized-block.html.erb
+++ b/app/views/public/api/v1/portals/_uncategorized-block.html.erb
@@ -6,7 +6,7 @@
- <% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
+ <% portal.articles.published.where(category_id: nil, locale: @locale).order(position: :asc).take(5).each do |article| %>
- <%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil).size %>
+ <%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil, locale: @locale).size %>
diff --git a/app/views/public/api/v1/portals/articles/show.html+documentation.erb b/app/views/public/api/v1/portals/articles/show.html+documentation.erb
new file mode 100644
index 000000000..76aeb804e
--- /dev/null
+++ b/app/views/public/api/v1/portals/articles/show.html+documentation.erb
@@ -0,0 +1,44 @@
+<% content_for :head do %>
+ <%= render 'public/api/v1/portals/documentation_layout/articles/meta_head',
+ article: @article, portal: @portal, og_image_url: @og_image_url %>
+<% end %>
+
+
+
+
+ <%= render 'public/api/v1/portals/documentation_layout/breadcrumb',
+ portal: @portal, locale: @locale, category: @article.category, article: @article %>
+ <%= render 'public/api/v1/portals/documentation_layout/articles/header',
+ portal: @portal, article: @article %>
+
+
+ <%= @parsed_content %>
+
+
+ <% if @article.category %>
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/public/api/v1/portals/categories/show.html+documentation.erb b/app/views/public/api/v1/portals/categories/show.html+documentation.erb
new file mode 100644
index 000000000..58b349d83
--- /dev/null
+++ b/app/views/public/api/v1/portals/categories/show.html+documentation.erb
@@ -0,0 +1,25 @@
+<% content_for :head do %>
+ <%= render 'public/api/v1/portals/documentation_layout/categories/meta_head',
+ category: @category, portal: @portal, og_image_url: @og_image_url %>
+<% end %>
+
+
+ <%= render 'public/api/v1/portals/documentation_layout/breadcrumb',
+ portal: @portal, locale: @locale, category: @category %>
+
+ <%= render 'public/api/v1/portals/documentation_layout/categories/header',
+ category: @category, articles: @articles, category_authors: @category_authors %>
+
+ <% if @articles.empty? %>
+ <%= render 'public/api/v1/portals/documentation_layout/empty_state' %>
+ <% else %>
+
+ <% @articles.each do |article| %>
+ <%= render 'public/api/v1/portals/documentation_layout/article_card',
+ portal: @portal,
+ article: article,
+ show_category: false %>
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_article_card.html.erb b/app/views/public/api/v1/portals/documentation_layout/_article_card.html.erb
new file mode 100644
index 000000000..6f66e7675
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_article_card.html.erb
@@ -0,0 +1,12 @@
+<%# locals: (portal:, article:, show_category: true) %>
+
+ <% if show_category && article.category %>
+
+ <% if article.category.icon.present? %><%= article.category.icon %><% end %>
+ <%= article.category.name %>
+
+ <% end %>
+ <%= article.title %>
+ <%= render_category_content(article.content) %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb b/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb
new file mode 100644
index 000000000..e3632cb35
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb
@@ -0,0 +1,10 @@
+<%# locals: (users:, size: :sm, overlap: '-ml-1.5') %>
+<% z_classes = %w[z-30 z-20 z-10] %>
+
+ <% users.first(3).each_with_index do |user, idx| %>
+ <%= render 'public/api/v1/portals/documentation_layout/user_avatar',
+ user: user,
+ size: size,
+ extra_class: "relative #{z_classes[idx]} #{idx.positive? ? overlap : ''}" %>
+ <% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb b/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb
new file mode 100644
index 000000000..3831e5dfe
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb
@@ -0,0 +1,31 @@
+<%# locals: (portal:, locale:, category: nil, article: nil) %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb b/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb
new file mode 100644
index 000000000..fa4ffc024
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb
@@ -0,0 +1,24 @@
+<%# locals: (portal:, category:, contributors: []) %>
+<% contributors = Array(contributors) %>
+
+
+ <%= category.icon.presence || '📁' %>
+
+ <%= category.name %>
+ <% if category.description.present? %>
+ <%= category.description %>
+ <% end %>
+
+ <% if contributors.any? %>
+ <%= render 'public/api/v1/portals/documentation_layout/avatar_group',
+ users: contributors, size: :sm, overlap: '-ml-1.5' %>
+ <% else %>
+
+ <% end %>
+
+ <%= I18n.t('public_portal.sidebar.browse') %>
+
+
+
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_empty_state.html.erb b/app/views/public/api/v1/portals/documentation_layout/_empty_state.html.erb
new file mode 100644
index 000000000..4d1b53f25
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_empty_state.html.erb
@@ -0,0 +1,3 @@
+
+
<%= local_assigns[:message] || I18n.t('public_portal.common.no_articles') %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_footer.html.erb b/app/views/public/api/v1/portals/documentation_layout/_footer.html.erb
new file mode 100644
index 000000000..1bb193451
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_footer.html.erb
@@ -0,0 +1,53 @@
+<%# locals: (portal:, global_config:) %>
+<%
+ socials = portal.social_profiles
+ social_definitions = [
+ { key: 'facebook', icon: 'i-ri-facebook-circle-fill', label: 'Facebook', base_url: 'https://facebook.com/' },
+ { key: 'x', icon: 'i-ri-twitter-x-fill', label: 'X', base_url: 'https://x.com/' },
+ { key: 'instagram', icon: 'i-ri-instagram-fill', label: 'Instagram', base_url: 'https://instagram.com/' },
+ { key: 'linkedin', icon: 'i-ri-linkedin-box-fill', label: 'LinkedIn', base_url: 'https://linkedin.com/' },
+ { key: 'youtube', icon: 'i-ri-youtube-fill', label: 'YouTube', base_url: 'https://youtube.com/' },
+ { key: 'tiktok', icon: 'i-ri-tiktok-fill', label: 'TikTok', base_url: 'https://tiktok.com/' },
+ { key: 'github', icon: 'i-ri-github-fill', label: 'GitHub', base_url: 'https://github.com/' },
+ { key: 'whatsapp', icon: 'i-ri-whatsapp-fill', label: 'WhatsApp', base_url: 'https://wa.me/' }
+ ]
+ active_socials = social_definitions.filter_map do |social|
+ handle = socials[social[:key]].to_s.strip
+ social.merge(url: "#{social[:base_url]}#{handle}") if handle.present?
+ end
+ show_branding = !portal.account.feature_enabled?('disable_branding')
+%>
+
+<% if show_branding || active_socials.any? %>
+
+<% end %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb
new file mode 100644
index 000000000..73d590518
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb
@@ -0,0 +1,32 @@
+<%# locals: (portal:, popular_topics: []) %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_section_header.html.erb b/app/views/public/api/v1/portals/documentation_layout/_section_header.html.erb
new file mode 100644
index 000000000..b1df7e6cf
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_section_header.html.erb
@@ -0,0 +1,6 @@
+
+ <%= title %>
+ <% if local_assigns[:subtitle].present? %>
+ <%= subtitle %>
+ <% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_sidebar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_sidebar.html.erb
new file mode 100644
index 000000000..1898bbd46
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_sidebar.html.erb
@@ -0,0 +1,89 @@
+<%# locals: (portal:, locale:, article: nil, category: nil) %>
+<%
+ current_category_slug = article&.category&.slug || category&.slug
+ current_article_slug = article&.slug
+ sidebar_categories = portal.categories.where(locale: locale).order(position: :asc)
+ sidebar_articles_by_category = portal.articles.published
+ .where(category_id: sidebar_categories.map(&:id))
+ .order(:position)
+ .group_by(&:category_id)
+ uncategorized_articles = portal.articles.published.where(category_id: nil, locale: locale).order(:position)
+%>
+
+
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
new file mode 100644
index 000000000..2b2f56547
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
@@ -0,0 +1,72 @@
+<%# locals: (portal:, locale:, article: nil, category: nil) %>
+<% home_url = public_portal_locale_path(portal.slug, locale) %>
+<% current = article ? :article : (category ? :category : :home) %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_user_avatar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_user_avatar.html.erb
new file mode 100644
index 000000000..994222e0c
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_user_avatar.html.erb
@@ -0,0 +1,18 @@
+<%
+ size = local_assigns[:size] || :sm
+ border = local_assigns[:border] || 'ring-2 ring-solid ring-white dark:ring-n-slate-2'
+ extra = local_assigns[:extra_class] || ''
+ size_map = {
+ xs: { box: 'w-4 h-4', text: 'text-xs', rounded: 'rounded' },
+ sm: { box: 'w-6 h-6', text: 'text-xs', rounded: 'rounded-md' },
+ md: { box: 'w-7 h-7', text: 'text-xs', rounded: 'rounded-md' },
+ lg: { box: 'w-10 h-10', text: 'text-sm', rounded: 'rounded-xl' }
+ }
+ s = size_map.fetch(size, size_map[:sm])
+ name = user.available_name
+%>
+<% if user.avatar_url.present? %>
+
+<% else %>
+
+<% end %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_actions.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_actions.html.erb
new file mode 100644
index 000000000..4993b4443
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_actions.html.erb
@@ -0,0 +1,42 @@
+<%# locals: (portal:, article:) %>
+<%
+ markdown_url = public_portal_article_markdown_url(portal.slug, article.slug)
+ encoded_prompt = ERB::Util.url_encode(I18n.t('public_portal.article_actions.llm_prompt', url: markdown_url))
+ chatgpt_url = "https://chatgpt.com/?q=#{encoded_prompt}"
+ claude_url = "https://claude.ai/new?q=#{encoded_prompt}"
+%>
+
+
+
+ <%= I18n.t('public_portal.article_actions.label') %>
+
+
+
+
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_header.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_header.html.erb
new file mode 100644
index 000000000..e9e3fd407
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_header.html.erb
@@ -0,0 +1,32 @@
+<%# locals: (portal:, article:) %>
+<% last_updated_on = article.updated_at.strftime("%b %-d, %Y") %>
+
+
+
+ <%= article.title %>
+
+
+ <%= render 'public/api/v1/portals/documentation_layout/articles/actions',
+ portal: portal, article: article %>
+
+
+ <% if article.author.present? %>
+
+ <%= render 'public/api/v1/portals/documentation_layout/user_avatar',
+ user: article.author,
+ size: :lg,
+ border: 'border border-solid border-n-weak' %>
+
+
<%= article.author.available_name %>
+
+ <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: last_updated_on) %>
+
+
+
+ <% else %>
+
+
+ <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: last_updated_on) %>
+
+ <% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
new file mode 100644
index 000000000..a236722ae
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
@@ -0,0 +1,17 @@
+<%= article.title %> | <%= portal.display_title %>
+<% if article.meta["title"].present? %>
+ ">
+ ">
+<% end %>
+<% if article.meta["description"].present? %>
+ ">
+ ">
+<% end %>
+<% if article.meta["tags"].present? %>
+ ">
+<% end %>
+<% if og_image_url.present? %>
+
+
+
+<% end %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_header.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_header.html.erb
new file mode 100644
index 000000000..471facc06
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/categories/_header.html.erb
@@ -0,0 +1,33 @@
+<% article_count_label = I18n.t(articles.size == 1 ? 'public_portal.common.article' : 'public_portal.common.articles') %>
+<% meta_row = capture do %>
+
+ <%= articles.size %> <%= article_count_label %>
+ <% if category_authors.any? %>
+ ·
+
+ <%= render 'public/api/v1/portals/documentation_layout/avatar_group',
+ users: category_authors, size: :xs, overlap: '-ml-0.5' %>
+ <%= I18n.t('public_portal.common.by') %> <%= format_authors_label(category_authors) %>
+
+ <% end %>
+
+<% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
new file mode 100644
index 000000000..7c8dec8d2
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
@@ -0,0 +1,11 @@
+<%= category.name %> | <%= portal.display_title %>
+
+<% if category.description.present? %>
+
+
+<% end %>
+<% if og_image_url.present? %>
+
+
+
+<% end %>
diff --git a/app/views/public/api/v1/portals/show.html+documentation.erb b/app/views/public/api/v1/portals/show.html+documentation.erb
new file mode 100644
index 000000000..d37d593dd
--- /dev/null
+++ b/app/views/public/api/v1/portals/show.html+documentation.erb
@@ -0,0 +1,34 @@
+<%= render 'public/api/v1/portals/documentation_layout/hero', portal: @portal, popular_topics: @popular_topics %>
+
+
+ <%= render 'public/api/v1/portals/documentation_layout/section_header',
+ title: I18n.t('public_portal.sidebar.browse_by_topic'),
+ subtitle: I18n.t('public_portal.sidebar.browse_by_topic_subtitle') %>
+
+ <% if @visible_categories.empty? %>
+ <%= render 'public/api/v1/portals/documentation_layout/empty_state' %>
+ <% else %>
+
+ <% @visible_categories.each do |category| %>
+ <%= render 'public/api/v1/portals/documentation_layout/category_card',
+ portal: @portal,
+ category: category,
+ contributors: @category_contributors[category.id] %>
+ <% end %>
+
+ <% end %>
+
+
+<% if @featured.any? %>
+
+ <%= render 'public/api/v1/portals/documentation_layout/section_header',
+ title: I18n.t('public_portal.sidebar.popular_articles'),
+ subtitle: I18n.t('public_portal.sidebar.popular_articles_subtitle'),
+ subtitle_class: 'mt-1.5 text-base text-n-slate-11' %>
+
+ <% @featured.each do |article| %>
+ <%= render 'public/api/v1/portals/documentation_layout/article_card', portal: @portal, article: article %>
+ <% end %>
+
+
+<% end %>
diff --git a/config/app.yml b/config/app.yml
index 1c93a1287..fec34cd07 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.14.0'
+ version: '4.14.1'
development:
<<: *shared
diff --git a/config/features.yml b/config/features.yml
index c469fed90..03105588b 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -17,10 +17,10 @@
display_name: Facebook Channel
enabled: true
help_url: https://chwt.app/hc/fb
-- name: channel_twitter
- display_name: Twitter Channel
- enabled: true
- deprecated: true
+- name: conversation_unread_counts
+ display_name: Conversation Unread Counts
+ enabled: false
+ chatwoot_internal: true
- name: ip_lookup
display_name: IP Lookup
enabled: false
diff --git a/config/installation_config.yml b/config/installation_config.yml
index e374d0948..673c6df4c 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -215,6 +215,18 @@
value:
locked: false
type: code
+- name: CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT
+ display_title: 'Captain Document Auto Sync Per Account Batch Limit'
+ description: 'Maximum syncable Captain documents to enqueue per account in one scheduler run. Defaults to 50.'
+ value: 50
+ locked: false
+ type: number
+- name: CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT
+ display_title: 'Captain Document Auto Sync Global Batch Limit'
+ description: 'Maximum syncable Captain documents to enqueue globally in one scheduler run. Defaults to 1000.'
+ value: 1000
+ locked: false
+ type: number
# End of Captain Config
# ------- Context.dev Config ------- #
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index b7b59493a..a7bf0234b 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -413,6 +413,22 @@ ar:
loading_placeholder: جاري البحث...
results_title: نتائج البحث
toc_header: 'في هذه الصفحة'
+ sidebar:
+ help_center: مركز المساعدة
+ categories: الفئات
+ language: اللغة
+ theme: المظهر
+ open_sidebar: فتح الشريط الجانبي
+ close_sidebar: إغلاق الشريط الجانبي
+ browse: تصفح
+ back_to_category: العودة إلى %{name}
+ browse_by_topic: تصفح حسب الموضوع
+ browse_by_topic_subtitle: اعثر على أدلة ودروس وأجوبة منظمة حسب الفئة.
+ popular_articles: المقالات الشائعة
+ popular_articles_subtitle: ما يقرأه الآخرون الآن.
+ popular_label: 'مواضيع شائعة:'
+ authors_others: "%{names} و %{count} آخرون"
+ primary_nav: التنقل الرئيسي
hero:
sub_title: ابحث عن المقالات هنا أو تصفح الفئات أدناه.
common:
@@ -427,6 +443,12 @@ ar:
others: الآخرين
by: بواسطة
no_articles: لا توجد مقالات
+ article_actions:
+ label: فتح في
+ view_markdown: عرض كـ Markdown
+ open_in_chatgpt: فتح في ChatGPT
+ open_in_claude: فتح في Claude
+ llm_prompt: "اقرأ المقال التالي وساعدني في فهمه: %{url}"
footer:
made_with: صنع بـ
header:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index dc2b4d3fe..b11a4fdae 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -81,6 +81,9 @@ en:
saml:
feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation
+ conversations:
+ unread_counts:
+ feature_not_enabled: Conversation unread counts feature not enabled for this account
data_import:
data_type:
invalid: Invalid data type
@@ -255,6 +258,7 @@ en:
whatsapp:
list_button_label: 'Choose an item'
call_permission_request_body: 'We would like to call you regarding your conversation.'
+ unsupported_message: 'This message is unavailable.'
voice_call:
twilio: 'Voice Call'
whatsapp: 'WhatsApp Call'
@@ -427,6 +431,22 @@ en:
loading_placeholder: Searching...
results_title: Search results
toc_header: 'On this page'
+ sidebar:
+ help_center: Help Center
+ categories: Categories
+ language: Language
+ theme: Theme
+ open_sidebar: Open sidebar
+ close_sidebar: Close sidebar
+ browse: Browse
+ back_to_category: Back to %{name}
+ browse_by_topic: Browse by topic
+ browse_by_topic_subtitle: Find guides, tutorials, and answers organised by category.
+ popular_articles: Popular articles
+ popular_articles_subtitle: What other people are reading right now.
+ popular_label: 'Popular topics:'
+ authors_others: "%{names} and %{count} others"
+ primary_nav: Primary
hero:
sub_title: Search for the articles here or browse the categories below.
common:
@@ -441,12 +461,19 @@ en:
others: others
by: By
no_articles: There are no articles here
+ article_actions:
+ label: Open in
+ view_markdown: View as Markdown
+ open_in_chatgpt: Open in ChatGPT
+ open_in_claude: Open in Claude
+ llm_prompt: "Read the following article and help me understand it: %{url}"
footer:
made_with: Made with
header:
go_to_homepage: Website
visit_website: Visit website
appearance:
+ title: Appearance
system: System
light: Light
dark: Dark
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 7f3d3a7d3..8564a7154 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -413,6 +413,22 @@ fr:
loading_placeholder: Recherche en cours...
results_title: Résultats de recherche
toc_header: 'Sur cette page'
+ sidebar:
+ help_center: "Centre d'aide"
+ categories: Catégories
+ language: Langue
+ theme: Thème
+ open_sidebar: Ouvrir la barre latérale
+ close_sidebar: Fermer la barre latérale
+ browse: Parcourir
+ back_to_category: Retour à %{name}
+ browse_by_topic: Parcourir par sujet
+ browse_by_topic_subtitle: Trouvez des guides, des tutoriels et des réponses organisés par catégorie.
+ popular_articles: Articles populaires
+ popular_articles_subtitle: Ce que les autres lisent en ce moment.
+ popular_label: 'Sujets populaires :'
+ authors_others: "%{names} et %{count} autres"
+ primary_nav: Navigation principale
hero:
sub_title: Recherchez les articles ici ou parcourez les catégories ci-dessous.
common:
@@ -427,6 +443,12 @@ fr:
others: autres
by: Par
no_articles: Il n'y a pas d'articles ici
+ article_actions:
+ label: Ouvrir dans
+ view_markdown: Voir en Markdown
+ open_in_chatgpt: Ouvrir dans ChatGPT
+ open_in_claude: Ouvrir dans Claude
+ llm_prompt: "Lis l'article suivant et aide-moi à le comprendre : %{url}"
footer:
made_with: Réalisé avec
header:
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index d69b12f04..73dd356a7 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -413,6 +413,22 @@ pt_BR:
loading_placeholder: Procurando...
results_title: Resultados de pesquisa
toc_header: 'Nesta página'
+ sidebar:
+ help_center: Central de Ajuda
+ categories: Categorias
+ language: Idioma
+ theme: Tema
+ open_sidebar: Abrir barra lateral
+ close_sidebar: Fechar barra lateral
+ browse: Navegar
+ back_to_category: Voltar para %{name}
+ browse_by_topic: Navegar por tópico
+ browse_by_topic_subtitle: Encontre guias, tutoriais e respostas organizados por categoria.
+ popular_articles: Artigos populares
+ popular_articles_subtitle: O que outras pessoas estão lendo agora.
+ popular_label: 'Tópicos populares:'
+ authors_others: "%{names} e mais %{count}"
+ primary_nav: Navegação principal
hero:
sub_title: Pesquise os artigos aqui ou navegue pelas categorias abaixo.
common:
@@ -427,6 +443,12 @@ pt_BR:
others: outros
by: Por
no_articles: Não há artigos aqui
+ article_actions:
+ label: Abrir em
+ view_markdown: Ver como Markdown
+ open_in_chatgpt: Abrir no ChatGPT
+ open_in_claude: Abrir no Claude
+ llm_prompt: "Leia o seguinte artigo e me ajude a entendê-lo: %{url}"
footer:
made_with: Criado com
header:
diff --git a/config/routes.rb b/config/routes.rb
index 01dab6a02..004a940a3 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -133,6 +133,7 @@ Rails.application.routes.draw do
collection do
get :meta
get :search
+ get :unread_counts, to: 'conversations/unread_counts#index'
post :filter
end
scope module: :conversations do
@@ -260,6 +261,8 @@ Rails.application.routes.draw do
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
end
+ post :enable_whatsapp_calling, on: :member
+ post :disable_whatsapp_calling, on: :member
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
@@ -377,8 +380,6 @@ Rails.application.routes.draw do
end
end
end
- resources :working_hours, only: [:update]
-
resources :portals do
member do
patch :archive
@@ -393,6 +394,7 @@ Rails.application.routes.draw do
resource :bulk_actions, only: [] do
post :translate
patch :update_status
+ patch :update_category
delete :delete_articles
end
end
@@ -585,13 +587,15 @@ Rails.application.routes.draw do
get 'hc/:slug', to: 'public/api/v1/portals#show'
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
- get 'hc/:slug/:locale', to: 'public/api/v1/portals#show'
+ get 'hc/:slug/:locale', to: 'public/api/v1/portals#show', as: :public_portal_locale
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
- get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
+ get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'
- get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
+ get 'hc/:slug/articles/:article_slug.md', to: 'public/api/v1/portals/articles#show_markdown', as: :public_portal_article_markdown,
+ defaults: { format: :md }
+ get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show', as: :public_portal_article
# ----------------------------------------------------------------------
# Used in mailer templates
diff --git a/db/migrate/20260508000000_repurpose_channel_twitter_flag_for_conversation_unread_counts.rb b/db/migrate/20260508000000_repurpose_channel_twitter_flag_for_conversation_unread_counts.rb
new file mode 100644
index 000000000..4c0bff5bd
--- /dev/null
+++ b/db/migrate/20260508000000_repurpose_channel_twitter_flag_for_conversation_unread_counts.rb
@@ -0,0 +1,20 @@
+class RepurposeChannelTwitterFlagForConversationUnreadCounts < ActiveRecord::Migration[7.1]
+ def up
+ # The channel_twitter flag (deprecated) has been renamed to conversation_unread_counts.
+ # Disable it on any accounts that had channel_twitter enabled so the repurposed
+ # flag starts in its intended default-off state.
+ Account.feature_conversation_unread_counts.find_each(batch_size: 100) do |account|
+ account.disable_features(:conversation_unread_counts)
+ account.save!(validate: false)
+ end
+
+ # Remove the stale channel_twitter entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
+ # ConfigLoader only adds new flags; it never removes renamed ones.
+ config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
+ return if config&.value.blank?
+
+ config.value = config.value.reject { |feature| feature['name'] == 'channel_twitter' }
+ config.save!
+ GlobalConfig.clear_cache
+ end
+end
diff --git a/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb b/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb
new file mode 100644
index 000000000..50cee2b0d
--- /dev/null
+++ b/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb
@@ -0,0 +1,16 @@
+class ChangeCaptainDocumentExternalLinkToText < ActiveRecord::Migration[7.0]
+ OLD_INDEX_NAME = 'index_captain_documents_on_assistant_id_and_external_link'.freeze
+ NEW_INDEX_NAME = 'idx_captain_documents_on_assistant_id_and_external_link_md5'.freeze
+
+ def up
+ remove_index :captain_documents, name: OLD_INDEX_NAME, if_exists: true
+ change_column :captain_documents, :external_link, :text, null: false
+ add_index :captain_documents, 'assistant_id, md5(external_link)', unique: true, name: NEW_INDEX_NAME, if_not_exists: true
+ end
+
+ def down
+ remove_index :captain_documents, name: NEW_INDEX_NAME, if_exists: true
+ change_column :captain_documents, :external_link, :string, null: false
+ add_index :captain_documents, [:assistant_id, :external_link], unique: true, name: OLD_INDEX_NAME, if_not_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9d9fe3cbc..f2c479571 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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_05_15_000000) do
+ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -370,7 +370,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
create_table "captain_documents", force: :cascade do |t|
t.string "name"
- t.string "external_link", null: false
+ t.text "external_link", null: false
t.text "content"
t.bigint "assistant_id", null: false
t.bigint "account_id", null: false
@@ -381,10 +381,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.integer "sync_status"
t.datetime "last_synced_at"
t.datetime "last_sync_attempted_at"
+ t.index "assistant_id, md5(external_link)", name: "idx_captain_documents_on_assistant_id_and_external_link_md5", unique: true
t.index ["account_id", "assistant_id", "sync_status", "last_synced_at"], name: "idx_captain_documents_on_account_assistant_sync_stats"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
- t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["status"], name: "index_captain_documents_on_status"
end
diff --git a/enterprise/app/controllers/api/v1/accounts/audit_logs_controller.rb b/enterprise/app/controllers/api/v1/accounts/audit_logs_controller.rb
index 29e02c1dd..7c569b3f1 100644
--- a/enterprise/app/controllers/api/v1/accounts/audit_logs_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/audit_logs_controller.rb
@@ -2,7 +2,7 @@ class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAcco
before_action :check_admin_authorization?
before_action :fetch_audit
- RESULTS_PER_PAGE = 15
+ RESULTS_PER_PAGE = 25
def show
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
index b9a6bbcc5..7e2817f69 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
@@ -75,7 +75,6 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
next unless document.syncable?
next unless document.available?
- next if document.sync_in_progress?
document.update!(
sync_status: :syncing,
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
index 23f410499..273c082b1 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
@@ -37,7 +37,6 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
def sync
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
- return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!(
sync_status: :syncing,
diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
index 1123699d8..0bea29843 100644
--- a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
@@ -27,7 +27,10 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def destroy
call = resolve_call!
+ rejecting = agent_rejecting_before_pickup?(call)
+ # Tear down provider side first so a teardown failure leaves the call repairable.
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
+ finalize_as_agent_reject!(call) if rejecting
render json: { status: 'success', id: call.conversation.display_id }
end
@@ -59,4 +62,21 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def render_call_already_accepted(error)
render json: { error: error.message }, status: :conflict
end
+
+ # A hangup before pickup is treated as an agent rejection, matching WhatsApp.
+ def agent_rejecting_before_pickup?(call)
+ call.ringing? && call.accepted_by_agent_id.nil?
+ end
+
+ def finalize_as_agent_reject!(call)
+ # Re-check under a row lock: a webhook may have accepted/completed the call
+ # while end_conference was in flight, so don't force agent_rejected on stale state.
+ rejected = call.with_lock do
+ next false unless agent_rejecting_before_pickup?(call)
+
+ call.update!(status: 'failed', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
+ true
+ end
+ Voice::CallMessageBuilder.new(call).update_status!(status: 'failed', agent: Current.user) if rejected
+ end
end
diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
index b54940586..0301d428b 100644
--- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
@@ -35,8 +35,14 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def initiate
@call = create_outbound_call
- @message = Voice::CallMessageBuilder.new(@call).perform!
- @call.update!(message_id: @message.id)
+ # Link the call to its message in one transaction so the message.created
+ # broadcast (an after_create_commit hook) fires only once call.message_id is
+ # set. Otherwise the live ringing bubble receives a message with no `call`
+ # payload (no direction/agent) and renders "Calling…" instead of "Handled by …".
+ ActiveRecord::Base.transaction do
+ @message = Voice::CallMessageBuilder.new(@call).perform!
+ @call.update!(message_id: @message.id)
+ end
end
private
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
index f9d828806..76f578bf1 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
@@ -3,12 +3,38 @@ module Enterprise::Api::V1::Accounts::InboxesController
super + ee_inbox_attributes
end
+ def enable_whatsapp_calling
+ return unless ensure_whatsapp_calling_supported
+
+ @inbox.channel.enable_voice_calling!
+ head :ok
+ rescue StandardError => e
+ render_could_not_create_error(e.message)
+ end
+
+ def disable_whatsapp_calling
+ return unless ensure_whatsapp_calling_supported
+
+ @inbox.channel.disable_voice_calling!
+ head :ok
+ rescue StandardError => e
+ render_could_not_create_error(e.message)
+ end
+
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
private
+ def ensure_whatsapp_calling_supported
+ channel = @inbox.channel
+ return true if channel.is_a?(Channel::Whatsapp) && channel.voice_calling_supported?
+
+ render_could_not_create_error('Inbox does not support WhatsApp calling')
+ false
+ end
+
def allowed_channel_types
super + ['voice']
end
diff --git a/enterprise/app/jobs/captain/documents/perform_sync_job.rb b/enterprise/app/jobs/captain/documents/perform_sync_job.rb
index eaef7d64d..2a33db3f3 100644
--- a/enterprise/app/jobs/captain/documents/perform_sync_job.rb
+++ b/enterprise/app/jobs/captain/documents/perform_sync_job.rb
@@ -48,9 +48,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
return if document.pdf_document?
with_lock(lock_key(document), LOCK_TIMEOUT) do
- mark_sync_started(document)
- result = Captain::Documents::SyncService.new(document.reload).perform
- log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
+ perform_sync(document, start_time)
end
rescue LockAcquisitionError
log_sync_outcome(document, result: :already_syncing)
@@ -64,6 +62,12 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
private
+ def perform_sync(document, start_time)
+ mark_sync_started(document)
+ result = Captain::Documents::SyncService.new(document.reload).perform
+ log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
+ end
+
def log_sync_outcome(document, **fields)
payload = {
document_id: document.id,
diff --git a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
index 393a307db..82693c0bb 100644
--- a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
+++ b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
@@ -1,21 +1,27 @@
class Captain::Documents::ScheduleSyncsJob < ApplicationJob
queue_as :scheduled_jobs
- PER_ACCOUNT_HOURLY_CAP = 50
- GLOBAL_HOURLY_CAP = 1000
- DUE_DOCUMENT_BATCH_SIZE = PER_ACCOUNT_HOURLY_CAP * 2 # Inspite of skipping, we should at least reach the hourly cap
+ DEFAULT_PER_ACCOUNT_BATCH_LIMIT = 50
+ DEFAULT_GLOBAL_BATCH_LIMIT = 1000
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
+ DAILY_SYNC_JITTER = 4.hours
+ WEEKLY_SYNC_JITTER = 1.day
+ MONTHLY_SYNC_JITTER = 4.days
- def perform
- @remaining_global_capacity = GLOBAL_HOURLY_CAP
+ def perform(plan_name = nil)
+ @per_account_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', DEFAULT_PER_ACCOUNT_BATCH_LIMIT)
+ @global_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', DEFAULT_GLOBAL_BATCH_LIMIT)
+ @remaining_global_capacity = @global_batch_limit
+ @plan_name = plan_name.to_s.downcase.presence
sync_intervals = Enterprise::Account.captain_document_sync_intervals
- stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0, documents_skipped: 0 }
+ stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
break if @remaining_global_capacity <= 0
stats[:accounts_scanned] += 1
next unless account.feature_enabled?('captain_document_auto_sync')
+ next unless account_in_selected_plan?(account)
stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval(sync_intervals)
@@ -24,7 +30,6 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
stats[:accounts_scheduled] += 1
result = enqueue_due_documents(account, interval)
stats[:documents_enqueued] += result[:enqueued]
- stats[:documents_skipped] += result[:skipped]
end
log_scheduler_summary(stats)
@@ -33,92 +38,85 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
private
def enqueue_due_documents(account, interval)
- per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
- result = { enqueued: 0, skipped: 0 }
- skipped_document_ids = []
+ per_account_limit = [@per_account_batch_limit, @remaining_global_capacity].min
+ result = { enqueued: 0 }
- while result[:enqueued] < per_account_limit
-
- documents = due_documents(account, interval, skipped_document_ids).limit(DUE_DOCUMENT_BATCH_SIZE).to_a
- break if documents.empty?
-
- documents.each do |document|
- break if result[:enqueued] >= per_account_limit
-
- process_due_document(document, result, skipped_document_ids)
- end
+ due_documents(account, interval).limit(per_account_limit).to_a.each do |document|
+ process_due_document(document, interval, result)
end
result
end
- def process_due_document(document, result, skipped_document_ids)
- return unless document.syncable?
+ def process_due_document(document, interval, result)
+ sync_execution_delay = sync_jitter(interval)
- # Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
- unless reserve_sync_slot(document)
- result[:skipped] += 1
- skipped_document_ids << document.id
- return
- end
-
- Captain::Documents::PerformSyncJob.perform_later(document)
+ Captain::Documents::PerformSyncJob.set(queue: :purgable, wait: sync_execution_delay).perform_later(document)
@remaining_global_capacity -= 1
result[:enqueued] += 1
end
- def due_documents(account, interval, skipped_document_ids)
+ def due_documents(account, interval)
syncing = Captain::Document.sync_statuses[:syncing]
synced = Captain::Document.sync_statuses[:synced]
failed = Captain::Document.sync_statuses[:failed]
+ stale_cutoff = SYNC_STALE_TIMEOUT.ago
+ # The scheduler runs at predictable plan windows. Use a wider due window so
+ # jittered executions do not miss the next window just because they finished later.
+ sync_due_before = due_window(interval).ago
documents = account.captain_documents.syncable.where(status: :available).where(
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
'(sync_status = ? AND last_sync_attempted_at < ?)',
- synced, interval.ago, failed, interval.ago, syncing, SYNC_STALE_TIMEOUT.ago
+ synced, sync_due_before, failed, sync_due_before, syncing, stale_cutoff
)
- documents = documents.where.not(id: skipped_document_ids) if skipped_document_ids.present?
documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id)
end
- def reserve_sync_slot(document)
- mark_sync_started(document)
- true
- rescue ActiveRecord::RecordInvalid => e
- log_document_skip(document, e)
- false
+ def configured_sync_limit(config_key, default)
+ configured_value = InstallationConfig.find_by(name: config_key)&.value
+ limit = configured_value.to_s.to_i
+ limit.positive? ? limit : default
end
- def log_document_skip(document, error)
- payload = {
- event: 'document_skipped',
- document_id: document.id,
- account_id: document.account_id,
- assistant_id: document.assistant_id,
- error_class: error.class.name,
- error_message: error.message,
- validation_errors: document.errors.full_messages
- }
+ def account_in_selected_plan?(account)
+ return true if @plan_name.blank?
- Rails.logger.warn("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
+ account_sync_plan(account) == @plan_name
+ end
+
+ def account_sync_plan(account)
+ plan = account.custom_attributes['plan_name']
+ plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
+ plan.to_s.downcase.presence
+ end
+
+ def sync_jitter(interval)
+ jitter_window = if interval <= 1.day
+ DAILY_SYNC_JITTER
+ elsif interval <= 1.week
+ WEEKLY_SYNC_JITTER
+ else
+ MONTHLY_SYNC_JITTER
+ end
+
+ rand(0..jitter_window.to_i).seconds
+ end
+
+ def due_window(interval)
+ (interval.to_i / 2).seconds
end
def log_scheduler_summary(stats)
payload = {
event: 'completed',
+ plan_name: @plan_name,
global_cap_hit: @remaining_global_capacity <= 0,
+ per_account_batch_limit: @per_account_batch_limit,
+ global_batch_limit: @global_batch_limit,
remaining_global_capacity: @remaining_global_capacity
}.merge(stats)
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
-
- def mark_sync_started(document)
- document.update!(
- sync_status: :syncing,
- sync_step: nil,
- last_sync_error_code: nil,
- last_sync_attempted_at: Time.current
- )
- end
end
diff --git a/enterprise/app/jobs/enterprise/internal/trigger_daily_scheduled_items_job.rb b/enterprise/app/jobs/enterprise/internal/trigger_daily_scheduled_items_job.rb
new file mode 100644
index 000000000..aa22310fb
--- /dev/null
+++ b/enterprise/app/jobs/enterprise/internal/trigger_daily_scheduled_items_job.rb
@@ -0,0 +1,19 @@
+module Enterprise::Internal::TriggerDailyScheduledItemsJob
+ def perform
+ super
+
+ Captain::Documents::ScheduleSyncsJob.perform_later('enterprise')
+ Captain::Documents::ScheduleSyncsJob.perform_later('business') if business_auto_sync_due?
+ Captain::Documents::ScheduleSyncsJob.perform_later('startups') if startup_auto_sync_due?
+ end
+
+ private
+
+ def business_auto_sync_due?
+ Time.current.utc.sunday?
+ end
+
+ def startup_auto_sync_due?
+ Time.current.utc.day == 1
+ end
+end
diff --git a/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb b/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb
deleted file mode 100644
index 9d3baa2d6..000000000
--- a/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-module Enterprise::Internal::TriggerHourlyScheduledItemsJob
- def perform
- super
-
- Captain::Documents::ScheduleSyncsJob.perform_later
- end
-end
diff --git a/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb b/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb
index b1e2bc053..db411f068 100644
--- a/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb
+++ b/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb
@@ -31,10 +31,11 @@ module Enterprise::Webhooks::WhatsappEventsJob
# and timer/recorder kick off before the contact actually answers.
def handle_call_events(channel, params)
value = params.dig(:entry, 0, :changes, 0, :value) || {}
+ contacts = value[:contacts]
Array(value[:calls]).each do |call_payload|
with_call_lock(channel, call_payload[:id]) do
- Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
+ Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload], contacts: contacts }).perform
end
end
diff --git a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
new file mode 100644
index 000000000..ed561d668
--- /dev/null
+++ b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
@@ -0,0 +1,103 @@
+class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
+ queue_as :low
+
+ retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
+ _account_id, _portal_id, user_id, generation_id = job.arguments
+ reason = "firecrawl exhausted: #{error.message}"
+ Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
+ job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
+ end
+
+ def perform(account_id, portal_id, user_id, generation_id)
+ return if Onboarding::HelpCenterGenerationState.current(generation_id).present?
+
+ process(
+ account: Account.find(account_id),
+ portal: Portal.find(portal_id),
+ user: User.find(user_id),
+ generation_id: generation_id
+ )
+ rescue Onboarding::HelpCenterErrors::CurationSkipped => e
+ Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
+ skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
+ end
+
+ private
+
+ def process(account:, portal:, user:, generation_id:)
+ plan = Onboarding::HelpCenterCurator.new(account: account).perform
+ articles = create_categories_and_build_article_payloads(portal, plan)
+
+ Onboarding::HelpCenterGenerationState.start(generation_id, total: articles.size)
+ enqueue_writer_jobs(
+ account_id: account.id,
+ portal_id: portal.id,
+ user_id: user.id,
+ generation_id: generation_id,
+ articles: articles
+ )
+ end
+
+ def create_categories_and_build_article_payloads(portal, plan)
+ ActiveRecord::Base.transaction do
+ categories_by_name = create_categories(portal, plan['categories'])
+ articles = build_article_payloads(
+ plan['articles'],
+ categories_by_name,
+ plan['allowed_urls']
+ )
+
+ if articles.empty?
+ raise Onboarding::HelpCenterErrors::CurationSkipped,
+ 'no articles after category or URL filtering'
+ end
+
+ articles
+ end
+ end
+
+ def create_categories(portal, categories)
+ locale = portal.default_locale
+ Array(categories).each_with_index.with_object({}) do |(cat, idx), acc|
+ name = cat['name'].to_s.strip
+ next if name.blank?
+
+ record = portal.categories.create!(
+ name: name,
+ description: cat['description'].to_s.strip.presence,
+ slug: "#{name.parameterize}-#{SecureRandom.hex(3)}",
+ locale: locale,
+ position: (idx + 1) * 10
+ )
+ acc[name] = record
+ end
+ end
+
+ def build_article_payloads(articles, categories_by_name, allowed_urls)
+ allowed_urls = Array(allowed_urls).to_set
+ Array(articles).filter_map do |article|
+ category_id = categories_by_name[article['category_name'].to_s]&.id
+ next if category_id.nil?
+
+ urls = Array(article['urls']).select { |url| allowed_urls.include?(url) }
+ next if urls.empty?
+
+ article.merge('category_id' => category_id, 'urls' => urls)
+ end
+ end
+
+ def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
+ articles.each do |article|
+ Onboarding::HelpCenterArticleWriterJob.perform_later(
+ account_id, portal_id, user_id, generation_id, { article: article }
+ )
+ end
+ end
+
+ def skip_and_broadcast(user:, generation_id:, reason:)
+ Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
+ Onboarding::HelpCenterBroadcaster.completed(
+ user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
+ )
+ end
+end
diff --git a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb
new file mode 100644
index 000000000..2c25b86e9
--- /dev/null
+++ b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb
@@ -0,0 +1,52 @@
+class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
+ queue_as :low
+
+ retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
+ job.send(:on_writer_failure, error)
+ end
+
+ discard_on Onboarding::HelpCenterErrors::ArticleBuildFailed do |job, error|
+ job.send(:on_writer_failure, error)
+ end
+
+ def perform(account_id, portal_id, user_id, generation_id, article_payload)
+ user = User.find(user_id)
+ payload = article_payload.with_indifferent_access
+ article = Onboarding::HelpCenterArticleBuilder.new(
+ account: Account.find(account_id),
+ portal: Portal.find(portal_id),
+ user: user,
+ article: payload[:article]
+ ).perform
+
+ finalize(user: user, generation_id: generation_id, article: article)
+ end
+
+ private
+
+ def on_writer_failure(error)
+ user, generation_id = failure_context
+ Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
+ finalize(user: user, generation_id: generation_id, article: nil)
+ end
+
+ def failure_context
+ _account_id, _portal_id, user_id, generation_id = arguments
+ [User.find_by(id: user_id), generation_id]
+ end
+
+ def finalize(user:, generation_id:, article:)
+ result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
+
+ if article
+ Onboarding::HelpCenterBroadcaster.article_generated(
+ user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
+ )
+ end
+ return unless result[:completed]
+
+ Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
+ rescue Onboarding::HelpCenterGenerationState::Missing => e
+ Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
+ end
+end
diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb
index f8c0580f8..e111cdd48 100644
--- a/enterprise/app/models/call.rb
+++ b/enterprise/app/models/call.rb
@@ -116,6 +116,7 @@ class Call < ApplicationRecord
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
+ end_reason: end_reason,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
accepted_by_agent_name: accepted_by_agent&.available_name,
diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb
index 0fe14813b..6eda0eb5a 100644
--- a/enterprise/app/models/captain/document.rb
+++ b/enterprise/app/models/captain/document.rb
@@ -5,7 +5,7 @@
# id :bigint not null, primary key
# content :text
# content_fingerprint :string
-# external_link :string not null
+# external_link :text not null
# last_sync_attempted_at :datetime
# last_sync_error_code :string
# last_synced_at :datetime
@@ -20,12 +20,12 @@
#
# Indexes
#
-# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
-# index_captain_documents_on_account_id (account_id)
-# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
-# index_captain_documents_on_assistant_id (assistant_id)
-# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
-# index_captain_documents_on_status (status)
+# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
+# idx_captain_documents_on_assistant_id_and_external_link_md5 (assistant_id, md5(external_link)) UNIQUE
+# index_captain_documents_on_account_id (account_id)
+# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
+# index_captain_documents_on_assistant_id (assistant_id)
+# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
diff --git a/enterprise/app/services/captain/llm/article_writer_schema.rb b/enterprise/app/services/captain/llm/article_writer_schema.rb
new file mode 100644
index 000000000..17d742461
--- /dev/null
+++ b/enterprise/app/services/captain/llm/article_writer_schema.rb
@@ -0,0 +1,12 @@
+class Captain::Llm::ArticleWriterSchema < RubyLLM::Schema
+ CONTENT_DESCRIPTION = 'Full article body in clean Markdown. Use headings, lists, and code fences where appropriate. ' \
+ 'Preserve steps, code samples, FAQs, troubleshooting detail. Strip marketing copy, navigation breadcrumbs, ' \
+ 'social/share footers, "edit this page" links, repeated CTAs. ' \
+ 'Total length must stay under 18000 characters; trim repetition and tangents before cutting substance.'.freeze
+ TITLE_DESCRIPTION = 'Concise article title (max 80 chars). Plain text, no markdown.'.freeze
+ DESCRIPTION_DESCRIPTION = 'One-sentence summary (max 200 chars) describing what the article teaches.'.freeze
+
+ string :title, description: TITLE_DESCRIPTION, max_length: 80
+ string :description, description: DESCRIPTION_DESCRIPTION, max_length: 200
+ string :content, description: CONTENT_DESCRIPTION, max_length: 18_000
+end
diff --git a/enterprise/app/services/captain/llm/article_writer_service.rb b/enterprise/app/services/captain/llm/article_writer_service.rb
new file mode 100644
index 000000000..b94027248
--- /dev/null
+++ b/enterprise/app/services/captain/llm/article_writer_service.rb
@@ -0,0 +1,102 @@
+class Captain::Llm::ArticleWriterService < Captain::BaseTaskService
+ RESPONSE_SCHEMA = Captain::Llm::ArticleWriterSchema
+ SOURCE_MAX_LENGTH = 60_000
+
+ # source_pages: Array<{ url: String, markdown: String }>, 1-3 entries.
+ pattr_initialize [:account!, :source_pages!, { hint_title: nil }]
+
+ def perform
+ response = make_api_call(model: writer_model, messages: messages, schema: RESPONSE_SCHEMA)
+ return response if response[:error]
+
+ response.merge(message: extract_payload(response[:message]))
+ end
+
+ private
+
+ def extract_payload(message)
+ return {} if message.blank?
+
+ data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
+ {
+ title: data[:title].to_s.strip,
+ description: data[:description].to_s.strip,
+ content: data[:content].to_s.strip
+ }
+ end
+
+ def messages
+ [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: user_prompt }
+ ]
+ end
+
+ def system_prompt
+ <<~PROMPT
+ You are rewriting web page content into a clean help-center article for a customer-support knowledge base.
+ You may receive 1 to 3 source pages. When given multiple sources, merge them into ONE coherent article:
+ deduplicate identical instructions, do not repeat the same step in different words, and order content
+ by the natural reading flow of the merged topic. When sources contradict, prefer the more authoritative
+ or detailed version. The result must read like a single article, not a stitched-together collage.
+
+ Preserve the substance: keep instructions, steps, code samples, configuration, troubleshooting, and FAQs intact.
+ Strip marketing copy, navigation breadcrumbs, "share this page" footers, repeated CTAs, and links to unrelated pages.
+ Output well-formatted Markdown — use headings, lists, and code fences where appropriate.
+ The body must stay under 18000 characters. If the combined sources are longer, trim repetition and tangents
+ before cutting steps or critical detail. Never invent content the sources do not support.
+
+ Write the title, description, and body in #{locale_name}.
+ If a source page is in another language, translate as you rewrite — do not copy source-language text into the output.
+ Code samples, command-line examples, API field names, and proper nouns stay in their original form.
+ PROMPT
+ end
+
+ def user_prompt
+ pages = Array(source_pages).reject { |p| p[:markdown].to_s.blank? }
+ per_source_cap = pages.size.positive? ? SOURCE_MAX_LENGTH / pages.size : SOURCE_MAX_LENGTH
+
+ sections = pages.each_with_index.map do |page, idx|
+ body = page[:markdown].to_s.truncate(per_source_cap, omission: "\n\n[source truncated for length]")
+ "=== Source #{idx + 1} of #{pages.size} (#{page[:url]}) ===\n#{body}"
+ end
+
+ parts = [
+ ("Suggested title (you may rewrite): #{hint_title}" if hint_title.present?),
+ 'Source pages (Markdown):',
+ sections.join("\n\n")
+ ].compact
+ parts.join("\n\n")
+ end
+
+ def locale_name
+ code = account.locale.to_s
+ LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
+ end
+
+ def event_name
+ 'article_writer'
+ end
+
+ def llm_credential
+ @llm_credential ||= system_llm_credential
+ end
+
+ def captain_tasks_enabled?
+ true
+ end
+
+ # Rewrite runs on the operator's OpenAI key during onboarding; should not
+ # debit the customer's captain_responses quota.
+ def counts_toward_usage?
+ false
+ end
+
+ def writer_model
+ 'gpt-5.2'
+ end
+
+ def build_follow_up_context?
+ false
+ end
+end
diff --git a/enterprise/app/services/captain/llm/help_center_curation_schema.rb b/enterprise/app/services/captain/llm/help_center_curation_schema.rb
new file mode 100644
index 000000000..1c2a53b92
--- /dev/null
+++ b/enterprise/app/services/captain/llm/help_center_curation_schema.rb
@@ -0,0 +1,30 @@
+class Captain::Llm::HelpCenterCurationSchema < RubyLLM::Schema
+ CATEGORIES_DESCRIPTION = 'High-level categories that group the chosen articles. Use only as many ' \
+ 'as the content naturally breaks into. Names must be short (1-3 words) and reusable.'.freeze
+ ARTICLES_DESCRIPTION = 'A curated starting set of help-center articles selected from the input URL list. ' \
+ 'Quality over quantity: only include pages with clear, high-value, substantive help ' \
+ 'content. Skip blog posts, marketing/landing pages, login, pricing, legal, careers, ' \
+ 'customer testimonials, press, about/company, whitepapers, support contact pages, ' \
+ 'terms of service, privacy policy.'.freeze
+ TITLE_DESCRIPTION = 'Concise article title (max 80 chars), rewritten if the source title is too long or marketing-y.'.freeze
+ CATEGORY_DESCRIPTION = 'One sentence describing what kind of articles belong in this category.'.freeze
+ URLS_DESCRIPTION = '1 to 3 source URLs from the input list. Prefer grouping when pages cover related ' \
+ 'aspects of the same topic — overview + deep-dive, FAQ + how-to, policy + FAQ, ' \
+ 'parent topic + its troubleshooting page. Merged sources give the writer more ' \
+ 'context and produce stronger articles than several thin stubs.'.freeze
+
+ array :categories, description: CATEGORIES_DESCRIPTION, min_items: 1, max_items: 10 do
+ object do
+ string :name, description: 'Short, human-readable category name (1-3 words).', max_length: 60
+ string :description, description: CATEGORY_DESCRIPTION, max_length: 200
+ end
+ end
+
+ array :articles, description: ARTICLES_DESCRIPTION, min_items: 1, max_items: 25 do
+ object do
+ array :urls, description: URLS_DESCRIPTION, min_items: 1, max_items: 3, of: :string
+ string :title, description: TITLE_DESCRIPTION, max_length: 80
+ string :category_name, description: 'Must exactly match one of the names emitted in the categories field.', max_length: 60
+ end
+ end
+end
diff --git a/enterprise/app/services/captain/llm/help_center_curation_service.rb b/enterprise/app/services/captain/llm/help_center_curation_service.rb
new file mode 100644
index 000000000..1f8b8acb2
--- /dev/null
+++ b/enterprise/app/services/captain/llm/help_center_curation_service.rb
@@ -0,0 +1,157 @@
+class Captain::Llm::HelpCenterCurationService < Captain::BaseTaskService
+ RESPONSE_SCHEMA = Captain::Llm::HelpCenterCurationSchema
+ MAX_LINKS_IN_PROMPT = 50
+ IGNORED_URL_PATTERN = /\.(?:pdf|jpe?g|png|gif|webp|svg|ico|bmp|tiff?|avif|heic)(?:\?|#|$)/i
+ # This model consistently outperforms 5.2 in generating tighter and more
+ # accurate curations.
+ CURATION_MODEL = 'gpt-4.1'.freeze
+
+ pattr_initialize [:account!, :links!]
+
+ def perform
+ response = make_api_call(model: CURATION_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
+ return response if response[:error]
+
+ response.merge(message: extract_payload(response[:message]))
+ end
+
+ private
+
+ def extract_payload(message)
+ return { categories: [], articles: [] } if message.blank?
+
+ data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
+ articles = Array(data[:articles])
+ used_names = articles.map { |a| a[:category_name].to_s }
+ categories = Array(data[:categories]).select { |c| used_names.include?(c[:name].to_s) }
+ { categories: categories, articles: articles }
+ end
+
+ def messages
+ [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: user_prompt }
+ ]
+ end
+
+ def system_prompt
+ <<~PROMPT
+ You are curating a help center for a company's customer-support widget.
+ You will be given a list of pages discovered on the company's website.
+ Pick pages that would make genuinely useful help-center articles for end users —
+ substantive how-to, FAQ, troubleshooting, policy, getting-started, account/billing
+ help, or product guide content.
+
+ This is a STARTING SET for the user, not a comprehensive corpus. The user will add
+ more articles later. Each article you pick costs downstream time, compute, and
+ money to scrape and rewrite — be deliberate. Only include pages with clear,
+ high-value, substantive help content. When unsure about a page's value, leave it
+ out. 8 strong articles beat 20 padded ones, even when the input has 20+ candidates.
+
+ Quality over quantity: do not pad with thin, overview, or marketing-adjacent pages
+ to hit a target count. If a site has only a few genuinely useful pages, return only
+ those few. The schema allows up to 25 articles, but treat that as a hard ceiling,
+ not a target — most sites should land well under it.
+
+ Skip marketing/landing pages, blog posts, login, pricing tiers, legal, careers, press, investor pages.
+ Group your picks into reusable categories — use as many as the content naturally breaks into.
+ Use the URL paths and page titles to judge relevance — do not invent URLs.
+
+ URL-path priority (preference order, not hard rules):
+ - First tier — almost always pick when present. Paths containing /support, /help,
+ /docs, /documentation, /faq, /faqs, /kb, /knowledge-base, /learn, /guides,
+ /getting-started, /how-to, /tutorial, /troubleshoot.
+ - Second tier — pick when the page carries user-relevant information a customer
+ would ask support about. Paths like /features, /pricing, /plans, /shipping,
+ /returns, /warranty, /security, individual product or category pages. Prefer
+ these only after first-tier picks; if a topic exists in both tiers, prefer the
+ first-tier URL.
+ - Skip — promotional, navigational, or boilerplate paths: /blog, /news, /press,
+ /careers, /jobs, /about, /team, /investors, /customers, /testimonials,
+ /case-studies, /login, /signup, /register, /legal, /terms, /privacy.
+
+ For each article, group 1 to 3 URLs that together cover a single topic. PREFER
+ grouping whenever pages overlap or complement each other — merged sources give
+ the writer more context and produce a stronger article than two thin stubs.
+
+ Strong signals to group multiple URLs (treat any of these as a green light):
+ - Same topic from different angles: overview + deep-dive, FAQ + how-to,
+ policy + FAQ, feature page + feature docs.
+ - Parent topic + its troubleshooting page (e.g. "Bank reconciliation" +
+ "Problems with bank reconciliation"; "SSO setup" + "SSO not working").
+ - Variant-specific guides on the same topic ("SSO setup" + "SSO with Okta";
+ "Webhooks overview" + "Webhook payload reference").
+ - A how-to split across step or platform pages (install on iOS + Android + web).
+ - FAQ entries that match a deep-dive article elsewhere on the site.
+
+ Before finalizing your picks, scan them for merge candidates: if two URLs are
+ about the same topic, they should almost always be one article, not two.
+
+ Don't group across distinct topics that merely share a category ("Setting up SSO"
+ and "Setting up MFA" stay separate). If a URL is marketing for a feature and
+ another is the feature's docs, pick the docs and skip the marketing.
+
+ Write all category names, category descriptions, and article titles in #{locale_name}.
+ The input page titles and descriptions may be in another language; translate the labels you emit into #{locale_name}.
+ Keep URLs unchanged.
+ PROMPT
+ end
+
+ def user_prompt
+ parts = [
+ "Company: #{account.name}",
+ ("Description: #{brand_info[:description]}" if brand_info[:description].present?),
+ ("Industries: #{industries_text}" if industries_text.present?),
+ 'Discovered pages (url — title — description):',
+ formatted_links
+ ].compact
+ parts.join("\n")
+ end
+
+ def locale_name
+ code = account.locale.to_s
+ LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
+ end
+
+ def formatted_links
+ Array(links).reject { |link| ignored_url?(link) }.first(MAX_LINKS_IN_PROMPT).map do |link|
+ data = link.is_a?(Hash) ? link.deep_symbolize_keys : {}
+ "- #{data[:url]} — #{data[:title].to_s.strip} — #{data[:description].to_s.strip}"
+ end.join("\n")
+ end
+
+ def ignored_url?(link)
+ url = link.is_a?(Hash) ? link.deep_symbolize_keys[:url].to_s : link.to_s
+ url.match?(IGNORED_URL_PATTERN)
+ end
+
+ def brand_info
+ @brand_info ||= (account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
+ end
+
+ def industries_text
+ Array(brand_info[:industries]).filter_map { |i| i.is_a?(Hash) ? i[:industry] : i }.join(', ').presence
+ end
+
+ def event_name
+ 'help_center_curation'
+ end
+
+ def llm_credential
+ @llm_credential ||= system_llm_credential
+ end
+
+ def captain_tasks_enabled?
+ true
+ end
+
+ # Onboarding curation runs on the operator's OpenAI key; it should not
+ # debit the customer's captain_responses quota.
+ def counts_toward_usage?
+ false
+ end
+
+ def build_follow_up_context?
+ false
+ end
+end
diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
index 932cee661..b9c3f6856 100644
--- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
+++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
@@ -11,10 +11,12 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_facebook
channel_email
channel_instagram
+ channel_tiktok
captain_integration
advanced_search_indexing
advanced_search
linear_integration
+ channel_voice
].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
diff --git a/enterprise/app/services/enterprise/website_branding_service.rb b/enterprise/app/services/enterprise/website_branding_service.rb
index 553317c43..c6f2fbdaa 100644
--- a/enterprise/app/services/enterprise/website_branding_service.rb
+++ b/enterprise/app/services/enterprise/website_branding_service.rb
@@ -58,6 +58,7 @@ module Enterprise::WebsiteBrandingService
socials: brand['socials'] || [],
links: brand['links'],
email: @email,
+ email_provider: detect_email_provider,
industries: brand.dig('industries', 'eic') || [],
stock: brand['stock'],
is_nsfw: brand['is_nsfw'] || false
diff --git a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
index ec29a5a38..2fcf4b5e7 100644
--- a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
@@ -40,6 +40,22 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
process_initiate_call_response(response)
end
+ # Sets WABA calling status ('ENABLED'/'DISABLED'). Returns true, or raises with
+ # Meta's user-facing message on failure so the caller can surface it.
+ def update_calling_status(status)
+ response = HTTParty.post(
+ "#{calls_phone_id_path}/settings",
+ headers: api_headers,
+ body: { calling: { status: status } }.to_json
+ )
+ return true if response.success?
+
+ parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
+ message = parsed.dig('error', 'error_user_msg') || parsed.dig('error', 'message') || 'Failed to update calling status'
+ Rails.logger.error "[WHATSAPP CALL] update_calling_status failed: status=#{response.code} body=#{response.body}"
+ raise message
+ end
+
private
def calls_phone_id_path
diff --git a/enterprise/app/services/firecrawl/configuration.rb b/enterprise/app/services/firecrawl/configuration.rb
new file mode 100644
index 000000000..0d574b102
--- /dev/null
+++ b/enterprise/app/services/firecrawl/configuration.rb
@@ -0,0 +1,31 @@
+module Firecrawl::Configuration
+ INSTALLATION_CONFIG_KEY = 'CAPTAIN_FIRECRAWL_API_KEY'.freeze
+ EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
+ DEFAULT_SCRAPE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
+
+ module_function
+
+ def configured?
+ api_key.present?
+ end
+
+ def client
+ key = api_key
+ raise ::Firecrawl::FirecrawlError, "#{INSTALLATION_CONFIG_KEY} is not configured" if key.blank?
+
+ ::Firecrawl::Client.new(api_key: key)
+ end
+
+ def api_key
+ InstallationConfig.find_by(name: INSTALLATION_CONFIG_KEY)&.value
+ end
+
+ def default_scrape_options(max_age: DEFAULT_SCRAPE_MAX_AGE_MS)
+ ::Firecrawl::Models::ScrapeOptions.new(
+ formats: ['markdown'],
+ only_main_content: true,
+ exclude_tags: EXCLUDE_TAGS,
+ max_age: max_age
+ )
+ end
+end
diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb
index c502b717a..748bf1efa 100644
--- a/enterprise/app/services/messages/audio_transcription_service.rb
+++ b/enterprise/app/services/messages/audio_transcription_service.rb
@@ -1,11 +1,12 @@
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
- WHISPER_MODEL = 'whisper-1'.freeze
- # Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes
- # = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API
- # as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription.
- WHISPER_BYTE_LIMIT = 25_000_000
+ TRANSCRIPTION_MODEL = 'gpt-4o-mini-transcribe'.freeze
+ # OpenAI's transcription endpoint hard limit is 25 MB *decimal* (25_000_000), not
+ # binary (25.megabytes = 26_214_400) — using the binary form leaks the 25.0–26.2 MB
+ # range to the API as 413s. Long audio (~70+ min Opus) keeps the attachment but skips
+ # transcription.
+ TRANSCRIPTION_BYTE_LIMIT = 25_000_000
attr_reader :attachment, :message, :account
@@ -42,7 +43,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
blob = attachment.file&.blob
return false unless blob
- blob.byte_size > WHISPER_BYTE_LIMIT
+ blob.byte_size > TRANSCRIPTION_BYTE_LIMIT
end
def fetch_audio_file
@@ -75,12 +76,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
- # temperature: 0.0 minimises Whisper's hallucinations on silence /
- # near-silent audio; non-zero values trigger spiraling repeats like
- # "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour.
+ # temperature: 0.0 minimises hallucinations on silence / near-silent
+ # audio; non-zero values trigger spiraling repeats — well-documented
+ # behaviour across OpenAI transcription models.
response = @client.audio.transcribe(
parameters: {
- model: WHISPER_MODEL,
+ model: TRANSCRIPTION_MODEL,
file: file,
temperature: 0.0
}
@@ -97,7 +98,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
def instrumentation_params(file_path)
{
span_name: 'llm.messages.audio_transcription',
- model: WHISPER_MODEL,
+ model: TRANSCRIPTION_MODEL,
account_id: account&.id,
feature_name: 'audio_transcription',
file_path: file_path
diff --git a/enterprise/app/services/onboarding/help_center_article_builder.rb b/enterprise/app/services/onboarding/help_center_article_builder.rb
new file mode 100644
index 000000000..dc6178ab8
--- /dev/null
+++ b/enterprise/app/services/onboarding/help_center_article_builder.rb
@@ -0,0 +1,71 @@
+class Onboarding::HelpCenterArticleBuilder
+ BuildFailed = Onboarding::HelpCenterErrors::ArticleBuildFailed
+
+ def initialize(account:, portal:, user:, article:)
+ @account = account
+ @portal = portal
+ @user = user
+
+ spec = article.with_indifferent_access
+ @urls = Array(spec[:urls]).map(&:to_s).reject(&:blank?)
+ @title = spec[:title]
+ @category_id = spec[:category_id]
+ end
+
+ def perform
+ raise BuildFailed, 'no source urls supplied' if @urls.empty?
+
+ source_pages = scrape(@urls)
+ raise BuildFailed, "scrape produced no usable pages for #{@urls.join(', ')}" if source_pages.empty?
+
+ payload = rewrite(source_pages)
+
+ @portal.articles.create!(
+ title: payload[:title],
+ description: payload[:description].presence,
+ content: payload[:content],
+ author_id: @user.id,
+ category_id: @category_id,
+ status: :draft,
+ meta: { source_urls: source_pages.pluck(:url) }
+ )
+ end
+
+ private
+
+ def scrape(urls)
+ job = Firecrawl::Configuration.client.batch_scrape(
+ urls,
+ Firecrawl::Models::BatchScrapeOptions.new(options: Firecrawl::Configuration.default_scrape_options)
+ )
+ Array(job.data).filter_map { |doc| normalize(doc) }
+ end
+
+ def normalize(doc)
+ metadata = doc&.metadata || {}
+ status = metadata['statusCode']
+ return nil if status.present? && !(200..299).cover?(status)
+ return nil if doc.markdown.to_s.blank?
+
+ {
+ url: metadata['sourceURL'] || metadata['url'],
+ markdown: doc.markdown.to_s,
+ page_title: metadata['title'].to_s.strip
+ }
+ end
+
+ def rewrite(source_pages)
+ response = Captain::Llm::ArticleWriterService.new(
+ account: @account,
+ source_pages: source_pages,
+ hint_title: @title.presence || source_pages.first[:page_title]
+ ).perform
+ raise BuildFailed, "writer LLM error: #{response[:error]}" if response[:error]
+
+ payload = response[:message] || {}
+ raise BuildFailed, 'writer returned blank content' if payload[:content].blank?
+ raise BuildFailed, 'writer returned blank title' if payload[:title].blank?
+
+ payload
+ end
+end
diff --git a/enterprise/app/services/onboarding/help_center_broadcaster.rb b/enterprise/app/services/onboarding/help_center_broadcaster.rb
new file mode 100644
index 000000000..e12ed4287
--- /dev/null
+++ b/enterprise/app/services/onboarding/help_center_broadcaster.rb
@@ -0,0 +1,29 @@
+module Onboarding::HelpCenterBroadcaster
+ ARTICLE_GENERATED = 'help_center.article_generated'.freeze
+ GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
+
+ module_function
+
+ def article_generated(user:, generation_id:, article:, articles_finished:)
+ broadcast(user, ARTICLE_GENERATED, {
+ generation_id: generation_id,
+ article_id: article.id,
+ articles_finished: articles_finished
+ })
+ end
+
+ def completed(user:, generation_id:, status:, skip_reason: nil)
+ broadcast(user, GENERATION_COMPLETED, {
+ generation_id: generation_id,
+ status: status,
+ skip_reason: skip_reason
+ })
+ end
+
+ def broadcast(user, event, payload)
+ token = user&.pubsub_token
+ return if token.blank?
+
+ ActionCableBroadcastJob.perform_later([token], event, payload)
+ end
+end
diff --git a/enterprise/app/services/onboarding/help_center_creation_service.rb b/enterprise/app/services/onboarding/help_center_creation_service.rb
new file mode 100644
index 000000000..7ccd9bff3
--- /dev/null
+++ b/enterprise/app/services/onboarding/help_center_creation_service.rb
@@ -0,0 +1,129 @@
+class Onboarding::HelpCenterCreationService
+ DEFAULT_PORTAL_COLOR = '#1f93ff'.freeze
+ LOGO_MAX_DOWNLOAD_SIZE = 5.megabytes
+
+ def initialize(account, user)
+ @account = account
+ @user = user
+ end
+
+ def perform
+ existing = existing_portal
+ return reuse_existing_portal(existing) if existing
+
+ @account.portals.create!(portal_attributes).tap do |portal|
+ attach_brand_logo(portal)
+ enqueue_article_generation(portal)
+ end
+ end
+
+ private
+
+ def existing_portal
+ @account.portals.first
+ end
+
+ def reuse_existing_portal(portal)
+ Rails.logger.info "[HelpCenterCreation] Reusing existing portal #{portal.id} for account #{@account.id}"
+ portal
+ end
+
+ def portal_attributes
+ {
+ name: portal_name,
+ slug: generate_slug,
+ color: portal_color,
+ page_title: portal_name,
+ header_text: header_text,
+ homepage_link: homepage_link,
+ channel_web_widget_id: web_widget_channel_id,
+ config: { default_locale: locale, allowed_locales: [locale] }
+ }.compact
+ end
+
+ def brand_info
+ @brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
+ end
+
+ def portal_name
+ brand_info[:title].presence || @account.name
+ end
+
+ def portal_color
+ hex = brand_info[:colors]&.first&.dig(:hex)
+ hex.to_s.match?(/\A#\h{6}\z/) ? hex : DEFAULT_PORTAL_COLOR
+ end
+
+ def header_text
+ brand_info[:slogan].presence || brand_info[:description].presence
+ end
+
+ def homepage_link
+ with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
+ end
+
+ def with_scheme(raw)
+ return raw if raw.blank?
+
+ raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
+ end
+
+ def custom_attributes_website
+ @account.custom_attributes['website']
+ end
+
+ def enqueue_article_generation(portal)
+ return if homepage_link.blank?
+
+ generation_id = SecureRandom.uuid
+ Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
+ rescue StandardError => e
+ Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
+ end
+
+ def attach_brand_logo(portal)
+ logo_url = brand_logo_url
+ return if logo_url.blank?
+
+ SafeFetch.fetch(logo_url, max_bytes: LOGO_MAX_DOWNLOAD_SIZE, allowed_content_type_prefixes: ['image/']) do |logo_file|
+ portal.logo.attach(
+ io: logo_file.tempfile,
+ filename: logo_file.original_filename,
+ content_type: logo_file.content_type
+ )
+ end
+ rescue StandardError => e
+ Rails.logger.error "[HelpCenterCreation] Logo attachment failed for account #{@account.id}: #{e.class} - #{e.message}"
+ end
+
+ def brand_logo_url
+ Array(brand_info[:logos]).filter_map do |logo|
+ logo.is_a?(Hash) ? logo[:url] : logo
+ end.find(&:present?)
+ end
+
+ def web_widget_channel_id
+ @account.inboxes.find_by(channel_type: 'Channel::WebWidget')&.channel_id
+ end
+
+ def locale
+ @account.locale.presence || 'en'
+ end
+
+ def generate_slug
+ slug_candidates.find { |slug| !Portal.exists?(slug: slug) } || fallback_slug
+ end
+
+ def slug_candidates
+ base = @account.name.to_s.parameterize.presence
+ return [] if base.blank?
+
+ first_token = base.split('-').first
+ [base, first_token, "#{first_token}-docs", "#{first_token}-help"].uniq
+ end
+
+ def fallback_slug
+ base = @account.name.to_s.parameterize.presence || 'portal'
+ "#{base}-#{SecureRandom.hex(4)}"
+ end
+end
diff --git a/enterprise/app/services/onboarding/help_center_curator.rb b/enterprise/app/services/onboarding/help_center_curator.rb
new file mode 100644
index 000000000..03ab7e407
--- /dev/null
+++ b/enterprise/app/services/onboarding/help_center_curator.rb
@@ -0,0 +1,65 @@
+class Onboarding::HelpCenterCurator
+ MAP_LIMIT = 500
+ MAP_SEARCH = 'docs help support faq'.freeze
+ MIN_ARTICLES = 3
+
+ Skipped = Onboarding::HelpCenterErrors::CurationSkipped
+
+ def initialize(account:)
+ @account = account
+ end
+
+ def perform
+ raise Skipped, 'Firecrawl not configured' unless Firecrawl::Configuration.configured?
+ raise Skipped, 'no website url' if website_url.blank?
+
+ links = discover_links
+ raise Skipped, 'map returned no links' if links.empty?
+
+ plan = curate(links)
+ raise Skipped, "only #{plan[:articles].size} articles curated (< #{MIN_ARTICLES} threshold)" if plan[:articles].size < MIN_ARTICLES
+
+ plan.merge(allowed_urls: extract_urls(links)).deep_stringify_keys
+ end
+
+ private
+
+ def discover_links
+ data = Firecrawl::Configuration.client.map(
+ website_url,
+ Firecrawl::Models::MapOptions.new(limit: MAP_LIMIT, search: MAP_SEARCH)
+ )
+ Array(data.links)
+ end
+
+ def extract_urls(links)
+ Array(links).filter_map do |link|
+ link['url'].presence
+ end.uniq
+ end
+
+ def curate(links)
+ response = Captain::Llm::HelpCenterCurationService.new(account: @account, links: links).perform
+ raise Skipped, "curator LLM error: #{response[:error]}" if response[:error]
+
+ response[:message] || { categories: [], articles: [] }
+ end
+
+ def website_url
+ @website_url ||= with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
+ end
+
+ def with_scheme(raw)
+ return raw if raw.blank?
+
+ raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
+ end
+
+ def custom_attributes_website
+ @account.custom_attributes['website']
+ end
+
+ def brand_info
+ @brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
+ end
+end
diff --git a/enterprise/app/services/onboarding/help_center_errors.rb b/enterprise/app/services/onboarding/help_center_errors.rb
new file mode 100644
index 000000000..78cc79813
--- /dev/null
+++ b/enterprise/app/services/onboarding/help_center_errors.rb
@@ -0,0 +1,4 @@
+module Onboarding::HelpCenterErrors
+ class CurationSkipped < StandardError; end
+ class ArticleBuildFailed < StandardError; end
+end
diff --git a/enterprise/app/services/onboarding/help_center_generation_state.rb b/enterprise/app/services/onboarding/help_center_generation_state.rb
new file mode 100644
index 000000000..1b10b30bd
--- /dev/null
+++ b/enterprise/app/services/onboarding/help_center_generation_state.rb
@@ -0,0 +1,45 @@
+class Onboarding::HelpCenterGenerationState
+ # TODO: Reduce TTL to 48 hours once the full rollout is done
+ TTL = 7.days.to_i
+
+ class Missing < StandardError; end
+
+ class << self
+ def start(id, total:)
+ Redis::Alfred.with do |conn|
+ conn.hset(key(id), 'status', 'generating', 'total', total.to_i, 'finished', 0)
+ conn.expire(key(id), TTL)
+ end
+ end
+
+ def record_article_finished(id)
+ Redis::Alfred.with do |conn|
+ total = conn.hget(key(id), 'total')
+ raise Missing, "missing state for generation #{id}" if total.blank?
+
+ finished = conn.hincrby(key(id), 'finished', 1)
+ completed = finished >= total.to_i
+ conn.hset(key(id), 'status', 'completed') if completed
+ conn.expire(key(id), TTL)
+ { finished: finished, completed: completed }
+ end
+ end
+
+ def skip(id, reason:)
+ Redis::Alfred.with do |conn|
+ conn.hset(key(id), 'status', 'skipped', 'skip_reason', reason.to_s)
+ conn.expire(key(id), TTL)
+ end
+ end
+
+ def current(id)
+ Redis::Alfred.with do |conn|
+ conn.hgetall(key(id)).presence
+ end
+ end
+
+ def key(id)
+ format(Redis::Alfred::HELP_CENTER_GENERATION, id: id)
+ end
+ end
+end
diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb
index 73ace3a78..942ee0cc0 100644
--- a/enterprise/app/services/voice/call_status/manager.rb
+++ b/enterprise/app/services/voice/call_status/manager.rb
@@ -4,6 +4,9 @@ class Voice::CallStatus::Manager
def process_status_update(status, duration: nil, timestamp: nil)
return unless Call::STATUSES.include?(status)
return if call.status == status
+ # Don't overwrite a terminal status — Twilio's late `completed` events would
+ # otherwise clobber an agent-rejection reason.
+ return if Call::TERMINAL_STATUSES.include?(call.status)
apply_call_updates!(status, duration: duration, timestamp: timestamp)
call.conversation.update!(last_activity_at: Time.zone.now)
diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb
index 7b8b0e684..eef70e76b 100644
--- a/enterprise/app/services/voice/inbound_call_builder.rb
+++ b/enterprise/app/services/voice/inbound_call_builder.rb
@@ -59,9 +59,17 @@ class Voice::InboundCallBuilder
end
def ensure_contact!
- account.contacts.find_or_create_by!(phone_number: from_number) do |record|
- record.name = from_number if record.name.blank?
+ contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record|
+ record.name = contact_name.presence || from_number
end
+ contact.update!(name: contact_name) if contact_name.present? && contact.name == from_number
+ contact
+ end
+
+ # WhatsApp inbound calls carry the caller's profile name in extra_meta; Twilio
+ # calls don't, so contact naming falls back to the phone number.
+ def contact_name
+ extra_meta['contact_name'].presence
end
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
@@ -74,15 +82,14 @@ class Voice::InboundCallBuilder
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(digits, :cloud)
end
+ # Mirror incoming-message routing: reuse the open conversation (or the last one when locked), else create new.
def resolve_conversation!(contact, contact_inbox)
- if inbox.lock_to_single_conversation
- reusable = account.conversations
- .where(contact_id: contact.id, inbox_id: inbox.id)
- .where.not(status: :resolved)
- .order(last_activity_at: :desc)
- .first
- return reusable if reusable
- end
+ reusable = if inbox.lock_to_single_conversation
+ contact_inbox.conversations.last
+ else
+ contact_inbox.conversations.where.not(status: :resolved).last
+ end
+ return reusable if reusable
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb
index 8a52ea6bc..93eba957c 100644
--- a/enterprise/app/services/whatsapp/call_service.rb
+++ b/enterprise/app/services/whatsapp/call_service.rb
@@ -20,7 +20,8 @@ class Whatsapp::CallService
next if call.terminal? || call.in_progress?
invoke_provider!(:reject_call)
- finalize_call('failed')
+ call.update!(accepted_by_agent_id: agent.id) if call.accepted_by_agent_id.nil?
+ finalize_call('failed', end_reason: 'agent_rejected')
end
call
end
diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb
index d290e96c2..afca508a8 100644
--- a/enterprise/app/services/whatsapp/incoming_call_service.rb
+++ b/enterprise/app/services/whatsapp/incoming_call_service.rb
@@ -73,15 +73,27 @@ class Whatsapp::IncomingCallService
def create_inbound_call(payload)
sdp_offer = payload.dig(:session, :sdp)
+ extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
+ name = caller_profile_name(payload)
+ extra_meta['contact_name'] = name if name.present?
+
call = Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
- provider: :whatsapp,
- extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
+ provider: :whatsapp, extra_meta: extra_meta
)
update_conversation(call)
broadcast_incoming(call, sdp_offer)
end
+ # Match strictly on wa_id (== calls[].from): in a batched payload missing this
+ # call's contact entry, borrowing another caller's name would corrupt this
+ # contact, so fall back to the phone number (nil here) instead of contacts.first.
+ def caller_profile_name(payload)
+ contacts = Array(params[:contacts]).map(&:with_indifferent_access)
+ match = contacts.find { |c| c[:wa_id].to_s == payload[:from].to_s }
+ match&.dig(:profile, :name).presence
+ end
+
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
# Meta's SDP answer so the handshake completes during ringing; the call
# stays in `ringing` until status=ACCEPTED arrives. Don't gate on
@@ -159,17 +171,29 @@ class Whatsapp::IncomingCallService
)
end
- # Ring the assignee if assigned; otherwise account-wide so any agent can pick up.
+ # Ring the assignee if any, else online inbox agents, else fall back to the
+ # inbox's own agents and account admins. Never the whole-account stream, which
+ # would ring online agents from unrelated inboxes.
def broadcast_incoming(call, sdp_offer)
contact = call.contact
token = call.conversation.assignee&.pubsub_token
+ streams = token ? [token] : (online_agent_streams.presence || fallback_agent_streams)
broadcast(call, 'voice_call.incoming',
- streams: token ? [token] : account_streams,
+ streams: streams,
direction: call.direction_label, inbox_id: call.inbox_id,
sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers,
caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url })
end
+ def online_agent_streams
+ inbox.available_agents.pluck('users.pubsub_token').compact
+ end
+
+ def fallback_agent_streams
+ user_ids = inbox.member_ids | inbox.account.administrators.ids
+ User.where(id: user_ids).pluck(:pubsub_token).compact
+ end
+
def broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) }
diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb
index d5c9a3351..665d4c80c 100644
--- a/lib/custom_markdown_renderer.rb
+++ b/lib/custom_markdown_renderer.rb
@@ -33,8 +33,31 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
super
end
+ def image(node)
+ src = escape_href(node.url)
+ width = extract_image_width(src)
+ plain do
+ out(%(
')
+ end
+ end
+
private
+ def extract_image_width(src)
+ query = URI.parse(src).query
+ raw = query && CGI.parse(query)['cw_image_width']&.first
+ return unless raw =~ /\A(\d+)px\z/
+
+ px = Regexp.last_match(1).to_i
+ "#{px}px" if px.between?(1, 2000)
+ rescue URI::InvalidURIError
+ nil
+ end
+
def surrounded_by_empty_lines?(node)
prev_node_empty?(node.previous) && next_node_empty?(node.next)
end
diff --git a/lib/events/types.rb b/lib/events/types.rb
index d742232c4..171649a7d 100644
--- a/lib/events/types.rb
+++ b/lib/events/types.rb
@@ -16,6 +16,7 @@ module Events::Types
# conversation events
CONVERSATION_CREATED = 'conversation.created'
CONVERSATION_UPDATED = 'conversation.updated'
+ CONVERSATION_DELETED = 'conversation.deleted'
CONVERSATION_READ = 'conversation.read'
CONVERSATION_BOT_HANDOFF = 'conversation.bot_handoff'
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
@@ -26,6 +27,7 @@ module Events::Types
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
+ CONVERSATION_UNREAD_COUNT_CHANGED = 'conversation.unread_count_changed'
ASSIGNEE_CHANGED = 'assignee.changed'
TEAM_CHANGED = 'team.changed'
CONVERSATION_TYPING_ON = 'conversation.typing_on'
diff --git a/lib/redis/alfred.rb b/lib/redis/alfred.rb
index e7e04ed60..529890341 100644
--- a/lib/redis/alfred.rb
+++ b/lib/redis/alfred.rb
@@ -21,10 +21,26 @@ module Redis::Alfred
$alfred.with { |conn| conn.get(key) }
end
+ def with(&)
+ $alfred.with(&)
+ end
+
def delete(key)
$alfred.with { |conn| conn.del(key) }
end
+ # atomic compare-and-delete (release a lock only if you still own it); WATCH/MULTI
+ # aborts the delete if the key changes between the check and the delete.
+ def delete_if_equals(key, expected_value)
+ $alfred.with do |conn|
+ conn.watch(key) do
+ next conn.unwatch unless conn.get(key) == expected_value
+
+ conn.multi { |transaction| transaction.del(key) }
+ end
+ end
+ end
+
# increment a key by 1. throws error if key value is incompatible
# sets key to 0 before operation if key doesn't exist
def incr(key)
@@ -40,6 +56,11 @@ module Redis::Alfred
$alfred.with { |conn| conn.expire(key, seconds) }
end
+ # get expiry of a key in seconds
+ def ttl(key)
+ $alfred.with { |conn| conn.ttl(key) }
+ end
+
# scan keys matching a pattern
def scan_each(match: nil, count: 100, &)
$alfred.with do |conn|
@@ -80,6 +101,10 @@ module Redis::Alfred
$alfred.with { |conn| conn.lrem(key, count, value) }
end
+ def pipelined(&)
+ $alfred.with { |conn| conn.pipelined(&) }
+ end
+
# hash operations
# add a key value to redis hash
@@ -102,13 +127,9 @@ module Redis::Alfred
# add score and value for a key
# Modern Redis syntax: zadd(key, [[score, member], ...])
def zadd(key, score, value = nil)
- if value.nil? && score.is_a?(Array)
- # New syntax: score is actually an array of [score, member] pairs
- $alfred.with { |conn| conn.zadd(key, score) }
- else
- # Support old syntax for backward compatibility
- $alfred.with { |conn| conn.zadd(key, [[score, value]]) }
- end
+ # New syntax: score is an array of [score, member] pairs; old syntax: discrete score/value
+ pairs = value.nil? && score.is_a?(Array) ? score : [[score, value]]
+ $alfred.with { |conn| conn.zadd(key, pairs) }
end
# get score of a value for key
diff --git a/lib/redis/lock_manager.rb b/lib/redis/lock_manager.rb
index 63063542c..4064d692c 100644
--- a/lib/redis/lock_manager.rb
+++ b/lib/redis/lock_manager.rb
@@ -49,6 +49,18 @@ class Redis::LockManager
true
end
+ def with_lock(key, timeout = LOCK_TIMEOUT)
+ return false unless lock(key, timeout)
+
+ begin
+ yield
+ ensure
+ unlock(key)
+ end
+
+ true
+ end
+
# Checks if the given key is currently locked.
#
# === Parameters
diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb
index 15ff553cc..fff60c342 100644
--- a/lib/redis/redis_keys.rb
+++ b/lib/redis/redis_keys.rb
@@ -9,6 +9,28 @@ module Redis::RedisKeys
# Whether a conversation is muted ?
CONVERSATION_MUTE_KEY = 'CONVERSATION::%d::MUTED'.freeze
CONVERSATION_DRAFT_MESSAGE = 'CONVERSATION::%d::DRAFT_MESSAGE'.freeze
+ UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d'.freeze
+ UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::READY::BASE'.freeze
+ UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::READY::ASSIGNMENT'.freeze
+ UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::BUILD_LOCK::BASE'.freeze
+ UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::BUILD_LOCK::ASSIGNMENT'.freeze
+ UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::INBOX::%d'.freeze
+ UNREAD_CONVERSATIONS_LABEL_INBOX =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::LABEL::%d::INBOX::%d'.freeze
+ UNREAD_CONVERSATIONS_TEAM_INBOX =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d'.freeze
+ UNREAD_CONVERSATIONS_INBOX_UNASSIGNED =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::INBOX::%d::UNASSIGNED'.freeze
+ UNREAD_CONVERSATIONS_INBOX_ASSIGNEE =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::INBOX::%d::ASSIGNEE::%d'.freeze
+ UNREAD_CONVERSATIONS_LABEL_INBOX_UNASSIGNED =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::LABEL::%d::INBOX::%d::UNASSIGNED'.freeze
+ UNREAD_CONVERSATIONS_LABEL_INBOX_ASSIGNEE =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::LABEL::%d::INBOX::%d::ASSIGNEE::%d'.freeze
+ UNREAD_CONVERSATIONS_TEAM_INBOX_UNASSIGNED =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d::UNASSIGNED'.freeze
+ UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d::ASSIGNEE::%d'.freeze
## User Keys
# SSO Auth Tokens
@@ -51,9 +73,12 @@ module Redis::RedisKeys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%d::AGENT::%d::CONVERSATION::%d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%d::AGENT::%d::*'.freeze
+ # At-most-one AssignmentJob per inbox in-flight (queued or running); further enqueues are skipped
+ AUTO_ASSIGNMENT_IN_FLIGHT_KEY = 'AUTO_ASSIGNMENT_IN_FLIGHT::%d'.freeze
## Account Onboarding
ACCOUNT_ONBOARDING_ENRICHMENT = 'ONBOARDING_ENRICHMENT::%d'.freeze
+ HELP_CENTER_GENERATION = 'HELP_CENTER_GENERATION::%s'.freeze
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze
diff --git a/lib/regex_helper.rb b/lib/regex_helper.rb
index 2eeb895ea..e9304f581 100644
--- a/lib/regex_helper.rb
+++ b/lib/regex_helper.rb
@@ -13,7 +13,9 @@ module RegexHelper
# while notifications use CommonMarker for better markdown processing
MENTION_REGEX = Regexp.new('\[(@[^\\]]+)\]\(mention://(?:user|team)/\d+/([^)]+)\)')
- TWILIO_CHANNEL_SMS_REGEX = Regexp.new('^\+\d{1,15}\z')
- TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new('^whatsapp:\+\d{1,15}\z')
- WHATSAPP_CHANNEL_REGEX = Regexp.new('^\d{1,15}\z')
+ TWILIO_CHANNEL_SMS_REGEX = Regexp.new('\A\+\d{1,15}\z')
+ WHATSAPP_BSUID_PATTERN = '[A-Z]{2}\.(?:ENT\.)?[A-Za-z0-9]{1,128}'.freeze
+ WHATSAPP_BSUID_REGEX = Regexp.new("\\A#{WHATSAPP_BSUID_PATTERN}\\z")
+ TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new("\\A(?:whatsapp:\\+\\d{1,15}|whatsapp:#{WHATSAPP_BSUID_PATTERN})\\z")
+ WHATSAPP_CHANNEL_REGEX = Regexp.new("\\A(?:\\d{1,15}|#{WHATSAPP_BSUID_PATTERN})\\z")
end
diff --git a/lib/safe_fetch.rb b/lib/safe_fetch.rb
index 7f89c03c4..f635758af 100644
--- a/lib/safe_fetch.rb
+++ b/lib/safe_fetch.rb
@@ -34,4 +34,8 @@ module SafeFetch
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
raise FetchError, e.message
end
+
+ def self.allow_private_network?
+ ActiveModel::Type::Boolean.new.cast(ENV.fetch('SAFE_FETCH_ALLOW_PRIVATE_NETWORK', false))
+ end
end
diff --git a/lib/safe_fetch/fetcher.rb b/lib/safe_fetch/fetcher.rb
index fa3c01f55..ab8a9863c 100644
--- a/lib/safe_fetch/fetcher.rb
+++ b/lib/safe_fetch/fetcher.rb
@@ -29,18 +29,20 @@ class SafeFetch::Fetcher
end
def stream_response(tempfile)
- response = nil
bytes_written = 0
- SsrfFilter.public_send(options.method, options.url, **options.request_options) do |res|
- response = res
+ perform_request do |res|
next unless res.is_a?(Net::HTTPSuccess)
validate_content_type!(res['content-type'])
bytes_written = write_response_body(res, tempfile, bytes_written)
end
+ end
- response
+ def perform_request(&)
+ return SafeFetch::PrivateNetworkRequest.new(options).perform(&) if SafeFetch.allow_private_network?
+
+ SsrfFilter.public_send(options.method, options.url, **options.request_options, &)
end
def validate_content_type!(content_type)
diff --git a/lib/safe_fetch/private_network_request.rb b/lib/safe_fetch/private_network_request.rb
new file mode 100644
index 000000000..9e9740ef4
--- /dev/null
+++ b/lib/safe_fetch/private_network_request.rb
@@ -0,0 +1,102 @@
+class SafeFetch::PrivateNetworkRequest
+ def initialize(options)
+ @options = options
+ end
+
+ def perform(&)
+ url = options.url
+ original_url = url
+ original_uri = URI(url)
+
+ (SsrfFilter::DEFAULT_MAX_REDIRECTS + 1).times do
+ uri = URI(url)
+ validate_scheme!(uri)
+
+ response, next_url = fetch_once(uri, resolved_addresses(uri.hostname).sample.to_s, original_uri, &)
+ return response if next_url.nil?
+
+ url = next_url
+ end
+
+ raise SsrfFilter::TooManyRedirects, "Got #{SsrfFilter::DEFAULT_MAX_REDIRECTS} redirects fetching #{original_url}"
+ end
+
+ private
+
+ attr_reader :options
+
+ def validate_scheme!(uri)
+ return if SsrfFilter::DEFAULT_SCHEME_WHITELIST.include?(uri.scheme)
+
+ raise SsrfFilter::InvalidUriScheme, "URI scheme '#{uri.scheme}' not in whitelist: #{SsrfFilter::DEFAULT_SCHEME_WHITELIST}"
+ end
+
+ def resolved_addresses(hostname)
+ ip_addresses = options.resolver.call(hostname)
+ raise SsrfFilter::UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty?
+
+ ip_addresses
+ end
+
+ def fetch_once(uri, ip_address, original_uri, &)
+ request = build_request(uri)
+ strip_sensitive_headers!(request, original_uri, uri)
+ validate_request!(request)
+
+ Net::HTTP.start(uri.hostname, uri.port, **http_options(uri, ip_address)) do |http|
+ response = http.request(request, &)
+ return response, redirect_location(response, uri)
+ end
+ end
+
+ def build_request(uri)
+ request = SsrfFilter::VERB_MAP[options.method].new(uri)
+ request['host'] = normalized_hostname(uri)
+
+ Array(options.request_options[:headers]).each { |header, value| request[header] = value }
+ request.body = options.body if options.body
+ options.request_options[:request_proc].call(request) if options.request_options[:request_proc].respond_to?(:call)
+
+ request
+ end
+
+ def http_options(uri, ip_address)
+ options.request_options[:http_options].merge(
+ use_ssl: uri.scheme == 'https',
+ ipaddr: ip_address
+ )
+ end
+
+ def strip_sensitive_headers!(request, original_uri, uri)
+ return unless different_origin?(original_uri, uri)
+
+ options.request_options[:sensitive_headers].each { |header| request.delete(header) }
+ end
+
+ def validate_request!(request)
+ request.each do |header, value|
+ next if header.count("\r\n").zero? && value.count("\r\n").zero?
+
+ raise SsrfFilter::CRLFInjection, "CRLF injection in header #{header} with value #{value}"
+ end
+ end
+
+ def redirect_location(response, uri)
+ return unless response.is_a?(Net::HTTPRedirection)
+
+ location = response['location']
+ return "#{uri.scheme}://#{normalized_hostname(uri)}#{location}" if location&.start_with?('/')
+
+ location
+ end
+
+ def normalized_hostname(uri)
+ return uri.hostname if (uri.port == 80 && uri.scheme == 'http') || (uri.port == 443 && uri.scheme == 'https')
+
+ "#{uri.hostname}:#{uri.port}"
+ end
+
+ def different_origin?(uri, other_uri)
+ uri.scheme != other_uri.scheme || uri.hostname != other_uri.hostname || uri.port != other_uri.port
+ end
+end
diff --git a/lib/safe_fetch/request_options.rb b/lib/safe_fetch/request_options.rb
index 72969a76d..6d11ebd19 100644
--- a/lib/safe_fetch/request_options.rb
+++ b/lib/safe_fetch/request_options.rb
@@ -53,6 +53,10 @@ class SafeFetch::RequestOptions
@validate_content_type
end
+ def resolver
+ SsrfFilter::DEFAULT_RESOLVER
+ end
+
private
def default_max_bytes
diff --git a/package.json b/package.json
index c6d2d95d2..8b96ba379 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.14.0",
+ "version": "4.14.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -34,8 +34,8 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.3.11",
- "@chatwoot/utils": "^0.0.52",
+ "@chatwoot/prosemirror-schema": "1.3.13",
+ "@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b018dbdfe..3a9efa7eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,11 +26,11 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.11
- version: 1.3.11
+ specifier: 1.3.13
+ version: 1.3.13
'@chatwoot/utils':
- specifier: ^0.0.52
- version: 0.0.52
+ specifier: ^0.0.55
+ version: 0.0.55
'@formkit/core':
specifier: ^1.7.2
version: 1.7.2
@@ -459,11 +459,11 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.11':
- resolution: {integrity: sha512-+GptIqY73/EtojrhAKX4UKwZF7NUB47DMzgYxmx8Vu07DLKCGe+8JnbHJnR9oBy8p5sn5VOO1ihNf/4Pg2RzIQ==}
+ '@chatwoot/prosemirror-schema@1.3.13':
+ resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==}
- '@chatwoot/utils@0.0.52':
- resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
+ '@chatwoot/utils@0.0.55':
+ resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -4993,7 +4993,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.11':
+ '@chatwoot/prosemirror-schema@1.3.13':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
@@ -5011,7 +5011,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
- '@chatwoot/utils@0.0.52':
+ '@chatwoot/utils@0.0.55':
dependencies:
date-fns: 2.30.0
diff --git a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb
index 7ee763dbc..1d378ab65 100644
--- a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb
@@ -263,6 +263,31 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
expect(response).to have_http_status(:success)
end
+ it 'permits contact label removal params' do
+ contact_one = create(:contact, account: account)
+ contact_two = create(:contact, account: account)
+
+ expect do
+ post "/api/v1/accounts/#{account.id}/bulk_actions",
+ headers: agent.create_new_auth_token,
+ params: {
+ type: 'Contact',
+ ids: [contact_one.id, contact_two.id],
+ labels: { remove: %w[vip support] },
+ extra: 'ignored'
+ }
+ end.to have_enqueued_job(Contacts::BulkActionJob).with(
+ account.id,
+ agent.id,
+ hash_including(
+ 'ids' => [contact_one.id.to_s, contact_two.id.to_s],
+ 'labels' => hash_including('remove' => %w[vip support])
+ )
+ )
+
+ expect(response).to have_http_status(:success)
+ end
+
it 'returns unauthorized for delete action when user is not admin' do
contact = create(:contact, account: account)
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index 19d080b47..f8fd446d2 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -101,6 +101,76 @@ RSpec.describe 'Conversations API', type: :request do
end
end
+ describe 'GET /api/v1/accounts/{account.id}/conversations/unread_counts' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/conversations/unread_counts"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:visible_inbox) { create(:inbox, account: account) }
+ let(:hidden_inbox) { create(:inbox, account: account) }
+ let(:label) { create(:label, account: account, title: 'billing', show_on_sidebar: true) }
+ let(:team) { create(:team, account: account, allow_auto_assign: false) }
+
+ before do
+ create(:inbox_member, user: agent, inbox: visible_inbox)
+ create(:team_member, user: agent, team: team)
+ end
+
+ after do
+ Conversations::UnreadCounts::Store.clear_account!(account.id)
+ end
+
+ context 'when conversation unread counts feature is enabled' do
+ before do
+ account.enable_features!(:conversation_unread_counts)
+ end
+
+ it 'returns unread conversation counts scoped to the signed-in user' do
+ create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
+ create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
+
+ get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['payload']).to eq(
+ 'inboxes' => { visible_inbox.id.to_s => 1 },
+ 'labels' => { label.id.to_s => 1 },
+ 'teams' => {}
+ )
+ end
+
+ it 'returns unread team conversation counts scoped to the signed-in user' do
+ create_unread_conversation(account: account, inbox: visible_inbox, team: team)
+ create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
+
+ get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
+ end
+ end
+
+ it 'returns forbidden when conversation unread counts feature is disabled' do
+ get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account')
+ end
+ end
+ end
+
describe 'GET /api/v1/accounts/{account.id}/conversations/search' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -777,6 +847,23 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.agent_last_seen_at).to be > initial_last_seen
end
+ it 'refreshes unread count cache when conversation is marked read' do
+ account.enable_features!(:conversation_unread_counts)
+ conversation.update!(agent_last_seen_at: 1.hour.ago)
+ create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
+ Conversations::UnreadCounts::Builder.new(account).build_base!
+
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ inbox_key = Conversations::UnreadCounts::Store.inbox_key(account.id, conversation.inbox_id)
+ expect(response).to have_http_status(:success)
+ expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
+ ensure
+ Conversations::UnreadCounts::Store.clear_account!(account.id)
+ end
+
it 'updates both if one timestamp is old even when the other is recent' do
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago)
# Ensure all messages are older than assignee_last_seen_at (no unread messages)
@@ -847,6 +934,22 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.agent_last_seen_at).to eq(last_seen_at)
expect(conversation.reload.assignee_last_seen_at).to eq(last_seen_at)
end
+
+ it 'refreshes unread count cache when conversation is marked unread' do
+ account.enable_features!(:conversation_unread_counts)
+ conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
+ Conversations::UnreadCounts::Builder.new(account).build_base!
+
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ inbox_key = Conversations::UnreadCounts::Store.inbox_key(account.id, conversation.inbox_id)
+ expect(response).to have_http_status(:success)
+ expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 1)
+ ensure
+ Conversations::UnreadCounts::Store.clear_account!(account.id)
+ end
end
end
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index 81240c557..860791c0e 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -170,7 +170,10 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
'allowed_locales' => [
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'en', 'draft' => false },
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'es', 'draft' => true }
- ]
+ ],
+ 'default_locale' => 'en',
+ 'layout' => 'classic',
+ 'social_profiles' => {}
}
)
end
diff --git a/spec/controllers/google/callbacks_controller_spec.rb b/spec/controllers/google/callbacks_controller_spec.rb
index a898ab395..b38fcfb59 100644
--- a/spec/controllers/google/callbacks_controller_spec.rb
+++ b/spec/controllers/google/callbacks_controller_spec.rb
@@ -8,12 +8,12 @@ RSpec.describe 'Google::CallbacksController', type: :request do
describe 'GET /google/callback' do
let(:response_body_success) do
- { id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
+ { id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
let(:response_body_success_without_name) do
- { id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
+ { id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
diff --git a/spec/controllers/microsoft/callbacks_controller_spec.rb b/spec/controllers/microsoft/callbacks_controller_spec.rb
index 6bd9a0583..129cfa383 100644
--- a/spec/controllers/microsoft/callbacks_controller_spec.rb
+++ b/spec/controllers/microsoft/callbacks_controller_spec.rb
@@ -8,12 +8,12 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
describe 'GET /microsoft/callback' do
let(:response_body_success) do
- { id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
+ { id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
let(:response_body_success_without_name) do
- { id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
+ { id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
diff --git a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
index 515013f37..8bebd3b9d 100644
--- a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
+++ b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
@@ -140,6 +140,38 @@ RSpec.describe 'Public Articles API', type: :request do
get "/hc/#{portal.slug}/articles/#{article_in_locale.slug}"
expect(response).to have_http_status(:success)
end
+
+ it 'resolves the locale from the article itself for an uncategorized article' do
+ uncategorized_article = create(:article, category: nil, locale: 'es', portal: portal,
+ account_id: account.id, author_id: agent.id)
+ get "/hc/#{portal.slug}/articles/#{uncategorized_article.slug}"
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('lang="es"')
+ end
+ end
+
+ describe 'GET /public/api/v1/portals/:slug/articles/:slug.md (markdown)' do
+ it 'serves the raw article markdown for a published article' do
+ get "/hc/#{portal.slug}/articles/#{article.slug}.md"
+
+ expect(response).to have_http_status(:success)
+ expect(response.headers['Content-Type']).to include('text/markdown')
+ expect(response.body).to eq(article.content)
+ end
+
+ it 'returns 404 for a draft article' do
+ draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id)
+
+ get "/hc/#{portal.slug}/articles/#{draft_article.slug}.md"
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it 'returns 404 if the article does not exist' do
+ get "/hc/#{portal.slug}/articles/non-existent-article.md"
+
+ expect(response).to have_http_status(:not_found)
+ end
end
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
diff --git a/spec/controllers/public/api/v1/portals/categories_controller_spec.rb b/spec/controllers/public/api/v1/portals/categories_controller_spec.rb
index 2e1d83eea..6636d9bc0 100644
--- a/spec/controllers/public/api/v1/portals/categories_controller_spec.rb
+++ b/spec/controllers/public/api/v1/portals/categories_controller_spec.rb
@@ -11,12 +11,13 @@ RSpec.describe 'Public Categories API', type: :request do
end
describe 'GET /public/api/v1/portals/:portal_slug/categories' do
- it 'Fetch all categories in the portal' do
+ it 'redirects to the locale home page' do
category = portal.categories.first
get "/hc/#{portal.slug}/#{category.locale}/categories"
- expect(response).to have_http_status(:success)
+ expect(response).to have_http_status(:moved_permanently)
+ expect(response).to redirect_to("/hc/#{portal.slug}/#{category.locale}")
end
end
diff --git a/spec/controllers/super_admin/accounts_controller_spec.rb b/spec/controllers/super_admin/accounts_controller_spec.rb
index 6b7e6eeed..e4ff81a08 100644
--- a/spec/controllers/super_admin/accounts_controller_spec.rb
+++ b/spec/controllers/super_admin/accounts_controller_spec.rb
@@ -32,6 +32,10 @@ RSpec.describe 'Super Admin accounts API', type: :request do
create(:team, account: account)
end
+ after do
+ Conversations::UnreadCounts::Store.clear_account!(account.id)
+ end
+
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/super_admin/accounts/#{account.id}/reset_cache"
@@ -52,6 +56,21 @@ RSpec.describe 'Super Admin accounts API', type: :request do
range = now_timestamp..(now_timestamp + 10)
expect(account.reload.cache_keys.values.all? { |v| range.cover?(v.to_i) }).to be(true)
end
+
+ it 'clears conversation unread count cache' do
+ inbox = account.inboxes.first
+ store = Conversations::UnreadCounts::Store
+ inbox_key = store.inbox_key(account.id, inbox.id)
+ store.mark_base_ready!(account.id)
+ store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
+
+ sign_in(super_admin, scope: :super_admin)
+ post "/super_admin/accounts/#{account.id}/reset_cache"
+
+ expect(response).to have_http_status(:redirect)
+ expect(store.base_ready?(account.id)).to be(false)
+ expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
+ end
end
end
diff --git a/spec/controllers/twilio/callbacks_controller_spec.rb b/spec/controllers/twilio/callbacks_controller_spec.rb
index d16acf229..1dc2991ae 100644
--- a/spec/controllers/twilio/callbacks_controller_spec.rb
+++ b/spec/controllers/twilio/callbacks_controller_spec.rb
@@ -10,7 +10,10 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do
'To' => '+0987654321',
'Body' => 'Test message',
'AccountSid' => 'AC123',
- 'SmsSid' => 'SM123'
+ 'SmsSid' => 'SM123',
+ 'ExternalUserId' => 'IN.2081978709342942',
+ 'ParentExternalUserId' => 'IN.ENT.9081726354',
+ 'ProfileUsername' => 'muhsin'
}
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/audit_logs_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/audit_logs_controller_spec.rb
index 8cf28f93a..7c6c69ec6 100644
--- a/spec/enterprise/controllers/api/v1/accounts/audit_logs_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/audit_logs_controller_spec.rb
@@ -51,10 +51,13 @@ RSpec.describe 'Enterprise Audit API', type: :request do
expect(json_response['audit_logs'][1]['action']).to eql('create')
expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name)
expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id)
- expect(json_response['current_page']).to be(1)
# contains audit log for account user as well
# contains audit logs for account update(enable audit logs)
- expect(json_response['total_entries']).to be(3)
+ expect(json_response.slice('current_page', 'per_page', 'total_entries')).to eql(
+ 'current_page' => 1,
+ 'per_page' => 25,
+ 'total_entries' => 3
+ )
end
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb
index 744fa4ea8..31a0bc36f 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb
@@ -147,7 +147,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
params: sync_params,
headers: admin.create_new_auth_token,
as: :json
- end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times
+ end.to have_enqueued_job(Captain::Documents::PerformSyncJob).on_queue('low').exactly(documents.size).times
documents.each do |document|
expect(document.reload).to have_attributes(
@@ -190,7 +190,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
expect(response).to have_http_status(:ok)
end
- it 'skips documents that already have a sync in progress' do
+ it 'queues documents that already have a sync in progress' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
@@ -199,9 +199,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token,
as: :json
- end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
+ end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document).on_queue('low')
expect(response).to have_http_status(:ok)
+ expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
end
it 'queues stale syncing documents again' do
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
index cb7731652..77cb25f49 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
@@ -243,7 +243,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
before do
create_list(:captain_document, 5, assistant: assistant, account: account)
- create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
+ InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').update!(value: captain_limits.to_json)
post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes,
headers: admin.create_new_auth_token
@@ -281,7 +281,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
- end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
+ end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low')
expect(document.reload).to have_attributes(
sync_status: 'syncing',
@@ -292,15 +292,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(response).to have_http_status(:accepted)
end
- it 'rejects documents that already have a sync in progress' do
+ it 'queues documents that already have a sync in progress' do
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
- end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
+ end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low')
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:accepted)
end
it 'queues stale syncing documents again' do
diff --git a/spec/enterprise/jobs/captain/documents/perform_sync_job_spec.rb b/spec/enterprise/jobs/captain/documents/perform_sync_job_spec.rb
new file mode 100644
index 000000000..dfec1c9d7
--- /dev/null
+++ b/spec/enterprise/jobs/captain/documents/perform_sync_job_spec.rb
@@ -0,0 +1,60 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Documents::PerformSyncJob, type: :job do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
+
+ def stub_lock(job)
+ allow(job).to receive(:with_lock).and_yield
+ end
+
+ def stub_page_fetch(content: 'Updated content')
+ fetch_result = Captain::Documents::SinglePageFetcher::Result.new(
+ success: true,
+ title: 'Updated title',
+ content: content
+ )
+ fetcher = instance_double(Captain::Documents::SinglePageFetcher, fetch: fetch_result)
+ allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
+ end
+
+ def stub_page_fetch_failure
+ fetcher = instance_double(Captain::Documents::SinglePageFetcher)
+ allow(fetcher).to receive(:fetch).and_raise(StandardError, 'boom')
+ allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
+ end
+
+ it 'syncs the document content' do
+ travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
+ job = described_class.new
+ stub_lock(job)
+ stub_page_fetch
+
+ job.perform(document)
+
+ expect(document.reload).to have_attributes(
+ sync_status: 'synced',
+ last_sync_attempted_at: Time.current,
+ last_synced_at: Time.current,
+ content: 'Updated content'
+ )
+ end
+ end
+
+ it 'marks unexpected failures as failed' do
+ travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
+ job = described_class.new
+ stub_lock(job)
+ stub_page_fetch_failure
+
+ expect { job.perform(document) }.to raise_error(StandardError, 'boom')
+
+ expect(document.reload).to have_attributes(
+ sync_status: 'failed',
+ last_sync_error_code: 'sync_error',
+ last_sync_attempted_at: Time.current
+ )
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
index 76604a0b5..3a241d0a1 100644
--- a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
+++ b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
@@ -5,11 +5,28 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
let(:assistant) { create(:captain_assistant, account: account) }
before do
- create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json)
+ set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', { business: 168, enterprise: 24, startups: 720, hacker: nil }.to_json)
+ set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 50)
+ set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 1000)
account.enable_features!('captain_document_auto_sync')
clear_enqueued_jobs
end
+ def set_installation_config(name, value)
+ InstallationConfig.find_or_initialize_by(name: name).tap do |config|
+ config.value = value
+ config.save!
+ end
+ end
+
+ def update_sync_limit(name, value)
+ InstallationConfig.find_by!(name: name).update!(value: value)
+ end
+
+ def sync_job_for(document)
+ have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
+ end
+
context 'when the account has not enabled auto-sync' do
before { account.disable_features!('captain_document_auto_sync') }
@@ -32,6 +49,25 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
end
end
+ context 'when a plan name is passed' do
+ it 'queues due documents only for that plan' do
+ enterprise_account = create(:account, custom_attributes: { plan_name: 'Enterprise' })
+ enterprise_account.enable_features!('captain_document_auto_sync')
+ enterprise_assistant = create(:captain_assistant, account: enterprise_account)
+ business_document = create(:captain_document, assistant: assistant, account: account, status: :available)
+ enterprise_document = create(:captain_document, assistant: enterprise_assistant, account: enterprise_account, status: :available)
+
+ business_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
+ enterprise_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
+ clear_enqueued_jobs
+
+ described_class.new.perform('enterprise')
+
+ expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(enterprise_document)
+ expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(business_document)
+ end
+ end
+
context 'when an available document has backfilled sync metadata' do
it 'leaves it alone when last synced within the plan cadence' do
create(
@@ -54,53 +90,34 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
account: account,
status: :available,
sync_status: :synced,
- last_synced_at: 3.days.ago
+ last_synced_at: 8.days.ago
)
clear_enqueued_jobs
expect { described_class.new.perform }
- .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
+ .to sync_job_for(document).on_queue('purgable')
end
- it 'marks the due document as syncing before queueing' do
+ it 'delays only the queued sync job' do
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
+ job = described_class.new
+ allow(job).to receive(:rand).and_return(30.minutes.to_i)
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
- last_synced_at: 3.days.ago
+ last_synced_at: 8.days.ago
)
clear_enqueued_jobs
- described_class.new.perform
+ job.perform
- expect(document.reload).to have_attributes(
- sync_status: 'syncing',
- last_sync_attempted_at: Time.current
- )
+ expect(Captain::Documents::PerformSyncJob)
+ .to have_been_enqueued.with(document).at(30.minutes.from_now)
end
end
-
- it 'does not queue the same document again while the reserved sync is fresh' do
- document = create(
- :captain_document,
- assistant: assistant,
- account: account,
- status: :available,
- sync_status: :synced,
- last_synced_at: 2.days.ago
- )
- clear_enqueued_jobs
-
- expect { described_class.new.perform }
- .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
-
- clear_enqueued_jobs
-
- expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
- end
end
context 'when an available document was synced within the plan cadence' do
@@ -116,78 +133,59 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
context 'when an available document was last synced before the plan cadence' do
it 'queues a sync for that document' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
- document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
+ document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
clear_enqueued_jobs
expect { described_class.new.perform }
- .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
+ .to sync_job_for(document)
+ end
+ end
+
+ context 'when jitter spreads queued sync execution' do
+ it 'uses a widened due window so jittered syncs do not skip the next plan run' do
+ travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
+ job = described_class.new
+ interval = 1.week
+ due_window = (interval.to_i / 2).seconds
+ allow(job).to receive(:rand).and_return(2.hours.to_i)
+ document = create(:captain_document, assistant: assistant, account: account, status: :available)
+
+ document.update!(sync_status: :synced, last_synced_at: (due_window - 1.minute).ago)
+ clear_enqueued_jobs
+
+ expect { job.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
+
+ document.update!(sync_status: :synced, last_synced_at: (due_window + 1.minute).ago)
+ clear_enqueued_jobs
+
+ expect { job.perform }
+ .to have_enqueued_job(Captain::Documents::PerformSyncJob)
+ .with(document)
+ .on_queue('purgable')
+ .at(2.hours.from_now)
+ end
end
- it 'skips invalid legacy documents without counting them against the account cap' do
- stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
- create(
- :captain_document,
- assistant: assistant,
- account: account,
- status: :in_progress,
- content: nil,
- external_link: 'https://example.com'
- )
- invalid_document = build(
- :captain_document,
- assistant: assistant,
- account: account,
- status: :available,
- sync_status: :synced,
- last_synced_at: 2.days.ago,
- last_sync_attempted_at: 2.days.ago,
- external_link: 'https://example.com/'
- )
- invalid_document.save!(validate: false)
- valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
- valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
- clear_enqueued_jobs
+ it 'uses a random delay inside the cadence window' do
+ travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
+ document = create(:captain_document, assistant: assistant, account: account, status: :available)
+ document.update!(sync_status: :synced, last_synced_at: 8.days.ago)
+ job = described_class.new
+ sync_execution_delay = 12_345.seconds
- expect { described_class.new.perform }.not_to raise_error
- expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(invalid_document)
- expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
- end
+ clear_enqueued_jobs
+ allow(job).to receive(:rand).with(0..described_class::WEEKLY_SYNC_JITTER.to_i).and_return(sync_execution_delay.to_i)
- it 'keeps paging due documents when invalid documents fill the first batch' do
- stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
- stub_const("#{described_class}::DUE_DOCUMENT_BATCH_SIZE", 1)
- create(
- :captain_document,
- assistant: assistant,
- account: account,
- status: :in_progress,
- content: nil,
- external_link: 'https://example.com'
- )
- invalid_document = build(
- :captain_document,
- assistant: assistant,
- account: account,
- status: :available,
- sync_status: :synced,
- last_synced_at: 2.days.ago,
- last_sync_attempted_at: 3.days.ago,
- external_link: 'https://example.com/'
- )
- invalid_document.save!(validate: false)
- valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
- valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
- clear_enqueued_jobs
-
- described_class.new.perform
-
- expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
+ expect { job.perform }
+ .to sync_job_for(document)
+ .at(sync_execution_delay.from_now)
+ end
end
end
context 'when more documents are due than the account cap allows' do
before do
- stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 2)
+ update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
end
it 'queues backfilled and oldest-attempted documents first' do
@@ -195,26 +193,47 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available)
- newest_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
- oldest_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
- backfilled_document.update!(sync_status: :synced, last_synced_at: 4.days.ago, last_sync_attempted_at: nil)
+ newest_document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
+ oldest_document.update!(sync_status: :synced, last_synced_at: 9.days.ago, last_sync_attempted_at: 9.days.ago)
+ backfilled_document.update!(sync_status: :synced, last_synced_at: 10.days.ago, last_sync_attempted_at: nil)
clear_enqueued_jobs
expect { described_class.new.perform }
- .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(backfilled_document)
- .and have_enqueued_job(Captain::Documents::PerformSyncJob).with(oldest_document)
+ .to sync_job_for(backfilled_document)
+ .and sync_job_for(oldest_document)
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document)
end
end
+ context 'when sync caps are configured' do
+ it 'uses installation config caps for per-account and global limits' do
+ update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
+ update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 3)
+
+ second_account = create(:account, custom_attributes: { plan_name: 'business' })
+ second_account.enable_features!('captain_document_auto_sync')
+ second_assistant = create(:captain_assistant, account: second_account)
+
+ first_account_documents = create_list(:captain_document, 3, assistant: assistant, account: account, status: :available)
+ second_account_documents = create_list(:captain_document, 3, assistant: second_assistant, account: second_account, status: :available)
+ (first_account_documents + second_account_documents).each do |document|
+ document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
+ end
+ clear_enqueued_jobs
+
+ expect { described_class.new.perform }
+ .to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(3).times
+ end
+ end
+
context 'when an available document failed before the plan cadence' do
it 'queues a sync for that document' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
- document.update!(sync_status: :failed, last_sync_attempted_at: 2.days.ago)
+ document.update!(sync_status: :failed, last_sync_attempted_at: 8.days.ago)
clear_enqueued_jobs
expect { described_class.new.perform }
- .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
+ .to sync_job_for(document)
end
end
@@ -228,7 +247,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
clear_enqueued_jobs
expect { described_class.new.perform }
- .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
+ .to sync_job_for(document)
end
end
diff --git a/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb b/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb
index e12efc54b..6aed60385 100644
--- a/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb
+++ b/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb
@@ -61,6 +61,16 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
end
end
+ it 'stores external links longer than 255 characters' do
+ long_url = "https://example.com/#{'arabic-product-slug-' * 300}"
+ payload[:metadata]['url'] = long_url
+
+ described_class.perform_now(assistant_id: assistant.id, payload: payload)
+
+ expect(assistant.documents.last.external_link).to eq(long_url)
+ expect(assistant.documents.last.external_link.length).to be > 255
+ end
+
context 'when an error occurs' do
it 'raises an error with a descriptive message' do
allow(Captain::Assistant).to receive(:find).and_raise(ActiveRecord::RecordNotFound)
diff --git a/spec/enterprise/jobs/captain/tools/simple_page_crawl_parser_job_spec.rb b/spec/enterprise/jobs/captain/tools/simple_page_crawl_parser_job_spec.rb
index 2425def85..64784bf75 100644
--- a/spec/enterprise/jobs/captain/tools/simple_page_crawl_parser_job_spec.rb
+++ b/spec/enterprise/jobs/captain/tools/simple_page_crawl_parser_job_spec.rb
@@ -130,17 +130,27 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
end
context 'when the failure is permanent' do
+ # `discard_on PermanentCrawlError` swallows the error in `perform_now`
+ # under normal conditions, but Zeitwerk reloading in CI can break the
+ # rescue_handlers chain so the error escapes. The behavioural contract
+ # we care about — no retries, correct document state — holds either
+ # way, so tolerate both.
+ def run_job
+ described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
+ rescue StandardError => e
+ # discard_on may have failed to swallow it; the contract still holds.
+ raise unless e.class.name == 'Captain::Tools::SimplePageCrawlParserJob::PermanentCrawlError' # rubocop:disable Style/ClassEqualityComparison
+ end
+
before do
allow(crawler).to receive(:status_code).and_return(404)
end
- it 'does not retry a discovered link that was never persisted' do
- expect do
- described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
- end.not_to change(assistant.documents, :count)
+ it 'does not persist a discovered link that was never stored' do
+ expect { run_job }.not_to change(assistant.documents, :count)
end
- it 'marks an existing document as available and failed without raising' do
+ it 'marks an existing document as available and failed' do
document = create(
:captain_document,
assistant: assistant,
@@ -150,9 +160,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
)
freeze_time do
- expect do
- described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
- end.not_to raise_error
+ run_job
expect(document.reload).to have_attributes(
status: 'available',
diff --git a/spec/enterprise/jobs/enterprise/internal/trigger_daily_scheduled_items_job_spec.rb b/spec/enterprise/jobs/enterprise/internal/trigger_daily_scheduled_items_job_spec.rb
new file mode 100644
index 000000000..3afc20328
--- /dev/null
+++ b/spec/enterprise/jobs/enterprise/internal/trigger_daily_scheduled_items_job_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+RSpec.describe Internal::TriggerDailyScheduledItemsJob do
+ before do
+ allow(ChatwootHub).to receive(:installation_identifier).and_return('test-installation-id')
+ allow(Captain::Documents::ScheduleSyncsJob).to receive(:perform_later)
+ end
+
+ it 'enqueues enterprise Captain document auto-sync every day' do
+ travel_to Time.zone.parse('2026-05-26 00:00:00 UTC') do
+ described_class.perform_now
+ end
+
+ expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
+ end
+
+ it 'enqueues business Captain document auto-sync weekly' do
+ travel_to Time.zone.parse('2026-05-24 00:00:00 UTC') do
+ described_class.perform_now
+ end
+
+ expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('business')
+ end
+
+ it 'enqueues startup Captain document auto-sync monthly' do
+ travel_to Time.zone.parse('2026-06-01 00:00:00 UTC') do
+ described_class.perform_now
+ end
+
+ expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('startups')
+ end
+
+ it 'does not enqueue business or startup Captain document auto-sync before their plan window' do
+ travel_to Time.zone.parse('2026-05-25 00:00:00 UTC') do
+ described_class.perform_now
+ end
+
+ expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
+ expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('business')
+ expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('startups')
+ end
+end
diff --git a/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb
new file mode 100644
index 000000000..62529866d
--- /dev/null
+++ b/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb
@@ -0,0 +1,188 @@
+require 'rails_helper'
+
+RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
+ let(:account) { create(:account) }
+ let(:portal) { create(:portal, account_id: account.id) }
+ let!(:admin) { create(:user, account: account, role: :administrator) }
+ let(:generation_id) { 'generation-123' }
+ let(:job_args) { [account.id, portal.id, admin.id, generation_id] }
+ let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
+ let(:curated_plan) do
+ {
+ 'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
+ 'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
+ 'articles' => [
+ { 'title' => 'Hello', 'urls' => ['https://x.test/a', 'https://evil.test/hallucinated'], 'category_name' => 'Getting Started' },
+ { 'title' => 'World', 'urls' => ['https://x.test/b'], 'category_name' => 'Getting Started' }
+ ]
+ }
+ end
+
+ before do
+ clear_enqueued_jobs
+ curator = instance_double(Onboarding::HelpCenterCurator, perform: curated_plan)
+ allow(Onboarding::HelpCenterCurator).to receive(:new).with(account: account).and_return(curator)
+ end
+
+ after do
+ Redis::Alfred.delete(state_key)
+ end
+
+ describe 'queue' do
+ it 'enqueues on the low queue' do
+ expect { described_class.perform_later(*job_args) }
+ .to have_enqueued_job(described_class).on_queue('low')
+ end
+ end
+
+ describe 'happy path' do
+ it 'creates categories, starts state with total/finished, and fans out article payloads' do
+ expect do
+ perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
+ end.to change { portal.categories.count }.by(1)
+
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
+ 'status' => 'generating', 'total' => '2', 'finished' => '0'
+ )
+ expect(enqueued_jobs).to include(
+ a_hash_including(
+ 'job_class' => Onboarding::HelpCenterArticleWriterJob.name,
+ 'arguments' => array_including(
+ account.id,
+ portal.id,
+ admin.id,
+ generation_id,
+ hash_including(
+ 'article' => hash_including(
+ 'title' => 'Hello',
+ 'urls' => ['https://x.test/a'],
+ 'category_id' => portal.categories.first.id
+ )
+ )
+ )
+ )
+ )
+ end
+ end
+
+ describe 'orphan article filtering' do
+ let(:curated_plan) do
+ {
+ 'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
+ 'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
+ 'articles' => [
+ { 'title' => 'Valid', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
+ { 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }
+ ]
+ }
+ end
+
+ it 'drops articles whose category was not emitted alongside them' do
+ perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
+
+ writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
+ expect(writer_jobs.size).to eq(1)
+ expect(writer_jobs.first['arguments']).to include(
+ hash_including('article' => hash_including('title' => 'Valid'))
+ )
+ end
+ end
+
+ describe 'article URL filtering' do
+ let(:curated_plan) do
+ {
+ 'allowed_urls' => ['https://x.test/a'],
+ 'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
+ 'articles' => [
+ { 'title' => 'Approved', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
+ { 'title' => 'Hallucinated', 'urls' => ['https://evil.test/hallucinated'], 'category_name' => 'Getting Started' }
+ ]
+ }
+ end
+
+ it 'drops articles with no approved source urls before fanout' do
+ perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
+
+ writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
+ expect(writer_jobs.size).to eq(1)
+ expect(writer_jobs.first['arguments']).to include(
+ hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
+ )
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
+ end
+ end
+
+ describe 'transaction rollback' do
+ let(:curated_plan) do
+ {
+ 'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
+ 'articles' => [{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }]
+ }
+ end
+
+ it 'leaves zero categories and marks state skipped when no article can be stamped' do
+ described_class.perform_now(*job_args)
+
+ expect(portal.categories.count).to eq(0)
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
+ 'status' => 'skipped',
+ 'skip_reason' => 'no articles after category or URL filtering'
+ )
+ end
+ end
+
+ describe 'idempotency' do
+ it 'no-ops when state already exists for this generation' do
+ Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
+
+ expect { described_class.perform_now(*job_args) }
+ .not_to(change { portal.categories.count })
+ expect(Onboarding::HelpCenterCurator).not_to have_received(:new)
+ end
+ end
+
+ describe 'curation skipped' do
+ it 'records skip_reason and transitions to skipped' do
+ curator = instance_double(Onboarding::HelpCenterCurator)
+ allow(curator).to receive(:perform).and_raise(
+ Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
+ )
+ allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
+
+ described_class.perform_now(*job_args)
+
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
+ 'status' => 'skipped', 'skip_reason' => 'no website url'
+ )
+ end
+ end
+
+ describe 'firecrawl retries' do
+ it 'transitions to skipped after retries exhaust' do
+ curator = instance_double(Onboarding::HelpCenterCurator)
+ allow(curator).to receive(:perform).and_raise(Firecrawl::FirecrawlError, 'rate limited')
+ allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
+
+ perform_enqueued_jobs { described_class.perform_later(*job_args) }
+
+ state = Onboarding::HelpCenterGenerationState.current(generation_id)
+ expect(state['status']).to eq('skipped')
+ expect(state['skip_reason']).to include('firecrawl exhausted')
+ end
+ end
+
+ describe 'broadcasts' do
+ it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
+ curator = instance_double(Onboarding::HelpCenterCurator)
+ allow(curator).to receive(:perform).and_raise(
+ Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
+ )
+ allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
+
+ payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
+ expect { described_class.perform_now(*job_args) }
+ .to have_enqueued_job(ActionCableBroadcastJob)
+ .with([admin.pubsub_token], 'help_center.generation_completed', payload)
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
new file mode 100644
index 000000000..a4db6b1d3
--- /dev/null
+++ b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
@@ -0,0 +1,159 @@
+require 'rails_helper'
+
+RSpec.describe Onboarding::HelpCenterArticleWriterJob do
+ let(:account) { create(:account) }
+ let(:portal) { create(:portal, account_id: account.id) }
+ let!(:admin) { create(:user, account: account, role: :administrator) }
+ let(:generation_id) { 'generation-123' }
+ let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
+ let(:article_payload) { { 'article' => article_spec } }
+ let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
+ let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
+
+ before do
+ Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
+ clear_enqueued_jobs
+ end
+
+ after do
+ Redis::Alfred.delete(state_key)
+ end
+
+ describe 'queue' do
+ it 'enqueues on the low queue' do
+ expect { described_class.perform_later(*job_args) }
+ .to have_enqueued_job(described_class).on_queue('low')
+ end
+ end
+
+ describe 'success path' do
+ let(:built_article) { instance_double(Article, id: 9876) }
+
+ before do
+ builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
+ allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
+ end
+
+ it 'invokes the builder and increments the Redis counter' do
+ described_class.perform_now(*job_args)
+
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
+ expect(Onboarding::HelpCenterArticleBuilder).to have_received(:new).with(
+ account: account,
+ portal: portal,
+ user: admin,
+ article: article_spec
+ )
+ end
+
+ it 'flips status to completed once the last writer finishes' do
+ described_class.perform_now(*job_args)
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('status' => 'generating')
+
+ described_class.perform_now(*job_args)
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
+ 'status' => 'completed', 'finished' => '2'
+ )
+ end
+ end
+
+ describe 'failure handling' do
+ it 'increments the counter on ArticleBuildFailed without re-raising' do
+ allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
+ Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
+ )
+
+ described_class.perform_now(*job_args)
+
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
+ end
+
+ it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
+ allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
+ Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
+ )
+ Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
+ payload = hash_including(generation_id: generation_id, status: 'completed')
+
+ expect { described_class.perform_now(*job_args) }
+ .to have_enqueued_job(ActionCableBroadcastJob)
+ .with([admin.pubsub_token], 'help_center.generation_completed', payload)
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
+ 'status' => 'completed', 'finished' => '2'
+ )
+ end
+
+ it 're-enqueues itself on transient Firecrawl errors' do
+ allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
+ Firecrawl::FirecrawlError, 'transient'
+ )
+
+ expect { described_class.perform_now(*job_args) }
+ .to have_enqueued_job(described_class).with(*job_args)
+ end
+
+ it 'increments the counter when Firecrawl retries are exhausted' do
+ allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
+ Firecrawl::FirecrawlError, 'always failing'
+ )
+
+ perform_enqueued_jobs do
+ described_class.perform_later(*job_args)
+ end
+
+ expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
+ end
+ end
+
+ describe 'broadcasts' do
+ let(:built_article) { instance_double(Article, id: 9876) }
+
+ before do
+ builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
+ allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
+ end
+
+ it 'broadcasts help_center.article_generated on success' do
+ payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
+ expect { described_class.perform_now(*job_args) }
+ .to have_enqueued_job(ActionCableBroadcastJob)
+ .with([admin.pubsub_token], 'help_center.article_generated', payload)
+ end
+
+ it 'broadcasts help_center.generation_completed when the last writer finishes' do
+ described_class.perform_now(*job_args)
+ payload = hash_including(generation_id: generation_id, status: 'completed')
+
+ expect { described_class.perform_now(*job_args) }
+ .to have_enqueued_job(ActionCableBroadcastJob)
+ .with([admin.pubsub_token], 'help_center.generation_completed', payload)
+ end
+
+ it 'does not broadcast article_generated on builder failure' do
+ allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
+ Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
+ )
+
+ expect { described_class.perform_now(*job_args) }
+ .not_to have_enqueued_job(ActionCableBroadcastJob)
+ .with(anything, 'help_center.article_generated', anything)
+ end
+
+ it 'broadcasts generation_completed on late retries past total' do
+ described_class.perform_now(*job_args)
+ described_class.perform_now(*job_args)
+ clear_enqueued_jobs
+
+ expect { described_class.perform_now(*job_args) }
+ .to have_enqueued_job(ActionCableBroadcastJob)
+ .with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
+ end
+
+ it 'skips progress broadcasts when state is missing' do
+ Redis::Alfred.delete(state_key)
+
+ expect { described_class.perform_now(*job_args) }
+ .not_to have_enqueued_job(ActionCableBroadcastJob)
+ end
+ end
+end
diff --git a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
new file mode 100644
index 000000000..cbb2d6c98
--- /dev/null
+++ b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
@@ -0,0 +1,72 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::Counter do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:other_agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:label) { create(:label, account: account, title: 'support', show_on_sidebar: true) }
+ let(:team) { create(:team, account: account, allow_auto_assign: false) }
+ let(:account_user) { account.account_users.find_by(user: agent) }
+ let(:store) { Conversations::UnreadCounts::Store }
+
+ before do
+ create(:inbox_member, user: agent, inbox: inbox)
+ create(:team_member, user: agent, team: team)
+ end
+
+ after do
+ store.clear_account!(account.id)
+ end
+
+ it 'uses base counts for custom roles with conversation_manage permission' do
+ account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_manage']))
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
+
+ result = described_class.new(account: account, user: agent).perform
+
+ expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
+ expect(result[:labels]).to eq(label.id.to_s => 2)
+ expect(result[:teams]).to eq(team.id.to_s => 2)
+ expect(store.assignment_ready?(account.id)).to be(false)
+ end
+
+ it 'counts assigned and unassigned conversations for conversation_unassigned_manage permission' do
+ account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']))
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
+
+ result = described_class.new(account: account, user: agent).perform
+
+ expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
+ expect(result[:labels]).to eq(label.id.to_s => 2)
+ expect(result[:teams]).to eq(team.id.to_s => 2)
+ expect(store.assignment_ready?(account.id)).to be(true)
+ end
+
+ it 'counts only assigned conversations for conversation_participating_manage permission' do
+ account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_participating_manage']))
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
+
+ result = described_class.new(account: account, user: agent).perform
+
+ expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
+ expect(result[:labels]).to eq(label.id.to_s => 1)
+ expect(result[:teams]).to eq(team.id.to_s => 1)
+ expect(store.assignment_ready?(account.id)).to be(true)
+ end
+
+ it 'returns zero for custom roles without conversation permissions' do
+ account_user.update!(custom_role: create(:custom_role, account: account, permissions: []))
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
+
+ result = described_class.new(account: account, user: agent).perform
+
+ expect(result).to eq(inboxes: {}, labels: {}, teams: {})
+ expect(store.base_ready?(account.id)).to be(false)
+ expect(store.assignment_ready?(account.id)).to be(false)
+ end
+end
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
index 212c0bf01..32752c2b2 100644
--- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
content_type: 'audio/mpeg'
)
allow(service).to receive(:can_transcribe?).and_return(true)
- allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1)
+ allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::TRANSCRIPTION_BYTE_LIMIT + 1)
end
it 'returns an error without calling Whisper' do
diff --git a/spec/enterprise/services/onboarding/help_center_article_builder_spec.rb b/spec/enterprise/services/onboarding/help_center_article_builder_spec.rb
new file mode 100644
index 000000000..a0aba2f91
--- /dev/null
+++ b/spec/enterprise/services/onboarding/help_center_article_builder_spec.rb
@@ -0,0 +1,18 @@
+require 'rails_helper'
+
+RSpec.describe Onboarding::HelpCenterArticleBuilder do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account, role: :administrator) }
+ let(:portal) { create(:portal, account_id: account.id) }
+
+ describe 'source url validation' do
+ it 'requires source urls' do
+ article = { urls: [], title: 'X' }
+ builder = described_class.new(account: account, portal: portal, user: user, article: article)
+
+ expect(Firecrawl::Configuration).not_to receive(:client)
+ expect { builder.perform }
+ .to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/)
+ end
+ end
+end
diff --git a/spec/enterprise/services/onboarding/help_center_creation_service_spec.rb b/spec/enterprise/services/onboarding/help_center_creation_service_spec.rb
new file mode 100644
index 000000000..c19f06f89
--- /dev/null
+++ b/spec/enterprise/services/onboarding/help_center_creation_service_spec.rb
@@ -0,0 +1,58 @@
+require 'rails_helper'
+
+RSpec.describe Onboarding::HelpCenterCreationService do
+ let(:account) { create(:account, custom_attributes: { 'website' => 'user-confirmed.com' }) }
+ let!(:admin) { create(:user, account: account, role: :administrator) }
+ let(:generation_id) { 'generation-123' }
+
+ before do
+ allow(SecureRandom).to receive(:uuid).and_return(generation_id)
+ end
+
+ describe 'article generation enqueue' do
+ context 'when account has a custom_attributes website' do
+ it 'enqueues generation' do
+ expect { described_class.new(account, admin).perform }
+ .to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
+ .with(account.id, kind_of(Integer), admin.id, generation_id)
+ end
+ end
+
+ context 'when account has only a brand_info domain' do
+ let(:account) { create(:account, custom_attributes: { 'brand_info' => { 'domain' => 'enrichment.com' } }) }
+
+ it 'uses the enrichment fallback and enqueues generation' do
+ expect { described_class.new(account, admin).perform }
+ .to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
+ .with(account.id, kind_of(Integer), admin.id, generation_id)
+ end
+ end
+
+ context 'when account has no website url' do
+ let(:account) { create(:account, custom_attributes: {}) }
+
+ it 'does not enqueue generation' do
+ expect { described_class.new(account, admin).perform }
+ .not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
+ end
+ end
+
+ context 'when a portal already exists' do
+ before { create(:portal, account_id: account.id) }
+
+ it 'does not enqueue generation' do
+ expect { described_class.new(account, admin).perform }
+ .not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
+ end
+ end
+
+ context 'when portal creation fails' do
+ it 'raises the error' do
+ allow(account.portals).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
+
+ expect { described_class.new(account, admin).perform }
+ .to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/onboarding/help_center_curator_spec.rb b/spec/enterprise/services/onboarding/help_center_curator_spec.rb
new file mode 100644
index 000000000..40812e1bb
--- /dev/null
+++ b/spec/enterprise/services/onboarding/help_center_curator_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe Onboarding::HelpCenterCurator do
+ let(:account) { create(:account, custom_attributes: { 'website' => 'chatwoot.com' }) }
+ let(:links) do
+ [
+ { 'url' => 'https://chatwoot.com/docs/a', 'title' => 'A' },
+ { url: 'https://chatwoot.com/docs/b', title: 'B' },
+ 'https://chatwoot.com/docs/c'
+ ]
+ end
+ let(:llm_response) do
+ {
+ message: {
+ categories: [{ name: 'Docs', description: 'Docs' }],
+ articles: [
+ { title: 'A', urls: ['https://chatwoot.com/docs/a'], category_name: 'Docs' },
+ { title: 'B', urls: ['https://chatwoot.com/docs/b'], category_name: 'Docs' },
+ { title: 'C', urls: ['https://chatwoot.com/docs/c'], category_name: 'Docs' }
+ ]
+ }
+ }
+ end
+
+ before do
+ firecrawl_client = instance_double(Firecrawl::Client, map: instance_double(Firecrawl::Models::MapData, links: links))
+ llm_service = instance_double(Captain::Llm::HelpCenterCurationService, perform: llm_response)
+
+ allow(Firecrawl::Configuration).to receive(:configured?).and_return(true)
+ allow(Firecrawl::Configuration).to receive(:client).and_return(firecrawl_client)
+ allow(Captain::Llm::HelpCenterCurationService).to receive(:new)
+ .with(account: account, links: links)
+ .and_return(llm_service)
+ end
+
+ it 'extracts allowed urls from Firecrawl string-keyed link hashes' do
+ result = described_class.new(account: account).perform
+
+ expect(result['allowed_urls']).to eq(['https://chatwoot.com/docs/a'])
+ end
+end
diff --git a/spec/enterprise/services/onboarding/help_center_generation_state_spec.rb b/spec/enterprise/services/onboarding/help_center_generation_state_spec.rb
new file mode 100644
index 000000000..979170872
--- /dev/null
+++ b/spec/enterprise/services/onboarding/help_center_generation_state_spec.rb
@@ -0,0 +1,61 @@
+require 'rails_helper'
+
+RSpec.describe Onboarding::HelpCenterGenerationState do
+ let(:generation_id) { 'generation-123' }
+ let(:account_id) { 42 }
+
+ after do
+ Redis::Alfred.delete(described_class.key(generation_id))
+ end
+
+ describe '.start' do
+ it 'stores status, total, finished, and sets a ttl' do
+ described_class.start(generation_id, total: 2)
+
+ Redis::Alfred.with do |conn|
+ expect(conn.hget(described_class.key(generation_id), 'status')).to eq('generating')
+ expect(conn.hget(described_class.key(generation_id), 'total')).to eq('2')
+ expect(conn.hget(described_class.key(generation_id), 'finished')).to eq('0')
+ expect(conn.ttl(described_class.key(generation_id))).to be_positive
+ end
+ end
+ end
+
+ describe '.record_article_finished' do
+ it 'increments finished and keeps completed true past the final count' do
+ described_class.start(generation_id, total: 2)
+
+ expect(described_class.record_article_finished(generation_id)).to eq(finished: 1, completed: false)
+ expect(described_class.current(generation_id)).to include('status' => 'generating')
+
+ expect(described_class.record_article_finished(generation_id)).to eq(finished: 2, completed: true)
+ expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '2')
+
+ expect(described_class.record_article_finished(generation_id)).to eq(finished: 3, completed: true)
+ expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '3')
+ end
+
+ it 'raises Missing when no state exists for the generation' do
+ expect { described_class.record_article_finished(generation_id) }
+ .to raise_error(described_class::Missing)
+ end
+ end
+
+ describe '.skip' do
+ it 'stores status and reason' do
+ described_class.start(generation_id, total: 2)
+ described_class.skip(generation_id, reason: 'no website url')
+
+ expect(described_class.current(generation_id)).to include(
+ 'status' => 'skipped',
+ 'skip_reason' => 'no website url'
+ )
+ end
+ end
+
+ describe '.current' do
+ it 'returns nil when no state exists' do
+ expect(described_class.current(generation_id)).to be_nil
+ end
+ end
+end
diff --git a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
index 53c7974f4..53b8ec52f 100644
--- a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
+++ b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
@@ -32,6 +32,9 @@ describe Whatsapp::IncomingCallService do
describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
+ let!(:agent) { create(:user, account: account) }
+
+ before { create(:inbox_member, inbox: inbox, user: agent) }
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
allow(ActionCable.server).to receive(:broadcast)
@@ -44,10 +47,16 @@ describe Whatsapp::IncomingCallService do
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
provider_call_id: provider_call_id)
expect(call.meta['sdp_offer']).to eq(sdp_offer)
+ # No agent is online, so the call falls back to the inbox's agents (and
+ # account admins) — never the whole-account stream.
expect(ActionCable.server).to have_received(:broadcast).with(
- "account_#{account.id}",
+ agent.pubsub_token,
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
)
+ expect(ActionCable.server).not_to have_received(:broadcast).with(
+ "account_#{account.id}",
+ hash_including(event: 'voice_call.incoming')
+ )
end
end
diff --git a/spec/helpers/instagram/integration_helper_spec.rb b/spec/helpers/instagram/integration_helper_spec.rb
index 7a8bb30a4..25ec46b58 100644
--- a/spec/helpers/instagram/integration_helper_spec.rb
+++ b/spec/helpers/instagram/integration_helper_spec.rb
@@ -82,6 +82,7 @@ RSpec.describe Instagram::IntegrationHelper do
context 'when client secret is not configured' do
let(:client_secret) { nil }
+ let(:valid_token) { 'any-token' }
it 'returns nil' do
expect(verify_instagram_token(valid_token)).to be_nil
diff --git a/spec/helpers/linear/integration_helper_spec.rb b/spec/helpers/linear/integration_helper_spec.rb
index 4f0f65c31..958baa4bf 100644
--- a/spec/helpers/linear/integration_helper_spec.rb
+++ b/spec/helpers/linear/integration_helper_spec.rb
@@ -65,6 +65,7 @@ RSpec.describe Linear::IntegrationHelper do
context 'when client secret is not configured' do
let(:client_secret) { nil }
+ let(:valid_token) { 'any-token' }
it 'returns nil' do
expect(verify_linear_token(valid_token)).to be_nil
diff --git a/spec/helpers/shopify/integration_helper_spec.rb b/spec/helpers/shopify/integration_helper_spec.rb
index 15b7120d4..bfad66d9a 100644
--- a/spec/helpers/shopify/integration_helper_spec.rb
+++ b/spec/helpers/shopify/integration_helper_spec.rb
@@ -65,6 +65,7 @@ RSpec.describe Shopify::IntegrationHelper do
context 'when client secret is not configured' do
let(:client_secret) { nil }
+ let(:valid_token) { 'any-token' }
it 'returns nil' do
expect(verify_shopify_token(valid_token)).to be_nil
diff --git a/spec/jobs/auto_assignment/assignment_job_spec.rb b/spec/jobs/auto_assignment/assignment_job_spec.rb
index d13f9fef8..b6d95789f 100644
--- a/spec/jobs/auto_assignment/assignment_job_spec.rb
+++ b/spec/jobs/auto_assignment/assignment_job_spec.rb
@@ -24,10 +24,11 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(3)
-
- expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
+ allow(Rails.logger).to receive(:info)
described_class.new.perform(inbox_id: inbox.id)
+
+ expect(Rails.logger).to have_received(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
end
it 'uses custom bulk limit from environment' do
@@ -67,16 +68,40 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
-
- expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
+ allow(Rails.logger).to receive(:error)
expect do
described_class.new.perform(inbox_id: inbox.id)
end.to raise_error(StandardError, 'Something went wrong')
+
+ expect(Rails.logger).to have_received(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
end
end
end
+ describe '.enqueue_for_inbox' do
+ after { Redis::Alfred.delete(format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)) }
+
+ it 'enqueues one run per inbox and coalesces concurrent triggers' do
+ allow(described_class).to receive(:perform_later).and_return(true)
+
+ expect(described_class.enqueue_for_inbox(inbox.id)).to be(true)
+ expect(described_class.enqueue_for_inbox(inbox.id)).to be(false)
+ expect(described_class).to have_received(:perform_later).once
+ end
+
+ it 'does not release a newer run marker when its own token is stale' do
+ key = format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)
+ Redis::Alfred.set(key, 'newer-token', ex: 300)
+ allow(AutoAssignment::AssignmentService).to receive(:new)
+ .and_return(instance_double(AutoAssignment::AssignmentService, perform_bulk_assignment: 0))
+
+ described_class.new.perform(inbox_id: inbox.id, token: 'stale-token')
+
+ expect(Redis::Alfred.get(key)).to eq('newer-token')
+ end
+ end
+
describe 'job configuration' do
it 'is queued in the default queue' do
expect(described_class.queue_name).to eq('default')
diff --git a/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb b/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
index e281f79f4..4f0a6f9d8 100644
--- a/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
+++ b/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
it 'queues assignment job for eligible inboxes' do
inbox_assignment_policy # ensure it exists
- expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
+ expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
described_class.new.perform
end
@@ -51,8 +51,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
- expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
- expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
+ expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
+ expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox2.id)
described_class.new.perform
end
@@ -65,7 +65,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end
it 'does not queue assignment job' do
- expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
+ expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
described_class.new.perform
end
@@ -78,7 +78,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end
it 'does not process the account' do
- expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
+ expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
described_class.new.perform
end
diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb
index da4b95b15..04336c619 100644
--- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb
+++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb
@@ -88,7 +88,10 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
end
context 'when the fetch service returns the email objects' do
- let(:inbound_mail) { create_inbound_email_from_fixture('welcome.eml').mail }
+ let(:inbound_mail) { instance_double(Mail::Message, message_id: 'message-id') }
+ let(:failure_cache_key) { "email_failures:#{inbound_mail.message_id}" }
+ let(:second_inbound_mail) { instance_double(Mail::Message, message_id: 'second-message-id') }
+ let(:second_failure_cache_key) { "email_failures:#{second_inbound_mail.message_id}" }
let(:mailbox) { double }
let(:exception_tracker) { double }
let(:fetch_service) { double }
@@ -101,6 +104,11 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
allow(fetch_service).to receive(:perform).and_return([inbound_mail])
end
+ after do
+ Rails.cache.delete(failure_cache_key)
+ Rails.cache.delete(second_failure_cache_key)
+ end
+
it 'calls the mailbox to create emails' do
allow(mailbox).to receive(:process)
@@ -111,6 +119,36 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
described_class.perform_now(imap_email_channel)
end
+ it 'marks the email as failed when processing times out' do
+ allow(Timeout).to receive(:timeout).and_raise(Timeout::Error)
+ allow(Rails.cache).to receive(:read).and_call_original
+ allow(Rails.cache).to receive(:read).with(failure_cache_key).and_return(nil)
+
+ expect(Rails.cache).to receive(:write).with(failure_cache_key, 1, expires_in: 6.hours)
+
+ described_class.perform_now(imap_email_channel)
+ end
+
+ it 'continues processing remaining emails when one email fails' do
+ allow(fetch_service).to receive(:perform).and_return([inbound_mail, second_inbound_mail])
+ allow(mailbox).to receive(:process).with(inbound_mail, imap_email_channel).and_raise(StandardError)
+ allow(mailbox).to receive(:process).with(second_inbound_mail, imap_email_channel)
+ allow(exception_tracker).to receive(:capture_exception)
+
+ described_class.perform_now(imap_email_channel)
+
+ expect(mailbox).to have_received(:process).with(second_inbound_mail, imap_email_channel)
+ end
+
+ it 'skips emails that have failed multiple times recently' do
+ allow(Rails.cache).to receive(:read).and_call_original
+ allow(Rails.cache).to receive(:read).with(failure_cache_key).and_return(3)
+
+ expect(mailbox).not_to receive(:process)
+
+ described_class.perform_now(imap_email_channel)
+ end
+
it 'logs errors if mailbox returns errors' do
allow(mailbox).to receive(:process).and_raise(StandardError)
diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
index ba8a19413..d82658102 100644
--- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb
+++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
@@ -97,6 +97,126 @@ RSpec.describe Webhooks::WhatsappEventsJob do
expect(Rails.logger).to receive(:warn).with("Inactive WhatsApp channel: unknown - #{unknown_phone}")
job.perform_now(phone_number: unknown_phone)
end
+
+ it 'uses from_user_id as the mutex sender for BSUID-only inbound messages' do
+ bsuid = 'IN.2081978709342942'
+ wb_params = params.deep_dup
+ wb_params[:entry].first[:changes].first[:value][:messages] = [
+ { from: '', from_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
+ ]
+ job_instance = described_class.new
+ mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
+
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
+
+ job_instance.perform(wb_params)
+ end
+
+ it 'prefers from_user_id as the mutex sender for mixed phone and BSUID inbound messages' do
+ bsuid = 'IN.2081978709342942'
+ wb_params = params.deep_dup
+ wb_params[:entry].first[:changes].first[:value][:messages] = [
+ { from: '919745786257', from_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
+ ]
+ job_instance = described_class.new
+ mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
+
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
+
+ job_instance.perform(wb_params)
+ end
+
+ it 'uses contact user_id as the mutex sender when message from_user_id is missing' do
+ bsuid = 'IN.2081978709342942'
+ wb_params = params.deep_dup
+ wb_params[:entry].first[:changes].first[:value][:contacts] = [
+ { profile: { name: 'Muhsin' }, wa_id: '919745786257', user_id: bsuid }
+ ]
+ wb_params[:entry].first[:changes].first[:value][:messages] = [
+ { from: '919745786257', id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
+ ]
+ job_instance = described_class.new
+ mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
+
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
+
+ job_instance.perform(wb_params)
+ end
+
+ it 'prefers parent BSUID as the mutex sender for inbound messages with both identifiers' do
+ bsuid = 'IN.2081978709342942'
+ parent_bsuid = 'IN.ENT.9081726354'
+ wb_params = params.deep_dup
+ wb_params[:entry].first[:changes].first[:value][:contacts] = [
+ { profile: { name: 'Muhsin' }, user_id: bsuid, parent_user_id: parent_bsuid }
+ ]
+ wb_params[:entry].first[:changes].first[:value][:messages] = [
+ { from_user_id: bsuid, from_parent_user_id: parent_bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
+ ]
+ job_instance = described_class.new
+ mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: parent_bsuid)
+
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
+
+ job_instance.perform(wb_params)
+ end
+
+ it 'uses to_user_id as the mutex sender for BSUID-only echo messages' do
+ bsuid = 'IN.2081978709342942'
+ wb_params = params.deep_dup
+ wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes'
+ wb_params[:entry].first[:changes].first[:value][:message_echoes] = [
+ { from: channel.phone_number.delete('+'), to: '', to_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
+ ]
+ job_instance = described_class.new
+ mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
+
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
+
+ job_instance.perform(wb_params)
+ end
+
+ it 'prefers parent BSUID as the mutex sender for echo messages with both identifiers' do
+ bsuid = 'IN.2081978709342942'
+ parent_bsuid = 'IN.ENT.9081726354'
+ wb_params = params.deep_dup
+ wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes'
+ wb_params[:entry].first[:changes].first[:value][:message_echoes] = [
+ {
+ from: channel.phone_number.delete('+'), to: '919745786257', to_user_id: bsuid, to_parent_user_id: parent_bsuid,
+ id: 'wamid-test', text: { body: 'Hello' }, type: 'text'
+ }
+ ]
+ job_instance = described_class.new
+ mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: parent_bsuid)
+
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
+
+ job_instance.perform(wb_params)
+ end
+
+ it 'prefers to_user_id as the mutex sender for mixed phone and BSUID echo messages' do
+ bsuid = 'IN.2081978709342942'
+ wb_params = params.deep_dup
+ wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes'
+ wb_params[:entry].first[:changes].first[:value][:message_echoes] = [
+ { from: channel.phone_number.delete('+'), to: '919745786257', to_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' },
+ type: 'text' }
+ ]
+ job_instance = described_class.new
+ mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
+
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
+
+ job_instance.perform(wb_params)
+ end
end
context 'when default provider' do
diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index cb13f6cf9..28c5e069c 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -257,4 +257,18 @@ describe CustomMarkdownRenderer do
end
end
end
+
+ describe '#image' do
+ it 'renders width in px with responsive cap and auto height' do
+ markdown = ''
+ expect(render_markdown(markdown)).to include(
+ 'style="width: 400px; max-width: 100%; height: auto;"'
+ )
+ end
+
+ it 'ignores a non-numeric width' do
+ markdown = ''
+ expect(render_markdown(markdown)).not_to include('style=')
+ end
+ end
end
diff --git a/spec/lib/redis/lock_manager_spec.rb b/spec/lib/redis/lock_manager_spec.rb
index 208594322..8e5feafb0 100644
--- a/spec/lib/redis/lock_manager_spec.rb
+++ b/spec/lib/redis/lock_manager_spec.rb
@@ -35,6 +35,28 @@ RSpec.describe Redis::LockManager do
end
end
+ describe '#with_lock' do
+ it 'yields when the lock is acquired and releases the lock' do
+ yielded = false
+
+ expect(lock_manager.with_lock(lock_key) { yielded = true }).to be true
+ expect(yielded).to be true
+ expect(lock_manager.locked?(lock_key)).to be false
+ end
+
+ it 'returns false without yielding when the lock is already acquired' do
+ lock_manager.lock(lock_key)
+
+ expect { |block| lock_manager.with_lock(lock_key, &block) }.not_to yield_control
+ expect(lock_manager.with_lock(lock_key) { raise 'should not run' }).to be false
+ end
+
+ it 'releases the lock when the block raises' do
+ expect { lock_manager.with_lock(lock_key) { raise 'boom' } }.to raise_error('boom')
+ expect(lock_manager.locked?(lock_key)).to be false
+ end
+ end
+
describe '#locked?' do
it 'returns true if a key is locked' do
lock_manager.lock(lock_key)
diff --git a/spec/lib/safe_fetch_spec.rb b/spec/lib/safe_fetch_spec.rb
index 8c54a092c..a124be774 100644
--- a/spec/lib/safe_fetch_spec.rb
+++ b/spec/lib/safe_fetch_spec.rb
@@ -205,6 +205,50 @@ RSpec.describe SafeFetch do
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
+
+ it 'allows private IP literals when private network access is enabled' do
+ private_url = 'http://192.168.3.21/image.png'
+ allow(Resolv).to receive(:getaddresses).with('192.168.3.21').and_return(['192.168.3.21'])
+ stub_request(:get, private_url).to_return(
+ status: 200,
+ body: File.new(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
+ with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
+ expect { described_class.fetch(private_url) { nil } }.not_to raise_error
+ end
+ end
+
+ it 'allows private hostnames when private network access is enabled' do
+ private_url = 'http://internal-webhook-service/image.png'
+ allow(Resolv).to receive(:getaddresses).with('internal-webhook-service').and_return(['10.0.0.5'])
+ stub_request(:get, private_url).to_return(
+ status: 200,
+ body: File.new(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
+ with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
+ expect { described_class.fetch(private_url) { nil } }.not_to raise_error
+ end
+ end
+
+ it 'allows redirects to private hostnames when private network access is enabled' do
+ redirect_url = 'http://example.com/redirect.png'
+ private_url = 'http://private.example.com/image.png'
+ allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5'])
+ stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url })
+ stub_request(:get, private_url).to_return(
+ status: 200,
+ body: File.new(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
+ with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
+ expect { described_class.fetch(redirect_url) { nil } }.not_to raise_error
+ end
+ end
end
context 'with content-type allowlist' do
diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb
index 8b18f1582..cdb9a93cb 100644
--- a/spec/listeners/action_cable_listener_spec.rb
+++ b/spec/listeners/action_cable_listener_spec.rb
@@ -231,4 +231,68 @@ describe ActionCableListener do
listener.conversation_updated(event)
end
end
+
+ describe '#conversation_unread_count_changed' do
+ let(:event_name) { :'conversation.unread_count_changed' }
+ let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) }
+ let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
+
+ before do
+ account.enable_features!(:conversation_unread_counts)
+ end
+
+ it 'sends a lightweight refresh event to inbox agents and admins' do
+ expect(conversation.inbox.reload.inbox_members.count).to eq(1)
+
+ expect(ActionCableBroadcastJob).to receive(:perform_later).with(
+ a_collection_containing_exactly(agent.pubsub_token, admin.pubsub_token),
+ 'conversation.unread_count_changed',
+ {
+ account_id: account.id
+ }
+ )
+
+ listener.conversation_unread_count_changed(event)
+ end
+
+ it 'does not broadcast unread count refresh to agents outside the inbox' do
+ expect(ActionCableBroadcastJob).not_to receive(:perform_later).with(
+ array_including(agent_without_inbox_access.pubsub_token),
+ anything,
+ anything
+ )
+
+ listener.conversation_unread_count_changed(event)
+ end
+
+ it 'does not broadcast when conversation unread counts feature is disabled' do
+ account.disable_features!(:conversation_unread_counts)
+
+ expect(ActionCableBroadcastJob).not_to receive(:perform_later)
+
+ listener.conversation_unread_count_changed(event)
+ end
+
+ it 'supports deleted conversation data' do
+ event = Events::Base.new(
+ event_name,
+ Time.zone.now,
+ conversation_data: {
+ id: conversation.id,
+ account_id: account.id,
+ inbox_id: conversation.inbox_id
+ }
+ )
+
+ expect(ActionCableBroadcastJob).to receive(:perform_later).with(
+ a_collection_containing_exactly(agent.pubsub_token, admin.pubsub_token),
+ 'conversation.unread_count_changed',
+ {
+ account_id: account.id
+ }
+ )
+
+ listener.conversation_unread_count_changed(event)
+ end
+ end
end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 76dbbcba2..38ca9694a 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -50,6 +50,44 @@ RSpec.describe Account do
end
end
+ describe 'conversation unread counts feature flag' do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:store) { Conversations::UnreadCounts::Store }
+ let(:inbox_key) { store.inbox_key(account.id, inbox.id) }
+
+ after do
+ store.clear_account!(account.id)
+ end
+
+ it 'clears unread count cache when the feature is enabled' do
+ build_unread_count_cache
+
+ account.enable_features!(:conversation_unread_counts)
+
+ expect(store.base_ready?(account.id)).to be(false)
+ expect(store.assignment_ready?(account.id)).to be(false)
+ expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
+ end
+
+ it 'clears unread count cache when the feature is disabled' do
+ account.enable_features!(:conversation_unread_counts)
+ build_unread_count_cache
+
+ account.disable_features!(:conversation_unread_counts)
+
+ expect(store.base_ready?(account.id)).to be(false)
+ expect(store.assignment_ready?(account.id)).to be(false)
+ expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
+ end
+
+ def build_unread_count_cache
+ store.mark_base_ready!(account.id)
+ store.mark_assignment_ready!(account.id)
+ store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
+ end
+ end
+
describe 'inbound_email_domain' do
let(:account) { create(:account) }
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index 9afc21f7c..95bc83932 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -223,12 +223,12 @@ RSpec.describe Channel::Whatsapp do
expect(channel.voice_enabled?).to be true
end
- it 'returns false for whatsapp_cloud channels without embedded_signup source' do
+ it 'returns true for manual whatsapp_cloud channels with calling_enabled' do
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true))
- expect(channel.voice_enabled?).to be false
+ expect(channel.voice_enabled?).to be true
end
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
diff --git a/spec/models/contact_inbox_spec.rb b/spec/models/contact_inbox_spec.rb
index c0a2faf9e..c58615e9e 100644
--- a/spec/models/contact_inbox_spec.rb
+++ b/spec/models/contact_inbox_spec.rb
@@ -56,16 +56,20 @@ RSpec.describe ContactInbox do
whatsapp_inbox = create(:channel_whatsapp, sync_templates: false, validate_provider_config: false).inbox
contact = create(:contact)
valid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '1234567890')
+ valid_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: 'IN.2081978709342942')
+ valid_parent_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: 'IN.ENT.9081726354')
ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '1234567890aaa')
ci_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '+1234567890')
expect(valid_source_id.valid?).to be(true)
+ expect(valid_bsuid_source_id.valid?).to be(true)
+ expect(valid_parent_bsuid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
- ['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)']
+ ["Source invalid source id for whatsapp inbox. valid Regex #{RegexHelper::WHATSAPP_CHANNEL_REGEX}"]
)
expect(ci_plus_in_source_id.valid?).to be(false)
expect(ci_plus_in_source_id.errors.full_messages).to eq(
- ['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)']
+ ["Source invalid source id for whatsapp inbox. valid Regex #{RegexHelper::WHATSAPP_CHANNEL_REGEX}"]
)
end
@@ -78,11 +82,11 @@ RSpec.describe ContactInbox do
expect(valid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
- ['Source invalid source id for twilio sms inbox. valid Regex (?-mix:^\\+\\d{1,15}\\z)']
+ ["Source invalid source id for twilio sms inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_SMS_REGEX}"]
)
expect(ci_without_plus_in_source_id.valid?).to be(false)
expect(ci_without_plus_in_source_id.errors.full_messages).to eq(
- ['Source invalid source id for twilio sms inbox. valid Regex (?-mix:^\\+\\d{1,15}\\z)']
+ ["Source invalid source id for twilio sms inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_SMS_REGEX}"]
)
end
@@ -90,18 +94,39 @@ RSpec.describe ContactInbox do
twilio_whatsapp_inbox = create(:channel_twilio_sms, medium: :whatsapp).inbox
contact = create(:contact)
valid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+1234567890')
+ valid_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:IN.2081978709342942')
+ valid_parent_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:IN.ENT.9081726354')
ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+1234567890aaa')
ci_without_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:1234567890')
expect(valid_source_id.valid?).to be(true)
+ expect(valid_bsuid_source_id.valid?).to be(true)
+ expect(valid_parent_bsuid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
- ['Source invalid source id for twilio whatsapp inbox. valid Regex (?-mix:^whatsapp:\\+\\d{1,15}\\z)']
+ ["Source invalid source id for twilio whatsapp inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_WHATSAPP_REGEX}"]
)
expect(ci_without_plus_in_source_id.valid?).to be(false)
expect(ci_without_plus_in_source_id.errors.full_messages).to eq(
- ['Source invalid source id for twilio whatsapp inbox. valid Regex (?-mix:^whatsapp:\\+\\d{1,15}\\z)']
+ ["Source invalid source id for twilio whatsapp inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_WHATSAPP_REGEX}"]
)
end
+
+ it 'rejects whatsapp BSUID source_id values longer than 128 alphanumeric characters' do
+ whatsapp_inbox = create(:channel_whatsapp, sync_templates: false, validate_provider_config: false).inbox
+ contact = create(:contact)
+ contact_inbox = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: "IN.#{'1' * 129}")
+
+ expect(contact_inbox.valid?).to be(false)
+ end
+
+ it 'rejects twilio whatsapp parent BSUID source_id values longer than 128 alphanumeric characters' do
+ twilio_whatsapp_inbox = create(:channel_twilio_sms, medium: :whatsapp).inbox
+ contact = create(:contact)
+ contact_inbox = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox,
+ source_id: "whatsapp:IN.ENT.#{'1' * 129}")
+
+ expect(contact_inbox.valid?).to be(false)
+ end
end
end
end
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 0bf90859e..58d64ea94 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -717,6 +717,25 @@ RSpec.describe Conversation do
expect { notification.reload }.to raise_error ActiveRecord::RecordNotFound
end
+
+ it 'dispatches conversation deleted event with unread count cache data' do
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+
+ conversation.destroy!
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.deleted',
+ kind_of(Time),
+ conversation_data: {
+ id: conversation.id,
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ assignee_id: conversation.assignee_id,
+ team_id: conversation.team_id,
+ cached_label_list: conversation.cached_label_list
+ }
+ )
+ end
end
describe 'validate invalid referer url' do
diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb
index f171009e8..38d9a7da6 100644
--- a/spec/models/portal_spec.rb
+++ b/spec/models/portal_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe Portal do
it 'Does not allow any other config than allowed_locales' do
portal.update(config: { 'some_other_key': 'test_value' })
expect(portal).not_to be_valid
- expect(portal.errors.full_messages[0]).to eq('Cofig in portal on some_other_key is not supported.')
+ expect(portal.errors.full_messages[0]).to eq('Config in portal on some_other_key is not supported.')
end
it 'falls back to no drafted locales for existing portals' do
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index d863dd58c..e83db4046 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -71,6 +71,7 @@ RSpec.configure do |config|
config.include FileUploadHelpers
config.include CsvSpecHelpers
config.include InstagramSpecHelpers
+ config.include ConversationsUnreadCountsHelpers
config.include Devise::Test::IntegrationHelpers, type: :request
config.include ActiveSupport::Testing::TimeHelpers
config.include ActionCable::TestHelper
diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb
index 36a8c7816..eb6ebf060 100644
--- a/spec/services/auto_assignment/assignment_service_spec.rb
+++ b/spec/services/auto_assignment/assignment_service_spec.rb
@@ -56,6 +56,19 @@ RSpec.describe AutoAssignment::AssignmentService do
expect(conversation.reload.assignee).to be_nil
end
+ it 'short-circuits without iterating conversations when no agents are online' do
+ 3.times do
+ conv = create(:conversation, inbox: inbox, status: 'open')
+ conv.update!(assignee_id: nil)
+ end
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
+
+ expect(service).not_to receive(:perform_for_conversation)
+
+ assigned_count = service.perform_bulk_assignment(limit: 10)
+ expect(assigned_count).to eq(0)
+ end
+
it 'respects the limit parameter' do
3.times do
conv = create(:conversation, inbox: inbox, status: 'open')
diff --git a/spec/services/contacts/bulk_action_service_spec.rb b/spec/services/contacts/bulk_action_service_spec.rb
index 0411c8455..77eeacff1 100644
--- a/spec/services/contacts/bulk_action_service_spec.rb
+++ b/spec/services/contacts/bulk_action_service_spec.rb
@@ -34,5 +34,19 @@ RSpec.describe Contacts::BulkActionService do
service.perform
end
end
+
+ context 'when labels are removed' do
+ let(:params) { { ids: [10, 20], labels: { remove: %w[vip] }, extra: 'ignored' } }
+
+ it 'delegates to the bulk remove labels service with permitted params' do
+ bulk_remove_service = instance_double(Contacts::BulkRemoveLabelsService, perform: true)
+
+ expect(Contacts::BulkRemoveLabelsService).to receive(:new)
+ .with(account: account, contact_ids: [10, 20], labels: %w[vip])
+ .and_return(bulk_remove_service)
+
+ service.perform
+ end
+ end
end
end
diff --git a/spec/services/contacts/bulk_remove_labels_service_spec.rb b/spec/services/contacts/bulk_remove_labels_service_spec.rb
new file mode 100644
index 000000000..db5a7c530
--- /dev/null
+++ b/spec/services/contacts/bulk_remove_labels_service_spec.rb
@@ -0,0 +1,54 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::BulkRemoveLabelsService do
+ subject(:service) do
+ described_class.new(
+ account: account,
+ contact_ids: [contact_one.id, contact_two.id, other_contact.id],
+ labels: labels
+ )
+ end
+
+ let(:account) { create(:account) }
+ let!(:contact_one) { create(:contact, account: account) }
+ let!(:contact_two) { create(:contact, account: account) }
+ let!(:other_contact) { create(:contact) }
+ let(:labels) { %w[vip] }
+
+ before do
+ contact_one.add_labels(%w[vip support])
+ contact_two.add_labels(%w[vip priority])
+ other_contact.add_labels(%w[vip support])
+ end
+
+ it 'removes labels from contacts that belong to the account' do
+ service.perform
+
+ expect(contact_one.reload.label_list).to contain_exactly('support')
+ expect(contact_two.reload.label_list).to contain_exactly('priority')
+ end
+
+ it 'does not remove labels from contacts outside the account' do
+ service.perform
+
+ expect(other_contact.reload.label_list).to contain_exactly('vip', 'support')
+ end
+
+ it 'returns ids of contacts that were updated' do
+ result = service.perform
+
+ expect(result[:success]).to be(true)
+ expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
+ end
+
+ it 'returns success with no updates when labels are blank' do
+ result = described_class.new(
+ account: account,
+ contact_ids: [contact_one.id],
+ labels: []
+ ).perform
+
+ expect(result).to eq(success: true, updated_contact_ids: [])
+ expect(contact_one.reload.label_list).to contain_exactly('vip', 'support')
+ end
+end
diff --git a/spec/services/conversations/unread_counts/builder_spec.rb b/spec/services/conversations/unread_counts/builder_spec.rb
new file mode 100644
index 000000000..4b3aec230
--- /dev/null
+++ b/spec/services/conversations/unread_counts/builder_spec.rb
@@ -0,0 +1,93 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::Builder do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:label) { create(:label, account: account, title: 'urgent', show_on_sidebar: true) }
+ let(:assignee) { create(:user, account: account, role: :agent) }
+ let(:team) { create(:team, account: account, allow_auto_assign: false) }
+ let(:store) { Conversations::UnreadCounts::Store }
+
+ after do
+ store.clear_account!(account.id)
+ end
+
+ describe '#build_base!' do
+ it 'stores unread open conversations by inbox and label inbox' do
+ unread_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
+ create_read_conversation
+ create_resolved_unread_conversation
+
+ described_class.new(account).build_base!
+
+ expect(store.base_ready?(account.id)).to be(true)
+ expect(redis_set_members(store.inbox_key(account.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
+ expect(redis_set_members(store.label_inbox_key(account.id, label.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
+ expect(redis_set_members(store.team_inbox_key(account.id, team.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
+ end
+
+ it 'clears assignment-aware cache data before rebuilding base data' do
+ assigned_conversation = create_unread_conversation(
+ account: account,
+ inbox: inbox,
+ labels: [label.title],
+ assignee: assignee,
+ team: team
+ )
+
+ described_class.new(account).build_assignment!
+ described_class.new(account).build_base!
+
+ expect(store.assignment_ready?(account.id)).to be(false)
+ expect(redis_set_members(store.inbox_assignee_key(account.id, inbox.id, assignee.id))).to be_empty
+ expect(redis_set_members(store.inbox_key(account.id, inbox.id))).to contain_exactly(assigned_conversation.id.to_s)
+ end
+ end
+
+ describe '#build_assignment!' do
+ it 'stores unread open conversations by unassigned and assignee dimensions' do
+ assigned_conversation = create_unread_conversation(
+ account: account,
+ inbox: inbox,
+ labels: [label.title],
+ assignee: assignee,
+ team: team
+ )
+ unassigned_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
+
+ described_class.new(account).build_assignment!
+
+ expect(store.assignment_ready?(account.id)).to be(true)
+ expect(redis_set_members(store.inbox_assignee_key(account.id, inbox.id, assignee.id))).to contain_exactly(assigned_conversation.id.to_s)
+ expect(redis_set_members(store.label_inbox_assignee_key(account.id, label.id, inbox.id, assignee.id))).to contain_exactly(
+ assigned_conversation.id.to_s
+ )
+ expect(redis_set_members(store.team_inbox_assignee_key(account.id, team.id, inbox.id, assignee.id))).to contain_exactly(
+ assigned_conversation.id.to_s
+ )
+ expect(redis_set_members(store.inbox_unassigned_key(account.id, inbox.id))).to contain_exactly(unassigned_conversation.id.to_s)
+ expect(redis_set_members(store.label_inbox_unassigned_key(account.id, label.id, inbox.id))).to contain_exactly(
+ unassigned_conversation.id.to_s
+ )
+ expect(redis_set_members(store.team_inbox_unassigned_key(account.id, team.id, inbox.id))).to contain_exactly(
+ unassigned_conversation.id.to_s
+ )
+ end
+ end
+
+ def create_read_conversation
+ conversation = create(:conversation, account: account, inbox: inbox, agent_last_seen_at: 1.minute.from_now)
+ create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming)
+ conversation
+ end
+
+ def create_resolved_unread_conversation
+ conversation = create_unread_conversation(account: account, inbox: inbox)
+ conversation.update!(status: :resolved)
+ conversation
+ end
+
+ def redis_set_members(key)
+ Redis::Alfred.pipelined { |pipeline| pipeline.smembers(key) }.first
+ end
+end
diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb
new file mode 100644
index 000000000..bfd10436e
--- /dev/null
+++ b/spec/services/conversations/unread_counts/counter_spec.rb
@@ -0,0 +1,95 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::Counter do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:visible_inbox) { create(:inbox, account: account) }
+ let(:hidden_inbox) { create(:inbox, account: account) }
+ let(:label) { create(:label, account: account, title: 'billing', show_on_sidebar: true) }
+ let(:hidden_label) { create(:label, account: account, title: 'internal', show_on_sidebar: false) }
+ let(:visible_team) { create(:team, account: account, allow_auto_assign: false) }
+ let(:store) { Conversations::UnreadCounts::Store }
+
+ before do
+ create(:inbox_member, user: agent, inbox: visible_inbox)
+ create(:team_member, user: agent, team: visible_team)
+ end
+
+ after do
+ store.clear_account!(account.id)
+ end
+
+ it 'builds the base cache on demand' do
+ create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
+
+ described_class.new(account: account, user: agent).perform
+
+ expect(store.base_ready?(account.id)).to be(true)
+ end
+
+ it 'uses a Redis lock while building the base cache on demand' do
+ lock_key = "UNREAD_CONVERSATIONS::V1::ACCOUNT::#{account.id}::BUILD_LOCK::BASE"
+ lock_manager = instance_double(Redis::LockManager)
+ allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
+ allow(lock_manager).to receive(:with_lock).with(lock_key, described_class::BUILD_LOCK_TTL).and_yield.and_return(true)
+
+ create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
+
+ described_class.new(account: account, user: agent).perform
+
+ expect(lock_manager).to have_received(:with_lock).with(lock_key, described_class::BUILD_LOCK_TTL)
+ end
+
+ it 'waits instead of rebuilding when another process owns the base build lock' do
+ lock_manager = instance_double(Redis::LockManager, with_lock: false)
+ counter = described_class.new(account: account, user: agent)
+
+ allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
+ allow(counter).to receive(:wait_for_cache_ready) { store.mark_base_ready!(account.id) }
+ expect(Conversations::UnreadCounts::Builder).not_to receive(:new)
+
+ counter.perform
+
+ expect(counter).to have_received(:wait_for_cache_ready)
+ expect(store.base_ready?(account.id)).to be(true)
+ end
+
+ it 'counts unread conversations only across inboxes visible to a normal agent' do
+ create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
+ create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title], team: visible_team)
+
+ result = described_class.new(account: account, user: agent).perform
+
+ expect(result).to eq(
+ inboxes: { visible_inbox.id.to_s => 1 },
+ labels: { label.id.to_s => 1 },
+ teams: { visible_team.id.to_s => 1 }
+ )
+ end
+
+ it 'counts unread conversations across all account inboxes for admins' do
+ create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
+ create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title], team: visible_team)
+
+ result = described_class.new(account: account, user: admin).perform
+
+ expect(result).to eq(
+ inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 },
+ labels: { label.id.to_s => 2 },
+ teams: { visible_team.id.to_s => 2 }
+ )
+ end
+
+ it 'does not return zero counts or labels hidden from the sidebar' do
+ create_unread_conversation(account: account, inbox: visible_inbox, labels: [hidden_label.title], team: visible_team)
+
+ result = described_class.new(account: account, user: agent).perform
+
+ expect(result).to eq(
+ inboxes: { visible_inbox.id.to_s => 1 },
+ labels: {},
+ teams: { visible_team.id.to_s => 1 }
+ )
+ end
+end
diff --git a/spec/services/conversations/unread_counts/listener_spec.rb b/spec/services/conversations/unread_counts/listener_spec.rb
new file mode 100644
index 000000000..fbb0a0835
--- /dev/null
+++ b/spec/services/conversations/unread_counts/listener_spec.rb
@@ -0,0 +1,164 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::Listener do
+ let(:listener) { described_class.instance }
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:notifier) { instance_double(Conversations::UnreadCounts::Notifier, perform: true) }
+
+ before do
+ allow(Conversations::UnreadCounts::Notifier).to receive(:new).and_return(notifier)
+ end
+
+ it 'refreshes unread counts when an incoming message is created' do
+ account.enable_features!(:conversation_unread_counts)
+ message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
+ event = Events::Base.new('message.created', Time.zone.now, message: message)
+
+ listener.message_created(event)
+
+ expect(Conversations::UnreadCounts::Notifier).to have_received(:new).with(conversation, changed_attributes: nil)
+ expect(notifier).to have_received(:perform)
+ end
+
+ it 'ignores outgoing message creation' do
+ message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
+ event = Events::Base.new('message.created', Time.zone.now, message: message)
+
+ listener.message_created(event)
+
+ expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
+ end
+
+ it 'ignores incoming message creation when conversation unread counts are disabled' do
+ message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
+ event = Events::Base.new('message.created', Time.zone.now, message: message)
+
+ expect(message).not_to receive(:conversation)
+
+ listener.message_created(event)
+
+ expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
+ end
+
+ it 'refreshes unread counts when conversation status changes' do
+ changed_attributes = { 'status' => %w[open resolved] }
+ event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+
+ listener.conversation_status_changed(event)
+
+ expect(Conversations::UnreadCounts::Notifier).to have_received(:new).with(conversation, changed_attributes: changed_attributes)
+ expect(notifier).to have_received(:perform)
+ end
+
+ it 'refreshes unread counts when labels change' do
+ changed_attributes = { label_list: [%w[old], %w[new]] }
+ event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+
+ listener.conversation_updated(event)
+
+ expect(Conversations::UnreadCounts::Notifier).to have_received(:new).with(conversation, changed_attributes: changed_attributes)
+ expect(notifier).to have_received(:perform)
+ end
+
+ it 'ignores conversation updates unrelated to unread count dimensions' do
+ event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] })
+
+ listener.conversation_updated(event)
+
+ expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
+ end
+
+ it 'refreshes unread counts when assignee changes' do
+ changed_attributes = { assignee_id: [nil, 1] }
+ event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+
+ listener.assignee_changed(event)
+
+ expect(Conversations::UnreadCounts::Notifier).to have_received(:new).with(conversation, changed_attributes: changed_attributes)
+ expect(notifier).to have_received(:perform)
+ end
+
+ it 'refreshes unread counts when team changes' do
+ changed_attributes = { team_id: [nil, 1] }
+ event = Events::Base.new('team.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+
+ listener.team_changed(event)
+
+ expect(Conversations::UnreadCounts::Notifier).to have_received(:new).with(conversation, changed_attributes: changed_attributes)
+ expect(notifier).to have_received(:perform)
+ end
+
+ it 'removes unread count memberships when a conversation is deleted' do
+ account.enable_features!(:conversation_unread_counts)
+ label = create(:label, account: account)
+ team = create(:team, account: account)
+ assignee = create(:user, account: account)
+ create(:team_member, team: team, user: assignee)
+ conversation.update!(assignee_id: assignee.id, team: team)
+ conversation.update_labels([label.title])
+ conversation.reload
+ conversation_data = deleted_conversation_data(conversation)
+ store.mark_base_ready!(account.id)
+ store.mark_assignment_ready!(account.id)
+ store.add_base_membership(
+ account_id: account.id,
+ inbox_id: conversation.inbox_id,
+ label_ids: [label.id],
+ team_id: team.id,
+ conversation_id: conversation.id
+ )
+ store.add_assignment_membership(
+ account_id: account.id,
+ inbox_id: conversation.inbox_id,
+ label_ids: [label.id],
+ assignee_id: assignee.id,
+ team_id: team.id,
+ conversation_id: conversation.id
+ )
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+
+ listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data))
+
+ expect(store.counts_for_keys(deleted_base_keys(conversation, label, team)).values).to all(eq(0))
+ expect(store.counts_for_keys(deleted_assignment_keys(conversation, label, team, assignee)).values).to all(eq(0))
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation_data: conversation_data.stringify_keys
+ )
+ ensure
+ store.clear_account!(account.id)
+ end
+
+ def deleted_conversation_data(conversation)
+ {
+ id: conversation.id,
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ assignee_id: conversation.assignee_id,
+ team_id: conversation.team_id,
+ cached_label_list: conversation.cached_label_list
+ }
+ end
+
+ def deleted_base_keys(conversation, label, team)
+ [
+ store.inbox_key(account.id, conversation.inbox_id),
+ store.label_inbox_key(account.id, label.id, conversation.inbox_id),
+ store.team_inbox_key(account.id, team.id, conversation.inbox_id)
+ ]
+ end
+
+ def deleted_assignment_keys(conversation, label, team, assignee)
+ [
+ store.inbox_assignee_key(account.id, conversation.inbox_id, assignee.id),
+ store.label_inbox_assignee_key(account.id, label.id, conversation.inbox_id, assignee.id),
+ store.team_inbox_assignee_key(account.id, team.id, conversation.inbox_id, assignee.id)
+ ]
+ end
+
+ def store
+ Conversations::UnreadCounts::Store
+ end
+end
diff --git a/spec/services/conversations/unread_counts/notifier_spec.rb b/spec/services/conversations/unread_counts/notifier_spec.rb
new file mode 100644
index 000000000..1b35d37f6
--- /dev/null
+++ b/spec/services/conversations/unread_counts/notifier_spec.rb
@@ -0,0 +1,48 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::Notifier do
+ let!(:conversation) { create(:conversation) }
+ let(:refresher) { instance_double(Conversations::UnreadCounts::Refresher, perform: refresh_result) }
+ let(:refresh_result) { true }
+
+ before do
+ conversation.account.enable_features!(:conversation_unread_counts)
+ allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(refresher)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ end
+
+ it 'dispatches unread count changed event after a successful refresh' do
+ described_class.new(conversation).perform
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
+
+ context 'when refresh does not change unread count memberships' do
+ let(:refresh_result) { false }
+
+ it 'does not dispatch unread count changed event' do
+ described_class.new(conversation).perform
+
+ expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
+ end
+ end
+
+ context 'when conversation unread counts feature is disabled' do
+ before do
+ conversation.account.disable_features!(:conversation_unread_counts)
+ allow(Conversations::UnreadCounts::Store).to receive(:clear_account!)
+ end
+
+ it 'does not refresh, clear cache, or dispatch unread count changed event' do
+ described_class.new(conversation).perform
+
+ expect(Conversations::UnreadCounts::Refresher).not_to have_received(:new)
+ expect(Conversations::UnreadCounts::Store).not_to have_received(:clear_account!)
+ expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
+ end
+ end
+end
diff --git a/spec/services/conversations/unread_counts/refresher_spec.rb b/spec/services/conversations/unread_counts/refresher_spec.rb
new file mode 100644
index 000000000..5f361e7aa
--- /dev/null
+++ b/spec/services/conversations/unread_counts/refresher_spec.rb
@@ -0,0 +1,182 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::Refresher do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:label) { create(:label, account: account, title: 'urgent', show_on_sidebar: true) }
+ let(:new_label) { create(:label, account: account, title: 'billing', show_on_sidebar: true) }
+ let(:team) { create(:team, account: account, allow_auto_assign: false) }
+ let(:new_team) { create(:team, account: account, allow_auto_assign: false) }
+ let(:assignee) { create(:user, account: account, role: :agent) }
+ let(:other_assignee) { create(:user, account: account, role: :agent) }
+ let(:store) { Conversations::UnreadCounts::Store }
+
+ after do
+ store.clear_account!(account.id)
+ end
+
+ it 'does not update redis when unread caches are not ready' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+
+ expect(described_class.new(conversation).perform).to be(false)
+ expect(store.counts_for_keys([store.inbox_key(account.id, inbox.id)])).to eq(store.inbox_key(account.id, inbox.id) => 0)
+ end
+
+ it 'adds an unread conversation to base cache' do
+ conversation = create(:conversation, account: account, inbox: inbox, agent_last_seen_at: 1.hour.ago)
+ conversation.update_labels([label.title])
+ store.mark_base_ready!(account.id)
+
+ create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
+
+ expect(described_class.new(conversation.reload).perform).to be(true)
+
+ expect(store.counts_for_keys(base_keys)).to eq(
+ store.inbox_key(account.id, inbox.id) => 1,
+ store.label_inbox_key(account.id, label.id, inbox.id) => 1
+ )
+ end
+
+ it 'returns false when refresh does not change unread counts' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_base!
+
+ create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming)
+
+ expect(described_class.new(conversation.reload).perform).to be(false)
+ expect(store.counts_for_keys(base_keys)).to eq(
+ store.inbox_key(account.id, inbox.id) => 1,
+ store.label_inbox_key(account.id, label.id, inbox.id) => 1
+ )
+ end
+
+ it 'removes a conversation from base cache when it becomes read' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_base!
+
+ conversation.update!(agent_last_seen_at: 1.minute.from_now)
+ expect(described_class.new(conversation.reload).perform).to be(true)
+
+ expect(store.counts_for_keys(base_keys).values).to all(eq(0))
+ end
+
+ it 'moves base label membership when labels change' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_base!
+
+ conversation.update_labels([new_label.title])
+ expect(described_class.new(conversation.reload, changed_attributes: { label_list: [[label.title], [new_label.title]] }).perform).to be(true)
+
+ expect(store.counts_for_keys([
+ store.label_inbox_key(account.id, label.id, inbox.id),
+ store.label_inbox_key(account.id, new_label.id, inbox.id)
+ ])).to eq(
+ store.label_inbox_key(account.id, label.id, inbox.id) => 0,
+ store.label_inbox_key(account.id, new_label.id, inbox.id) => 1
+ )
+ end
+
+ it 'moves base team membership when team changes' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, team: team)
+ Conversations::UnreadCounts::Builder.new(account).build_base!
+
+ conversation.update!(team: new_team)
+ expect(described_class.new(conversation.reload, changed_attributes: { team_id: [team.id, new_team.id] }).perform).to be(true)
+
+ expect(store.counts_for_keys([
+ store.team_inbox_key(account.id, team.id, inbox.id),
+ store.team_inbox_key(account.id, new_team.id, inbox.id)
+ ])).to eq(
+ store.team_inbox_key(account.id, team.id, inbox.id) => 0,
+ store.team_inbox_key(account.id, new_team.id, inbox.id) => 1
+ )
+ end
+
+ it 'moves assignment-aware membership when assignee changes' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: assignee)
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+
+ conversation.update!(assignee: other_assignee)
+ described_class.new(conversation.reload, changed_attributes: { assignee_id: [assignee.id, other_assignee.id] }).perform
+
+ expect(store.counts_for_keys([
+ store.inbox_assignee_key(account.id, inbox.id, assignee.id),
+ store.inbox_assignee_key(account.id, inbox.id, other_assignee.id)
+ ])).to eq(
+ store.inbox_assignee_key(account.id, inbox.id, assignee.id) => 0,
+ store.inbox_assignee_key(account.id, inbox.id, other_assignee.id) => 1
+ )
+ end
+
+ it 'does not remove unassigned membership when assignee did not change' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: assignee)
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+ allow(store).to receive(:remove_assignment_membership).and_call_original
+
+ conversation.update_labels([new_label.title])
+ described_class.new(conversation.reload, changed_attributes: { label_list: [[label.title], [new_label.title]] }).perform
+
+ expect(store).to have_received(:remove_assignment_membership).with(hash_including(assignee_ids: [assignee.id]))
+ end
+
+ it 'moves assignment-aware unassigned label membership when labels change' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+
+ conversation.update_labels([new_label.title])
+ result = described_class.new(
+ conversation.reload,
+ changed_attributes: { label_list: [[label.title], [new_label.title]] }
+ ).perform
+
+ expect(result).to be(true)
+ expect(store.counts_for_keys([
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id),
+ store.label_inbox_unassigned_key(account.id, new_label.id, inbox.id)
+ ])).to eq(
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id) => 0,
+ store.label_inbox_unassigned_key(account.id, new_label.id, inbox.id) => 1
+ )
+ end
+
+ it 'removes assignment-aware unassigned membership when conversation is resolved' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+
+ conversation.update!(status: :resolved)
+ result = described_class.new(conversation.reload, changed_attributes: { status: %w[open resolved] }).perform
+
+ expect(result).to be(true)
+ expect(store.counts_for_keys([
+ store.inbox_unassigned_key(account.id, inbox.id),
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id)
+ ])).to eq(
+ store.inbox_unassigned_key(account.id, inbox.id) => 0,
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id) => 0
+ )
+ end
+
+ it 'moves assignment-aware team membership when team changes' do
+ create(:team_member, user: assignee, team: new_team)
+ conversation = create_unread_conversation(account: account, inbox: inbox, assignee: assignee, team: team)
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+
+ conversation.update!(team: new_team)
+ described_class.new(conversation.reload, changed_attributes: { team_id: [team.id, new_team.id] }).perform
+
+ expect(store.counts_for_keys([
+ store.team_inbox_assignee_key(account.id, team.id, inbox.id, assignee.id),
+ store.team_inbox_assignee_key(account.id, new_team.id, inbox.id, assignee.id)
+ ])).to eq(
+ store.team_inbox_assignee_key(account.id, team.id, inbox.id, assignee.id) => 0,
+ store.team_inbox_assignee_key(account.id, new_team.id, inbox.id, assignee.id) => 1
+ )
+ end
+
+ def base_keys
+ [
+ store.inbox_key(account.id, inbox.id),
+ store.label_inbox_key(account.id, label.id, inbox.id)
+ ]
+ end
+end
diff --git a/spec/services/conversations/unread_counts/store_spec.rb b/spec/services/conversations/unread_counts/store_spec.rb
new file mode 100644
index 000000000..fcf21c985
--- /dev/null
+++ b/spec/services/conversations/unread_counts/store_spec.rb
@@ -0,0 +1,200 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::Store do
+ let(:account_id) { 1 }
+ let(:inbox_id) { 2 }
+ let(:label_id) { 3 }
+ let(:user_id) { 4 }
+ let(:conversation_id) { 5 }
+ let(:team_id) { 6 }
+
+ after do
+ described_class.clear_account!(account_id)
+ end
+
+ describe 'key builders' do
+ it 'builds base keys using the Redis key naming convention' do
+ expect(described_class.inbox_key(account_id, inbox_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::INBOX::2'
+ )
+ expect(described_class.label_inbox_key(account_id, label_id, inbox_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::LABEL::3::INBOX::2'
+ )
+ expect(described_class.team_inbox_key(account_id, team_id, inbox_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::TEAM::6::INBOX::2'
+ )
+ end
+
+ it 'builds assignment-aware keys using the Redis key naming convention' do
+ expect(described_class.inbox_unassigned_key(account_id, inbox_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::INBOX::2::UNASSIGNED'
+ )
+ expect(described_class.inbox_assignee_key(account_id, inbox_id, user_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::INBOX::2::ASSIGNEE::4'
+ )
+ expect(described_class.label_inbox_unassigned_key(account_id, label_id, inbox_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::LABEL::3::INBOX::2::UNASSIGNED'
+ )
+ expect(described_class.label_inbox_assignee_key(account_id, label_id, inbox_id, user_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::LABEL::3::INBOX::2::ASSIGNEE::4'
+ )
+ expect(described_class.team_inbox_unassigned_key(account_id, team_id, inbox_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::TEAM::6::INBOX::2::UNASSIGNED'
+ )
+ expect(described_class.team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::TEAM::6::INBOX::2::ASSIGNEE::4'
+ )
+ end
+ end
+
+ describe 'ready markers' do
+ it 'tracks base and assignment readiness independently' do
+ expect(described_class.base_ready?(account_id)).to be(false)
+ expect(described_class.assignment_ready?(account_id)).to be(false)
+
+ described_class.mark_base_ready!(account_id)
+ described_class.mark_assignment_ready!(account_id)
+
+ expect(described_class.base_ready?(account_id)).to be(true)
+ expect(described_class.assignment_ready?(account_id)).to be(true)
+ expect(ttl_for('UNREAD_CONVERSATIONS::V1::ACCOUNT::1::READY::BASE')).to be_within(5).of(Conversations::UnreadCounts::READY_TTL)
+ expect(ttl_for('UNREAD_CONVERSATIONS::V1::ACCOUNT::1::READY::ASSIGNMENT')).to be_within(5).of(Conversations::UnreadCounts::READY_TTL)
+ end
+ end
+
+ describe 'set operations' do
+ it 'adds, counts, and removes base memberships' do
+ described_class.add_base_membership(
+ account_id: account_id,
+ inbox_id: inbox_id,
+ label_ids: [label_id],
+ team_id: team_id,
+ conversation_id: conversation_id
+ )
+
+ expect(described_class.counts_for_keys(base_keys)).to eq(
+ described_class.inbox_key(account_id, inbox_id) => 1,
+ described_class.label_inbox_key(account_id, label_id, inbox_id) => 1,
+ described_class.team_inbox_key(account_id, team_id, inbox_id) => 1
+ )
+ expect(base_keys.map { |key| ttl_for(key) }).to all(be_within(5).of(Conversations::UnreadCounts::SET_TTL))
+
+ described_class.remove_base_membership(
+ account_id: account_id,
+ inbox_ids: [inbox_id],
+ label_ids: [label_id],
+ team_ids: [team_id],
+ conversation_id: conversation_id
+ )
+
+ expect(described_class.counts_for_keys(base_keys).values).to all(eq(0))
+ end
+
+ it 'checks memberships for a conversation across keys' do
+ described_class.add_base_membership(
+ account_id: account_id,
+ inbox_id: inbox_id,
+ label_ids: [label_id],
+ team_id: team_id,
+ conversation_id: conversation_id
+ )
+
+ expect(described_class.memberships_for_keys(base_keys, conversation_id)).to eq(
+ described_class.inbox_key(account_id, inbox_id) => true,
+ described_class.label_inbox_key(account_id, label_id, inbox_id) => true,
+ described_class.team_inbox_key(account_id, team_id, inbox_id) => true
+ )
+ expect(described_class.memberships_for_keys(base_keys, 999).values).to all(be(false))
+ end
+
+ it 'adds, counts, and removes assignment-aware memberships' do
+ described_class.add_assignment_membership(
+ account_id: account_id,
+ inbox_id: inbox_id,
+ label_ids: [label_id],
+ assignee_id: user_id,
+ team_id: team_id,
+ conversation_id: conversation_id
+ )
+
+ expect(described_class.counts_for_keys(assignment_keys)).to eq(
+ described_class.inbox_assignee_key(account_id, inbox_id, user_id) => 1,
+ described_class.label_inbox_assignee_key(account_id, label_id, inbox_id, user_id) => 1,
+ described_class.team_inbox_assignee_key(account_id, team_id, inbox_id, user_id) => 1
+ )
+ expect(assignment_keys.map { |key| ttl_for(key) }).to all(be_within(5).of(Conversations::UnreadCounts::SET_TTL))
+
+ described_class.remove_assignment_membership(
+ account_id: account_id,
+ inbox_ids: [inbox_id],
+ label_ids: [label_id],
+ assignee_ids: [user_id],
+ team_ids: [team_id],
+ conversation_id: conversation_id
+ )
+
+ expect(described_class.counts_for_keys(assignment_keys).values).to all(eq(0))
+ end
+
+ it 'sets expiry on bulk membership writes' do
+ described_class.add_memberships(
+ account_id: account_id,
+ memberships: [{
+ inbox_id: inbox_id,
+ label_ids: [label_id],
+ team_id: team_id,
+ conversation_id: conversation_id
+ }]
+ )
+
+ expect(base_keys.map { |key| ttl_for(key) }).to all(be_within(5).of(Conversations::UnreadCounts::SET_TTL))
+ end
+
+ it 'clears all account memberships' do
+ described_class.mark_base_ready!(account_id)
+ described_class.mark_assignment_ready!(account_id)
+ described_class.add_base_membership(
+ account_id: account_id,
+ inbox_id: inbox_id,
+ label_ids: [label_id],
+ team_id: team_id,
+ conversation_id: conversation_id
+ )
+ described_class.add_assignment_membership(
+ account_id: account_id,
+ inbox_id: inbox_id,
+ label_ids: [label_id],
+ assignee_id: user_id,
+ team_id: team_id,
+ conversation_id: conversation_id
+ )
+
+ described_class.clear_account!(account_id)
+
+ expect(described_class.base_ready?(account_id)).to be(false)
+ expect(described_class.assignment_ready?(account_id)).to be(false)
+ expect(described_class.counts_for_keys(base_keys).values).to all(eq(0))
+ expect(described_class.counts_for_keys(assignment_keys).values).to all(eq(0))
+ end
+ end
+
+ def base_keys
+ [
+ described_class.inbox_key(account_id, inbox_id),
+ described_class.label_inbox_key(account_id, label_id, inbox_id),
+ described_class.team_inbox_key(account_id, team_id, inbox_id)
+ ]
+ end
+
+ def assignment_keys
+ [
+ described_class.inbox_assignee_key(account_id, inbox_id, user_id),
+ described_class.label_inbox_assignee_key(account_id, label_id, inbox_id, user_id),
+ described_class.team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
+ ]
+ end
+
+ def ttl_for(key)
+ Redis::Alfred.ttl(key)
+ end
+end
diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb
index 190a6c45a..d50a0113d 100644
--- a/spec/services/twilio/incoming_message_service_spec.rb
+++ b/spec/services/twilio/incoming_message_service_spec.rb
@@ -403,6 +403,114 @@ describe Twilio::IncomingMessageService do
expect(existing_contact.name).to eq('Alice Johnson')
end
+ describe 'When the incoming WhatsApp message only has BSUID identifiers' do
+ let!(:whatsapp_twilio_channel) do
+ create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
+ inbox: create(:inbox, account: account, greeting_enabled: false))
+ end
+
+ it 'creates a contact and conversation without a phone number' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:IN.2081978709342942',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'testing bsuid',
+ ProfileName: 'Muhsin',
+ ProfileUsername: 'muhsin',
+ ExternalUserId: 'IN.2081978709342942',
+ ParentExternalUserId: 'IN.ENT.9081726354'
+ }
+
+ described_class.new(params: params).perform
+
+ contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:IN.2081978709342942')
+ contact = contact_inbox.contact
+ parent_contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:IN.ENT.9081726354')
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('testing bsuid')
+ expect(contact).to have_attributes(name: 'Muhsin', phone_number: nil)
+ expect(contact.additional_attributes).to include(
+ 'social_whatsapp_user_name' => 'muhsin',
+ 'social_profiles' => { 'whatsapp' => 'muhsin' }
+ )
+ expect(parent_contact_inbox.contact).to eq(contact)
+ end
+
+ it 'uses the BSUID without the provider prefix as the fallback contact name' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:IN.2081978709342942',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'testing bsuid',
+ ExternalUserId: 'IN.2081978709342942'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('IN.2081978709342942')
+ end
+
+ it 'links phone and BSUID source ids to the same contact' do
+ phone_with_bsuid_params = {
+ SmsSid: 'SMxx1',
+ From: 'whatsapp:+919745786257',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'phone and bsuid',
+ ProfileName: 'Muhsin',
+ ExternalUserId: 'IN.2081978709342942'
+ }
+ bsuid_only_params = {
+ SmsSid: 'SMxx2',
+ From: 'whatsapp:IN.2081978709342942',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'bsuid only',
+ ExternalUserId: 'IN.2081978709342942'
+ }
+
+ described_class.new(params: phone_with_bsuid_params).perform
+ contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:+919745786257')
+ bsuid_contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:IN.2081978709342942')
+
+ expect { described_class.new(params: bsuid_only_params).perform }.not_to raise_error
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.count).to eq(2)
+ expect(whatsapp_twilio_channel.inbox.messages.pluck(:content)).to contain_exactly('phone and bsuid', 'bsuid only')
+ expect(bsuid_contact_inbox.contact).to eq(contact_inbox.contact)
+ end
+
+ it 'backfills contact phone number when a phone arrives after BSUID-only creation' do
+ bsuid_only_params = {
+ SmsSid: 'SMxx1',
+ From: 'whatsapp:IN.2081978709342942',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'bsuid first',
+ ExternalUserId: 'IN.2081978709342942'
+ }
+ phone_with_bsuid_params = {
+ SmsSid: 'SMxx2',
+ From: 'whatsapp:+919745786257',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'phone follow up',
+ ProfileName: 'Muhsin',
+ ExternalUserId: 'IN.2081978709342942'
+ }
+
+ described_class.new(params: bsuid_only_params).perform
+ bsuid_contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:IN.2081978709342942')
+
+ described_class.new(params: phone_with_bsuid_params).perform
+
+ phone_contact_inbox = whatsapp_twilio_channel.inbox.contact_inboxes.find_by!(source_id: 'whatsapp:+919745786257')
+ expect(phone_contact_inbox.contact).to eq(bsuid_contact_inbox.contact)
+ expect(bsuid_contact_inbox.contact.reload.phone_number).to eq('+919745786257')
+ end
+ end
+
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb
index 2ecf60acb..fe6b179c1 100644
--- a/spec/services/whatsapp/incoming_message_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_service_spec.rb
@@ -86,6 +86,110 @@ describe Whatsapp::IncomingMessageService do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.messages.count).to eq(1)
end
+
+ it 'creates a contact and conversation when only BSUID is present' do
+ params = {
+ 'contacts' => [{
+ 'profile' => { 'name' => 'Muhsin', 'username' => 'muhsin' },
+ 'user_id' => 'IN.2081978709342942',
+ 'parent_user_id' => 'IN.ENT.9081726354'
+ }],
+ 'messages' => [{
+ 'from_user_id' => 'IN.2081978709342942',
+ 'from_parent_user_id' => 'IN.ENT.9081726354',
+ 'id' => 'wamid.bsuid-only-message',
+ 'text' => { 'body' => 'testing bsuid' },
+ 'timestamp' => '1778579582',
+ 'type' => 'text'
+ }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+
+ contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942')
+ contact = contact_inbox.contact
+ parent_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.ENT.9081726354')
+ expect(whatsapp_channel.inbox.conversations.count).to eq(1)
+ expect(whatsapp_channel.inbox.messages.first.content).to eq('testing bsuid')
+ expect(contact).to have_attributes(name: 'Muhsin', phone_number: nil)
+ expect(contact.additional_attributes).to include(
+ 'social_whatsapp_user_name' => 'muhsin',
+ 'social_profiles' => { 'whatsapp' => 'muhsin' }
+ )
+ expect(parent_contact_inbox.contact).to eq(contact)
+ end
+
+ it 'links phone and BSUID source ids to the same contact' do
+ phone_with_bsuid_params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Muhsin' }, 'wa_id' => '919745786257', 'user_id' => 'IN.2081978709342942' }],
+ 'messages' => [{
+ 'from' => '919745786257',
+ 'from_user_id' => 'IN.2081978709342942',
+ 'id' => 'wamid.phone-bsuid-message',
+ 'text' => { 'body' => 'phone and bsuid' },
+ 'timestamp' => '1778579582',
+ 'type' => 'text'
+ }]
+ }.with_indifferent_access
+ bsuid_only_params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Muhsin' }, 'user_id' => 'IN.2081978709342942' }],
+ 'messages' => [{
+ 'from_user_id' => 'IN.2081978709342942',
+ 'id' => 'wamid.bsuid-follow-up-message',
+ 'text' => { 'body' => 'bsuid only' },
+ 'timestamp' => '1778579583',
+ 'type' => 'text'
+ }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: phone_with_bsuid_params).perform
+ contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: '919745786257')
+ bsuid_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942')
+
+ expect { described_class.new(inbox: whatsapp_channel.inbox, params: bsuid_only_params).perform }.not_to raise_error
+ expect(whatsapp_channel.inbox.contact_inboxes.count).to eq(2)
+ expect(whatsapp_channel.inbox.messages.pluck(:content)).to contain_exactly('phone and bsuid', 'bsuid only')
+ expect(bsuid_contact_inbox.contact).to eq(contact_inbox.contact)
+ end
+
+ it 'backfills contact phone number when a phone arrives after BSUID-only creation' do
+ bsuid_only_params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Muhsin' }, 'user_id' => 'IN.2081978709342942' }],
+ 'messages' => [{
+ 'from_user_id' => 'IN.2081978709342942',
+ 'id' => 'wamid.bsuid-first-message',
+ 'text' => { 'body' => 'bsuid first' },
+ 'timestamp' => '1778579582',
+ 'type' => 'text'
+ }]
+ }.with_indifferent_access
+ phone_with_bsuid_params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Muhsin' }, 'wa_id' => '919745786257', 'user_id' => 'IN.2081978709342942' }],
+ 'messages' => [{
+ 'from' => '919745786257',
+ 'from_user_id' => 'IN.2081978709342942',
+ 'id' => 'wamid.phone-follow-up-message',
+ 'text' => { 'body' => 'phone follow up' },
+ 'timestamp' => '1778579583',
+ 'type' => 'text'
+ }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: bsuid_only_params).perform
+ bsuid_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942')
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: phone_with_bsuid_params).perform
+
+ phone_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: '919745786257')
+ expect(phone_contact_inbox.contact).to eq(bsuid_contact_inbox.contact)
+ expect(bsuid_contact_inbox.contact.reload.phone_number).to eq('+919745786257')
+ end
+
+ it 'keeps cloud BSUID source ids in the Meta-provided shape' do
+ service = described_class.new(inbox: whatsapp_channel.inbox, params: params)
+
+ expect(service.send(:whatsapp_source_id, 'whatsapp:IN.2081978709342942')).to eq('whatsapp:IN.2081978709342942')
+ end
end
context 'when unsupported message types' do
@@ -102,7 +206,7 @@ describe Whatsapp::IncomingMessageService do
expect(whatsapp_channel.inbox.messages.count).to eq(0)
end
- it 'ignores type unsupported and does not create ghost conversation' do
+ it 'stores type unsupported as a placeholder message so the conversation is not headless' do
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{
@@ -113,9 +217,12 @@ describe Whatsapp::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
- expect(whatsapp_channel.inbox.conversations.count).to eq(0)
- expect(Contact.count).to eq(0)
- expect(whatsapp_channel.inbox.messages.count).to eq(0)
+ expect(whatsapp_channel.inbox.conversations.count).to eq(1)
+ expect(Contact.count).to eq(1)
+ expect(whatsapp_channel.inbox.messages.count).to eq(1)
+ message = whatsapp_channel.inbox.messages.last
+ expect(message.content).to eq('This message is unavailable.')
+ expect(message.content_attributes['is_unsupported']).to be(true)
end
end
@@ -145,6 +252,24 @@ describe Whatsapp::IncomingMessageService do
expect(message.reload.status).to eq('read')
end
+ it 'stores BSUID source ids from status contacts' do
+ bsuid = 'IN.2081978709342942'
+ parent_bsuid = 'IN.ENT.9081726354'
+ status_params = {
+ 'contacts' => [{ 'wa_id' => from, 'user_id' => bsuid, 'parent_user_id' => parent_bsuid }],
+ 'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'delivered' }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform
+
+ source_rows = whatsapp_channel.inbox.contact_inboxes.where(source_id: [from, bsuid, parent_bsuid]).pluck(:source_id, :contact_id)
+ expect(source_rows).to contain_exactly(
+ [from, contact_inbox.contact_id],
+ [bsuid, contact_inbox.contact_id],
+ [parent_bsuid, contact_inbox.contact_id]
+ )
+ end
+
it 'update status message to failed' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'failed',
diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
index 4b6841811..70c29c092 100644
--- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
@@ -97,6 +97,98 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
end
+ context 'when BSUID identifiers are present' do
+ it 'creates a contact and conversation when only BSUID is present' do
+ bsuid_params = {
+ phone_number: whatsapp_channel.phone_number,
+ object: 'whatsapp_business_account',
+ entry: [{
+ changes: [{
+ value: {
+ contacts: [{
+ profile: { name: 'Muhsin', username: 'muhsin' },
+ user_id: 'IN.2081978709342942',
+ parent_user_id: 'IN.ENT.9081726354'
+ }],
+ messages: [{
+ from_user_id: 'IN.2081978709342942',
+ from_parent_user_id: 'IN.ENT.9081726354',
+ id: 'wamid.cloud-bsuid-only-message',
+ text: { body: 'testing bsuid' },
+ timestamp: '1778579582',
+ type: 'text'
+ }]
+ }
+ }]
+ }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: bsuid_params).perform
+
+ contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942')
+ contact = contact_inbox.contact
+ parent_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.ENT.9081726354')
+
+ expect(whatsapp_channel.inbox.conversations.count).to eq(1)
+ expect(whatsapp_channel.inbox.messages.first.content).to eq('testing bsuid')
+ expect(contact).to have_attributes(name: 'Muhsin', phone_number: nil)
+ expect(contact.additional_attributes).to include(
+ 'social_whatsapp_user_name' => 'muhsin',
+ 'social_profiles' => { 'whatsapp' => 'muhsin' }
+ )
+ expect(parent_contact_inbox.contact).to eq(contact)
+ end
+
+ it 'links phone and BSUID source ids to the same contact' do
+ phone_with_bsuid_params = {
+ phone_number: whatsapp_channel.phone_number,
+ object: 'whatsapp_business_account',
+ entry: [{
+ changes: [{
+ value: {
+ contacts: [{ profile: { name: 'Muhsin' }, wa_id: '919745786257', user_id: 'IN.2081978709342942' }],
+ messages: [{
+ from: '919745786257',
+ from_user_id: 'IN.2081978709342942',
+ id: 'wamid.cloud-phone-bsuid-message',
+ text: { body: 'phone and bsuid' },
+ timestamp: '1778579582',
+ type: 'text'
+ }]
+ }
+ }]
+ }]
+ }.with_indifferent_access
+ bsuid_only_params = {
+ phone_number: whatsapp_channel.phone_number,
+ object: 'whatsapp_business_account',
+ entry: [{
+ changes: [{
+ value: {
+ contacts: [{ profile: { name: 'Muhsin' }, user_id: 'IN.2081978709342942' }],
+ messages: [{
+ from_user_id: 'IN.2081978709342942',
+ id: 'wamid.cloud-bsuid-follow-up-message',
+ text: { body: 'bsuid only' },
+ timestamp: '1778579583',
+ type: 'text'
+ }]
+ }
+ }]
+ }]
+ }.with_indifferent_access
+
+ described_class.new(inbox: whatsapp_channel.inbox, params: phone_with_bsuid_params).perform
+ contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: '919745786257')
+ bsuid_contact_inbox = whatsapp_channel.inbox.contact_inboxes.find_by!(source_id: 'IN.2081978709342942')
+
+ expect { described_class.new(inbox: whatsapp_channel.inbox, params: bsuid_only_params).perform }.not_to raise_error
+ expect(whatsapp_channel.inbox.contact_inboxes.count).to eq(2)
+ expect(whatsapp_channel.inbox.messages.pluck(:content)).to contain_exactly('phone and bsuid', 'bsuid only')
+ expect(bsuid_contact_inbox.contact).to eq(contact_inbox.contact)
+ end
+ end
+
context 'when invalid params' do
it 'will not throw error' do
described_class.new(inbox: whatsapp_channel.inbox, params: { phone_number: whatsapp_channel.phone_number,
diff --git a/spec/support/conversations_unread_counts_helpers.rb b/spec/support/conversations_unread_counts_helpers.rb
new file mode 100644
index 000000000..e1a98d034
--- /dev/null
+++ b/spec/support/conversations_unread_counts_helpers.rb
@@ -0,0 +1,11 @@
+module ConversationsUnreadCountsHelpers
+ def create_unread_conversation(account:, inbox:, labels: [], assignee: nil, team: nil)
+ create(:team_member, user: assignee, team: team) if assignee.present? && team.present? && !team.members.exists?(assignee.id)
+
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: assignee, team: team, agent_last_seen_at: 1.hour.ago)
+ conversation.update_labels(labels) if labels.present?
+
+ create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
+ conversation
+ end
+end
diff --git a/swagger/definitions/request/agent/create_payload.yml b/swagger/definitions/request/agent/create_payload.yml
index 1daeae83a..77180d282 100644
--- a/swagger/definitions/request/agent/create_payload.yml
+++ b/swagger/definitions/request/agent/create_payload.yml
@@ -17,12 +17,12 @@ properties:
enum: ['agent', 'administrator']
description: Whether its administrator or agent
example: 'agent'
- availability_status:
+ availability:
type: string
- enum: ['available', 'busy', 'offline']
- description: The availability setting of the agent.
- example: 'available'
+ enum: ['online', 'busy', 'offline']
+ description: The configured availability of the agent.
+ example: 'online'
auto_offline:
type: boolean
- description: Whether the availability status of agent is configured to go offline automatically when away.
+ description: Whether the agent is automatically marked offline when they are away.
example: true
diff --git a/swagger/definitions/request/agent/update_payload.yml b/swagger/definitions/request/agent/update_payload.yml
index fc8d1457d..168d46f49 100644
--- a/swagger/definitions/request/agent/update_payload.yml
+++ b/swagger/definitions/request/agent/update_payload.yml
@@ -7,12 +7,12 @@ properties:
enum: ['agent', 'administrator']
description: Whether its administrator or agent
example: 'agent'
- availability_status:
+ availability:
type: string
- enum: ['available', 'busy', 'offline']
- description: The availability status of the agent.
- example: 'available'
+ enum: ['online', 'busy', 'offline']
+ description: The configured availability of the agent.
+ example: 'online'
auto_offline:
type: boolean
- description: Whether the availability status of agent is configured to go offline automatically when away.
+ description: Whether the agent is automatically marked offline when they are away.
example: true
diff --git a/swagger/definitions/request/webhooks/create_update_payload.yml b/swagger/definitions/request/webhooks/create_update_payload.yml
index 485d532e4..d3ad3560c 100644
--- a/swagger/definitions/request/webhooks/create_update_payload.yml
+++ b/swagger/definitions/request/webhooks/create_update_payload.yml
@@ -21,6 +21,8 @@ properties:
'contact_created',
'contact_updated',
'webwidget_triggered',
+ 'conversation_typing_on',
+ 'conversation_typing_off',
]
description: The events you want to subscribe to.
example:
diff --git a/swagger/definitions/resource/agent.yml b/swagger/definitions/resource/agent.yml
index 1d7b2b4c3..cabd1ee27 100644
--- a/swagger/definitions/resource/agent.yml
+++ b/swagger/definitions/resource/agent.yml
@@ -6,11 +6,15 @@ properties:
type: integer
availability_status:
type: string
- enum: ['available', 'busy', 'offline']
- description: The availability status of the agent computed by Chatwoot.
+ enum: ['online', 'busy', 'offline']
+ readOnly: true
+ description: >-
+ The effective availability status of the agent, derived from the configured availability,
+ auto-offline setting, and current presence. To update an agent's configured availability,
+ use the availability field in create or update requests.
auto_offline:
type: boolean
- description: Whether the availability status of agent is configured to go offline automatically when away.
+ description: Whether the agent is automatically marked offline when they are away.
confirmed:
type: boolean
description: Whether the agent has confirmed their email address.
diff --git a/swagger/definitions/resource/webhook.yml b/swagger/definitions/resource/webhook.yml
index da5cb112a..a0184afb9 100644
--- a/swagger/definitions/resource/webhook.yml
+++ b/swagger/definitions/resource/webhook.yml
@@ -21,9 +21,15 @@ properties:
"contact_updated",
"message_created",
"message_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
description: The list of subscribed events
+ secret:
+ type: string
+ nullable: true
+ description: Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available.
account_id:
type: number
description: The id of the account which the webhook object belongs to
diff --git a/swagger/paths/application/audit_logs/index.yml b/swagger/paths/application/audit_logs/index.yml
index 8fcd22256..df7966c57 100644
--- a/swagger/paths/application/audit_logs/index.yml
+++ b/swagger/paths/application/audit_logs/index.yml
@@ -24,7 +24,7 @@ responses:
per_page:
type: integer
description: Number of items per page
- example: 15
+ example: 25
total_entries:
type: integer
description: Total number of audit log entries
@@ -49,4 +49,4 @@ responses:
content:
application/json:
schema:
- $ref: '#/components/schemas/bad_request_error'
\ No newline at end of file
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 94d1f04d3..8867bf52a 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -1649,7 +1649,7 @@
"per_page": {
"type": "integer",
"description": "Number of items per page",
- "example": 15
+ "example": 25
},
"total_entries": {
"type": "integer",
@@ -9892,15 +9892,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -10315,11 +10316,18 @@
"contact_updated",
"message_created",
"message_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The list of subscribed events"
},
+ "secret": {
+ "type": "string",
+ "nullable": true,
+ "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available."
+ },
"account_id": {
"type": "number",
"description": "The id of the account which the webhook object belongs to"
@@ -11595,19 +11603,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -11627,19 +11635,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -12339,7 +12347,9 @@
"message_updated",
"contact_created",
"contact_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The events you want to subscribe to.",
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index a013b3694..98be4a321 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -192,7 +192,7 @@
"per_page": {
"type": "integer",
"description": "Number of items per page",
- "example": 15
+ "example": 25
},
"total_entries": {
"type": "integer",
@@ -8399,15 +8399,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -8822,11 +8823,18 @@
"contact_updated",
"message_created",
"message_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The list of subscribed events"
},
+ "secret": {
+ "type": "string",
+ "nullable": true,
+ "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available."
+ },
"account_id": {
"type": "number",
"description": "The id of the account which the webhook object belongs to"
@@ -10102,19 +10110,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -10134,19 +10142,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -10846,7 +10854,9 @@
"message_updated",
"contact_created",
"contact_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The events you want to subscribe to.",
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index 763e090b1..721345716 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -1664,15 +1664,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -2087,11 +2088,18 @@
"contact_updated",
"message_created",
"message_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The list of subscribed events"
},
+ "secret": {
+ "type": "string",
+ "nullable": true,
+ "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available."
+ },
"account_id": {
"type": "number",
"description": "The id of the account which the webhook object belongs to"
@@ -3367,19 +3375,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -3399,19 +3407,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -4111,7 +4119,9 @@
"message_updated",
"contact_created",
"contact_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The events you want to subscribe to.",
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index 50bf2212b..0a526b38b 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -1079,15 +1079,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -1502,11 +1503,18 @@
"contact_updated",
"message_created",
"message_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The list of subscribed events"
},
+ "secret": {
+ "type": "string",
+ "nullable": true,
+ "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available."
+ },
"account_id": {
"type": "number",
"description": "The id of the account which the webhook object belongs to"
@@ -2782,19 +2790,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -2814,19 +2822,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -3526,7 +3534,9 @@
"message_updated",
"contact_created",
"contact_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The events you want to subscribe to.",
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index f1e471e79..0b2a3f398 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -1840,15 +1840,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -2263,11 +2264,18 @@
"contact_updated",
"message_created",
"message_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The list of subscribed events"
},
+ "secret": {
+ "type": "string",
+ "nullable": true,
+ "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available."
+ },
"account_id": {
"type": "number",
"description": "The id of the account which the webhook object belongs to"
@@ -3543,19 +3551,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -3575,19 +3583,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -4287,7 +4295,9 @@
"message_updated",
"contact_created",
"contact_updated",
- "webwidget_triggered"
+ "webwidget_triggered",
+ "conversation_typing_on",
+ "conversation_typing_off"
]
},
"description": "The events you want to subscribe to.",
diff --git a/tailwind.config.js b/tailwind.config.js
index 26ff7da20..b4c4b9078 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -22,7 +22,7 @@ const defaultSansFonts = [
const tailwindConfig = {
darkMode: 'class',
content: [
- './enterprise/app/views/**/*.html.erb',
+ './enterprise/app/views/**/*.erb',
'./app/javascript/widget/**/*.vue',
'./app/javascript/v3/**/*.vue',
'./app/javascript/dashboard/**/*.vue',
@@ -34,7 +34,7 @@ const tailwindConfig = {
'./app/javascript/dashboard/composables/**/*.js',
'./app/javascript/dashboard/components-next/**/*.js',
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
- './app/views/**/*.html.erb',
+ './app/views/**/*.erb',
],
theme: {
extend: {
@@ -48,6 +48,7 @@ const tailwindConfig = {
440: '440',
460: '460',
520: '520',
+ 620: '620',
},
typography: {
bubble: {
diff --git a/theme/colors.js b/theme/colors.js
index b8995d18f..0f4d2af10 100644
--- a/theme/colors.js
+++ b/theme/colors.js
@@ -227,6 +227,9 @@ export const colors = {
black: '#000000',
brand: '#2781F6',
+ portal: 'var(--dynamic-portal-color)',
+ 'portal-soft': 'var(--dynamic-portal-color-soft)',
+ 'portal-faint': 'var(--dynamic-portal-color-faint)',
background: 'rgb(var(--background-color) / )',
'input-background': 'rgba(var(--background-input-box))',
surface: {
diff --git a/theme/icons.js b/theme/icons.js
index 266c7ddfa..2bd218cb0 100644
--- a/theme/icons.js
+++ b/theme/icons.js
@@ -113,6 +113,23 @@ export const icons = {
width: 16,
height: 20,
},
+ 'file-pfx': {
+ body: `
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ width: 16,
+ height: 20,
+ },
bin: {
body: ``,
width: 16,
@@ -297,6 +314,21 @@ export const icons = {
width: 24,
height: 24,
},
+ 'outlook-color': {
+ body: ``,
+ width: 1040.8409,
+ height: 742.6319,
+ },
+ 'instagram-color': {
+ body: ``,
+ width: 256,
+ height: 256,
+ },
+ 'line-color': {
+ body: ``,
+ width: 427,
+ height: 427,
+ },
voice: {
body: ``,
width: 24,
@@ -428,5 +460,10 @@ export const icons = {
width: 15,
height: 15,
},
+ 'tag-remove': {
+ body: ``,
+ width: 24,
+ height: 24,
+ },
/** Ends */
};
diff --git a/vite.config.ts b/vite.config.ts
index 17d47fc76..5c3fee26b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -45,6 +45,13 @@ if (isLibraryMode) {
export default defineConfig({
plugins: plugins,
+ css: {
+ preprocessorOptions: {
+ scss: {
+ api: 'modern-compiler',
+ },
+ },
+ },
build: {
rollupOptions: {
output: {