-
- {{ webhook.url }}
+
+
+ {{ webhook.name }}
+
+ {{ webhook.url }}
+
+
+
+ {{ webhook.url }}
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue
index a0eeb3ab9..1eb9640ac 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue
@@ -1,6 +1,7 @@
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue
deleted file mode 100644
index 280e1d6ee..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue
+++ /dev/null
@@ -1,119 +0,0 @@
-
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue
new file mode 100644
index 000000000..bed1128fd
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue
new file mode 100644
index 000000000..3050e12c7
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue
@@ -0,0 +1,313 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue
new file mode 100644
index 000000000..394b7cdc2
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapDateRangeSelector.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapDateRangeSelector.vue
new file mode 100644
index 000000000..a2b05a959
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapDateRangeSelector.vue
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue
new file mode 100644
index 000000000..79377a6a3
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+ {{ tooltipText }}
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue
new file mode 100644
index 000000000..24530d0c7
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js
new file mode 100644
index 000000000..28b050542
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js
@@ -0,0 +1,34 @@
+import { ref } from 'vue';
+
+export function useHeatmapTooltip() {
+ const visible = ref(false);
+ const x = ref(0);
+ const y = ref(0);
+ const value = ref(null);
+
+ let timeoutId = null;
+
+ const show = (event, cellValue) => {
+ clearTimeout(timeoutId);
+
+ // Update position immediately for smooth movement
+ const rect = event.target.getBoundingClientRect();
+ x.value = rect.left + rect.width / 2;
+ y.value = rect.top;
+
+ // Only delay content update and visibility
+ timeoutId = setTimeout(() => {
+ value.value = cellValue;
+ visible.value = true;
+ }, 100);
+ };
+
+ const hide = () => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => {
+ visible.value = false;
+ }, 50);
+ };
+
+ return { visible, x, y, value, show, hide };
+}
diff --git a/app/javascript/dashboard/routes/index.js b/app/javascript/dashboard/routes/index.js
index e69d0b5ad..27465eb39 100644
--- a/app/javascript/dashboard/routes/index.js
+++ b/app/javascript/dashboard/routes/index.js
@@ -18,8 +18,17 @@ export const validateAuthenticateRoutePermission = (to, next) => {
return '';
}
- if (!to.name) {
- return next(frontendURL(`accounts/${user.account_id}/dashboard`));
+ const { accounts = [], account_id: accountId } = user;
+
+ if (!accounts.length) {
+ if (to.name === 'no_accounts') {
+ return next();
+ }
+ return next(frontendURL('no-accounts'));
+ }
+
+ if (to.name === 'no_accounts' || !to.name) {
+ return next(frontendURL(`accounts/${accountId}/dashboard`));
}
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
diff --git a/app/javascript/dashboard/store/captain/response.js b/app/javascript/dashboard/store/captain/response.js
index 5f8c2eee1..0822b43cd 100644
--- a/app/javascript/dashboard/store/captain/response.js
+++ b/app/javascript/dashboard/store/captain/response.js
@@ -1,9 +1,22 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
import { createStore } from './storeFactory';
+const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
+
export default createStore({
name: 'CaptainResponse',
API: CaptainResponseAPI,
+ getters: {
+ getPendingCount: state => state.meta.pendingCount || 0,
+ },
+ mutations: {
+ [SET_PENDING_COUNT](state, count) {
+ state.meta = {
+ ...state.meta,
+ pendingCount: Number(count),
+ };
+ },
+ },
actions: mutations => ({
removeBulkResponses: ({ commit, state }, ids) => {
const updatedRecords = state.records.filter(
@@ -28,5 +41,18 @@ export default createStore({
commit(mutations.SET, updatedRecords);
},
+ fetchPendingCount: async ({ commit }, assistantId) => {
+ try {
+ const response = await CaptainResponseAPI.get({
+ status: 'pending',
+ page: 1,
+ assistantId,
+ });
+ const count = response.data?.meta?.total_count || 0;
+ commit(SET_PENDING_COUNT, count);
+ } catch (error) {
+ commit(SET_PENDING_COUNT, 0);
+ }
+ },
}),
});
diff --git a/app/javascript/dashboard/store/captain/storeFactory.js b/app/javascript/dashboard/store/captain/storeFactory.js
index ad669f62b..d57b98862 100644
--- a/app/javascript/dashboard/store/captain/storeFactory.js
+++ b/app/javascript/dashboard/store/captain/storeFactory.js
@@ -49,6 +49,7 @@ export const createMutations = mutationTypes => ({
},
[mutationTypes.SET_META](state, meta) {
state.meta = {
+ ...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
};
@@ -69,7 +70,7 @@ export const createCrudActions = (API, mutationTypes) => ({
});
export const createStore = options => {
- const { name, API, actions, getters } = options;
+ const { name, API, actions, getters, mutations } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
@@ -81,7 +82,10 @@ export const createStore = options => {
...createGetters(),
...(getters || {}),
},
- mutations: createMutations(mutationTypes),
+ mutations: {
+ ...createMutations(mutationTypes),
+ ...(mutations || {}),
+ },
actions: {
...createCrudActions(API, mutationTypes),
...customActions,
diff --git a/app/javascript/dashboard/store/captain/storeFactory.spec.js b/app/javascript/dashboard/store/captain/storeFactory.spec.js
index 0aec26e40..44637d429 100644
--- a/app/javascript/dashboard/store/captain/storeFactory.spec.js
+++ b/app/javascript/dashboard/store/captain/storeFactory.spec.js
@@ -175,7 +175,7 @@ describe('storeFactory', () => {
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
- expect(commit).toHaveBeenCalledWith(mutationTypes.ADD, data);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
diff --git a/app/javascript/dashboard/store/captain/storeFactoryHelper.js b/app/javascript/dashboard/store/captain/storeFactoryHelper.js
index 7a04d2e81..ddb7367d7 100644
--- a/app/javascript/dashboard/store/captain/storeFactoryHelper.js
+++ b/app/javascript/dashboard/store/captain/storeFactoryHelper.js
@@ -22,7 +22,7 @@ export const showRecord =
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
- commit(mutationTypes.ADD, response.data);
+ commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
diff --git a/app/javascript/dashboard/store/modules/conversationStats.js b/app/javascript/dashboard/store/modules/conversationStats.js
index 353c1e59a..ba3e5c455 100644
--- a/app/javascript/dashboard/store/modules/conversationStats.js
+++ b/app/javascript/dashboard/store/modules/conversationStats.js
@@ -26,12 +26,12 @@ const fetchMetaData = async (commit, params) => {
};
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
-const longDebouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 8000);
+const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
- 1500,
+ 10000,
false,
- 10000
+ 20000
);
export const actions = {
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 9886be679..5d788e8ce 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -78,6 +78,11 @@ export const getters = {
return false;
}
+ // Filter out authentication templates
+ if (template.category === 'AUTHENTICATION') {
+ return false;
+ }
+
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js
index bb5364bb5..99b2acf18 100644
--- a/app/javascript/dashboard/store/modules/reports.js
+++ b/app/javascript/dashboard/store/modules/reports.js
@@ -57,11 +57,13 @@ const state = {
uiFlags: {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
+ isFetchingAccountResolutionsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
+ accountResolutionHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
@@ -89,6 +91,9 @@ const getters = {
getAccountConversationHeatmapData(_state) {
return _state.overview.accountConversationHeatmap;
},
+ getAccountResolutionHeatmapData(_state) {
+ return _state.overview.accountResolutionHeatmap;
+ },
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
@@ -130,6 +135,16 @@ export const actions = {
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
});
},
+ fetchAccountResolutionHeatmap({ commit }, reportObj) {
+ commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, true);
+ Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => {
+ let { data } = heatmapData;
+ data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
+
+ commit(types.default.SET_RESOLUTION_HEATMAP_DATA, data);
+ commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, false);
+ });
+ },
fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary(
@@ -287,6 +302,9 @@ const mutations = {
[types.default.SET_HEATMAP_DATA](_state, heatmapData) {
_state.overview.accountConversationHeatmap = heatmapData;
},
+ [types.default.SET_RESOLUTION_HEATMAP_DATA](_state, heatmapData) {
+ _state.overview.accountResolutionHeatmap = heatmapData;
+ },
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching[metric] = value;
},
@@ -299,6 +317,9 @@ const mutations = {
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
},
+ [types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING](_state, flag) {
+ _state.overview.uiFlags.isFetchingAccountResolutionsHeatmap = flag;
+ },
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
_state.accountSummary = summaryData;
},
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
index eeb52b1dc..ac2aab26b 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
@@ -265,6 +265,39 @@ describe('#getters', () => {
expect(result[0].name).toBe('regular_template');
});
+ it('filters out authentication templates', () => {
+ const authenticationTemplates = [
+ {
+ name: 'auth_template',
+ status: 'approved',
+ category: 'AUTHENTICATION',
+ components: [
+ { type: 'BODY', text: 'Your verification code is {{1}}' },
+ ],
+ },
+ {
+ name: 'regular_template',
+ status: 'approved',
+ category: 'MARKETING',
+ components: [{ type: 'BODY', text: 'Regular message' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: authenticationTemplates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('regular_template');
+ });
+
it('returns valid templates from fixture data', () => {
const state = {
records: [
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 4f361e140..68ff79e66 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -187,6 +187,8 @@ export default {
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
+ SET_RESOLUTION_HEATMAP_DATA: 'SET_RESOLUTION_HEATMAP_DATA',
+ TOGGLE_RESOLUTION_HEATMAP_LOADING: 'TOGGLE_RESOLUTION_HEATMAP_LOADING',
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
diff --git a/app/javascript/shared/constants/links.js b/app/javascript/shared/constants/links.js
index b9732c0a2..94d3de3f6 100644
--- a/app/javascript/shared/constants/links.js
+++ b/app/javascript/shared/constants/links.js
@@ -6,3 +6,5 @@ export const REPLY_POLICY = {
WHATSAPP_CLOUD:
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
};
+
+export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs';
diff --git a/app/javascript/survey/i18n/locale/bn.json b/app/javascript/survey/i18n/locale/bn.json
new file mode 100644
index 000000000..beee65ac5
--- /dev/null
+++ b/app/javascript/survey/i18n/locale/bn.json
@@ -0,0 +1,19 @@
+{
+ "SURVEY": {
+ "DESCRIPTION": "Dear customer 👋, please take a few moments to share feedback about the conversation you had with {inboxName}.",
+ "RATING": {
+ "LABEL": "Rate your conversation",
+ "SUCCESS_MESSAGE": "Thank you for submitting the rating"
+ },
+ "FEEDBACK": {
+ "LABEL": "Do you have any thoughts you'd like to share?",
+ "PLACEHOLDER": "Your feedback (optional)",
+ "BUTTON_TEXT": "Submit feedback"
+ },
+ "API": {
+ "SUCCESS_MESSAGE": "Survey updated successfully",
+ "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
+ }
+ },
+ "POWERED_BY": "Powered by Chatwoot"
+}
diff --git a/app/javascript/survey/i18n/locale/et.json b/app/javascript/survey/i18n/locale/et.json
new file mode 100644
index 000000000..beee65ac5
--- /dev/null
+++ b/app/javascript/survey/i18n/locale/et.json
@@ -0,0 +1,19 @@
+{
+ "SURVEY": {
+ "DESCRIPTION": "Dear customer 👋, please take a few moments to share feedback about the conversation you had with {inboxName}.",
+ "RATING": {
+ "LABEL": "Rate your conversation",
+ "SUCCESS_MESSAGE": "Thank you for submitting the rating"
+ },
+ "FEEDBACK": {
+ "LABEL": "Do you have any thoughts you'd like to share?",
+ "PLACEHOLDER": "Your feedback (optional)",
+ "BUTTON_TEXT": "Submit feedback"
+ },
+ "API": {
+ "SUCCESS_MESSAGE": "Survey updated successfully",
+ "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
+ }
+ },
+ "POWERED_BY": "Powered by Chatwoot"
+}
diff --git a/app/javascript/survey/i18n/locale/it.json b/app/javascript/survey/i18n/locale/it.json
index 55459b9da..d7d71320f 100644
--- a/app/javascript/survey/i18n/locale/it.json
+++ b/app/javascript/survey/i18n/locale/it.json
@@ -1,13 +1,13 @@
{
"SURVEY": {
- "DESCRIPTION": "Caro cliente 👋, si prega di prendere qualche minuto per condividere i commenti sulla conversazione che hai avuto con {inboxName}.",
+ "DESCRIPTION": "Caro cliente 👋, ti chiediamo gentilmente di prenderti qualche minuto per condividere il tuo feedback sulla conversazione avuta con {inboxName}.",
"RATING": {
"LABEL": "Valuta la conversazione",
"SUCCESS_MESSAGE": "Grazie per aver inviato la valutazione"
},
"FEEDBACK": {
"LABEL": "Hai dei pensieri che vorresti condividere?",
- "PLACEHOLDER": "Il tuo feedback (facoltativo)",
+ "PLACEHOLDER": "Il tuo feedback (opzionale)",
"BUTTON_TEXT": "Invia feedback"
},
"API": {
diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue
index 04fb939c8..11a61d3dd 100644
--- a/app/javascript/v3/views/login/Index.vue
+++ b/app/javascript/v3/views/login/Index.vue
@@ -259,9 +259,9 @@ export default {
}"
>
-
+
-
+
e
+ Rails.logger.error "Bulk assignment failed for inbox #{inbox_id}: #{e.message}"
+ raise e if Rails.env.test?
+ end
+
+ private
+
+ def bulk_assignment_limit
+ ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
+ end
+end
diff --git a/app/jobs/auto_assignment/periodic_assignment_job.rb b/app/jobs/auto_assignment/periodic_assignment_job.rb
new file mode 100644
index 000000000..63500507e
--- /dev/null
+++ b/app/jobs/auto_assignment/periodic_assignment_job.rb
@@ -0,0 +1,19 @@
+class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
+ queue_as :scheduled_jobs
+
+ def perform
+ Account.find_in_batches do |accounts|
+ accounts.each do |account|
+ next unless account.feature_enabled?('assignment_v2')
+
+ account.inboxes.joins(:assignment_policy).find_in_batches do |inboxes|
+ inboxes.each do |inbox|
+ next unless inbox.auto_assignment_v2_enabled?
+
+ AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/jobs/contacts/bulk_action_job.rb b/app/jobs/contacts/bulk_action_job.rb
new file mode 100644
index 000000000..e05a9541f
--- /dev/null
+++ b/app/jobs/contacts/bulk_action_job.rb
@@ -0,0 +1,14 @@
+class Contacts::BulkActionJob < ApplicationJob
+ queue_as :medium
+
+ def perform(account_id, user_id, params)
+ account = Account.find(account_id)
+ user = User.find(user_id)
+
+ Contacts::BulkActionService.new(
+ account: account,
+ user: user,
+ params: params
+ ).perform
+ end
+end
diff --git a/app/jobs/conversation_reply_email_job.rb b/app/jobs/conversation_reply_email_job.rb
new file mode 100644
index 000000000..5d186bf29
--- /dev/null
+++ b/app/jobs/conversation_reply_email_job.rb
@@ -0,0 +1,15 @@
+class ConversationReplyEmailJob < ApplicationJob
+ queue_as :mailers
+
+ def perform(conversation_id, last_queued_id)
+ conversation = Conversation.find(conversation_id)
+
+ if conversation.messages.incoming&.last&.content_type == 'incoming_email'
+ ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later
+ else
+ ConversationReplyMailer.with(account: conversation.account).reply_with_summary(conversation, last_queued_id).deliver_later
+ end
+
+ Redis::Alfred.delete(format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id))
+ end
+end
diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb
index b1cee960b..0aba9f7f1 100644
--- a/app/jobs/send_reply_job.rb
+++ b/app/jobs/send_reply_job.rb
@@ -1,27 +1,29 @@
class SendReplyJob < ApplicationJob
queue_as :high
+ CHANNEL_SERVICES = {
+ 'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
+ 'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
+ 'Channel::Line' => ::Line::SendOnLineService,
+ 'Channel::Telegram' => ::Telegram::SendOnTelegramService,
+ 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
+ 'Channel::Sms' => ::Sms::SendOnSmsService,
+ 'Channel::Instagram' => ::Instagram::SendOnInstagramService,
+ 'Channel::Email' => ::Email::SendOnEmailService,
+ 'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
+ 'Channel::Api' => ::Messages::SendEmailNotificationService
+ }.freeze
+
def perform(message_id)
message = Message.find(message_id)
- conversation = message.conversation
- channel_name = conversation.inbox.channel.class.to_s
+ channel_name = message.conversation.inbox.channel.class.to_s
- services = {
- 'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
- 'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
- 'Channel::Line' => ::Line::SendOnLineService,
- 'Channel::Telegram' => ::Telegram::SendOnTelegramService,
- 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
- 'Channel::Sms' => ::Sms::SendOnSmsService,
- 'Channel::Instagram' => ::Instagram::SendOnInstagramService
- }
+ return send_on_facebook_page(message) if channel_name == 'Channel::FacebookPage'
- case channel_name
- when 'Channel::FacebookPage'
- send_on_facebook_page(message)
- else
- services[channel_name].new(message: message).perform if services[channel_name].present?
- end
+ service_class = CHANNEL_SERVICES[channel_name]
+ return unless service_class
+
+ service_class.new(message: message).perform
end
private
diff --git a/app/jobs/webhooks/instagram_events_job.rb b/app/jobs/webhooks/instagram_events_job.rb
index 3db8b6ba9..6383daff8 100644
--- a/app/jobs/webhooks/instagram_events_job.rb
+++ b/app/jobs/webhooks/instagram_events_job.rb
@@ -8,7 +8,7 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
def perform(entries)
@entries = entries
- key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
+ key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
with_lock(key) do
process_entries(entries)
end
@@ -77,6 +77,23 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries&.first&.dig(:id)
end
+ def contact_instagram_id
+ entry = @entries&.first
+ return nil unless entry
+
+ # Handle both messaging and standby arrays
+ messaging = (entry[:messaging].presence || entry[:standby] || []).first
+ return nil unless messaging
+
+ # For echo messages (outgoing from our account), use recipient's ID (the contact)
+ # For incoming messages (from contact), use sender's ID (the contact)
+ if messaging.dig(:message, :is_echo)
+ messaging.dig(:recipient, :id)
+ else
+ messaging.dig(:sender, :id)
+ end
+ end
+
def sender_id
@entries&.dig(0, :messaging, 0, :sender, :id)
end
diff --git a/app/mailboxes/application_mailbox.rb b/app/mailboxes/application_mailbox.rb
index 9fc7a435d..a77f5ea43 100644
--- a/app/mailboxes/application_mailbox.rb
+++ b/app/mailboxes/application_mailbox.rb
@@ -4,38 +4,21 @@ class ApplicationMailbox < ActionMailbox::Base
# Last part is the regex for the UUID
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
- CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)}
- # routes as a reply to existing conversations
+ # Route all emails to verified channels to the unified reply mailbox
+ # The ConversationFinder will determine if it's a reply or new conversation
routing(
- ->(inbound_mail) { valid_to_address?(inbound_mail) && (reply_uuid_mail?(inbound_mail) || in_reply_to_mail?(inbound_mail)) } => :reply
- )
-
- # routes as a new conversation in email channel
- routing(
- ->(inbound_mail) { valid_to_address?(inbound_mail) && EmailChannelFinder.new(inbound_mail.mail).perform.present? } => :support
+ lambda { |inbound_mail|
+ valid_to_address?(inbound_mail) &&
+ (reply_uuid_mail?(inbound_mail) || EmailChannelFinder.new(inbound_mail.mail).perform.present?)
+ } => :reply
)
# catchall
routing(all: :default)
class << self
- # checks if follow this pattern then send it to reply_mailbox
- #
- def in_reply_to_mail?(inbound_mail)
- in_reply_to = inbound_mail.mail.in_reply_to
-
- in_reply_to.present? && (
- in_reply_to_matches?(in_reply_to) || Message.exists?(source_id: in_reply_to)
- )
- end
-
- def in_reply_to_matches?(in_reply_to)
- Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
- end
-
- # checks if follow this pattern send it to reply_mailbox
- # reply+@
+ # checks if follows this pattern: reply+@
def reply_uuid_mail?(inbound_mail)
inbound_mail.mail.to&.any? do |email|
conversation_uuid = email.split('@')[0]
diff --git a/app/mailboxes/imap/imap_mailbox.rb b/app/mailboxes/imap/imap_mailbox.rb
index 5fea49722..27bc88e06 100644
--- a/app/mailboxes/imap/imap_mailbox.rb
+++ b/app/mailboxes/imap/imap_mailbox.rb
@@ -3,6 +3,8 @@ class Imap::ImapMailbox
include IncomingEmailValidityHelper
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
+ FALLBACK_CONVERSATION_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
+
def process(mail, channel)
@inbound_mail = mail
@channel = channel
@@ -49,19 +51,32 @@ class Imap::ImapMailbox
end
def find_conversation_by_reference_ids
- return if @inbound_mail.references.blank? && in_reply_to.present?
+ return if @inbound_mail.references.blank?
message = find_message_by_references
+ if message.present?
+ conversation = @inbox.conversations.find_by(id: message.conversation_id)
+ return conversation if conversation.present?
+ end
- return if message.nil?
-
- @inbox.conversations.find(message.conversation_id)
+ # FALLBACK_PATTERN use to find a conversation that is started by an agent (no incoming message yet)
+ conversation_id = find_conversation_by_references
+ @inbox.conversations.find_by(uuid: conversation_id) if conversation_id.present?
end
def in_reply_to
@processed_mail.in_reply_to
end
+ def find_conversation_by_references
+ references = Array.wrap(@inbound_mail.references)
+ references.each do |message_id|
+ match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
+
+ return match[2] if match.present?
+ end
+ end
+
def find_message_by_references
message_to_return = nil
diff --git a/app/mailboxes/reply_mailbox.rb b/app/mailboxes/reply_mailbox.rb
index 0b1efd09a..6e766f83e 100644
--- a/app/mailboxes/reply_mailbox.rb
+++ b/app/mailboxes/reply_mailbox.rb
@@ -1,88 +1,38 @@
class ReplyMailbox < ApplicationMailbox
- attr_accessor :conversation_uuid, :processed_mail
+ attr_accessor :conversation, :processed_mail
- # Last part is the regex for the UUID
- # Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
- EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
-
- before_processing :conversation_uuid_from_to_address,
- :find_relative_conversation
+ before_processing :find_conversation
def process
- return if @conversation.blank?
+ # Return early if no conversation was found (e.g., notification emails, suspended accounts)
+ return unless @conversation
- decorate_mail
- create_message
- add_attachments_to_message
+ # Wrap everything in a transaction to ensure atomicity
+ # This prevents orphan conversations if message/attachment creation fails
+ # and ensures idempotency on job retry (conversation won't be duplicated)
+ ActiveRecord::Base.transaction do
+ persist_conversation_if_needed
+ decorate_mail
+ create_message
+ add_attachments_to_message
+ end
end
private
- def find_relative_conversation
- if @conversation_uuid
- find_conversation_with_uuid
- elsif mail.in_reply_to.present?
- find_conversation_with_in_reply_to
- end
+ def find_conversation
+ @conversation = Mailbox::ConversationFinder.new(mail).find
+ # Log when email is rejected
+ Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
end
- def conversation_uuid_from_to_address
- @mail = MailPresenter.new(mail)
+ def persist_conversation_if_needed
+ # Save the conversation if it's a new record (from NewConversationStrategy)
+ # We persist here instead of in the strategy to maintain transaction integrity
+ return unless @conversation.new_record?
- return if @mail.mail_receiver.blank?
-
- @mail.mail_receiver.each do |email|
- username = email.split('@')[0]
- match_result = username.match(ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN)
- if match_result
- @conversation_uuid = match_result.captures
- break
- end
- end
- @conversation_uuid
- end
-
- # find conversation uuid from below pattern
- # reply+@
- def find_conversation_with_uuid
- @conversation = Conversation.find_by(uuid: conversation_uuid)
- validate_resource @conversation
- end
-
- def find_conversation_by_uuid(match_result)
- @conversation_uuid = match_result.captures[0]
-
- find_conversation_with_uuid
- end
-
- def find_conversation_by_message_id(in_reply_to)
- @message = Message.find_by(source_id: in_reply_to)
- @conversation = @message.conversation if @message.present?
- @conversation_uuid = @conversation.uuid if @conversation.present?
- end
-
- # find conversation uuid from below pattern
- #
- def find_conversation_with_in_reply_to
- match_result = nil
- in_reply_to_addresses = mail.in_reply_to
- in_reply_to_addresses = [in_reply_to_addresses] if in_reply_to_addresses.is_a?(String)
- in_reply_to_addresses.each do |in_reply_to|
- match_result = in_reply_to.match(::ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN)
- break if match_result
- end
- find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
- end
-
- def find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
- find_conversation_by_uuid(match_result) if match_result
- find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
- end
-
- def validate_resource(resource)
- Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
-
- resource
+ @conversation.save!
+ Rails.logger.info "Created new conversation #{@conversation.id} for email #{mail.message_id}"
end
def decorate_mail
diff --git a/app/mailboxes/support_mailbox.rb b/app/mailboxes/support_mailbox.rb
deleted file mode 100644
index 6279293b2..000000000
--- a/app/mailboxes/support_mailbox.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-class SupportMailbox < ApplicationMailbox
- include IncomingEmailValidityHelper
- attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
-
- before_processing :find_channel,
- :load_account,
- :load_inbox,
- :decorate_mail
-
- def process
- Rails.logger.info "Processing email #{mail.message_id} from #{original_sender_email} to #{mail.to} with subject #{mail.subject}"
-
- # Skip processing email if it belongs to any of the edge cases
- return unless incoming_email_from_valid_email?
-
- ActiveRecord::Base.transaction do
- find_or_create_contact
- find_or_create_conversation
- create_message
- add_attachments_to_message
- end
- end
-
- private
-
- def find_channel
- find_channel_with_to_mail if @channel.blank?
-
- raise 'Email channel/inbox not found' if @channel.nil?
-
- @channel
- end
-
- def find_channel_with_to_mail
- @channel = EmailChannelFinder.new(mail).perform
- end
-
- def load_account
- @account = @channel.account
- end
-
- def load_inbox
- @inbox = @channel.inbox
- end
-
- def decorate_mail
- @processed_mail = MailPresenter.new(mail, @account)
- end
-
- def find_conversation_by_in_reply_to
- return if in_reply_to.blank?
-
- @account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
- end
-
- def in_reply_to
- mail['In-Reply-To'].try(:value)
- end
-
- def original_sender_email
- @processed_mail.original_sender&.downcase
- end
-
- def find_or_create_conversation
- @conversation = find_conversation_by_in_reply_to || ::Conversation.create!({
- account_id: @account.id,
- inbox_id: @inbox.id,
- contact_id: @contact.id,
- contact_inbox_id: @contact_inbox.id,
- additional_attributes: {
- in_reply_to: in_reply_to,
- source: 'email',
- auto_reply: @processed_mail.auto_reply?,
- mail_subject: @processed_mail.subject,
- initiated_at: {
- timestamp: Time.now.utc
- }
- }
- })
- end
-
- def find_or_create_contact
- @contact = @inbox.contacts.from_email(original_sender_email)
- if @contact.present?
- @contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
- else
- create_contact
- end
- end
-
- def identify_contact_name
- processed_mail.sender_name || processed_mail.from.first.split('@').first
- end
-end
diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb
index a82c65440..8dbe67bf8 100644
--- a/app/mailers/conversation_reply_mailer.rb
+++ b/app/mailers/conversation_reply_mailer.rb
@@ -39,8 +39,7 @@ class ConversationReplyMailer < ApplicationMailer
init_conversation_attributes(message.conversation)
@message = message
- reply_mail_object = prepare_mail(true)
- message.update(source_id: reply_mail_object.message_id)
+ prepare_mail(true)
end
def conversation_transcript(conversation, to_email)
diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb
index a8fadb61e..b1124dd75 100644
--- a/app/models/channel/email.rb
+++ b/app/models/channel/email.rb
@@ -40,6 +40,12 @@ class Channel::Email < ApplicationRecord
AUTHORIZATION_ERROR_THRESHOLD = 10
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ if Chatwoot.encryption_configured?
+ encrypts :imap_password
+ encrypts :smtp_password
+ end
+
self.table_name = 'channel_email'
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb
index 1b6e151cb..1866d245b 100644
--- a/app/models/channel/facebook_page.rb
+++ b/app/models/channel/facebook_page.rb
@@ -21,6 +21,12 @@ class Channel::FacebookPage < ApplicationRecord
include Channelable
include Reauthorizable
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ if Chatwoot.encryption_configured?
+ encrypts :page_access_token
+ encrypts :user_access_token
+ end
+
self.table_name = 'channel_facebook_pages'
validates :page_id, uniqueness: { scope: :account_id }
diff --git a/app/models/channel/instagram.rb b/app/models/channel/instagram.rb
index 964a4c1a2..7e6444b30 100644
--- a/app/models/channel/instagram.rb
+++ b/app/models/channel/instagram.rb
@@ -19,6 +19,9 @@ class Channel::Instagram < ApplicationRecord
include Reauthorizable
self.table_name = 'channel_instagram'
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ encrypts :access_token if Chatwoot.encryption_configured?
+
AUTHORIZATION_ERROR_THRESHOLD = 1
validates :access_token, presence: true
diff --git a/app/models/channel/line.rb b/app/models/channel/line.rb
index a417dbf64..63b0924da 100644
--- a/app/models/channel/line.rb
+++ b/app/models/channel/line.rb
@@ -18,6 +18,12 @@
class Channel::Line < ApplicationRecord
include Channelable
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ if Chatwoot.encryption_configured?
+ encrypts :line_channel_secret
+ encrypts :line_channel_token
+ end
+
self.table_name = 'channel_line'
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb
index b00897614..b18c6dbc9 100644
--- a/app/models/channel/telegram.rb
+++ b/app/models/channel/telegram.rb
@@ -17,6 +17,9 @@
class Channel::Telegram < ApplicationRecord
include Channelable
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured?
+
self.table_name = 'channel_telegram'
EDITABLE_ATTRS = [:bot_token].freeze
diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb
index 73e5c873e..2f9130cbb 100644
--- a/app/models/channel/twilio_sms.rb
+++ b/app/models/channel/twilio_sms.rb
@@ -28,6 +28,9 @@ class Channel::TwilioSms < ApplicationRecord
self.table_name = 'channel_twilio_sms'
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ encrypts :auth_token if Chatwoot.encryption_configured?
+
validates :account_sid, presence: true
# The same parameter is used to store api_key_secret if api_key authentication is opted
validates :auth_token, presence: true
diff --git a/app/models/channel/twitter_profile.rb b/app/models/channel/twitter_profile.rb
index d0f765e9f..4ec167ce5 100644
--- a/app/models/channel/twitter_profile.rb
+++ b/app/models/channel/twitter_profile.rb
@@ -19,6 +19,12 @@
class Channel::TwitterProfile < ApplicationRecord
include Channelable
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ if Chatwoot.encryption_configured?
+ encrypts :twitter_access_token
+ encrypts :twitter_access_token_secret
+ end
+
self.table_name = 'channel_twitter_profiles'
validates :profile_id, uniqueness: { scope: :account_id }
diff --git a/app/models/concerns/auto_assignment_handler.rb b/app/models/concerns/auto_assignment_handler.rb
index de5fdae7d..a1198200a 100644
--- a/app/models/concerns/auto_assignment_handler.rb
+++ b/app/models/concerns/auto_assignment_handler.rb
@@ -14,7 +14,13 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
- ::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
+ if inbox.auto_assignment_v2_enabled?
+ # Use new assignment system
+ AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
+ else
+ # Use legacy assignment system
+ AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
+ end
end
def should_run_auto_assignment?
diff --git a/app/models/concerns/inbox_agent_availability.rb b/app/models/concerns/inbox_agent_availability.rb
new file mode 100644
index 000000000..289da7db9
--- /dev/null
+++ b/app/models/concerns/inbox_agent_availability.rb
@@ -0,0 +1,28 @@
+module InboxAgentAvailability
+ extend ActiveSupport::Concern
+
+ def available_agents
+ online_agent_ids = fetch_online_agent_ids
+ return inbox_members.none if online_agent_ids.empty?
+
+ inbox_members
+ .joins(:user)
+ .where(users: { id: online_agent_ids })
+ .includes(:user)
+ end
+
+ def member_ids_with_assignment_capacity
+ member_ids
+ end
+
+ private
+
+ def fetch_online_agent_ids
+ OnlineStatusTracker.get_available_users(account_id)
+ .select { |_key, value| value.eql?('online') }
+ .keys
+ .map(&:to_i)
+ end
+end
+
+InboxAgentAvailability.prepend_mod_with('InboxAgentAvailability')
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 4ec63acc2..eb97a22e8 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -228,14 +228,17 @@ class Conversation < ApplicationRecord
def determine_conversation_status
self.status = :resolved and return if contact.blocked?
- # Message template hooks aren't executed for conversations from campaigns
- # So making these conversations open for agent visibility
- return if campaign.present?
+ return handle_campaign_status if campaign.present?
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
self.status = :pending if inbox.active_bot?
end
+ def handle_campaign_status
+ # If campaign has no sender (bot-initiated) and inbox has active bot, let bot handle it
+ self.status = :pending if campaign.sender_id.nil? && inbox.active_bot?
+ end
+
def notify_conversation_creation
dispatcher_dispatch(CONVERSATION_CREATED)
end
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index b0f13d222..894c57bcc 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -44,6 +44,7 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
+ include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
@@ -190,6 +191,10 @@ class Inbox < ApplicationRecord
members.ids
end
+ def auto_assignment_v2_enabled?
+ account.feature_enabled?('assignment_v2')
+ end
+
private
def default_name_for_blank_name
diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb
index ca77fa13d..97d3f91ae 100644
--- a/app/models/integrations/hook.rb
+++ b/app/models/integrations/hook.rb
@@ -21,6 +21,9 @@ class Integrations::Hook < ApplicationRecord
before_validation :ensure_hook_type
after_create :trigger_setup_if_crm
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ encrypts :access_token, deterministic: true if Chatwoot.encryption_configured?
+
validates :account_id, presence: true
validates :app_id, presence: true
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
diff --git a/app/models/message.rb b/app/models/message.rb
index 2079e31a9..2964d9286 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -153,15 +153,6 @@ class Message < ApplicationRecord
merge_sender_attributes(data)
end
- def search_data
- data = attributes.symbolize_keys
- data[:conversation] = conversation.present? ? conversation_push_event_data : nil
- data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
- data[:sender] = sender.push_event_data if sender
- data[:inbox] = inbox
- data
- end
-
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
@@ -259,6 +250,10 @@ class Message < ApplicationRecord
true
end
+ def search_data
+ Messages::SearchDataPresenter.new(self).search_data
+ end
+
private
def prevent_message_flooding
@@ -300,7 +295,6 @@ class Message < ApplicationRecord
def execute_after_create_commit_callbacks
# rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911
reopen_conversation
- notify_via_mail
set_conversation_activity
dispatch_create_events
send_reply
@@ -386,48 +380,6 @@ class Message < ApplicationRecord
::MessageTemplates::HookExecutionService.new(message: self).perform
end
- def email_notifiable_webwidget?
- inbox.web_widget? && inbox.channel.continuity_via_email
- end
-
- def email_notifiable_api_channel?
- inbox.api? && inbox.account.feature_enabled?('email_continuity_on_api_channel')
- end
-
- def email_notifiable_channel?
- email_notifiable_webwidget? || %w[Email].include?(inbox.inbox_type) || email_notifiable_api_channel?
- end
-
- def can_notify_via_mail?
- return unless email_notifiable_message?
- return unless email_notifiable_channel?
- return if conversation.contact.email.blank?
-
- true
- end
-
- def notify_via_mail
- return unless can_notify_via_mail?
-
- trigger_notify_via_mail
- end
-
- def trigger_notify_via_mail
- return EmailReplyWorker.perform_in(1.second, id) if inbox.inbox_type == 'Email'
-
- # will set a redis key for the conversation so that we don't need to send email for every new message
- # last few messages coupled together is sent every 2 minutes rather than one email for each message
- # if redis key exists there is an unprocessed job that will take care of delivering the email
- return if Redis::Alfred.get(conversation_mail_key).present?
-
- Redis::Alfred.setex(conversation_mail_key, id)
- ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, id)
- end
-
- def conversation_mail_key
- format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
- end
-
def validate_attachments_limit(_attachment)
errors.add(:attachments, message: 'exceeded maximum allowed') if attachments.size >= NUMBER_OF_PERMITTED_ATTACHMENTS
end
diff --git a/app/models/reporting_event.rb b/app/models/reporting_event.rb
index 2f141786b..f083e32a1 100644
--- a/app/models/reporting_event.rb
+++ b/app/models/reporting_event.rb
@@ -35,4 +35,21 @@ class ReportingEvent < ApplicationRecord
belongs_to :user, optional: true
belongs_to :inbox, optional: true
belongs_to :conversation, optional: true
+
+ # Scopes for filtering
+ scope :filter_by_date_range, lambda { |range|
+ where(created_at: range) if range.present?
+ }
+
+ scope :filter_by_inbox_id, lambda { |inbox_id|
+ where(inbox_id: inbox_id) if inbox_id.present?
+ }
+
+ scope :filter_by_user_id, lambda { |user_id|
+ where(user_id: user_id) if user_id.present?
+ }
+
+ scope :filter_by_name, lambda { |name|
+ where(name: name) if name.present?
+ }
end
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
index 105ffdc91..3fafb4354 100644
--- a/app/models/webhook.rb
+++ b/app/models/webhook.rb
@@ -3,6 +3,7 @@
# Table name: webhooks
#
# id :bigint not null, primary key
+# name :string
# subscriptions :jsonb
# url :string
# webhook_type :integer default("account_type")
diff --git a/app/policies/conversation_policy.rb b/app/policies/conversation_policy.rb
index 931e17435..d23f29e54 100644
--- a/app/policies/conversation_policy.rb
+++ b/app/policies/conversation_policy.rb
@@ -4,6 +4,44 @@ class ConversationPolicy < ApplicationPolicy
end
def destroy?
- @account_user&.administrator?
+ administrator?
+ end
+
+ def show?
+ administrator? || agent_bot? || agent_can_view_conversation?
+ end
+
+ private
+
+ def agent_can_view_conversation?
+ inbox_access? || team_access?
+ end
+
+ def administrator?
+ account_user&.administrator?
+ end
+
+ def agent_bot?
+ user.is_a?(AgentBot)
+ end
+
+ def inbox_access?
+ user.inboxes.where(account_id: account&.id).exists?(id: record.inbox_id)
+ end
+
+ def team_access?
+ return false if record.team_id.blank?
+
+ user.teams.where(account_id: account&.id).exists?(id: record.team_id)
+ end
+
+ def assigned_to_user?
+ record.assignee_id == user.id
+ end
+
+ def participant?
+ record.conversation_participants.exists?(user_id: user.id)
end
end
+
+ConversationPolicy.prepend_mod_with('ConversationPolicy')
diff --git a/app/presenters/messages/search_data_presenter.rb b/app/presenters/messages/search_data_presenter.rb
new file mode 100644
index 000000000..7d0638add
--- /dev/null
+++ b/app/presenters/messages/search_data_presenter.rb
@@ -0,0 +1,58 @@
+class Messages::SearchDataPresenter < SimpleDelegator
+ def search_data
+ {
+ **searchable_content,
+ **message_attributes,
+ additional_attributes: additional_attributes_data,
+ conversation: conversation_data
+ }
+ end
+
+ private
+
+ def searchable_content
+ {
+ content: content,
+ attachments: attachment_data,
+ content_attributes: content_attributes_data
+ }
+ end
+
+ def message_attributes
+ {
+ account_id: account_id,
+ inbox_id: inbox_id,
+ conversation_id: conversation_id,
+ message_type: message_type,
+ private: private,
+ created_at: created_at,
+ source_id: source_id,
+ sender_id: sender_id,
+ sender_type: sender_type
+ }
+ end
+
+ def attachment_data
+ attachments.filter_map do |a|
+ { transcribed_text: a.meta&.dig('transcribed_text') }
+ end.presence
+ end
+
+ def content_attributes_data
+ email_subject = content_attributes.dig(:email, :subject)
+ return {} if email_subject.blank?
+
+ { email: { subject: email_subject } }
+ end
+
+ def conversation_data
+ { id: conversation.display_id }
+ end
+
+ def additional_attributes_data
+ {
+ campaign_id: additional_attributes&.dig('campaign_id'),
+ automation_rule_id: content_attributes&.dig('automation_rule_id')
+ }
+ end
+end
diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb
new file mode 100644
index 000000000..59e7b5a7b
--- /dev/null
+++ b/app/services/auto_assignment/assignment_service.rb
@@ -0,0 +1,90 @@
+class AutoAssignment::AssignmentService
+ pattr_initialize [:inbox!]
+
+ def perform_bulk_assignment(limit: 100)
+ return 0 unless inbox.auto_assignment_v2_enabled?
+
+ assigned_count = 0
+
+ unassigned_conversations(limit).each do |conversation|
+ assigned_count += 1 if perform_for_conversation(conversation)
+ end
+
+ assigned_count
+ end
+
+ private
+
+ def perform_for_conversation(conversation)
+ return false unless assignable?(conversation)
+
+ agent = find_available_agent
+ return false unless agent
+
+ assign_conversation(conversation, agent)
+ end
+
+ def assignable?(conversation)
+ conversation.status == 'open' &&
+ conversation.assignee_id.nil?
+ end
+
+ def unassigned_conversations(limit)
+ scope = inbox.conversations.unassigned.open
+
+ scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
+ scope.reorder(last_activity_at: :asc, created_at: :asc)
+ else
+ scope.reorder(created_at: :asc)
+ end
+
+ scope.limit(limit)
+ end
+
+ def find_available_agent
+ agents = filter_agents_by_rate_limit(inbox.available_agents)
+ return nil if agents.empty?
+
+ round_robin_selector.select_agent(agents)
+ end
+
+ def filter_agents_by_rate_limit(agents)
+ agents.select do |agent_member|
+ rate_limiter = build_rate_limiter(agent_member.user)
+ rate_limiter.within_limit?
+ end
+ end
+
+ def assign_conversation(conversation, agent)
+ conversation.update!(assignee: agent)
+
+ rate_limiter = build_rate_limiter(agent)
+ rate_limiter.track_assignment(conversation)
+
+ dispatch_assignment_event(conversation, agent)
+ true
+ end
+
+ def dispatch_assignment_event(conversation, agent)
+ Rails.configuration.dispatcher.dispatch(
+ Events::Types::ASSIGNEE_CHANGED,
+ Time.zone.now,
+ conversation: conversation,
+ user: agent
+ )
+ end
+
+ def build_rate_limiter(agent)
+ AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent)
+ end
+
+ def round_robin_selector
+ @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
+ end
+
+ def assignment_config
+ @assignment_config ||= inbox.auto_assignment_config || {}
+ end
+end
+
+AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
diff --git a/app/services/auto_assignment/rate_limiter.rb b/app/services/auto_assignment/rate_limiter.rb
new file mode 100644
index 000000000..d22f1f9b7
--- /dev/null
+++ b/app/services/auto_assignment/rate_limiter.rb
@@ -0,0 +1,49 @@
+class AutoAssignment::RateLimiter
+ pattr_initialize [:inbox!, :agent!]
+
+ def within_limit?
+ return true unless enabled?
+
+ current_count < limit
+ end
+
+ def track_assignment(conversation)
+ return unless enabled?
+
+ assignment_key = build_assignment_key(conversation.id)
+ Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
+ end
+
+ def current_count
+ return 0 unless enabled?
+
+ pattern = assignment_key_pattern
+ Redis::Alfred.keys_count(pattern)
+ end
+
+ private
+
+ def enabled?
+ limit.present? && limit.positive?
+ end
+
+ def limit
+ config&.fair_distribution_limit&.to_i || Math
+ end
+
+ def window
+ config&.fair_distribution_window&.to_i || 24.hours.to_i
+ end
+
+ def config
+ @config ||= inbox.assignment_policy
+ end
+
+ def assignment_key_pattern
+ format(Redis::RedisKeys::ASSIGNMENT_KEY_PATTERN, inbox_id: inbox.id, agent_id: agent.id)
+ end
+
+ def build_assignment_key(conversation_id)
+ format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation_id)
+ end
+end
diff --git a/app/services/auto_assignment/round_robin_selector.rb b/app/services/auto_assignment/round_robin_selector.rb
new file mode 100644
index 000000000..65e72090d
--- /dev/null
+++ b/app/services/auto_assignment/round_robin_selector.rb
@@ -0,0 +1,16 @@
+class AutoAssignment::RoundRobinSelector
+ pattr_initialize [:inbox!]
+
+ def select_agent(available_agents)
+ return nil if available_agents.empty?
+
+ agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
+ round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
+ end
+
+ private
+
+ def round_robin_service
+ @round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
+ end
+end
diff --git a/app/services/contacts/bulk_action_service.rb b/app/services/contacts/bulk_action_service.rb
new file mode 100644
index 000000000..a0e11ad9a
--- /dev/null
+++ b/app/services/contacts/bulk_action_service.rb
@@ -0,0 +1,44 @@
+class Contacts::BulkActionService
+ def initialize(account:, user:, params:)
+ @account = account
+ @user = user
+ @params = params.deep_symbolize_keys
+ end
+
+ def perform
+ return delete_contacts if delete_requested?
+ return assign_labels if labels_to_add.any?
+
+ Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
+ { success: false, error: 'unknown_operation' }
+ end
+
+ private
+
+ def assign_labels
+ Contacts::BulkAssignLabelsService.new(
+ account: @account,
+ contact_ids: ids,
+ labels: labels_to_add
+ ).perform
+ end
+
+ def delete_contacts
+ Contacts::BulkDeleteService.new(
+ account: @account,
+ contact_ids: ids
+ ).perform
+ end
+
+ def ids
+ Array(@params[:ids]).compact
+ end
+
+ def labels_to_add
+ @labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
+ end
+
+ def delete_requested?
+ @params[:action_name] == 'delete'
+ end
+end
diff --git a/app/services/contacts/bulk_assign_labels_service.rb b/app/services/contacts/bulk_assign_labels_service.rb
new file mode 100644
index 000000000..4aa4a5de5
--- /dev/null
+++ b/app/services/contacts/bulk_assign_labels_service.rb
@@ -0,0 +1,19 @@
+class Contacts::BulkAssignLabelsService
+ def initialize(account:, contact_ids:, labels:)
+ @account = account
+ @contact_ids = Array(contact_ids)
+ @labels = Array(labels).compact_blank
+ end
+
+ def perform
+ return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank?
+
+ contacts = @account.contacts.where(id: @contact_ids)
+
+ contacts.find_each do |contact|
+ contact.add_labels(@labels)
+ end
+
+ { success: true, updated_contact_ids: contacts.pluck(:id) }
+ end
+end
diff --git a/app/services/contacts/bulk_delete_service.rb b/app/services/contacts/bulk_delete_service.rb
new file mode 100644
index 000000000..d197f17f6
--- /dev/null
+++ b/app/services/contacts/bulk_delete_service.rb
@@ -0,0 +1,18 @@
+class Contacts::BulkDeleteService
+ def initialize(account:, contact_ids: [])
+ @account = account
+ @contact_ids = Array(contact_ids).compact
+ end
+
+ def perform
+ return if @contact_ids.blank?
+
+ contacts.find_each(&:destroy!)
+ end
+
+ private
+
+ def contacts
+ @account.contacts.where(id: @contact_ids)
+ end
+end
diff --git a/app/services/email/send_on_email_service.rb b/app/services/email/send_on_email_service.rb
new file mode 100644
index 000000000..b3a0060b1
--- /dev/null
+++ b/app/services/email/send_on_email_service.rb
@@ -0,0 +1,18 @@
+class Email::SendOnEmailService < Base::SendOnChannelService
+ private
+
+ def channel_class
+ Channel::Email
+ end
+
+ def perform_reply
+ return unless message.email_notifiable_message?
+
+ reply_mail = ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
+ Rails.logger.info("Email message #{message.id} sent with source_id: #{reply_mail.message_id}")
+ message.update(source_id: reply_mail.message_id)
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: message.account).capture_exception
+ Messages::StatusUpdateService.new(message, 'failed', e.message).perform
+ end
+end
diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb
index 8654e0adf..4e0bd7013 100644
--- a/app/services/llm_formatter/conversation_llm_formatter.rb
+++ b/app/services/llm_formatter/conversation_llm_formatter.rb
@@ -39,7 +39,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
end
def format_message(message)
- sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
+ sender = case message.sender_type
+ when 'User'
+ 'Support Agent'
+ when 'Contact'
+ 'User'
+ else
+ 'Bot'
+ end
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n"
end
diff --git a/app/services/mailbox/conversation_finder.rb b/app/services/mailbox/conversation_finder.rb
new file mode 100644
index 000000000..2f8128ecd
--- /dev/null
+++ b/app/services/mailbox/conversation_finder.rb
@@ -0,0 +1,29 @@
+class Mailbox::ConversationFinder
+ DEFAULT_STRATEGIES = [
+ Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
+ Mailbox::ConversationFinderStrategies::InReplyToStrategy,
+ Mailbox::ConversationFinderStrategies::ReferencesStrategy,
+ Mailbox::ConversationFinderStrategies::NewConversationStrategy
+ ].freeze
+
+ def initialize(mail, strategies: DEFAULT_STRATEGIES)
+ @mail = mail
+ @strategies = strategies
+ end
+
+ def find
+ @strategies.each do |strategy_class|
+ conversation = strategy_class.new(@mail).find
+
+ next unless conversation
+
+ strategy_name = strategy_class.name.demodulize.underscore
+ Rails.logger.info "Conversation found via #{strategy_name} strategy"
+ return conversation
+ end
+
+ # Should not reach here if NewConversationStrategy is in the chain
+ Rails.logger.error 'No conversation found via any strategy (NewConversationStrategy missing?)'
+ nil
+ end
+end
diff --git a/app/services/mailbox/conversation_finder_strategies/base_strategy.rb b/app/services/mailbox/conversation_finder_strategies/base_strategy.rb
new file mode 100644
index 000000000..c1e738805
--- /dev/null
+++ b/app/services/mailbox/conversation_finder_strategies/base_strategy.rb
@@ -0,0 +1,13 @@
+class Mailbox::ConversationFinderStrategies::BaseStrategy
+ attr_reader :mail
+
+ def initialize(mail)
+ @mail = mail
+ end
+
+ # Returns Conversation or nil
+ # Subclasses must implement this method
+ def find
+ raise NotImplementedError, "#{self.class} must implement #find"
+ end
+end
diff --git a/app/services/mailbox/conversation_finder_strategies/in_reply_to_strategy.rb b/app/services/mailbox/conversation_finder_strategies/in_reply_to_strategy.rb
new file mode 100644
index 000000000..b14f851f5
--- /dev/null
+++ b/app/services/mailbox/conversation_finder_strategies/in_reply_to_strategy.rb
@@ -0,0 +1,48 @@
+class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
+ # Patterns from ApplicationMailbox
+ MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
+
+ # FALLBACK_PATTERN is used when building In-Reply-To headers in ConversationReplyMailer
+ # when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
+ # This happens when:
+ # - A conversation is started by an agent (no incoming message yet)
+ # - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
+ # - The incoming message doesn't have email metadata with a message_id
+ # In these cases, we use a conversation-level identifier instead of a message-level one.
+ FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
+
+ def find
+ return nil if mail.in_reply_to.blank?
+
+ in_reply_to_addresses = Array.wrap(mail.in_reply_to)
+
+ in_reply_to_addresses.each do |in_reply_to|
+ # Try extracting UUID from patterns
+ uuid = extract_uuid_from_patterns(in_reply_to)
+ if uuid
+ conversation = Conversation.find_by(uuid: uuid)
+ return conversation if conversation
+ end
+
+ # Try finding by message source_id
+ message = Message.find_by(source_id: in_reply_to)
+ return message.conversation if message&.conversation
+ end
+
+ nil
+ end
+
+ private
+
+ def extract_uuid_from_patterns(message_id)
+ # Try message-specific pattern first
+ match = MESSAGE_PATTERN.match(message_id)
+ return match[1] if match
+
+ # Try conversation fallback pattern
+ match = FALLBACK_PATTERN.match(message_id)
+ return match[2] if match
+
+ nil
+ end
+end
diff --git a/app/services/mailbox/conversation_finder_strategies/new_conversation_strategy.rb b/app/services/mailbox/conversation_finder_strategies/new_conversation_strategy.rb
new file mode 100644
index 000000000..a21fcebe5
--- /dev/null
+++ b/app/services/mailbox/conversation_finder_strategies/new_conversation_strategy.rb
@@ -0,0 +1,83 @@
+class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
+ include MailboxHelper
+ include IncomingEmailValidityHelper
+
+ attr_accessor :processed_mail, :account, :inbox, :contact, :contact_inbox, :conversation, :channel
+
+ def initialize(mail)
+ super(mail)
+ @channel = EmailChannelFinder.new(mail).perform
+ return unless @channel
+
+ @account = @channel.account
+ @inbox = @channel.inbox
+ @processed_mail = MailPresenter.new(mail, @account)
+ end
+
+ # This strategy prepares a new conversation but doesn't persist it yet.
+ # Why we don't use create! here:
+ # - Avoids orphan conversations if message/attachment creation fails later
+ # - Prevents duplicate conversations on job retry (no idempotency issue)
+ # - Follows the pattern from old SupportMailbox where everything was in one transaction
+ # The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
+ def find
+ return nil unless @channel # No valid channel found
+ return nil unless incoming_email_from_valid_email? # Skip edge cases
+
+ # Check if conversation already exists by in_reply_to
+ existing_conversation = find_conversation_by_in_reply_to
+ return existing_conversation if existing_conversation
+
+ # Prepare contact (persisted) and build conversation (not persisted)
+ find_or_create_contact
+ build_conversation
+ end
+
+ private
+
+ def find_or_create_contact
+ @contact = @inbox.contacts.from_email(original_sender_email)
+ if @contact.present?
+ @contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
+ else
+ create_contact
+ end
+ end
+
+ def original_sender_email
+ @processed_mail.original_sender&.downcase
+ end
+
+ def identify_contact_name
+ @processed_mail.sender_name || @processed_mail.from.first.split('@').first
+ end
+
+ def build_conversation
+ # Build but don't persist - ReplyMailbox will save in transaction with message
+ @conversation = ::Conversation.new(
+ account_id: @account.id,
+ inbox_id: @inbox.id,
+ contact_id: @contact.id,
+ contact_inbox_id: @contact_inbox.id,
+ additional_attributes: {
+ in_reply_to: in_reply_to,
+ source: 'email',
+ auto_reply: @processed_mail.auto_reply?,
+ mail_subject: @processed_mail.subject,
+ initiated_at: {
+ timestamp: Time.now.utc
+ }
+ }
+ )
+ end
+
+ def in_reply_to
+ mail['In-Reply-To'].try(:value)
+ end
+
+ def find_conversation_by_in_reply_to
+ return if in_reply_to.blank?
+
+ @account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
+ end
+end
diff --git a/app/services/mailbox/conversation_finder_strategies/receiver_uuid_strategy.rb b/app/services/mailbox/conversation_finder_strategies/receiver_uuid_strategy.rb
new file mode 100644
index 000000000..39b155cf8
--- /dev/null
+++ b/app/services/mailbox/conversation_finder_strategies/receiver_uuid_strategy.rb
@@ -0,0 +1,26 @@
+class Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
+ # Pattern from ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN
+ UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
+
+ def find
+ uuid = extract_uuid_from_receivers
+ return nil unless uuid
+
+ Conversation.find_by(uuid: uuid)
+ end
+
+ private
+
+ def extract_uuid_from_receivers
+ mail_presenter = MailPresenter.new(mail)
+ return nil if mail_presenter.mail_receiver.blank?
+
+ mail_presenter.mail_receiver.each do |email|
+ username = email.split('@').first
+ match = username.match(UUID_PATTERN)
+ return match[1] if match
+ end
+
+ nil
+ end
+end
diff --git a/app/services/mailbox/conversation_finder_strategies/references_strategy.rb b/app/services/mailbox/conversation_finder_strategies/references_strategy.rb
new file mode 100644
index 000000000..86a0aa3c5
--- /dev/null
+++ b/app/services/mailbox/conversation_finder_strategies/references_strategy.rb
@@ -0,0 +1,59 @@
+class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
+ # Patterns from ApplicationMailbox
+ MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
+
+ # FALLBACK_PATTERN is used when building References headers in ConversationReplyMailer
+ # when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
+ # This happens when:
+ # - A conversation is started by an agent (no incoming message yet)
+ # - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
+ # - The incoming message doesn't have email metadata with a message_id
+ # In these cases, we use a conversation-level identifier instead of a message-level one.
+ FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
+
+ def initialize(mail)
+ super(mail)
+ # Get channel once upfront to use for scoped queries
+ @channel = EmailChannelFinder.new(mail).perform
+ end
+
+ def find
+ return nil if mail.references.blank?
+ return nil unless @channel # No valid channel found
+
+ references = Array.wrap(mail.references)
+
+ references.each do |reference|
+ conversation = find_conversation_from_reference(reference)
+ return conversation if conversation
+ end
+
+ nil
+ end
+
+ private
+
+ def find_conversation_from_reference(reference)
+ # Try extracting UUID from patterns
+ uuid = extract_uuid_from_patterns(reference)
+ if uuid
+ # Query scoped to inbox - prevents cross-account/cross-inbox matches at database level
+ conversation = Conversation.find_by(uuid: uuid, inbox_id: @channel.inbox.id)
+ return conversation if conversation
+ end
+
+ # We scope to the inbox, that way we filter out messages and conversations that don't belong to the channel
+ message = Message.find_by(source_id: reference, inbox_id: @channel.inbox.id)
+ message&.conversation
+ end
+
+ def extract_uuid_from_patterns(message_id)
+ match = MESSAGE_PATTERN.match(message_id)
+ return match[1] if match
+
+ match = FALLBACK_PATTERN.match(message_id)
+ return match[2] if match
+
+ nil
+ end
+end
diff --git a/app/services/messages/send_email_notification_service.rb b/app/services/messages/send_email_notification_service.rb
new file mode 100644
index 000000000..25a77b0d5
--- /dev/null
+++ b/app/services/messages/send_email_notification_service.rb
@@ -0,0 +1,38 @@
+class Messages::SendEmailNotificationService
+ pattr_initialize [:message!]
+
+ def perform
+ return unless should_send_email_notification?
+
+ conversation = message.conversation
+ conversation_mail_key = format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
+
+ # Atomically set redis key to prevent duplicate email workers. Keep the key alive longer than
+ # the worker delay (1 hour) so slow queues don't enqueue duplicate jobs, but let it expire if
+ # the worker never manages to clean up.
+ return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
+
+ ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
+ end
+
+ private
+
+ def should_send_email_notification?
+ return false unless message.email_notifiable_message?
+ return false if message.conversation.contact.email.blank?
+
+ email_reply_enabled?
+ end
+
+ def email_reply_enabled?
+ inbox = message.inbox
+ case inbox.channel.class.to_s
+ when 'Channel::WebWidget'
+ inbox.channel.continuity_via_email
+ when 'Channel::Api'
+ inbox.account.feature_enabled?('email_continuity_on_api_channel')
+ else
+ false
+ end
+ end
+end
diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb
index 7a74488e2..5d695ebb2 100644
--- a/app/services/twilio/incoming_message_service.rb
+++ b/app/services/twilio/incoming_message_service.rb
@@ -44,6 +44,12 @@ class Twilio::IncomingMessageService
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
end
+ def normalized_phone_number
+ return phone_number unless twilio_channel.whatsapp?
+
+ Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
+ end
+
def formatted_phone_number
TelephoneNumber.parse(phone_number).international_number
end
@@ -53,8 +59,10 @@ class Twilio::IncomingMessageService
end
def set_contact
+ source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
+
contact_inbox = ::ContactInboxWithContactBuilder.new(
- source_id: params[:From],
+ source_id: source_id,
inbox: inbox,
contact_attributes: contact_attributes
).perform
diff --git a/app/services/whatsapp/channel_creation_service.rb b/app/services/whatsapp/channel_creation_service.rb
index 154f55520..74882c7e5 100644
--- a/app/services/whatsapp/channel_creation_service.rb
+++ b/app/services/whatsapp/channel_creation_service.rb
@@ -10,7 +10,7 @@ class Whatsapp::ChannelCreationService
validate_parameters!
existing_channel = find_existing_channel
- raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
+ raise I18n.t('errors.whatsapp.phone_number_already_exists', phone_number: existing_channel.phone_number) if existing_channel
create_channel_with_inbox
end
@@ -26,7 +26,6 @@ class Whatsapp::ChannelCreationService
def find_existing_channel
Channel::Whatsapp.find_by(
- account: @account,
phone_number: @phone_info[:phone_number]
)
end
diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb
index 705babbba..46ad255aa 100644
--- a/app/services/whatsapp/incoming_message_service_helpers.rb
+++ b/app/services/whatsapp/incoming_message_service_helpers.rb
@@ -47,17 +47,8 @@ module Whatsapp::IncomingMessageServiceHelpers
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
end
- def argentina_phone_number?(phone_number)
- phone_number.match(/^54/)
- end
-
- def normalised_argentina_mobil_number(phone_number)
- # Remove 9 before country code
- phone_number.sub(/^549/, '54')
- end
-
def processed_waid(waid)
- Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
+ Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
end
def error_webhook_event?(message)
diff --git a/app/services/whatsapp/phone_number_normalization_service.rb b/app/services/whatsapp/phone_number_normalization_service.rb
index cd10db0d0..1e52d9b02 100644
--- a/app/services/whatsapp/phone_number_normalization_service.rb
+++ b/app/services/whatsapp/phone_number_normalization_service.rb
@@ -1,23 +1,32 @@
# Service to handle phone number normalization for WhatsApp messages
# Currently supports Brazil and Argentina phone number format variations
-# Designed to be extensible for additional countries in future PRs
-#
-# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
+# Supports both WhatsApp Cloud API and Twilio WhatsApp providers
class Whatsapp::PhoneNumberNormalizationService
def initialize(inbox)
@inbox = inbox
end
- # Main entry point for phone number normalization
- # Returns the source_id of an existing contact if found, otherwise returns original waid
- def normalize_and_find_contact(waid)
- normalizer = find_normalizer_for_country(waid)
- return waid unless normalizer
+ # @param raw_number [String] The phone number in provider-specific format
+ # - Cloud: "5541988887777" (clean number)
+ # - Twilio: "whatsapp:+5541988887777" (prefixed format)
+ # @param provider [Symbol] :cloud or :twilio
+ # @return [String] Normalized source_id in provider format or original if not found
+ def normalize_and_find_contact_by_provider(raw_number, provider)
+ # Extract clean number based on provider format
+ clean_number = extract_clean_number(raw_number, provider)
- normalized_waid = normalizer.normalize(waid)
- existing_contact_inbox = find_existing_contact_inbox(normalized_waid)
+ # Find appropriate normalizer for the country
+ normalizer = find_normalizer_for_country(clean_number)
+ return raw_number unless normalizer
- existing_contact_inbox&.source_id || waid
+ # Normalize the clean number
+ normalized_clean_number = normalizer.normalize(clean_number)
+
+ # Format for provider and check for existing contact
+ provider_format = format_for_provider(normalized_clean_number, provider)
+ existing_contact_inbox = find_existing_contact_inbox(provider_format)
+
+ existing_contact_inbox&.source_id || raw_number
end
private
@@ -33,6 +42,26 @@ class Whatsapp::PhoneNumberNormalizationService
inbox.contact_inboxes.find_by(source_id: normalized_waid)
end
+ # Extract clean number from provider-specific format
+ def extract_clean_number(raw_number, provider)
+ case provider
+ when :twilio
+ raw_number.gsub(/^whatsapp:\+/, '') # Remove prefix: "whatsapp:+5541988887777" → "5541988887777"
+ else
+ raw_number # Default fallback for unknown providers
+ end
+ end
+
+ # Format normalized number for provider-specific storage
+ def format_for_provider(clean_number, provider)
+ case provider
+ when :twilio
+ "whatsapp:+#{clean_number}" # Add prefix: "5541988887777" → "whatsapp:+5541988887777"
+ else
+ clean_number # Default for :cloud and unknown providers: "5541988887777"
+ end
+ end
+
NORMALIZERS = [
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
diff --git a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder
index fac8fdcf2..5406cf183 100644
--- a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder
+++ b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder
@@ -1,4 +1,5 @@
json.id webhook.id
+json.name webhook.name
json.url webhook.url
json.account_id webhook.account_id
json.subscriptions webhook.subscriptions
diff --git a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
index feb5dff96..f5f827e4c 100644
--- a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
+++ b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
@@ -1,9 +1,11 @@
-<% if @message.content %>
- <%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
+<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
+<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
+<% elsif @message.content %>
+<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% end %>
<% if @large_attachments.present? %>
- Attachments:
- <% @large_attachments.each do |attachment| %>
- <%= attachment.file.filename.to_s %>
- <% end %>
+Attachments:
+<% @large_attachments.each do |attachment| %>
+<%= attachment.file.filename.to_s %>
+<% end %>
<% end %>
diff --git a/app/workers/conversation_reply_email_worker.rb b/app/workers/conversation_reply_email_worker.rb
deleted file mode 100644
index 0eddecaf7..000000000
--- a/app/workers/conversation_reply_email_worker.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# TODO: lets move this to active job, since thats what we use over all
-class ConversationReplyEmailWorker
- include Sidekiq::Worker
- sidekiq_options queue: :mailers
-
- def perform(conversation_id, last_queued_id)
- @conversation = Conversation.find(conversation_id)
-
- # send the email
- if @conversation.messages.incoming&.last&.content_type == 'incoming_email'
- ConversationReplyMailer.with(account: @conversation.account).reply_without_summary(@conversation, last_queued_id).deliver_later
- else
- ConversationReplyMailer.with(account: @conversation.account).reply_with_summary(@conversation, last_queued_id).deliver_later
- end
-
- # delete the redis set from the first new message on the conversation
- Redis::Alfred.delete(conversation_mail_key)
- end
-
- private
-
- def email_inbox?
- @conversation.inbox&.inbox_type == 'Email'
- end
-
- def conversation_mail_key
- format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: @conversation.id)
- end
-end
diff --git a/app/workers/email_reply_worker.rb b/app/workers/email_reply_worker.rb
deleted file mode 100644
index 14b668637..000000000
--- a/app/workers/email_reply_worker.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-class EmailReplyWorker
- include Sidekiq::Worker
- sidekiq_options queue: :mailers, retry: 3
-
- def perform(message_id)
- message = Message.find(message_id)
-
- return unless message.email_notifiable_message?
-
- # send the email
- ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
- rescue StandardError => e
- ChatwootExceptionTracker.new(e, account: message.account).capture_exception
- Messages::StatusUpdateService.new(message, 'failed', e.message).perform
- end
-end
diff --git a/config/app.yml b/config/app.yml
index 34d044656..e3d9c2c69 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.6.0'
+ version: '4.7.0'
development:
<<: *shared
diff --git a/config/application.rb b/config/application.rb
index d644dd28f..aa150794a 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -75,7 +75,11 @@ module Chatwoot
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
+ # TODO: Remove once encryption is mandatory and legacy plaintext is migrated.
config.active_record.encryption.support_unencrypted_data = true
+ # Extend deterministic queries so they match both encrypted and plaintext rows
+ config.active_record.encryption.extend_queries = true
+ # Store a per-row key reference to support future key rotation
config.active_record.encryption.store_key_references = true
end
end
@@ -94,6 +98,8 @@ module Chatwoot
end
def self.encryption_configured?
+ # TODO: Once Active Record encryption keys are mandatory (target 3-4 releases out),
+ # remove this guard and assume encryption is always enabled.
# Check if proper encryption keys are configured
# MFA/2FA features should only be enabled when proper keys are set
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 96561f2ad..dd5c71a4d 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -6,9 +6,24 @@ Sidekiq.configure_client do |config|
config.redis = Redis::Config.app
end
+# Logs whenever a job is pulled off Redis for execution.
+class ChatwootDequeuedLogger
+ def call(_worker, job, queue)
+ payload = job['args'].first
+ Sidekiq.logger.info("Dequeued #{job['wrapped']} #{payload['job_id']} from #{queue}")
+ yield
+ end
+end
+
Sidekiq.configure_server do |config|
config.redis = Redis::Config.app
+ if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_SIDEKIQ_DEQUEUE_LOGGER', false))
+ config.server_middleware do |chain|
+ chain.add ChatwootDequeuedLogger
+ end
+ end
+
# skip the default start stop logging
if Rails.env.production?
config.logger.formatter = Sidekiq::Logger::Formatters::JSON.new
diff --git a/config/locales/am.yml b/config/locales/am.yml
index 28b6f375a..59ecaf187 100644
--- a/config/locales/am.yml
+++ b/config/locales/am.yml
@@ -62,6 +62,9 @@ am:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ am:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ am:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 369e73507..77c3f3c21 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -62,6 +62,9 @@ ar:
invalid: إيميل غير صالح
phone_number:
invalid: يجب أن يكون بصيغة e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: يجب أن تكون فريدة من نوعها في الفئة والبوابة
@@ -73,6 +76,7 @@ ar:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ar:
captain:
resolved: 'تم تحديد هذه المحادثة كمحلولة بواسطة %{user_name} بسبب عدم النشاط'
open: 'تم تحديد هذه المحادثة كمفتوحة بواسطة %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'تم تحديث حالة المحادثة لـ"مغلقة" بواسطة %{user_name}'
contact_resolved: 'تم حل المحادثة بواسطة %{contact_name}'
diff --git a/config/locales/az.yml b/config/locales/az.yml
index 3b04a2a82..ecf46da3b 100644
--- a/config/locales/az.yml
+++ b/config/locales/az.yml
@@ -62,6 +62,9 @@ az:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ az:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ az:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/bg.yml b/config/locales/bg.yml
index 0b65acf7e..9f806ddb8 100644
--- a/config/locales/bg.yml
+++ b/config/locales/bg.yml
@@ -62,6 +62,9 @@ bg:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ bg:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ bg:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/bn.yml b/config/locales/bn.yml
new file mode 100644
index 000000000..6bea58292
--- /dev/null
+++ b/config/locales/bn.yml
@@ -0,0 +1,423 @@
+#Files in the config/locales directory are used for internationalization
+#and are automatically loaded by Rails. If you want to use locales other
+#than English, add the necessary files in this directory.
+#To use the locales, use `I18n.t`:
+#I18n.t 'hello'
+#In views, this is aliased to just `t`:
+#<%= t('hello') %>
+#To use a different locale, set it with `I18n.locale`:
+#I18n.locale = :es
+#This would use the information in config/locales/es.yml.
+#The following keys must be escaped otherwise they will not be retrieved by
+#the default I18n backend:
+#true, false, on, off, yes, no
+#Instead, surround them with single quotes.
+#en:
+#'true': 'foo'
+#To learn more, please read the Rails Internationalization guide
+#available at https://guides.rubyonrails.org/i18n.html.
+bn:
+ hello: 'Hello world'
+ inbox:
+ reauthorization:
+ success: 'Channel reauthorized successfully'
+ not_required: 'Reauthorization is not required for this inbox'
+ invalid_channel: 'Invalid channel type for reauthorization'
+ auth:
+ saml:
+ invalid_email: 'Please enter a valid email address'
+ authentication_failed: 'Authentication failed. Please check your credentials and try again.'
+ messages:
+ reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
+ reset_password_failure: Uh ho! We could not find any user with the specified email.
+ reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
+ login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
+ saml_not_available: SAML authentication is not available in this installation.
+ inbox_deletetion_response: Your inbox deletion request will be processed in some time.
+ errors:
+ validations:
+ presence: must not be blank
+ webhook:
+ invalid: Invalid events
+ signup:
+ disposable_email: We do not allow disposable emails
+ blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support.
+ invalid_email: You have entered an invalid email
+ email_already_exists: 'You have already signed up for an account with %{email}'
+ invalid_params: 'Invalid, please check the signup paramters and try again'
+ failed: Signup failed
+ assignment_policy:
+ not_found: Assignment policy not found
+ saml:
+ feature_not_enabled: SAML feature not enabled for this account
+ data_import:
+ data_type:
+ invalid: Invalid data type
+ contacts:
+ import:
+ failed: File is blank
+ export:
+ success: We will notify you once contacts export file is ready to view.
+ email:
+ invalid: Invalid email
+ phone_number:
+ invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
+ categories:
+ locale:
+ unique: should be unique in the category and portal
+ dyte:
+ invalid_message_type: 'Invalid message type. Action not permitted'
+ slack:
+ invalid_channel_id: 'Invalid slack channel. Please try again'
+ whatsapp:
+ token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
+ invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
+ phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
+ reauthorization:
+ generic: 'Failed to reauthorize WhatsApp. Please try again.'
+ not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+ inboxes:
+ imap:
+ socket_error: Please check the network connection, IMAP address and try again.
+ no_response_error: Please check the IMAP credentials and try again.
+ host_unreachable_error: Host unreachable, Please check the IMAP address, IMAP port and try again.
+ connection_timed_out_error: Connection timed out for %{address}:%{port}
+ connection_closed_error: Connection closed.
+ validations:
+ name: should not start or end with symbols, and it should not have < > / \ @ characters.
+ custom_filters:
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
+ invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
+ invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
+ invalid_query_operator: Query operator must be either "AND" or "OR".
+ invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
+ mfa:
+ already_enabled: MFA is already enabled
+ not_enabled: MFA is not enabled
+ invalid_code: Invalid verification code
+ invalid_backup_code: Invalid backup code
+ invalid_token: Invalid or expired MFA token
+ invalid_credentials: Invalid credentials or verification code
+ feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ profile:
+ mfa:
+ enabled: MFA enabled successfully
+ disabled: MFA disabled successfully
+ account_saml_settings:
+ invalid_certificate: must be a valid X.509 certificate in PEM format
+ reports:
+ period: Reporting period %{since} to %{until}
+ utc_warning: The report generated is in UTC timezone
+ agent_csv:
+ agent_name: Agent name
+ conversations_count: Assigned conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution Count
+ avg_customer_waiting_time: Avg customer waiting time
+ inbox_csv:
+ inbox_name: Inbox name
+ inbox_type: Inbox type
+ conversations_count: No. of conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ label_csv:
+ label_title: Label
+ conversations_count: No. of conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Resolution Count
+ team_csv:
+ team_name: Team name
+ conversations_count: Conversations count
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution Count
+ avg_customer_waiting_time: Avg customer waiting time
+ conversation_traffic_csv:
+ timezone: Timezone
+ sla_csv:
+ conversation_id: Conversation ID
+ sla_policy_breached: SLA Policy
+ assignee: Assignee
+ team: Team
+ inbox: Inbox
+ labels: Labels
+ conversation_link: Link to the Conversation
+ breached_events: Breached Events
+ default_group_by: day
+ csat:
+ headers:
+ contact_name: Contact Name
+ contact_email_address: Contact Email Address
+ contact_phone_number: Contact Phone Number
+ link_to_the_conversation: Link to the conversation
+ agent_name: Agent Name
+ rating: Rating
+ feedback: Feedback Comment
+ recorded_at: Recorded date
+ notifications:
+ notification_title:
+ conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
+ conversation_assignment: 'A conversation (#%{display_id}) has been assigned to you'
+ assigned_conversation_new_message: 'A new message is created in conversation (#%{display_id})'
+ conversation_mention: 'You have been mentioned in conversation (#%{display_id})'
+ sla_missed_first_response: 'SLA target first response missed for conversation (#%{display_id})'
+ sla_missed_next_response: 'SLA target next response missed for conversation (#%{display_id})'
+ sla_missed_resolution: 'SLA target resolution missed for conversation (#%{display_id})'
+ attachment: 'Attachment'
+ no_content: 'No content'
+ conversations:
+ captain:
+ handoff: 'Transferring to another agent for further assistance.'
+ messages:
+ instagram_story_content: '%{story_sender} mentioned you in the story: '
+ instagram_deleted_story_content: This story is no longer available.
+ deleted: This message was deleted
+ whatsapp:
+ list_button_label: 'Choose an item'
+ delivery_status:
+ error_code: 'Error code: %{error_code}'
+ activity:
+ captain:
+ resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
+ open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
+ status:
+ resolved: 'Conversation was marked resolved by %{user_name}'
+ contact_resolved: 'Conversation was resolved by %{contact_name}'
+ open: 'Conversation was reopened by %{user_name}'
+ pending: 'Conversation was marked as pending by %{user_name}'
+ snoozed: 'Conversation was snoozed by %{user_name}'
+ auto_resolved_days: 'Conversation was marked resolved by system due to %{count} days of inactivity'
+ auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
+ auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
+ system_auto_open: System reopened the conversation due to a new incoming message.
+ priority:
+ added: '%{user_name} set the priority to %{new_priority}'
+ updated: '%{user_name} changed the priority from %{old_priority} to %{new_priority}'
+ removed: '%{user_name} removed the priority'
+ assignee:
+ self_assigned: '%{user_name} self-assigned this conversation'
+ assigned: 'Assigned to %{assignee_name} by %{user_name}'
+ removed: 'Conversation unassigned by %{user_name}'
+ team:
+ assigned: 'Assigned to %{team_name} by %{user_name}'
+ assigned_with_assignee: 'Assigned to %{assignee_name} via %{team_name} by %{user_name}'
+ removed: 'Unassigned from %{team_name} by %{user_name}'
+ labels:
+ added: '%{user_name} added %{labels}'
+ removed: '%{user_name} removed %{labels}'
+ sla:
+ added: '%{user_name} added SLA policy %{sla_name}'
+ removed: '%{user_name} removed SLA policy %{sla_name}'
+ linear:
+ issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
+ issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
+ issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
+ csat:
+ not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
+ auto_resolve:
+ not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
+ muted: '%{user_name} has muted the conversation'
+ unmuted: '%{user_name} has unmuted the conversation'
+ auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
+ templates:
+ greeting_message_body: '%{account_name} typically replies in a few hours.'
+ ways_to_reach_you_message_body: 'Give the team a way to reach you.'
+ email_input_box_message_body: 'Get notified by email'
+ csat_input_message_body: 'Please rate the conversation'
+ reply:
+ email:
+ header:
+ notifications: 'Notifications'
+ from_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
+ reply_with_name: '%{assignee_name} from %{inbox_name} '
+ friendly_name: '%{sender_name} from %{business_name} <%{from_email}>'
+ professional_name: '%{business_name} <%{from_email}>'
+ channel_email:
+ header:
+ reply_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
+ reply_with_inbox_name: '%{inbox_name} <%{from_email}>'
+ email_subject: 'New messages on this conversation'
+ transcript_subject: 'Conversation Transcript'
+ survey:
+ response: 'Please rate this conversation, %{link}'
+ contacts:
+ online:
+ delete: '%{contact_name} is Online, please try again later'
+ integration_apps:
+ #Note: webhooks and dashboard_apps don't need short_description as they use different modal components
+ dashboard_apps:
+ name: 'Dashboard Apps'
+ description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
+ dyte:
+ name: 'Dyte'
+ short_description: 'Start video/voice calls with customers directly from Chatwoot.'
+ description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
+ meeting_name: '%{agent_name} has started a meeting'
+ slack:
+ name: 'Slack'
+ short_description: 'Receive notifications and respond to conversations directly in Slack.'
+ description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
+ webhooks:
+ name: 'Webhooks'
+ description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
+ dialogflow:
+ name: 'Dialogflow'
+ short_description: 'Build chatbots to handle initial queries before transferring to agents.'
+ description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
+ google_translate:
+ name: 'Google Translate'
+ short_description: 'Automatically translate customer messages for agents.'
+ description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
+ openai:
+ name: 'OpenAI'
+ short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
+ description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
+ linear:
+ name: 'Linear'
+ short_description: 'Create and link Linear issues directly from conversations.'
+ description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
+ notion:
+ name: 'Notion'
+ short_description: 'Integrate databases, documents and pages directly with Captain.'
+ description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
+ shopify:
+ name: 'Shopify'
+ short_description: 'Access order details and customer data from your Shopify store.'
+ description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
+ leadsquared:
+ name: 'LeadSquared'
+ short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
+ description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
+ captain:
+ copilot_message_required: Message is required
+ copilot_error: 'Please connect an assistant to this inbox to use Copilot'
+ copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+ copilot:
+ using_tool: 'Using tool %{function_name}'
+ completed_tool_call: 'Completed %{function_name} tool call'
+ invalid_tool_call: 'Invalid tool call'
+ tool_not_available: 'Tool not available'
+ documents:
+ limit_exceeded: 'Document limit exceeded'
+ pdf_format_error: 'must be a PDF file'
+ pdf_size_error: 'must be less than 10MB'
+ pdf_upload_failed: 'Failed to upload PDF to OpenAI'
+ pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
+ pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+ pdf_processing_success: 'Successfully processed PDF document %{document_id}'
+ faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
+ using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
+ using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
+ response_creation_error: 'Error in creating response document: %{error}'
+ missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
+ openai_api_error: 'OpenAI API Error: %{error}'
+ starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
+ stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
+ paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
+ processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
+ chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
+ page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
+ public_portal:
+ search:
+ search_placeholder: Search for article by title or body...
+ empty_placeholder: No results found.
+ loading_placeholder: Searching...
+ results_title: Search results
+ toc_header: 'On this page'
+ hero:
+ sub_title: Search for the articles here or browse the categories below.
+ common:
+ home: Home
+ last_updated_on: Last updated on %{last_updated_on}
+ view_all_articles: View all
+ article: article
+ articles: articles
+ author: author
+ authors: authors
+ other: other
+ others: others
+ by: By
+ no_articles: There are no articles here
+ footer:
+ made_with: Made with
+ header:
+ go_to_homepage: Website
+ visit_website: Visit website
+ appearance:
+ system: System
+ light: Light
+ dark: Dark
+ featured_articles: Featured Articles
+ uncategorized: Uncategorized
+ 404:
+ title: Page not found
+ description: We couldn't find the page you were looking for.
+ back_to_home: Go to home page
+ slack_unfurl:
+ fields:
+ name: Name
+ email: Email
+ phone_number: Phone
+ company_name: Company
+ inbox_name: Inbox
+ inbox_type: Inbox Type
+ button: Open conversation
+ time_units:
+ days:
+ one: '%{count} day'
+ other: '%{count} days'
+ hours:
+ one: '%{count} hour'
+ other: '%{count} hours'
+ minutes:
+ one: '%{count} minute'
+ other: '%{count} minutes'
+ seconds:
+ one: '%{count} second'
+ other: '%{count} seconds'
+ automation:
+ system_name: 'Automation System'
+ crm:
+ no_message: 'No messages in conversation'
+ attachment: '[Attachment: %{type}]'
+ no_content: '[No content]'
+ created_activity: |
+ New conversation started on %{brand_name}
+
+ Channel: %{channel_info}
+ Created: %{formatted_creation_time}
+ Conversation ID: %{display_id}
+ View in %{brand_name}: %{url}
+ transcript_activity: |
+ Conversation Transcript from %{brand_name}
+
+ Channel: %{channel_info}
+ Conversation ID: %{display_id}
+ View in %{brand_name}: %{url}
+
+ Transcript:
+ %{format_messages}
+ agent_capacity_policy:
+ inbox_already_assigned: 'Inbox has already been assigned to this policy'
+ portals:
+ send_instructions:
+ email_required: 'Email is required'
+ invalid_email_format: 'Invalid email format'
+ custom_domain_not_configured: 'Custom domain is not configured'
+ instructions_sent_successfully: 'Instructions sent successfully'
+ subject: 'Finish setting up %{custom_domain}'
+ ssl_status:
+ custom_domain_not_configured: 'Custom domain is not configured'
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index 62537fbb2..51a3e06e4 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -62,6 +62,9 @@ ca:
invalid: Correu electrònic invàlid
phone_number:
invalid: hauria d'estar en format e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: hauria de ser únic a la categoria i al portal
@@ -73,6 +76,7 @@ ca:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ca:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'La conversa va ser marcada com resolta per %{user_name}'
contact_resolved: 'La conversa va ser resolta per %{contact_name}'
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index 39d56b284..9daeea48a 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -62,6 +62,9 @@ cs:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ cs:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ cs:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Konverzace byla vyřešena uživatelem %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/da.yml b/config/locales/da.yml
index b15244766..81834a427 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -62,6 +62,9 @@ da:
invalid: Invalid email
phone_number:
invalid: skal være i e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: bør være unik i kategorien og portalen
@@ -73,6 +76,7 @@ da:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ da:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Samtalen blev markeret som løst af %{user_name}'
contact_resolved: 'Samtalen blev løst af %{contact_name}'
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 44863a78f..c805c47d3 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -62,6 +62,9 @@ de:
invalid: Ungültige E-Mail
phone_number:
invalid: sollte im e164-Format vorliegen
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: sollte in der Kategorie und im Portal eindeutig sein
@@ -73,6 +76,7 @@ de:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ de:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Das Gespräch wurde von %{user_name} gelöst'
contact_resolved: 'Konversation wurde von %{contact_name} gelöst'
diff --git a/config/locales/devise.bn.yml b/config/locales/devise.bn.yml
new file mode 100644
index 000000000..058a690f7
--- /dev/null
+++ b/config/locales/devise.bn.yml
@@ -0,0 +1,61 @@
+#Additional translations at https://github.com/plataformatec/devise/wiki/I18n
+bn:
+ devise:
+ confirmations:
+ confirmed: "Your email address has been successfully confirmed."
+ send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid %{authentication_keys}/password or account is not verified yet."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account is locked."
+ not_found_in_database: "Invalid %{authentication_keys} or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: "You need to sign in or sign up before continuing."
+ unconfirmed: "You have to confirm your email address before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation Instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock instructions"
+ password_change:
+ subject: "Password Changed"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password has been changed successfully. You are now signed in."
+ updated_not_active: "Your password has been changed successfully."
+ registrations:
+ destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address."
+ updated: "Your account has been updated successfully."
+ sessions:
+ signed_in: "Signed in successfully."
+ signed_out: "Signed out successfully."
+ already_signed_out: "Signed out successfully."
+ unlocks:
+ send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/locales/devise.et.yml b/config/locales/devise.et.yml
new file mode 100644
index 000000000..cbad6ba07
--- /dev/null
+++ b/config/locales/devise.et.yml
@@ -0,0 +1,61 @@
+#Additional translations at https://github.com/plataformatec/devise/wiki/I18n
+et:
+ devise:
+ confirmations:
+ confirmed: "Your email address has been successfully confirmed."
+ send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid %{authentication_keys}/password or account is not verified yet."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account is locked."
+ not_found_in_database: "Invalid %{authentication_keys} or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: "You need to sign in or sign up before continuing."
+ unconfirmed: "You have to confirm your email address before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation Instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock instructions"
+ password_change:
+ subject: "Password Changed"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password has been changed successfully. You are now signed in."
+ updated_not_active: "Your password has been changed successfully."
+ registrations:
+ destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address."
+ updated: "Your account has been updated successfully."
+ sessions:
+ signed_in: "Signed in successfully."
+ signed_out: "Signed out successfully."
+ already_signed_out: "Signed out successfully."
+ unlocks:
+ send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/locales/el.yml b/config/locales/el.yml
index c9578bbbf..63c4a686d 100644
--- a/config/locales/el.yml
+++ b/config/locales/el.yml
@@ -62,6 +62,9 @@ el:
invalid: Ακατάλληλο email
phone_number:
invalid: πρέπει να είναι σε μορφή e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: πρέπει να είναι μοναδικό στην κατηγορία και την πύλη
@@ -73,6 +76,7 @@ el:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ el:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Η συνομιλία έχει επιλυθεί από τον %{user_name}'
contact_resolved: 'Η συνομιλία επιλύθηκε από τον %{contact_name}'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6afab9253..d0f256146 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -90,6 +90,7 @@ en:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -202,6 +203,8 @@ en:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 6e679273d..8a035df7b 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -62,6 +62,9 @@ es:
invalid: Email inválido
phone_number:
invalid: debe estar en formato e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: debe ser único en la categoría y el portal
@@ -73,6 +76,7 @@ es:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ es:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'La conversación fue marcada por %{user_name}'
contact_resolved: 'Conversación fue resuelta por %{contact_name}'
diff --git a/config/locales/et.yml b/config/locales/et.yml
new file mode 100644
index 000000000..e60df2b42
--- /dev/null
+++ b/config/locales/et.yml
@@ -0,0 +1,423 @@
+#Files in the config/locales directory are used for internationalization
+#and are automatically loaded by Rails. If you want to use locales other
+#than English, add the necessary files in this directory.
+#To use the locales, use `I18n.t`:
+#I18n.t 'hello'
+#In views, this is aliased to just `t`:
+#<%= t('hello') %>
+#To use a different locale, set it with `I18n.locale`:
+#I18n.locale = :es
+#This would use the information in config/locales/es.yml.
+#The following keys must be escaped otherwise they will not be retrieved by
+#the default I18n backend:
+#true, false, on, off, yes, no
+#Instead, surround them with single quotes.
+#en:
+#'true': 'foo'
+#To learn more, please read the Rails Internationalization guide
+#available at https://guides.rubyonrails.org/i18n.html.
+et:
+ hello: 'Hello world'
+ inbox:
+ reauthorization:
+ success: 'Channel reauthorized successfully'
+ not_required: 'Reauthorization is not required for this inbox'
+ invalid_channel: 'Invalid channel type for reauthorization'
+ auth:
+ saml:
+ invalid_email: 'Please enter a valid email address'
+ authentication_failed: 'Authentication failed. Please check your credentials and try again.'
+ messages:
+ reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
+ reset_password_failure: Uh ho! We could not find any user with the specified email.
+ reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
+ login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
+ saml_not_available: SAML authentication is not available in this installation.
+ inbox_deletetion_response: Your inbox deletion request will be processed in some time.
+ errors:
+ validations:
+ presence: must not be blank
+ webhook:
+ invalid: Invalid events
+ signup:
+ disposable_email: We do not allow disposable emails
+ blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support.
+ invalid_email: You have entered an invalid email
+ email_already_exists: 'You have already signed up for an account with %{email}'
+ invalid_params: 'Invalid, please check the signup paramters and try again'
+ failed: Signup failed
+ assignment_policy:
+ not_found: Assignment policy not found
+ saml:
+ feature_not_enabled: SAML feature not enabled for this account
+ data_import:
+ data_type:
+ invalid: Invalid data type
+ contacts:
+ import:
+ failed: File is blank
+ export:
+ success: We will notify you once contacts export file is ready to view.
+ email:
+ invalid: Invalid email
+ phone_number:
+ invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
+ categories:
+ locale:
+ unique: should be unique in the category and portal
+ dyte:
+ invalid_message_type: 'Invalid message type. Action not permitted'
+ slack:
+ invalid_channel_id: 'Invalid slack channel. Please try again'
+ whatsapp:
+ token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
+ invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
+ phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
+ reauthorization:
+ generic: 'Failed to reauthorize WhatsApp. Please try again.'
+ not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+ inboxes:
+ imap:
+ socket_error: Please check the network connection, IMAP address and try again.
+ no_response_error: Please check the IMAP credentials and try again.
+ host_unreachable_error: Host unreachable, Please check the IMAP address, IMAP port and try again.
+ connection_timed_out_error: Connection timed out for %{address}:%{port}
+ connection_closed_error: Connection closed.
+ validations:
+ name: should not start or end with symbols, and it should not have < > / \ @ characters.
+ custom_filters:
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
+ invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
+ invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
+ invalid_query_operator: Query operator must be either "AND" or "OR".
+ invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
+ mfa:
+ already_enabled: MFA is already enabled
+ not_enabled: MFA is not enabled
+ invalid_code: Invalid verification code
+ invalid_backup_code: Invalid backup code
+ invalid_token: Invalid or expired MFA token
+ invalid_credentials: Invalid credentials or verification code
+ feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ profile:
+ mfa:
+ enabled: MFA enabled successfully
+ disabled: MFA disabled successfully
+ account_saml_settings:
+ invalid_certificate: must be a valid X.509 certificate in PEM format
+ reports:
+ period: Reporting period %{since} to %{until}
+ utc_warning: The report generated is in UTC timezone
+ agent_csv:
+ agent_name: Agent name
+ conversations_count: Assigned conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution Count
+ avg_customer_waiting_time: Avg customer waiting time
+ inbox_csv:
+ inbox_name: Inbox name
+ inbox_type: Inbox type
+ conversations_count: No. of conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ label_csv:
+ label_title: Label
+ conversations_count: No. of conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Resolution Count
+ team_csv:
+ team_name: Team name
+ conversations_count: Conversations count
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution Count
+ avg_customer_waiting_time: Avg customer waiting time
+ conversation_traffic_csv:
+ timezone: Timezone
+ sla_csv:
+ conversation_id: Conversation ID
+ sla_policy_breached: SLA Policy
+ assignee: Assignee
+ team: Team
+ inbox: Inbox
+ labels: Labels
+ conversation_link: Link to the Conversation
+ breached_events: Breached Events
+ default_group_by: day
+ csat:
+ headers:
+ contact_name: Contact Name
+ contact_email_address: Contact Email Address
+ contact_phone_number: Contact Phone Number
+ link_to_the_conversation: Link to the conversation
+ agent_name: Agent Name
+ rating: Rating
+ feedback: Feedback Comment
+ recorded_at: Recorded date
+ notifications:
+ notification_title:
+ conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
+ conversation_assignment: 'A conversation (#%{display_id}) has been assigned to you'
+ assigned_conversation_new_message: 'A new message is created in conversation (#%{display_id})'
+ conversation_mention: 'You have been mentioned in conversation (#%{display_id})'
+ sla_missed_first_response: 'SLA target first response missed for conversation (#%{display_id})'
+ sla_missed_next_response: 'SLA target next response missed for conversation (#%{display_id})'
+ sla_missed_resolution: 'SLA target resolution missed for conversation (#%{display_id})'
+ attachment: 'Attachment'
+ no_content: 'No content'
+ conversations:
+ captain:
+ handoff: 'Transferring to another agent for further assistance.'
+ messages:
+ instagram_story_content: '%{story_sender} mentioned you in the story: '
+ instagram_deleted_story_content: This story is no longer available.
+ deleted: This message was deleted
+ whatsapp:
+ list_button_label: 'Choose an item'
+ delivery_status:
+ error_code: 'Error code: %{error_code}'
+ activity:
+ captain:
+ resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
+ open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
+ status:
+ resolved: 'Conversation was marked resolved by %{user_name}'
+ contact_resolved: 'Conversation was resolved by %{contact_name}'
+ open: 'Conversation was reopened by %{user_name}'
+ pending: 'Conversation was marked as pending by %{user_name}'
+ snoozed: 'Conversation was snoozed by %{user_name}'
+ auto_resolved_days: 'Conversation was marked resolved by system due to %{count} days of inactivity'
+ auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
+ auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
+ system_auto_open: System reopened the conversation due to a new incoming message.
+ priority:
+ added: '%{user_name} set the priority to %{new_priority}'
+ updated: '%{user_name} changed the priority from %{old_priority} to %{new_priority}'
+ removed: '%{user_name} removed the priority'
+ assignee:
+ self_assigned: '%{user_name} self-assigned this conversation'
+ assigned: 'Assigned to %{assignee_name} by %{user_name}'
+ removed: 'Conversation unassigned by %{user_name}'
+ team:
+ assigned: 'Assigned to %{team_name} by %{user_name}'
+ assigned_with_assignee: 'Assigned to %{assignee_name} via %{team_name} by %{user_name}'
+ removed: 'Unassigned from %{team_name} by %{user_name}'
+ labels:
+ added: '%{user_name} added %{labels}'
+ removed: '%{user_name} removed %{labels}'
+ sla:
+ added: '%{user_name} added SLA policy %{sla_name}'
+ removed: '%{user_name} removed SLA policy %{sla_name}'
+ linear:
+ issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
+ issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
+ issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
+ csat:
+ not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
+ auto_resolve:
+ not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
+ muted: '%{user_name} has muted the conversation'
+ unmuted: '%{user_name} has unmuted the conversation'
+ auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
+ templates:
+ greeting_message_body: '%{account_name} typically replies in a few hours.'
+ ways_to_reach_you_message_body: 'Give the team a way to reach you.'
+ email_input_box_message_body: 'Get notified by email'
+ csat_input_message_body: 'Please rate the conversation'
+ reply:
+ email:
+ header:
+ notifications: 'Notifications'
+ from_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
+ reply_with_name: '%{assignee_name} from %{inbox_name} '
+ friendly_name: '%{sender_name} from %{business_name} <%{from_email}>'
+ professional_name: '%{business_name} <%{from_email}>'
+ channel_email:
+ header:
+ reply_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
+ reply_with_inbox_name: '%{inbox_name} <%{from_email}>'
+ email_subject: 'New messages on this conversation'
+ transcript_subject: 'Conversation Transcript'
+ survey:
+ response: 'Please rate this conversation, %{link}'
+ contacts:
+ online:
+ delete: '%{contact_name} is Online, please try again later'
+ integration_apps:
+ #Note: webhooks and dashboard_apps don't need short_description as they use different modal components
+ dashboard_apps:
+ name: 'Dashboard Apps'
+ description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
+ dyte:
+ name: 'Dyte'
+ short_description: 'Start video/voice calls with customers directly from Chatwoot.'
+ description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
+ meeting_name: '%{agent_name} has started a meeting'
+ slack:
+ name: 'Slack'
+ short_description: 'Receive notifications and respond to conversations directly in Slack.'
+ description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
+ webhooks:
+ name: 'Webhooks'
+ description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
+ dialogflow:
+ name: 'Dialogflow'
+ short_description: 'Build chatbots to handle initial queries before transferring to agents.'
+ description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
+ google_translate:
+ name: 'Google Translate'
+ short_description: 'Automatically translate customer messages for agents.'
+ description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
+ openai:
+ name: 'OpenAI'
+ short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
+ description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
+ linear:
+ name: 'Linear'
+ short_description: 'Create and link Linear issues directly from conversations.'
+ description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
+ notion:
+ name: 'Notion'
+ short_description: 'Integrate databases, documents and pages directly with Captain.'
+ description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
+ shopify:
+ name: 'Shopify'
+ short_description: 'Access order details and customer data from your Shopify store.'
+ description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
+ leadsquared:
+ name: 'LeadSquared'
+ short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
+ description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
+ captain:
+ copilot_message_required: Message is required
+ copilot_error: 'Please connect an assistant to this inbox to use Copilot'
+ copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+ copilot:
+ using_tool: 'Using tool %{function_name}'
+ completed_tool_call: 'Completed %{function_name} tool call'
+ invalid_tool_call: 'Invalid tool call'
+ tool_not_available: 'Tool not available'
+ documents:
+ limit_exceeded: 'Document limit exceeded'
+ pdf_format_error: 'must be a PDF file'
+ pdf_size_error: 'must be less than 10MB'
+ pdf_upload_failed: 'Failed to upload PDF to OpenAI'
+ pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
+ pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+ pdf_processing_success: 'Successfully processed PDF document %{document_id}'
+ faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
+ using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
+ using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
+ response_creation_error: 'Error in creating response document: %{error}'
+ missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
+ openai_api_error: 'OpenAI API Error: %{error}'
+ starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
+ stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
+ paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
+ processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
+ chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
+ page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
+ public_portal:
+ search:
+ search_placeholder: Search for article by title or body...
+ empty_placeholder: No results found.
+ loading_placeholder: Searching...
+ results_title: Search results
+ toc_header: 'On this page'
+ hero:
+ sub_title: Search for the articles here or browse the categories below.
+ common:
+ home: Home
+ last_updated_on: Last updated on %{last_updated_on}
+ view_all_articles: View all
+ article: article
+ articles: articles
+ author: author
+ authors: authors
+ other: other
+ others: others
+ by: By
+ no_articles: There are no articles here
+ footer:
+ made_with: Made with
+ header:
+ go_to_homepage: Website
+ visit_website: Visit website
+ appearance:
+ system: System
+ light: Light
+ dark: Dark
+ featured_articles: Featured Articles
+ uncategorized: Uncategorized
+ 404:
+ title: Page not found
+ description: We couldn't find the page you were looking for.
+ back_to_home: Go to home page
+ slack_unfurl:
+ fields:
+ name: Name
+ email: Email
+ phone_number: Phone
+ company_name: Company
+ inbox_name: Inbox
+ inbox_type: Inbox Type
+ button: Open conversation
+ time_units:
+ days:
+ one: '%{count} day'
+ other: '%{count} days'
+ hours:
+ one: '%{count} hour'
+ other: '%{count} hours'
+ minutes:
+ one: '%{count} minute'
+ other: '%{count} minutes'
+ seconds:
+ one: '%{count} second'
+ other: '%{count} seconds'
+ automation:
+ system_name: 'Automation System'
+ crm:
+ no_message: 'No messages in conversation'
+ attachment: '[Attachment: %{type}]'
+ no_content: '[No content]'
+ created_activity: |
+ New conversation started on %{brand_name}
+
+ Channel: %{channel_info}
+ Created: %{formatted_creation_time}
+ Conversation ID: %{display_id}
+ View in %{brand_name}: %{url}
+ transcript_activity: |
+ Conversation Transcript from %{brand_name}
+
+ Channel: %{channel_info}
+ Conversation ID: %{display_id}
+ View in %{brand_name}: %{url}
+
+ Transcript:
+ %{format_messages}
+ agent_capacity_policy:
+ inbox_already_assigned: 'Inbox has already been assigned to this policy'
+ portals:
+ send_instructions:
+ email_required: 'Email is required'
+ invalid_email_format: 'Invalid email format'
+ custom_domain_not_configured: 'Custom domain is not configured'
+ instructions_sent_successfully: 'Instructions sent successfully'
+ subject: 'Finish setting up %{custom_domain}'
+ ssl_status:
+ custom_domain_not_configured: 'Custom domain is not configured'
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index bbe2fc8cf..bb9db01e9 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -62,6 +62,9 @@ fa:
invalid: ایمیل نامعتبر است
phone_number:
invalid: باید در قالب e164 باشد
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: باید منحصر به فرد در دستهبندی و پورتال باشد
@@ -73,6 +76,7 @@ fa:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ fa:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'مکالمه توسط ایجنت %{user_name} حل شده، اعلام شده بود'
contact_resolved: 'گفتگو توسط %{contact_name} حل شد'
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index 09d6451d3..94f68aa8d 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -62,6 +62,9 @@ fi:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ fi:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ fi:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '%{user_name} merkitsi keskustelun ratkaistuksi'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 284a87ac0..460e520aa 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -62,6 +62,9 @@ fr:
invalid: Email non valide
phone_number:
invalid: Doit être au format e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: Doit être unique dans la catégorie et le portail
@@ -73,6 +76,7 @@ fr:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ fr:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'La conversation a été marquée résolue par %{user_name}'
contact_resolved: 'La conversation a été résolue par %{contact_name}'
diff --git a/config/locales/he.yml b/config/locales/he.yml
index f006ed806..56e32d142 100644
--- a/config/locales/he.yml
+++ b/config/locales/he.yml
@@ -62,6 +62,9 @@ he:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ he:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ he:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'השיחה סומנה כפתורה על ידי %{user_name}'
contact_resolved: 'השיחה נפתרה על ידי %{contact_name}'
diff --git a/config/locales/hi.yml b/config/locales/hi.yml
index 98bbda2ec..0e47a998c 100644
--- a/config/locales/hi.yml
+++ b/config/locales/hi.yml
@@ -62,6 +62,9 @@ hi:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ hi:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ hi:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/hr.yml b/config/locales/hr.yml
index 2eb2b7756..3239824bf 100644
--- a/config/locales/hr.yml
+++ b/config/locales/hr.yml
@@ -62,6 +62,9 @@ hr:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ hr:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ hr:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/hu.yml b/config/locales/hu.yml
index 901148f83..585afcd24 100644
--- a/config/locales/hu.yml
+++ b/config/locales/hu.yml
@@ -62,6 +62,9 @@ hu:
invalid: Hibás email
phone_number:
invalid: e164 formátumban kell megadni
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: egyedinek kell lennie a kategóriában a portálon
@@ -73,6 +76,7 @@ hu:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ hu:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'A beszélgetést lezárta %{user_name}'
contact_resolved: 'A beszélgetést megoldottra állította: %{contact_name}'
diff --git a/config/locales/hy.yml b/config/locales/hy.yml
index b1a0ca023..8b61be9c4 100644
--- a/config/locales/hy.yml
+++ b/config/locales/hy.yml
@@ -62,6 +62,9 @@ hy:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ hy:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ hy:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/id.yml b/config/locales/id.yml
index 3b4527420..167b860a9 100644
--- a/config/locales/id.yml
+++ b/config/locales/id.yml
@@ -62,6 +62,9 @@ id:
invalid: Email tidak valid
phone_number:
invalid: harus dalam format e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: harus unik dalam kategori dan portal
@@ -73,6 +76,7 @@ id:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ id:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Percakapan ditandai selesai oleh %{user_name}'
contact_resolved: 'Percakapan diselesaikan oleh %{contact_name}'
diff --git a/config/locales/is.yml b/config/locales/is.yml
index 24c01f849..47db2a83c 100644
--- a/config/locales/is.yml
+++ b/config/locales/is.yml
@@ -62,6 +62,9 @@ is:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: ætti að vera einstakt í flokki og gátt
@@ -73,6 +76,7 @@ is:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ is:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Samtal var merkt sem leyst af %{user_name}'
contact_resolved: 'Samtal var leyst af %{contact_name}'
diff --git a/config/locales/it.yml b/config/locales/it.yml
index 806c3e0a9..ba6c7cf5e 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -17,23 +17,23 @@
#To learn more, please read the Rails Internationalization guide
#available at https://guides.rubyonrails.org/i18n.html.
it:
- hello: 'Ciao mondo'
+ hello: 'Hello world'
inbox:
reauthorization:
- success: 'Channel reauthorized successfully'
- not_required: 'Reauthorization is not required for this inbox'
- invalid_channel: 'Invalid channel type for reauthorization'
+ success: 'Canale riautorizzato con successo'
+ not_required: 'Riautorizzazione non necessaria per questo canale'
+ invalid_channel: 'Canale non valido per riautorizzazione'
auth:
saml:
invalid_email: 'Inserisci un indirizzo email valido'
- authentication_failed: 'Authentication failed. Please check your credentials and try again.'
+ authentication_failed: 'Autenticazione fallita. Verifica le credenziali e riprova.'
messages:
- reset_password_success: Woot! Richiesta di reimpostazione della password riuscita. Controlla la tua mail per le istruzioni.
- reset_password_failure: Uh ho! Non siamo riusciti a trovare alcun utente con l'email specificata.
- reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
- login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
- saml_not_available: SAML authentication is not available in this installation.
- inbox_deletetion_response: Your inbox deletion request will be processed in some time.
+ reset_password_success: Wooh! Richiesta di reimpostazione della password riuscita. Controlla la tua mail per le istruzioni.
+ reset_password_failure: Oh oh! Non siamo riusciti a trovare alcun utente con l'email specificata.
+ reset_password_saml_user: Questo account utilizza autenticazione SAML. Non è possibile resettare la password. Contatta il tuo amministratore.
+ login_saml_user: Questo account utilizza autenticazione SAML. Effettua il login dal portale SAML della tua organizzazione.
+ saml_not_available: Autenticazione SAML non disponibile in questa installazione.
+ inbox_deletetion_response: La tua richiesta di cancellazione inbox verrà elaborata.
errors:
validations:
presence: non deve essere vuoto
@@ -41,15 +41,15 @@ it:
invalid: Eventi non validi
signup:
disposable_email: Non consentiamo email usa e getta
- blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support.
+ blocked_domain: Questo dominio non è consentito. Se credi che sia un errore, contatta il supporto.
invalid_email: Hai inserito un'email non valida
email_already_exists: 'Ti sei già registrato per un account con %{email}'
- invalid_params: 'Invalid, please check the signup paramters and try again'
+ invalid_params: 'Non valido, controlla i dati di registrazione e riprova'
failed: Registrazione non riuscita
assignment_policy:
- not_found: Assignment policy not found
+ not_found: Policy di assegnazione non trovata
saml:
- feature_not_enabled: SAML feature not enabled for this account
+ feature_not_enabled: Funzionalità SAML non attiva per questo account
data_import:
data_type:
invalid: Tipo di dato non valido
@@ -57,25 +57,29 @@ it:
import:
failed: Il file è vuoto
export:
- success: We will notify you once contacts export file is ready to view.
+ success: Verrai avvisato una volta che il file di esportazione dei contatti è pronto per essere visualizzato.
email:
invalid: Email non valida
phone_number:
- invalid: dovrebbe essere nel formato e164
+ invalid: deve essere nel formato e164
+ companies:
+ domain:
+ invalid: deve essere un dominio valido
categories:
locale:
- unique: dovrebbe essere unico nella categoria e nel portale
+ unique: deve essere unico nella categoria e nel portale
dyte:
- invalid_message_type: 'Invalid message type. Action not permitted'
+ invalid_message_type: 'Tipo messaggio non valido. Azione non consentita'
slack:
- invalid_channel_id: 'Invalid slack channel. Please try again'
+ invalid_channel_id: 'Canale slack non valido. Riprova'
whatsapp:
- token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
- invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
- phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ token_exchange_failed: 'Impossibile scambiare il codice con il token di accesso. Riprova.'
+ invalid_token_permissions: 'Il token di accesso non dispone dei permessi richiesti per WhatsApp.'
+ phone_info_fetch_failed: 'Impossibile recuperare le informazioni sul numero di telefono. Riprova.'
+ phone_number_already_exists: 'Esiste già un canale per questo numero di telefono: %{phone_number}, si prega di contattare il supporto se l''errore persiste'
reauthorization:
- generic: 'Failed to reauthorize WhatsApp. Please try again.'
- not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+ generic: 'Impossibile riautorizzare WhatsApp. Riprova.'
+ not_supported: 'La riautorizzazione non è supportata per questo tipo di canale WhatsApp.'
inboxes:
imap:
socket_error: Controlla la connessione di rete, l'indirizzo IMAP e riprova.
@@ -84,95 +88,95 @@ it:
connection_timed_out_error: Connessione scaduta per %{address}:%{port}
connection_closed_error: Connessione chiusa.
validations:
- name: non dovrebbe iniziare o terminare con i simboli, e non dovrebbe avere < > / \ @ caratteri.
+ name: non dovrebbe iniziare o terminare con simboli, e non dovrebbe avere i caratteri < > / \ @
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
- invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
- invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
- invalid_query_operator: Query operator must be either "AND" or "OR".
- invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ number_of_records: Limite raggiunto. Il numero massimo di filtri personalizzati consentiti per un utente per account è 1000.
+ invalid_attribute: Chiave di attributo non valida - [%{key}]. La chiave dovrebbe essere una di [%{allowed_keys}] o un attributo personalizzato definito nell'account.
+ invalid_operator: Operatore non valido. Gli operatori consentiti per %{attribute_name} sono [%{allowed_keys}].
+ invalid_query_operator: L'operatore della query deve essere "E" o "O".
+ invalid_value: Valore non valido. I valori forniti per %{attribute_name} non sono validi
custom_attribute_definition:
- key_conflict: The provided key is not allowed as it might conflict with default attributes.
+ key_conflict: La chiave fornita non è consentita in quanto potrebbe entrare in conflitto con gli attributi predefiniti.
mfa:
- already_enabled: MFA is already enabled
- not_enabled: MFA is not enabled
- invalid_code: Invalid verification code
- invalid_backup_code: Invalid backup code
- invalid_token: Invalid or expired MFA token
- invalid_credentials: Invalid credentials or verification code
- feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ already_enabled: MFA già attiva
+ not_enabled: MFA non attiva
+ invalid_code: Codice di verifica non valido
+ invalid_backup_code: Codice di backup non valido
+ invalid_token: Token MFA non valido o scaduto
+ invalid_credentials: Credenziali o codice di verifica non validi
+ feature_unavailable: MFA non disponibile. Configura le chiavi di cifratura.
profile:
mfa:
- enabled: MFA enabled successfully
- disabled: MFA disabled successfully
+ enabled: MFA attivata con successo
+ disabled: MFA disattivata con successo
account_saml_settings:
- invalid_certificate: must be a valid X.509 certificate in PEM format
+ invalid_certificate: deve essere un certificato X.509 valido in formato PEM
reports:
period: Periodo di segnalazione da %{since} a %{until}
- utc_warning: The report generated is in UTC timezone
+ utc_warning: Il report generato è nel fuso orario UTC
agent_csv:
- agent_name: Nome agente
- conversations_count: Assigned conversations
- avg_first_response_time: Avg first response time
- avg_resolution_time: Avg resolution time
+ agent_name: Nome operatore
+ conversations_count: Conversazioni assegnate
+ avg_first_response_time: Tempo medio di prima risposta
+ avg_resolution_time: Tempo medio di risoluzione
resolution_count: Conteggio risoluzioni
- avg_customer_waiting_time: Avg customer waiting time
+ avg_customer_waiting_time: Tempo medio di attesa cliente
inbox_csv:
- inbox_name: Nome casella
- inbox_type: Tipo casella
+ inbox_name: Nome inbox
+ inbox_type: Tipo inbox
conversations_count: Numero di conversazioni
- avg_first_response_time: Avg first response time
- avg_resolution_time: Avg resolution time
+ avg_first_response_time: Tempo medio di prima risposta
+ avg_resolution_time: Tempo medio di risoluzione
label_csv:
label_title: Etichetta
conversations_count: Numero di conversazioni
- avg_first_response_time: Avg first response time
- avg_resolution_time: Avg resolution time
+ avg_first_response_time: Tempo medio di prima risposta
+ avg_resolution_time: Tempo medio di risoluzione
avg_reply_time: Tempo medio di risposta
resolution_count: Conteggio risoluzioni
team_csv:
team_name: Nome del team
conversations_count: Numero di conversazioni
- avg_first_response_time: Avg first response time
- avg_resolution_time: Avg resolution time
+ avg_first_response_time: Tempo medio di prima risposta
+ avg_resolution_time: Tempo medio di risoluzione
resolution_count: Conteggio risoluzioni
- avg_customer_waiting_time: Avg customer waiting time
+ avg_customer_waiting_time: Tempo medio di attesa cliente
conversation_traffic_csv:
- timezone: Timezone
+ timezone: Fuso Orario
sla_csv:
- conversation_id: Conversation ID
- sla_policy_breached: SLA Policy
- assignee: Assignee
+ conversation_id: ID Conversazione
+ sla_policy_breached: Policy SLA
+ assignee: Assegnatario
team: Team
- inbox: Casella
+ inbox: Inbox
labels: Etichette
- conversation_link: Link to the Conversation
- breached_events: Breached Events
+ conversation_link: Link alla Conversazione
+ breached_events: Eventi Violati
default_group_by: giorno
csat:
headers:
- contact_name: Nome contatto
- contact_email_address: Indirizzo email contatto
- contact_phone_number: Numero di telefono contatto
+ contact_name: Nome Contatto
+ contact_email_address: Indirizzo Email Contatto
+ contact_phone_number: Numero di Telefono Contatto
link_to_the_conversation: Link alla conversazione
- agent_name: Nome dell'agente
+ agent_name: Nome Operatore
rating: Valutazione
- feedback: Commento del feedback
+ feedback: Commento di Feedback
recorded_at: Data di registrazione
notifications:
notification_title:
- conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
- conversation_assignment: 'A conversation (#%{display_id}) has been assigned to you'
- assigned_conversation_new_message: 'A new message is created in conversation (#%{display_id})'
- conversation_mention: 'You have been mentioned in conversation (#%{display_id})'
- sla_missed_first_response: 'SLA target first response missed for conversation (#%{display_id})'
- sla_missed_next_response: 'SLA target next response missed for conversation (#%{display_id})'
- sla_missed_resolution: 'SLA target resolution missed for conversation (#%{display_id})'
- attachment: 'Attachment'
- no_content: 'No content'
+ conversation_creation: 'Una conversazione (#%{display_id}) è stata creata in %{inbox_name}'
+ conversation_assignment: 'Una conversazione (#%{display_id}) ti è stata assegnata'
+ assigned_conversation_new_message: 'Un nuovo messaggio è stato creato nella conversazione (#%{display_id})'
+ conversation_mention: 'Sei stato menzionato nella conversazione (#%{display_id})'
+ sla_missed_first_response: 'Target SLA di prima risposta mancato per la conversazione (#%{display_id})'
+ sla_missed_next_response: 'Target SLA di risposta successiva mancato per la conversazione (#%{display_id})'
+ sla_missed_resolution: 'Target SLA di risoluzione mancato per la conversazione (#%{display_id})'
+ attachment: 'Allegato'
+ no_content: 'Nessun contenuto'
conversations:
captain:
- handoff: 'Trasferimento ad un altro agente per ulteriore assistenza.'
+ handoff: 'Trasferendo ad un altro operatore per ulteriore assistenza.'
messages:
instagram_story_content: '%{story_sender} ti ha menzionato nella storia: '
instagram_deleted_story_content: Questa storia non è più disponibile.
@@ -180,11 +184,13 @@ it:
whatsapp:
list_button_label: 'Scegli un elemento'
delivery_status:
- error_code: 'Error code: %{error_code}'
+ error_code: 'Codice errore: %{error_code}'
activity:
captain:
- resolved: 'La conversazione è stata segnata risolta da %{user_name} a causa di inattività'
- open: 'Conversation was marked open by %{user_name}'
+ resolved: 'La conversazione è stata segnata risolta da %{user_name} per inattività'
+ open: 'La conversazione è stata riaperta da %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'La conversazione è stata riaperta dal sistema a causa di un errore con il Bot Agente.'
status:
resolved: 'La conversazione è stata contrassegnata come risolta da %{user_name}'
contact_resolved: 'La conversazione è stata risolta da %{contact_name}'
@@ -192,41 +198,41 @@ it:
pending: 'La conversazione è stata contrassegnata come in attesa da %{user_name}'
snoozed: 'La conversazione è stata posticipata da %{user_name}'
auto_resolved_days: 'La conversazione è stata contrassegnata come risolta dal sistema a causa di %{count} giorni d''inattività'
- auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
- auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
- system_auto_open: System reopened the conversation due to a new incoming message.
+ auto_resolved_hours: 'La conversazione è stata contrassegnata come risolta dal sistema a causa di %{count} ore d''inattività'
+ auto_resolved_minutes: 'La conversazione è stata contrassegnata come risolta dal sistema a causa di %{count} minuti d''inattività'
+ system_auto_open: Il sistema ha riaperto la conversazione a causa di un nuovo messaggio in arrivo.
priority:
- added: '%{user_name} set the priority to %{new_priority}'
- updated: '%{user_name} changed the priority from %{old_priority} to %{new_priority}'
- removed: '%{user_name} removed the priority'
+ added: '%{user_name} ha impostato la priorità su %{new_priority}'
+ updated: '%{user_name} ha modificato la priorità da %{old_priority} a %{new_priority}'
+ removed: '%{user_name} ha rimosso la priorità'
assignee:
self_assigned: '%{user_name} si è assegnato a questa conversazione'
assigned: 'Assegnato a %{assignee_name} da %{user_name}'
- removed: 'Conversazione non assegnata da %{user_name}'
+ removed: 'Conversazione disassegnata da %{user_name}'
team:
- assigned: 'Assegnato a %{team_name} da %{user_name}'
- assigned_with_assignee: 'Assegnato a %{assignee_name} tramite %{team_name} da %{user_name}'
+ assigned: 'Assegnata a %{team_name} da %{user_name}'
+ assigned_with_assignee: 'Assegnata a %{assignee_name} via %{team_name} da %{user_name}'
removed: 'Assegnazione a %{team_name} rimossa da %{user_name}'
labels:
added: '%{user_name} ha aggiunto %{labels}'
removed: '%{user_name} ha rimosso %{labels}'
sla:
- added: '%{user_name} added SLA policy %{sla_name}'
- removed: '%{user_name} removed SLA policy %{sla_name}'
+ added: '%{user_name} ha aggiunto la policy SLA %{sla_name}'
+ removed: '%{user_name} ha rimosso la policy SLA %{sla_name}'
linear:
issue_created: 'Issue Linear %{issue_id} è stata creata da %{user_name}'
issue_linked: 'Issue Linear %{issue_id} è stata collegata da %{user_name}'
issue_unlinked: 'Issue Linear %{issue_id} è stata scollegata da %{user_name}'
csat:
- not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
+ not_sent_due_to_messaging_window: 'Sondaggio CSAT non inviato a causa di restrizioni sui messaggi in uscita'
auto_resolve:
- not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
+ not_sent_due_to_messaging_window: 'Messaggio di auto-risoluzione non inviato a causa di restrizioni sui messaggi in uscita'
muted: '%{user_name} ha silenziato la conversazione'
unmuted: '%{user_name} ha riattivato l''audio della conversazione'
- auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
+ auto_resolution_message: 'La conversazione sta per essere risolta per inattività. Avvia una nuova conversazione se hai bisogno di ulteriore assistenza.'
templates:
greeting_message_body: '%{account_name}, in genere, risponde in poche ore.'
- ways_to_reach_you_message_body: 'Offri al team un modo per raggiungerti.'
+ ways_to_reach_you_message_body: 'Dai al team un modo per contattarti.'
email_input_box_message_body: 'Ricevi notifiche via email'
csat_input_message_body: 'Valuta la conversazione'
reply:
@@ -242,7 +248,7 @@ it:
reply_with_name: '%{assignee_name} da %{inbox_name} <%{from_email}>'
reply_with_inbox_name: '%{inbox_name} <%{from_email}>'
email_subject: 'Nuovi messaggi in questa conversazione'
- transcript_subject: 'Trascrizione della conversazione'
+ transcript_subject: 'Trascrizione della Conversazione'
survey:
response: 'Valuta questa conversazione, %{link}'
contacts:
@@ -251,167 +257,167 @@ it:
integration_apps:
#Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps:
- name: 'App dashboard'
- description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
+ name: 'App Dashboard'
+ description: 'Le App Dashboard consentono di creare e incorporare applicazioni che mostrano informazioni sugli utenti, ordini o cronologia dei pagamenti, fornendo maggiore contesto agli operatori.'
dyte:
name: 'Dyte'
- short_description: 'Start video/voice calls with customers directly from Chatwoot.'
- description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
- meeting_name: '%{agent_name} has started a meeting'
+ short_description: 'Avvia chiamate/videochiamate con i clienti direttamente da Chatwoot.'
+ description: 'Dyte è un prodotto che integra funzionalità audio e video nella tua applicazione. Grazie a questa integrazione, i tuoi operatori possono avviare videochiamate/chiamate vocali con i tuoi clienti direttamente da qui.'
+ meeting_name: '%{agent_name} ha avviato un meeting'
slack:
name: 'Slack'
- short_description: 'Receive notifications and respond to conversations directly in Slack.'
- description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
+ short_description: 'Ricevi notifiche e rispondi alle conversazioni direttamente in Slack.'
+ description: "Integra Slack per mantenere il tuo team sempre aggiornato. Questa integrazione ti consente di ricevere notifiche per le nuove conversazioni e di rispondere direttamente dall'interfaccia di Slack."
webhooks:
name: 'Webhook'
- description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
+ description: 'Gli eventi Webhook forniscono aggiornamenti in tempo reale sulle attività nel tuo account Chatwoot. Puoi iscriverti ai tuoi eventi preferiti, e Chatwoot ti invierà i callback HTTP con gli aggiornamenti.'
dialogflow:
name: 'Dialogflow'
- short_description: 'Build chatbots to handle initial queries before transferring to agents.'
- description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
+ short_description: 'Configura chatbot per gestire le richieste iniziali prima di trasferire agli operatori.'
+ description: 'Crea chatbot con Dialogflow e integrali facilmente nella tua Inbox. Questi bot possono gestire le richieste iniziali prima di trasferirle a un operatore del servizio clienti.'
google_translate:
name: 'Google Translate'
- short_description: 'Automatically translate customer messages for agents.'
- description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
+ short_description: 'Traduci automaticamente i messaggi dei clienti per gli operatori.'
+ description: "Integra Google Translate per aiutare gli operatori a tradurre facilmente i messaggi dei clienti. Questa integrazione rileva automaticamente la lingua e la converte nella lingua preferita dall'operatore o dall'amministratore."
openai:
name: 'OpenAI'
- short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
- description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
+ short_description: 'Suggerimenti di risposta, riassunti, e miglioramento dei messaggi tramite AI.'
+ description: 'Sfrutta la potenza degli LLM di OpenAI con funzionalità quali suggerimenti di risposta, riepilogo, riformulazione dei messaggi, controllo ortografico e classificazione delle etichette.'
linear:
name: 'Linear'
short_description: 'Crea e collega issue Linear direttamente dalle conversazioni.'
- description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
+ description: 'Crea issue in Linear direttamente dalla finestra di conversazione. In alternativa, collega issue Linear esistenti per un processo di monitoraggio più snello ed efficiente.'
notion:
name: 'Notion'
short_description: 'Integra database, documenti e pagine direttamente con Captain.'
- description: 'Collega il tuo spazio di lavoro Notion per consentire al Captain di accedere e generare risposte intelligenti utilizzando i contenuti dai tuoi database, documenti, e pagine per fornire più assistenza clienti contestuale.'
+ description: 'Collega il tuo workspace Notion per consentire a Captain di accedere e generare risposte intelligenti utilizzando i contenuti dai tuoi database, documenti, e pagine per fornire assistenza clienti più contestuale.'
shopify:
name: 'Shopify'
- short_description: 'Access order details and customer data from your Shopify store.'
- description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
+ short_description: 'Integra ordini e clienti dal tuo store Shopify.'
+ description: 'Collega il tuo store Shopify per accedere ai dettagli degli ordini, alle informazioni sui clienti e ai dati dei prodotti direttamente all''interno delle tue conversazioni e aiuta il tuo team di supporto a fornire un''assistenza più rapida e contestualizzata ai tuoi clienti.'
leadsquared:
name: 'LeadSquared'
- short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
- description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
+ short_description: 'Sincronizza contatti e conversazioni con LeadSquared CRM.'
+ description: 'Sincronizza i contatti e conversazioni con il CRM LeadSquared. Questa integrazione crea automaticamente i lead in LeadSquared quando vengono aggiunti nuovi contatti e registra l''attività di conversazione per fornire al team di vendita un contesto completo.'
captain:
- copilot_message_required: Il messaggio è obbligatorio
- copilot_error: 'Please connect an assistant to this inbox to use Copilot'
- copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+ copilot_message_required: Messaggio richiesto
+ copilot_error: 'Connetti un assistente a questa Inbox per usare Copilot'
+ copilot_limit: 'Hai terminato i crediti Copilot. Puoi acquistare altri crediti dalla sezione fatturazione.'
copilot:
- using_tool: 'Using tool %{function_name}'
- completed_tool_call: 'Completed %{function_name} tool call'
+ using_tool: 'Utilizzando tool %{function_name}'
+ completed_tool_call: 'Tool %{function_name} utilizzato'
invalid_tool_call: 'Chiamata strumento non valida'
tool_not_available: 'Strumento non disponibile'
documents:
- limit_exceeded: 'Document limit exceeded'
- pdf_format_error: 'must be a PDF file'
- pdf_size_error: 'must be less than 10MB'
- pdf_upload_failed: 'Failed to upload PDF to OpenAI'
- pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
- pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
- pdf_processing_success: 'Successfully processed PDF document %{document_id}'
- faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
- using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
- using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
- response_creation_error: 'Error in creating response document: %{error}'
- missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
- openai_api_error: 'OpenAI API Error: %{error}'
- starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
- stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
- paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
- processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
- chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
- page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ limit_exceeded: 'Raggiunto limite documenti'
+ pdf_format_error: 'deve essere un file PDF'
+ pdf_size_error: 'deve essere inferiore a 10MB'
+ pdf_upload_failed: 'Caricamento PDF a OpenAI fallito'
+ pdf_upload_success: 'PDF caricato con successo (file_id: %{file_id})'
+ pdf_processing_failed: 'Errore nell''elaborazione del documento PDF %{document_id}: %{error}'
+ pdf_processing_success: 'Documento PDF %{document_id} elaborato correttamente'
+ faq_generation_complete: 'Generazione FAQ completata. Totale FAQ generate: %{count}'
+ using_paginated_faq: 'Uso della generazione di FAQ paginate per il documento %{document_id}'
+ using_standard_faq: 'Uso della generazione di FAQ standard per il documento %{document_id}'
+ response_creation_error: 'Errore nella creazione del documento di risposta: %{error}'
+ missing_openai_file_id: 'Il documento deve avere openai_file_id per l''elaborazione paginata'
+ openai_api_error: 'Errore API OpenAI: %{error}'
+ starting_paginated_faq: 'Avvio della generazione di FAQ paginate (%{pages_per_chunk} pagine per chunk)'
+ stopping_faq_generation: 'Elaborazione interrotta. Motivo: %{reason}'
+ paginated_faq_complete: 'Generazione paginata completa. FAQ totali: %{total_faqs}, Pagine elaborate: %{pages_processed}'
+ processing_pages: 'Elaborazione pagine %{start}-%{end} (iterazione %{iteration})'
+ chunk_generated: 'Il chunk ha generato %{chunk_faqs} FAQ. Totale finora: %{total_faqs}'
+ page_processing_error: 'Errore nell''elaborazione delle pagine %{start}-%{end}: %{error}'
custom_tool:
- slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
+ slug_generation_failed: 'Impossibile generare slug univoco dopo 5 tentativi'
public_portal:
search:
- search_placeholder: Search for article by title or body...
+ search_placeholder: Cerca articolo tramite titolo o testo...
empty_placeholder: Nessun risultato trovato.
- loading_placeholder: Searching...
- results_title: Search results
- toc_header: 'On this page'
+ loading_placeholder: Ricerca...
+ results_title: Risultati di ricerca
+ toc_header: 'Su questa pagina'
hero:
- sub_title: Search for the articles here or browse the categories below.
+ sub_title: Cerca gli articoli qui oppure sfoglia le categorie qui sotto.
common:
home: Home
- last_updated_on: Last updated on %{last_updated_on}
- view_all_articles: View all
- article: article
+ last_updated_on: Ultimo aggiornamento %{last_updated_on}
+ view_all_articles: Vedi tutti
+ article: articolo
articles: articoli
author: autore
- authors: authors
- other: other
- others: others
- by: By
- no_articles: There are no articles here
+ authors: autori
+ other: altro
+ others: altri
+ by: Da
+ no_articles: Non ci sono articoli qui
footer:
- made_with: Made with
+ made_with: Realizzato con
header:
- go_to_homepage: Website
+ go_to_homepage: Sito Web
visit_website: Visita sito
appearance:
- system: System
- light: Light
- dark: Dark
- featured_articles: Featured Articles
- uncategorized: Uncategorized
+ system: Sistema
+ light: Chiaro
+ dark: Scuro
+ featured_articles: Articoli in evidenza
+ uncategorized: Senza categoria
404:
- title: Page not found
- description: We couldn't find the page you were looking for.
- back_to_home: Go to home page
+ title: Pagina non trovata
+ description: Non siamo riusciti a trovare la pagina che stavi cercando.
+ back_to_home: Vai alla pagina iniziale
slack_unfurl:
fields:
name: Nome
email: Email
- phone_number: Phone
+ phone_number: Telefono
company_name: Azienda
- inbox_name: Casella
- inbox_type: Inbox Type
+ inbox_name: Inbox
+ inbox_type: Tipo Inbox
button: Apri conversazione
time_units:
days:
- one: '%{count} day'
- other: '%{count} days'
+ one: '%{count} giorno'
+ other: '%{count} giorni'
hours:
- one: '%{count} hour'
- other: '%{count} hours'
+ one: '%{count} ora'
+ other: '%{count} ore'
minutes:
- one: '%{count} minute'
- other: '%{count} minutes'
+ one: '%{count} minuto'
+ other: '%{count} minuti'
seconds:
- one: '%{count} second'
- other: '%{count} seconds'
+ one: '%{count} secondo'
+ other: '%{count} secondi'
automation:
- system_name: 'Sistema di Automazione'
+ system_name: 'Automation System'
crm:
- no_message: 'No messages in conversation'
- attachment: '[Attachment: %{type}]'
+ no_message: 'Nessun messaggio nella conversazione'
+ attachment: '[Allegato: %{type}]'
no_content: '[No content]'
created_activity: |
- New conversation started on %{brand_name}
+ Nuova conversazione iniziata su %{brand_name}
- Channel: %{channel_info}
- Created: %{formatted_creation_time}
- Conversation ID: %{display_id}
- View in %{brand_name}: %{url}
+ Canale: %{channel_info}
+ Creata: %{formatted_creation_time}
+ ID Conversazione: %{display_id}
+ Visualizza in %{brand_name}: %{url}
transcript_activity: |
- Conversation Transcript from %{brand_name}
+ Trascrizione della conversazione da %{brand_name}
- Channel: %{channel_info}
- Conversation ID: %{display_id}
- View in %{brand_name}: %{url}
+ Canale: %{channel_info}
+ ID Conversazione: %{display_id}
+ Visualizza in %{brand_name}: %{url}
- Transcript:
+ Trascrizione:
%{format_messages}
agent_capacity_policy:
- inbox_already_assigned: 'Inbox has already been assigned to this policy'
+ inbox_already_assigned: 'La Inbox è già stata assegnata a questa policy'
portals:
send_instructions:
- email_required: 'L''email è obbligatoria'
- invalid_email_format: 'Invalid email format'
- custom_domain_not_configured: 'Custom domain is not configured'
- instructions_sent_successfully: 'Instructions sent successfully'
- subject: 'Finish setting up %{custom_domain}'
+ email_required: 'Email richiesta'
+ invalid_email_format: 'Formato email non valido'
+ custom_domain_not_configured: 'Il dominio personalizzato non è configurato'
+ instructions_sent_successfully: 'Istruzioni inviate con successo'
+ subject: 'Termina la configurazione di %{custom_domain}'
ssl_status:
- custom_domain_not_configured: 'Custom domain is not configured'
+ custom_domain_not_configured: 'Il dominio personalizzato non è configurato'
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 336f70b91..d1753cb7a 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -62,6 +62,9 @@ ja:
invalid: 無効なEメールです
phone_number:
invalid: e164形式である必要があります
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: カテゴリとポータルで一意である必要があります
@@ -73,6 +76,7 @@ ja:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ja:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '%{user_name} によって会話は解決済みになりました'
contact_resolved: '%{contact_name} によって会話が解決されました'
diff --git a/config/locales/ka.yml b/config/locales/ka.yml
index c8f9d200d..25e2370ee 100644
--- a/config/locales/ka.yml
+++ b/config/locales/ka.yml
@@ -62,6 +62,9 @@ ka:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ ka:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ka:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 042e2fae4..707332172 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -62,6 +62,9 @@ ko:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ ko:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ko:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/lt.yml b/config/locales/lt.yml
index 63b508066..daf7b00ee 100644
--- a/config/locales/lt.yml
+++ b/config/locales/lt.yml
@@ -62,6 +62,9 @@ lt:
invalid: Neteisingas el. paštas
phone_number:
invalid: turėtų būti e164 formato
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: turėtų būti unikalūs kategorijoje ir portale
@@ -73,6 +76,7 @@ lt:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ lt:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Pokalbį pažymėjo %{user_name} kaip baigtą'
contact_resolved: 'Pokalbį užbaigė %{contact_name}'
diff --git a/config/locales/lv.yml b/config/locales/lv.yml
index 12b1d85a7..3fd92993b 100644
--- a/config/locales/lv.yml
+++ b/config/locales/lv.yml
@@ -62,6 +62,9 @@ lv:
invalid: Nederīga e-pasta adrese
phone_number:
invalid: vajadzētu būt E.164 formātā
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: vajadzētu būt unikālai, kategorijā un portālā
@@ -73,6 +76,7 @@ lv:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ lv:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '%{user_name} sarunu atzīmēja kā atrisinātu'
contact_resolved: '%{contact_name} atrisināja sarunu'
diff --git a/config/locales/ml.yml b/config/locales/ml.yml
index 80bd12a50..ee5ee8f7f 100644
--- a/config/locales/ml.yml
+++ b/config/locales/ml.yml
@@ -62,6 +62,9 @@ ml:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ ml:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ml:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'സംഭാഷണം %{user_name} പരിഹരിച്ചതായി അടയാളപ്പെടുത്തി'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/ms.yml b/config/locales/ms.yml
index 5d6963421..460986218 100644
--- a/config/locales/ms.yml
+++ b/config/locales/ms.yml
@@ -62,6 +62,9 @@ ms:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ ms:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ms:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/ne.yml b/config/locales/ne.yml
index b4477805a..57b0ddc4c 100644
--- a/config/locales/ne.yml
+++ b/config/locales/ne.yml
@@ -62,6 +62,9 @@ ne:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ ne:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ne:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 1d361f807..73e15b0c3 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -62,6 +62,9 @@ nl:
invalid: Ongeldig email
phone_number:
invalid: moet in e164-formaat zijn
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: moet uniek zijn in de categorie en portal
@@ -73,6 +76,7 @@ nl:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ nl:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Gesprek werd gemarkeerd door %{user_name}'
contact_resolved: 'Gesprek werd opgelost door %{contact_name}'
diff --git a/config/locales/no.yml b/config/locales/no.yml
index ebb23976d..41ce03ef5 100644
--- a/config/locales/no.yml
+++ b/config/locales/no.yml
@@ -62,6 +62,9 @@
invalid: Ugyldig epost
phone_number:
invalid: skal være i e164-format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: må være unikt i kategorien og portalen
@@ -73,6 +76,7 @@
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Samtale ble løst av %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index 9ade3104a..5b727d944 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -62,6 +62,9 @@ pl:
invalid: Nieprawidłowy adres e-mail
phone_number:
invalid: powinno być w formacie e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: powinno być unikalne w kategorii i portalu
@@ -73,6 +76,7 @@ pl:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ pl:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Rozmowa została oznaczona przez %{user_name}'
contact_resolved: 'Rozmowa została rozwiązana przez %{contact_name}'
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index cf5b51e07..168941db0 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -62,6 +62,9 @@ pt:
invalid: Email inválido
phone_number:
invalid: deve estar no formato e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: deve ser único na categoria e no portal
@@ -73,6 +76,7 @@ pt:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ pt:
captain:
resolved: 'A conversa foi marcada como resolvida por %{user_name} devido à inatividade'
open: 'A conversa foi marcada como aberta por %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversa foi marcada como resolvida por %{user_name}'
contact_resolved: 'Conversa foi resolvida por %{contact_name}'
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index 01e532aad..f8f6c0428 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -62,6 +62,9 @@ pt_BR:
invalid: E-mail inválido
phone_number:
invalid: deve estar no formato e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: deve ser único na categoria e no portal
@@ -73,6 +76,7 @@ pt_BR:
token_exchange_failed: 'Falha ao trocar o código por um token de acesso. Por favor, tente novamente.'
invalid_token_permissions: 'O token de acesso não tem as permissões necessárias para o WhatsApp.'
phone_info_fetch_failed: 'Falha ao obter a informação do número de telefone. Por favor, tente novamente.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Falha ao reautenticar o WhatsApp. Por favor, tente novamente.'
not_supported: 'Reautenticação não é suportado por este tipo de canal WhatsApp.'
@@ -185,6 +189,8 @@ pt_BR:
captain:
resolved: 'A conversa foi marcada como resolvida por %{user_name} por inatividade'
open: 'A conversa foi aberta por %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversa foi marcada como resolvida por %{user_name}'
contact_resolved: 'A conversa foi resolvida por %{contact_name}'
diff --git a/config/locales/ro.yml b/config/locales/ro.yml
index ae7ca5605..b00c1689c 100644
--- a/config/locales/ro.yml
+++ b/config/locales/ro.yml
@@ -62,6 +62,9 @@ ro:
invalid: E-mail invalid
phone_number:
invalid: ar trebui să fie în format e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: ar trebui să fie unic în categorie și portal
@@ -73,6 +76,7 @@ ro:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ro:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversația a fost marcată de %{user_name}'
contact_resolved: 'Conversația a fost rezolvată de %{contact_name}'
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 3dc011305..90d41e87b 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -20,19 +20,19 @@ ru:
hello: 'Привет мир'
inbox:
reauthorization:
- success: 'Channel reauthorized successfully'
- not_required: 'Reauthorization is not required for this inbox'
- invalid_channel: 'Invalid channel type for reauthorization'
+ success: 'Канал успешно повторно авторизирован'
+ not_required: 'Повторная авторизация не требуется для этого ящика'
+ invalid_channel: 'Неверный тип канала для повторной авторизации'
auth:
saml:
invalid_email: 'Пожалуйста, введите действительный адрес электронной почты'
- authentication_failed: 'Authentication failed. Please check your credentials and try again.'
+ authentication_failed: 'Ошибка аутентификации. Пожалуйста, проверьте ваши учетные данные и повторите попытку.'
messages:
reset_password_success: Круто! Запрос на сброс пароля удался. Проверьте почту для получения инструкций.
reset_password_failure: Ой! Мы не смогли найти пользователя с указанным email.
- reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
- login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
- saml_not_available: SAML authentication is not available in this installation.
+ reset_password_saml_user: Эта учетная запись использует SAML-аутентификацию. Сброс пароля недоступен. Пожалуйста, обратитесь к администратору.
+ login_saml_user: Эта учетная запись использует SAML аутентификацию. Пожалуйста, войдите через SAML провайдера вашей организации.
+ saml_not_available: SAML аутентификация не доступна в этой установке.
inbox_deletetion_response: Ваш запрос на удаление входящих сообщений будет обработан через некоторое время.
errors:
validations:
@@ -47,9 +47,9 @@ ru:
invalid_params: 'Неверно, проверьте параметры регистрации и повторите попытку'
failed: Ошибка регистрации
assignment_policy:
- not_found: Assignment policy not found
+ not_found: Политика назначения не найдена
saml:
- feature_not_enabled: SAML feature not enabled for this account
+ feature_not_enabled: Функция SAML не включена для этой учетной записи
data_import:
data_type:
invalid: Недопустимый тип данных
@@ -62,6 +62,9 @@ ru:
invalid: Неверный email
phone_number:
invalid: должен иметь формат e164
+ companies:
+ domain:
+ invalid: должно быть корректным доменным именем
categories:
locale:
unique: Должны быть уникальными в категории и портале
@@ -70,12 +73,13 @@ ru:
slack:
invalid_channel_id: 'Неправильный канал slack - попробуйте еще раз'
whatsapp:
- token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
- invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
- phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ token_exchange_failed: 'Не удалось обменять код на токена доступа. Пожалуйста, попробуйте еще раз.'
+ invalid_token_permissions: 'Токен доступа не имеет необходимых прав для WhatsApp.'
+ phone_info_fetch_failed: 'Не удалось получить информацию о номере телефона. Пожалуйста, попробуйте еще раз.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
- generic: 'Failed to reauthorize WhatsApp. Please try again.'
- not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+ generic: 'Не удалось повторно авторизовать WhatsApp. Пожалуйста, попробуйте еще раз.'
+ not_supported: 'Повторная авторизация не поддерживается для данного типа канала WhatsApp.'
inboxes:
imap:
socket_error: Пожалуйста, проверьте сетевое подключение, адрес IMAP и повторите попытку.
@@ -94,19 +98,19 @@ ru:
custom_attribute_definition:
key_conflict: Предоставленный ключ не разрешён, так как он может конфликтовать со стандартными атрибутами.
mfa:
- already_enabled: MFA is already enabled
- not_enabled: MFA is not enabled
- invalid_code: Invalid verification code
- invalid_backup_code: Invalid backup code
- invalid_token: Invalid or expired MFA token
- invalid_credentials: Invalid credentials or verification code
- feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ already_enabled: MFA уже включен
+ not_enabled: MFA не включена
+ invalid_code: Неверный код подтверждения
+ invalid_backup_code: Неверный резервный код
+ invalid_token: Недопустимый или просроченный MFA токен
+ invalid_credentials: Неверные учетные данные или проверочный код
+ feature_unavailable: Функция MFA недоступна. Пожалуйста, настройте ключи шифрования.
profile:
mfa:
- enabled: MFA enabled successfully
- disabled: MFA disabled successfully
+ enabled: MFA успешно включен
+ disabled: MFA успешно отключен
account_saml_settings:
- invalid_certificate: must be a valid X.509 certificate in PEM format
+ invalid_certificate: должен быть действительным сертификатом X.509 в формате PEM
reports:
period: Отчётный период с %{since} по %{until}
utc_warning: Отчёт создан в часовом поясе UTC
@@ -128,7 +132,7 @@ ru:
conversations_count: Количество диалогов
avg_first_response_time: Среднее время первого ответа
avg_resolution_time: Среднее время завершения
- avg_reply_time: Avg reply time
+ avg_reply_time: Среднее время ответа
resolution_count: Количество завершенных
team_csv:
team_name: Название команды
@@ -172,19 +176,21 @@ ru:
no_content: 'Нет содержимого'
conversations:
captain:
- handoff: 'Transferring to another agent for further assistance.'
+ handoff: 'Передача другому агенту для дальнейшей помощи.'
messages:
instagram_story_content: '%{story_sender} упомянул Вас в истории: '
instagram_deleted_story_content: Эта история больше недоступна.
deleted: Это сообщение было удалено
whatsapp:
- list_button_label: 'Choose an item'
+ list_button_label: 'Выберите элемент'
delivery_status:
error_code: 'Код ошибки: %{error_code}'
activity:
captain:
- resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
- open: 'Conversation was marked open by %{user_name}'
+ resolved: 'Диалог был решен %{user_name} из-за бездействия'
+ open: 'Диалог был открыт %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Диалог был открыт системой из-за ошибки бота агента.'
status:
resolved: '%{user_name} завершил диалог'
contact_resolved: 'Разговор был закрыт %{contact_name}'
@@ -192,8 +198,8 @@ ru:
pending: 'Разговор был помечен как ожидающий %{user_name}'
snoozed: 'Разговор был помечен как отложенный %{user_name}'
auto_resolved_days: 'Разговор был помечен системой решённым из-за неактивности в течение %{count} дней'
- auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
- auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
+ auto_resolved_hours: 'Диалог был решен системой из-за %{count} часов бездействия'
+ auto_resolved_minutes: 'Диалог был решён системой из-за %{count} минут бездействия'
system_auto_open: Система переоткрыла разговор из-за нового входящего сообщения.
priority:
added: '%{user_name} установил приоритет на %{new_priority}'
@@ -214,11 +220,11 @@ ru:
added: '%{user_name} добавил политику SLA %{sla_name}'
removed: '%{user_name} удалил политику SLA %{sla_name}'
linear:
- issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
- issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
- issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
+ issue_created: 'Линейная задача %{issue_id} была создана %{user_name}'
+ issue_linked: 'Линейная задача %{issue_id} была связана с %{user_name}'
+ issue_unlinked: 'Линейная задача %{issue_id} была отвязана от %{user_name}'
csat:
- not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
+ not_sent_due_to_messaging_window: 'Опрос CSAT не отправлен из-за ограничений исходящих сообщений'
auto_resolve:
not_sent_due_to_messaging_window: 'Сообщение автозавершения не отправлено из-за ограничений исходящих сообщений'
muted: '%{user_name} заглушил(а) этот разговор'
@@ -255,75 +261,75 @@ ru:
description: 'Панель приложений позволяет вам создавать и вставлять приложения, отображающие информацию о пользователе, заказы или историю платежей, обеспечивая больший контекст для агентов поддержки.'
dyte:
name: 'Dyte'
- short_description: 'Start video/voice calls with customers directly from Chatwoot.'
+ short_description: 'Начните видео/голосовые звонки с клиентов прямо из Chatwoot.'
description: 'Dyte - это продукт, который интегрирует функции аудио и видео в ваше приложение. С помощью этой интеграции ваши агенты могут начать видео/голосовые звонки с вашими клиентами прямо из Chatwoot.'
meeting_name: '%{agent_name} приступил к встрече'
slack:
name: 'Slack'
- short_description: 'Receive notifications and respond to conversations directly in Slack.'
+ short_description: 'Получать уведомления и отвечать на разговоры прямо в Slack.'
description: "Интегрируйте Chatwoot с Slack для синхронизации команды. Эта интеграция позволяет получать уведомления о новых разговорах и отвечать на них непосредственно в интерфейсе Slack."
webhooks:
name: 'Webhooks'
description: 'События Webhook предоставляют обновления об активности в вашем аккаунте Chatwoot в режиме реального времени. Вы можете подписаться на ваши предпочтительные события, и Chatwoot будет отправлять вам HTTP-ответы с обновлениями.'
dialogflow:
name: 'Диалог'
- short_description: 'Build chatbots to handle initial queries before transferring to agents.'
+ short_description: 'Постройте чат ботов для обработки начальных запросов перед передачей агентам.'
description: 'Создайте чатботов с помощью Dialogflow и легко интегрируйте их в ваш источник. Эти боты могут обрабатывать начальные запросы, прежде чем передавать их агенту поддержки.'
google_translate:
name: 'Google Перевод'
- short_description: 'Automatically translate customer messages for agents.'
+ short_description: 'Автоматически переводить сообщения клиентов для агентов.'
description: "Интегрируйте Google Translate, чтобы помочь агентам легко переводить сообщения клиентов. Эта интеграция автоматически определяет язык и преобразует его в язык, предпочтительный для агента или администратора."
openai:
name: 'OpenAI'
- short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
+ short_description: 'Предложение ответов, краткое изложение и повышение эффективности сообщений с помощью ИИ.'
description: 'Используйте LLM OpenAI с такими функциями, как предложение ответов, резюмирование, перефразирование сообщений, проверка орфографии и подстановка категорий.'
linear:
name: 'Linear'
- short_description: 'Create and link Linear issues directly from conversations.'
+ short_description: 'Создать и связать Линейные задачи непосредственно из диалогов.'
description: 'Создавайте или прикрепляйте уже существующие задачи в Linear непосредственно из окна диалога для более упорядоченного и эффективного процесса отслеживания проблем.'
notion:
name: 'Notion'
- short_description: 'Integrate databases, documents and pages directly with Captain.'
- description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
+ short_description: 'Интеграция баз данных, документов и страниц напрямую с Captain.'
+ description: 'Подключите ваше рабочее пространство Notion, чтобы включить Captain для доступа к Ии ответам, используя содержимое из вашей базы данных, документов и страниц, чтобы обеспечить более точную поддержку клиентов.'
shopify:
name: 'Shopify'
- short_description: 'Access order details and customer data from your Shopify store.'
+ short_description: 'Доступ к информации о заказе и данным о клиентах из магазина Shopify.'
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
leadsquared:
name: 'LeadSquared'
- short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
- description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
+ short_description: 'Синхронизация контактов и диалогов с LeadSquared CRM.'
+ description: 'Синхронизация контактов и диалогов с LeadSquared CRM. Эта интеграция автоматически создает лиды в LeadSquared при добавлении новых контактов, и ведет разговор активности, чтобы обеспечить вашу команду продаж полным контекстом.'
captain:
copilot_message_required: Необходимо ввести сообщение
copilot_error: 'Пожалуйста, подключите ассистента к этому источнику входящих для использования Copilot'
copilot_limit: 'У вас закончились кредиты для Copilot. Вы можете купить дополнительные кредиты в разделе биллинга.'
copilot:
- using_tool: 'Using tool %{function_name}'
- completed_tool_call: 'Completed %{function_name} tool call'
- invalid_tool_call: 'Invalid tool call'
- tool_not_available: 'Tool not available'
+ using_tool: 'Использование инструмента %{function_name}'
+ completed_tool_call: 'Вызов инструмента %{function_name}'
+ invalid_tool_call: 'Неверный вызов инструмента'
+ tool_not_available: 'Инструмент недоступен'
documents:
- limit_exceeded: 'Document limit exceeded'
- pdf_format_error: 'must be a PDF file'
- pdf_size_error: 'must be less than 10MB'
- pdf_upload_failed: 'Failed to upload PDF to OpenAI'
- pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
- pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
- pdf_processing_success: 'Successfully processed PDF document %{document_id}'
- faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
- using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
- using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
- response_creation_error: 'Error in creating response document: %{error}'
- missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
- openai_api_error: 'OpenAI API Error: %{error}'
- starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
- stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
- paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
- processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
- chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
- page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ limit_exceeded: 'Превышен лимит документов'
+ pdf_format_error: 'должен быть PDF файлом'
+ pdf_size_error: 'должно быть меньше 10МБ'
+ pdf_upload_failed: 'Не удалось загрузить PDF в OpenAI'
+ pdf_upload_success: 'PDF успешно загружен с file_id: %{file_id}'
+ pdf_processing_failed: 'Не удалось обработать PDF документ %{document_id}: %{error}'
+ pdf_processing_success: 'Документ PDF %{document_id} успешно обработан'
+ faq_generation_complete: 'Генерация FAQ завершено. Всего FAQ: %{count}'
+ using_paginated_faq: 'Использование нумерованной генерации FAQ для документа %{document_id}'
+ using_standard_faq: 'Использование стандартной генерации FAQ для документа %{document_id}'
+ response_creation_error: 'Ошибка при создании документа: %{error}'
+ missing_openai_file_id: 'Документ должен иметь openai_file_id для обработки страницы'
+ openai_api_error: 'Ошибка OpenAI API: %{error}'
+ starting_paginated_faq: 'Запуск постраничной генерации FAQ (%{pages_per_chunk} страниц на документ)'
+ stopping_faq_generation: 'Обработка остановлена. Причина: %{reason}'
+ paginated_faq_complete: 'Постраничная генерация завершена. Всего FAQ: %{total_faqs}, обработанных страниц: %{pages_processed}'
+ processing_pages: 'Обработка страниц %{start}-%{end} (итерация %{iteration})'
+ chunk_generated: 'Страниц сгенерировано %{chunk_faqs} FAQs. Всего на данный момент: %{total_faqs}'
+ page_processing_error: 'Ошибка при обработке страниц %{start}-%{end}: %{error}'
custom_tool:
- slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
+ slug_generation_failed: 'Невозможно сгенерировать уникальный slug после 5 попыток'
public_portal:
search:
search_placeholder: Поиск статьи по названию или содержанию...
@@ -349,7 +355,7 @@ ru:
made_with: Сделано с
header:
go_to_homepage: Сайт
- visit_website: Visit website
+ visit_website: Посетить сайт
appearance:
system: Система
light: Светлая
@@ -391,35 +397,35 @@ ru:
many: '%{count} секунд'
other: '%{count} секунд'
automation:
- system_name: 'Automation System'
+ system_name: 'Система автоматизации'
crm:
- no_message: 'No messages in conversation'
- attachment: '[Attachment: %{type}]'
+ no_message: 'В диалоге нет сообщений'
+ attachment: '[Вложение: %{type}]'
no_content: '[Нет содержимого]'
created_activity: |
- New conversation started on %{brand_name}
+ Новый диалог начался на %{brand_name}
- Channel: %{channel_info}
- Created: %{formatted_creation_time}
- Conversation ID: %{display_id}
- View in %{brand_name}: %{url}
+ Канал: %{channel_info}
+ Создано: %{formatted_creation_time}
+ ID разговора: %{display_id}
+ Просмотрено в %{brand_name}: %{url}
transcript_activity: |
- Conversation Transcript from %{brand_name}
+ Расшифровка разговора из %{brand_name}
- Channel: %{channel_info}
- Conversation ID: %{display_id}
- View in %{brand_name}: %{url}
+ Канал: %{channel_info}
+ ID разговора: %{display_id}
+ Просмотр %{brand_name}: %{url}
- Transcript:
+ Расшифровка:
%{format_messages}
agent_capacity_policy:
- inbox_already_assigned: 'Inbox has already been assigned to this policy'
+ inbox_already_assigned: 'Входящие уже были назначены на эту политику'
portals:
send_instructions:
email_required: 'Необходимо указать Email'
- invalid_email_format: 'Invalid email format'
- custom_domain_not_configured: 'Custom domain is not configured'
- instructions_sent_successfully: 'Instructions sent successfully'
- subject: 'Finish setting up %{custom_domain}'
+ invalid_email_format: 'Неправильный формат email'
+ custom_domain_not_configured: 'Пользовательский домен не настроен'
+ instructions_sent_successfully: 'Инструкции успешно отправлены'
+ subject: 'Завершить настройку %{custom_domain}'
ssl_status:
- custom_domain_not_configured: 'Custom domain is not configured'
+ custom_domain_not_configured: 'Пользовательский домен не настроен'
diff --git a/config/locales/sh.yml b/config/locales/sh.yml
index b52dd4af5..e918e4f58 100644
--- a/config/locales/sh.yml
+++ b/config/locales/sh.yml
@@ -62,6 +62,9 @@ sh:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ sh:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ sh:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index 9c0b5c0a3..51c376df2 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -62,6 +62,9 @@ sk:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ sk:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ sk:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/sl.yml b/config/locales/sl.yml
index 76b21fb83..ba73fed9f 100644
--- a/config/locales/sl.yml
+++ b/config/locales/sl.yml
@@ -62,6 +62,9 @@ sl:
invalid: Napačen e-poštni naslov
phone_number:
invalid: mora biti v formatu e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: mora biti edinstven v kategoriji in portalu
@@ -73,6 +76,7 @@ sl:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ sl:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '%{user_name} je pogovor označil za rešenega'
contact_resolved: 'Pogovor je razrešil %{contact_name}'
diff --git a/config/locales/sq.yml b/config/locales/sq.yml
index d87ab44e2..b2874d78f 100644
--- a/config/locales/sq.yml
+++ b/config/locales/sq.yml
@@ -62,6 +62,9 @@ sq:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ sq:
token_exchange_failed: 'Dështoi shkëmbimi i kodit për tokenin e aksesit. Ju lutemi, provoni përsëri.'
invalid_token_permissions: 'Tokeni i aksesit nuk ka lejet e nevojshme për WhatsApp.'
phone_info_fetch_failed: 'Dështoi marrja e informacionit të numrit të telefonit. Ju lutemi, provoni përsëri.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Dështoi riautorizimi i WhatsApp-it. Ju lutemi, provoni përsëri.'
not_supported: 'Riautorizimi nuk mbështetet për këtë lloj kanali të WhatsApp-it.'
@@ -185,6 +189,8 @@ sq:
captain:
resolved: 'Biseda u shënua si e zgjidhur nga %{user_name} për shkak të mungesës së aktivitetit'
open: 'Biseda u shënua si e hapur nga %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index a8f9b23a8..c0d384a15 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -62,6 +62,9 @@ sr-Latn:
invalid: Neispravna e-pošta
phone_number:
invalid: treba biti u e164 formatu
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: treba biti jedinstvena u kategoriji i portalu
@@ -73,6 +76,7 @@ sr-Latn:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ sr-Latn:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Razgovor je označen kao rešen od strane %{user_name}'
contact_resolved: 'Razgovor je rešen od strane %{contact_name}'
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index 77a3a1e84..c12ee447d 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -62,6 +62,9 @@ sv:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ sv:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ sv:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Konversationen har markerats som löst av %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/ta.yml b/config/locales/ta.yml
index 2af7ba7bb..1f84cf894 100644
--- a/config/locales/ta.yml
+++ b/config/locales/ta.yml
@@ -62,6 +62,9 @@ ta:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ ta:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ta:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'உரையாடலுக்கு %{user_name} தீர்வு வழங்கியுள்ளார்'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/th.yml b/config/locales/th.yml
index 287f6d4f2..c6882b804 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -62,6 +62,9 @@ th:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ th:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ th:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/tl.yml b/config/locales/tl.yml
index fb6de4e8c..a82593d8b 100644
--- a/config/locales/tl.yml
+++ b/config/locales/tl.yml
@@ -62,6 +62,9 @@ tl:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ tl:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ tl:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index f046ee82e..5cca4e559 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -62,6 +62,9 @@ tr:
invalid: Hatalı e-posta
phone_number:
invalid: e164 formatında olmalı
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: kategori ve portalde tekil olmalı
@@ -73,6 +76,7 @@ tr:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Bu telefon numarası için zaten bir kanal mevcut: %{phone_number}, hata devam ederse lütfen destek ile iletişime geçin'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -183,17 +187,19 @@ tr:
error_code: 'Hata kodu: %{error_code}'
activity:
captain:
- resolved: 'Sohbet, %{user_name} tarafından etkinlik olmadığı için çözüldü olarak işaretlendi'
- open: 'Sohbet, %{user_name} tarafından açık olarak işaretlendi'
+ resolved: 'Konuşma, %{user_name} tarafından etkinlik olmadığı için çözüldü olarak işaretlendi'
+ open: 'Konuşma, %{user_name} tarafından açık olarak işaretlendi'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Konuşma %{user_name} tarafından çözümlendi olarak işaretlendi'
contact_resolved: 'Konuşma %{contact_name} tarafından çözümlendi olarak işaretlendi'
open: 'Konuşma %{user_name} tarafından açık olarak işaretlendi'
pending: 'Konuşma, %{user_name} tarafından bekleyen olarak işaretlendi'
snoozed: 'Konuşma, %{user_name} tarafından erteledi olarak işaretlendi'
- auto_resolved_days: ' %{count} günlük hareketsizlik nedeniyle konuşma, sistem tarafından çözümlendi olarak işaretlendi'
- auto_resolved_hours: 'Sohbet, sistem tarafından %{count} saat etkinlik olmadığı için çözüldü olarak işaretlendi'
- auto_resolved_minutes: 'Sohbet, sistem tarafından %{count} dakika etkinlik olmadığı için çözüldü olarak işaretlendi'
+ auto_resolved_days: 'Konuşma, %{count} günlük hareketsizlik nedeniyle sistem tarafından çözümlendi olarak işaretlendi'
+ auto_resolved_hours: 'Konuşma, %{count} saatlik hareketsizlik nedeniyle sistem tarafından çözümlendi olarak işaretlendi'
+ auto_resolved_minutes: 'Konuşma, %{count} dakikalık hareketsizlik nedeniyle sistem tarafından çözümlendi olarak işaretlendi'
system_auto_open: Sistem, yeni gelen bir mesaj nedeniyle konuşmayı tekrar açtı.
priority:
added: '%{user_name} önceliği %{new_priority} olarak ayarladı'
@@ -212,7 +218,7 @@ tr:
removed: '%{user_name}, %{labels} kaldırdı'
sla:
added: '%{user_name} added SLA policy %{sla_name}'
- removed: '%{user_name} removed SLA policy %{sla_name}'
+ removed: '%{user_name}, %{sla_name} adlı SLA politikasını kaldırdı'
linear:
issue_created: 'Linear sorun %{issue_id} %{user_name} tarafından oluşturuldu'
issue_linked: 'Linear sorun %{issue_id} %{user_name} tarafından bağlandı'
@@ -260,7 +266,7 @@ tr:
meeting_name: '%{agent_name} bir toplantı başlattı'
slack:
name: 'Slack'
- short_description: 'Slack üzerinden doğrudan bildirim alın ve sohbetlere yanıt verin.'
+ short_description: 'Doğrudan Slack''te bildirimler alın ve konuşmalara yanıt verin.'
description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
webhooks:
name: 'Webhooks'
@@ -385,21 +391,21 @@ tr:
automation:
system_name: 'Otomasyon Sistemi'
crm:
- no_message: 'Sohbette mesaj yok'
+ no_message: 'Konuşmada mesaj yok'
attachment: '[Ek: %{type}]'
- no_content: '[No content]'
+ no_content: '[İçerik yok]'
created_activity: |
- Yeni sohbet başlatıldı: %{brand_name}
+ Yeni konuşma başlatıldı: %{brand_name}
Kanal: %{channel_info}
Oluşturulma: %{formatted_creation_time}
- Sohbet Kimliği: %{display_id}
+ Konuşma Kimliği: %{display_id}
%{brand_name}'de görüntüle: %{url}
transcript_activity: |
%{brand_name} sohbet dökümü
Kanal: %{channel_info}
- Sohbet Kimliği: %{display_id}
+ Konuşma Kimliği: %{display_id}
%{brand_name}'de görüntüle: %{url}
Döküm:
diff --git a/config/locales/uk.yml b/config/locales/uk.yml
index 29006859a..b993f11cc 100644
--- a/config/locales/uk.yml
+++ b/config/locales/uk.yml
@@ -62,6 +62,9 @@ uk:
invalid: Невірний email
phone_number:
invalid: має бути у форматі e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: має бути унікальним на категорії і порталі
@@ -73,6 +76,7 @@ uk:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ uk:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Розмова була відмічена як вирішена %{user_name}'
contact_resolved: 'Діалог був закритий %{contact_name}'
diff --git a/config/locales/ur.yml b/config/locales/ur.yml
index 1f033f2a6..3ce206c7a 100644
--- a/config/locales/ur.yml
+++ b/config/locales/ur.yml
@@ -62,6 +62,9 @@ ur:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ ur:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ur:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/ur_IN.yml b/config/locales/ur_IN.yml
index 304c8f405..1c60bbbe2 100644
--- a/config/locales/ur_IN.yml
+++ b/config/locales/ur_IN.yml
@@ -62,6 +62,9 @@ ur:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ ur:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ ur:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index ba84f6bbd..4a58f028e 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -62,6 +62,9 @@ vi:
invalid: Email không hợp lệ
phone_number:
invalid: nên theo đinh dạng e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: phải là duy nhất trong danh mục và cổng thông tin
@@ -73,6 +76,7 @@ vi:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ vi:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Cuộc trò chuyện được đánh dấu là đã giải quyết bởi %{user_name}'
contact_resolved: 'Hội thoại đã được giải quyết bởi %{contact_name}'
diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml
index 234c3758d..86a9ca702 100644
--- a/config/locales/zh_CN.yml
+++ b/config/locales/zh_CN.yml
@@ -62,6 +62,9 @@ zh_CN:
invalid: 无效的电子邮件
phone_number:
invalid: 应该是e164格式
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: 在类别和门户中应该是唯一的
@@ -73,6 +76,7 @@ zh_CN:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ zh_CN:
captain:
resolved: '对话被系统标记为已解决, 原因是 %{user_name} 不活跃'
open: '对话被 %{user_name} 打开'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '对话被标记由 %{user_name} 解决'
contact_resolved: '对话被 %{contact_name} 重新打开'
diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml
index 572271664..de3b45df9 100644
--- a/config/locales/zh_TW.yml
+++ b/config/locales/zh_TW.yml
@@ -62,6 +62,9 @@ zh_TW:
invalid: 無效的email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -73,6 +76,7 @@ zh_TW:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -185,6 +189,8 @@ zh_TW:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '被%{user_name}標記的對話已解決。'
contact_resolved: 'Conversation was resolved by %{contact_name}'
diff --git a/config/routes.rb b/config/routes.rb
index 639c51da7..9c6866e1d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -141,6 +141,7 @@ Rails.application.routes.draw do
post :custom_attributes
get :attachments
get :inbox_assistant
+ get :reporting_events if ChatwootApp.enterprise?
end
end
@@ -186,6 +187,7 @@ Rails.application.routes.draw do
get :download
end
end
+ resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
diff --git a/config/schedule.yml b/config/schedule.yml
index b5b21b4ce..c039719fa 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -53,3 +53,9 @@ bulk_auto_assignment_job:
cron: '*/15 * * * *'
class: 'Inboxes::BulkAutoAssignmentJob'
queue: scheduled_jobs
+
+# executed every 30 minutes for assignment_v2
+periodic_assignment_job:
+ cron: '*/30 * * * *'
+ class: 'AutoAssignment::PeriodicAssignmentJob'
+ queue: scheduled_jobs
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 50a47a20b..138cf78b3 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -27,6 +27,7 @@
- purgable
- housekeeping
- async_database_migration
+ - bulk_reindex_low
- active_storage_analysis
- active_storage_purge
- action_mailbox_incineration
diff --git a/db/migrate/20251010143218_add_name_to_webhooks.rb b/db/migrate/20251010143218_add_name_to_webhooks.rb
new file mode 100644
index 000000000..693ecc35a
--- /dev/null
+++ b/db/migrate/20251010143218_add_name_to_webhooks.rb
@@ -0,0 +1,5 @@
+class AddNameToWebhooks < ActiveRecord::Migration[7.1]
+ def change
+ add_column :webhooks, :name, :string, null: true
+ end
+end
diff --git a/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb b/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb
new file mode 100644
index 000000000..2fb387242
--- /dev/null
+++ b/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb
@@ -0,0 +1,16 @@
+class AddUniqueIndexToCompaniesDomain < ActiveRecord::Migration[7.1]
+ def up
+ remove_index :companies, name: 'index_companies_on_domain_and_account_id', if_exists: true
+
+ add_index :companies, [:account_id, :domain],
+ unique: true,
+ name: 'index_companies_on_account_and_domain',
+ where: 'domain IS NOT NULL'
+ end
+
+ def down
+ remove_index :companies, name: 'index_companies_on_account_and_domain', if_exists: true
+ add_index :companies, [:domain, :account_id],
+ name: 'index_companies_on_domain_and_account_id'
+ end
+end
diff --git a/db/migrate/20251022152158_add_index_to_conversations_identifier.rb b/db/migrate/20251022152158_add_index_to_conversations_identifier.rb
new file mode 100644
index 000000000..e7d02b53d
--- /dev/null
+++ b/db/migrate/20251022152158_add_index_to_conversations_identifier.rb
@@ -0,0 +1,6 @@
+class AddIndexToConversationsIdentifier < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+ def change
+ add_index :conversations, [:identifier, :account_id], name: 'index_conversations_on_identifier_and_account_id', algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c0d539f6a..0aeea5cc9 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: 2025_10_03_091242) do
+ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -577,8 +577,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
t.index ["account_id"], name: "index_companies_on_account_id"
- t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
@@ -676,6 +676,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.index ["contact_id"], name: "index_conversations_on_contact_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
+ t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
t.index ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
@@ -1229,6 +1230,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.datetime "updated_at", null: false
t.integer "webhook_type", default: 0
t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"]
+ t.string "name"
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
end
diff --git a/deployment/chatwoot-worker.1.service b/deployment/chatwoot-worker.1.service
index ea893d20c..6cc169c9e 100644
--- a/deployment/chatwoot-worker.1.service
+++ b/deployment/chatwoot-worker.1.service
@@ -17,7 +17,7 @@ StandardInput=null
SyslogIdentifier=%p
MemoryMax=1.5G
-MemoryHigh=1.4G
+MemoryHigh=infinity
MemorySwapMax=0
OOMPolicy=stop
diff --git a/enterprise/app/controllers/api/v1/accounts/reporting_events_controller.rb b/enterprise/app/controllers/api/v1/accounts/reporting_events_controller.rb
new file mode 100644
index 000000000..eb282b0ee
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/reporting_events_controller.rb
@@ -0,0 +1,30 @@
+class Api::V1::Accounts::ReportingEventsController < Api::V1::Accounts::EnterpriseAccountsController
+ include DateRangeHelper
+
+ RESULTS_PER_PAGE = 25
+
+ before_action :check_admin_authorization?
+ before_action :set_reporting_events, only: [:index]
+ before_action :set_current_page, only: [:index]
+
+ def index
+ @reporting_events = @reporting_events.page(@current_page).per(RESULTS_PER_PAGE)
+ @total_count = @reporting_events.total_count
+ end
+
+ private
+
+ def set_reporting_events
+ @reporting_events = Current.account.reporting_events
+ .includes(:conversation, :user, :inbox)
+ .filter_by_date_range(range)
+ .filter_by_inbox_id(params[:inbox_id])
+ .filter_by_user_id(params[:user_id])
+ .filter_by_name(params[:name])
+ .order(created_at: :desc)
+ end
+
+ def set_current_page
+ @current_page = (params[:page] || 1).to_i
+ end
+end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
index c0ecc2a45..f87c06658 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
@@ -11,6 +11,10 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end
end
+ def reporting_events
+ @reporting_events = @conversation.reporting_events.order(created_at: :asc)
+ end
+
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 15f2ace56..828123321 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -10,8 +10,12 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
Current.executed_by = @assistant
- ActiveRecord::Base.transaction do
- generate_and_process_response
+ if captain_v2_enabled?
+ generate_response_with_v2
+ else
+ ActiveRecord::Base.transaction do
+ generate_and_process_response
+ end
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
@@ -26,16 +30,20 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
- @response = if captain_v2_enabled?
- Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
- message_history: collect_previous_messages
- )
- else
- Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
- message_history: collect_previous_messages
- )
- end
+ @response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
+ message_history: collect_previous_messages
+ )
+ process_response
+ end
+ def generate_response_with_v2
+ @response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
+ message_history: collect_previous_messages
+ )
+ process_response
+ end
+
+ def process_response
return process_action('handoff') if handoff_requested?
create_messages
@@ -123,6 +131,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def captain_v2_enabled?
- return account.feature_enabled?('captain_integration_v2')
+ account.feature_enabled?('captain_integration_v2')
end
end
diff --git a/enterprise/app/jobs/captain/tools/firecrawl_parser_job.rb b/enterprise/app/jobs/captain/tools/firecrawl_parser_job.rb
index 41a50a35e..f34b9a640 100644
--- a/enterprise/app/jobs/captain/tools/firecrawl_parser_job.rb
+++ b/enterprise/app/jobs/captain/tools/firecrawl_parser_job.rb
@@ -5,11 +5,13 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
assistant = Captain::Assistant.find(assistant_id)
metadata = payload[:metadata]
+ canonical_url = normalize_link(metadata['url'])
document = assistant.documents.find_or_initialize_by(
- external_link: metadata['url']
+ external_link: canonical_url
)
document.update!(
+ external_link: canonical_url,
content: payload[:markdown],
name: metadata['title'],
status: :available
@@ -17,4 +19,10 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
end
+
+ private
+
+ def normalize_link(raw_url)
+ raw_url.to_s.delete_suffix('/')
+ end
end
diff --git a/enterprise/app/jobs/captain/tools/simple_page_crawl_parser_job.rb b/enterprise/app/jobs/captain/tools/simple_page_crawl_parser_job.rb
index ccf34adf2..89054f385 100644
--- a/enterprise/app/jobs/captain/tools/simple_page_crawl_parser_job.rb
+++ b/enterprise/app/jobs/captain/tools/simple_page_crawl_parser_job.rb
@@ -15,11 +15,11 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
- document = assistant.documents.find_or_initialize_by(
- external_link: page_link
- )
+ normalized_link = normalize_link(page_link)
+ document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
document.update!(
+ external_link: normalized_link,
name: page_title[0..254], content: content[0..14_999], status: :available
)
rescue StandardError => e
@@ -28,6 +28,10 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
private
+ def normalize_link(raw_link)
+ raw_link.to_s.delete_suffix('/')
+ end
+
def limit_exceeded?(account)
limits = account.usage_limits[:captain][:documents]
limits[:current_available].negative? || limits[:current_available].zero?
diff --git a/enterprise/app/jobs/migration/company_account_batch_job.rb b/enterprise/app/jobs/migration/company_account_batch_job.rb
new file mode 100644
index 000000000..2e6c20f19
--- /dev/null
+++ b/enterprise/app/jobs/migration/company_account_batch_job.rb
@@ -0,0 +1,54 @@
+class Migration::CompanyAccountBatchJob < ApplicationJob
+ queue_as :low
+
+ def perform(account)
+ account.contacts
+ .where.not(email: nil)
+ .find_in_batches(batch_size: 1000) do |contact_batch|
+ process_contact_batch(contact_batch, account)
+ end
+ end
+
+ private
+
+ def process_contact_batch(contacts, account)
+ contacts.each do |contact|
+ next unless should_process?(contact)
+
+ company = find_or_create_company(contact, account)
+ # rubocop:disable Rails/SkipsModelValidations
+ contact.update_column(:company_id, company.id) if company
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+ end
+
+ def should_process?(contact)
+ return false if contact.company_id.present?
+ return false if contact.email.blank?
+
+ Companies::BusinessEmailDetectorService.new(contact.email).perform
+ end
+
+ def find_or_create_company(contact, account)
+ domain = extract_domain(contact.email)
+ company_name = derive_company_name(contact, domain)
+
+ Company.find_or_create_by!(account: account, domain: domain) do |company|
+ company.name = company_name
+ end
+ rescue ActiveRecord::RecordNotUnique
+ # Race condition: Another job created it between our check and create
+ # just find the one that was created
+
+ Company.find_by(account: account, domain: domain)
+ end
+
+ def extract_domain(email)
+ email.split('@').last&.downcase
+ end
+
+ def derive_company_name(contact, domain)
+ contact.additional_attributes&.dig('company_name').presence ||
+ domain.split('.').first.tr('-_', ' ').titleize
+ end
+end
diff --git a/enterprise/app/jobs/migration/company_backfill_job.rb b/enterprise/app/jobs/migration/company_backfill_job.rb
new file mode 100644
index 000000000..db9a3370f
--- /dev/null
+++ b/enterprise/app/jobs/migration/company_backfill_job.rb
@@ -0,0 +1,17 @@
+class Migration::CompanyBackfillJob < ApplicationJob
+ queue_as :low
+
+ def perform
+ Rails.logger.info 'Starting company backfill migration...'
+ account_count = 0
+ Account.find_in_batches(batch_size: 100) do |accounts|
+ accounts.each do |account|
+ Rails.logger.info "Enqueuing company backfill for account #{account.id}"
+ Migration::CompanyAccountBatchJob.perform_later(account)
+ account_count += 1
+ end
+ end
+
+ Rails.logger.info "Company backfill migration complete. Enqueued jobs for #{account_count} accounts."
+ end
+end
diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb
index 6adeee036..112f9deea 100644
--- a/enterprise/app/models/applied_sla.rb
+++ b/enterprise/app/models/applied_sla.rb
@@ -22,7 +22,7 @@ class AppliedSla < ApplicationRecord
belongs_to :sla_policy
belongs_to :conversation
- has_many :sla_events, dependent: :destroy
+ has_many :sla_events, dependent: :destroy_async
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
before_validation :ensure_account_id
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index 771360659..4f039d57a 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -88,7 +88,7 @@ class Captain::Assistant < ApplicationRecord
private
def agent_name
- name
+ name.parameterize(separator: '_')
end
def agent_tools
diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb
index c278eb879..751162b28 100644
--- a/enterprise/app/models/captain/document.rb
+++ b/enterprise/app/models/captain/document.rb
@@ -37,6 +37,7 @@ class Captain::Document < ApplicationRecord
validate :validate_file_attachment, if: -> { pdf_file.attached? }
before_validation :ensure_account_id
before_validation :set_external_link_for_pdf
+ before_validation :normalize_external_link
enum status: {
in_progress: 0,
@@ -47,7 +48,7 @@ class Captain::Document < ApplicationRecord
after_create_commit :enqueue_crawl_job
after_create_commit :update_document_usage
after_destroy :update_document_usage
- after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
+ after_commit :enqueue_response_builder_job
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
@@ -94,15 +95,18 @@ class Captain::Document < ApplicationRecord
end
def enqueue_response_builder_job
- return if status != 'available'
+ return unless should_enqueue_response_builder?
Captain::Documents::ResponseBuilderJob.perform_later(self)
end
def should_enqueue_response_builder?
- # Only enqueue when status changes to available
- # Avoid re-enqueueing when metadata is updated by the job itself
- saved_change_to_status? && status == 'available'
+ return false if destroyed?
+ return false unless available?
+
+ return saved_change_to_status? if pdf_document?
+
+ (saved_change_to_status? || saved_change_to_content?) && content.present?
end
def update_document_usage
@@ -140,4 +144,11 @@ class Captain::Document < ApplicationRecord
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
end
+
+ def normalize_external_link
+ return if external_link.blank?
+ return if pdf_document?
+
+ self.external_link = external_link.delete_suffix('/')
+ end
end
diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb
index d876a7127..c43468804 100644
--- a/enterprise/app/models/captain/scenario.rb
+++ b/enterprise/app/models/captain/scenario.rb
@@ -56,7 +56,7 @@ class Captain::Scenario < ApplicationRecord
private
def agent_name
- "#{title} Agent".titleize
+ "#{title} Agent".parameterize(separator: '_')
end
def agent_tools
diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb
index 764cb2a9c..fde6cb122 100644
--- a/enterprise/app/models/company.rb
+++ b/enterprise/app/models/company.rb
@@ -12,9 +12,9 @@
#
# Indexes
#
-# index_companies_on_account_id (account_id)
-# index_companies_on_domain_and_account_id (domain,account_id)
-# index_companies_on_name_and_account_id (name,account_id)
+# index_companies_on_account_and_domain (account_id,domain) UNIQUE WHERE (domain IS NOT NULL)
+# index_companies_on_account_id (account_id)
+# index_companies_on_name_and_account_id (name,account_id)
#
class Company < ApplicationRecord
include Avatarable
@@ -24,6 +24,7 @@ class Company < ApplicationRecord
with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
message: I18n.t('errors.companies.domain.invalid')
}
+ validates :domain, uniqueness: { scope: :account_id }, if: -> { domain.present? }
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
belongs_to :account
diff --git a/enterprise/app/models/enterprise/concerns/contact.rb b/enterprise/app/models/enterprise/concerns/contact.rb
index 9139fc67e..09885a947 100644
--- a/enterprise/app/models/enterprise/concerns/contact.rb
+++ b/enterprise/app/models/enterprise/concerns/contact.rb
@@ -2,5 +2,30 @@ module Enterprise::Concerns::Contact
extend ActiveSupport::Concern
included do
belongs_to :company, optional: true
+
+ after_commit :associate_company_from_email,
+ on: [:create, :update],
+ if: :should_associate_company?
+ end
+
+ private
+
+ def should_associate_company?
+ # Only trigger if:
+ # 1. Contact has an email
+ # 2. Contact doesn't have a compan yet
+ # 3. Email was just set/changed
+ # 4. Email was previously nil (first time getting email)
+ email.present? &&
+ company_id.nil? &&
+ saved_change_to_email? &&
+ saved_change_to_email.first.nil?
+ end
+
+ def associate_company_from_email
+ Contacts::CompanyAssociationService.new.associate_company_from_email(self)
+ rescue StandardError => e
+ Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
+ # Don't fail the contact save if the company association fails
end
end
diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb
index 6f450bed1..0de61db23 100644
--- a/enterprise/app/models/enterprise/concerns/inbox.rb
+++ b/enterprise/app/models/enterprise/concerns/inbox.rb
@@ -6,5 +6,6 @@ module Enterprise::Concerns::Inbox
has_one :captain_assistant,
through: :captain_inbox,
class_name: 'Captain::Assistant'
+ has_many :inbox_capacity_limits, dependent: :destroy
end
end
diff --git a/enterprise/app/models/enterprise/inbox_agent_availability.rb b/enterprise/app/models/enterprise/inbox_agent_availability.rb
new file mode 100644
index 000000000..886b55278
--- /dev/null
+++ b/enterprise/app/models/enterprise/inbox_agent_availability.rb
@@ -0,0 +1,31 @@
+module Enterprise::InboxAgentAvailability
+ extend ActiveSupport::Concern
+
+ def member_ids_with_assignment_capacity
+ return member_ids unless capacity_filtering_enabled?
+
+ # Get online agents with capacity
+ agents = available_agents
+ agents = filter_by_capacity(agents)
+ agents.map(&:user_id)
+ end
+
+ private
+
+ def filter_by_capacity(inbox_members_scope)
+ return inbox_members_scope unless capacity_filtering_enabled?
+
+ inbox_members_scope.select do |inbox_member|
+ capacity_service.agent_has_capacity?(inbox_member.user, self)
+ end
+ end
+
+ def capacity_filtering_enabled?
+ account.feature_enabled?('assignment_v2') &&
+ account.account_users.joins(:agent_capacity_policy).exists?
+ end
+
+ def capacity_service
+ @capacity_service ||= Enterprise::AutoAssignment::CapacityService.new
+ end
+end
diff --git a/enterprise/app/policies/enterprise/conversation_policy.rb b/enterprise/app/policies/enterprise/conversation_policy.rb
new file mode 100644
index 000000000..d956db2d7
--- /dev/null
+++ b/enterprise/app/policies/enterprise/conversation_policy.rb
@@ -0,0 +1,42 @@
+module Enterprise::ConversationPolicy
+ def show?
+ return false unless super
+ return true unless custom_role_permissions?
+
+ permissions = custom_role_permissions
+ return true if manage_all_conversations?(permissions)
+ return true if permits_unassigned_manage?(permissions)
+
+ permits_participating?(permissions)
+ end
+
+ private
+
+ def manage_all_conversations?(permissions)
+ permissions.include?('conversation_manage')
+ end
+
+ def permits_unassigned_manage?(permissions)
+ return false unless permissions.include?('conversation_unassigned_manage')
+
+ unassigned_conversation? || assigned_to_user?
+ end
+
+ def permits_participating?(permissions)
+ return false unless permissions.include?('conversation_participating_manage')
+
+ assigned_to_user? || participant?
+ end
+
+ def unassigned_conversation?
+ record.assignee_id.nil?
+ end
+
+ def custom_role_permissions?
+ account_user&.custom_role_id.present?
+ end
+
+ def custom_role_permissions
+ account_user&.custom_role&.permissions || []
+ end
+end
diff --git a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
new file mode 100644
index 000000000..d3e7d4983
--- /dev/null
+++ b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
@@ -0,0 +1,129 @@
+class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseOpenAiService
+ MAX_CONTENT_LENGTH = 8000
+
+ def initialize(website_url)
+ super()
+ @website_url = normalize_url(website_url)
+ @website_content = nil
+ @favicon_url = nil
+ end
+
+ def analyze
+ fetch_website_content
+ return error_response('Failed to fetch website content') unless @website_content
+
+ extract_business_info
+ rescue StandardError => e
+ Rails.logger.error "[Captain Onboarding] Website analysis error: #{e.message}"
+ error_response(e.message)
+ end
+
+ private
+
+ def normalize_url(url)
+ return url if url.match?(%r{\Ahttps?://})
+
+ "https://#{url}"
+ end
+
+ def fetch_website_content
+ crawler = Captain::Tools::SimplePageCrawlService.new(@website_url)
+
+ text_content = crawler.body_text_content
+ page_title = crawler.page_title
+ meta_description = crawler.meta_description
+
+ if page_title.blank? && meta_description.blank? && text_content.blank?
+ Rails.logger.error "[Captain Onboarding] Failed to fetch #{@website_url}: No content found"
+ return false
+ end
+
+ combined_content = []
+ combined_content << "Title: #{page_title}" if page_title.present?
+ combined_content << "Description: #{meta_description}" if meta_description.present?
+ combined_content << text_content
+
+ @website_content = clean_and_truncate_content(combined_content.join("\n\n"))
+ @favicon_url = crawler.favicon_url
+ true
+ rescue StandardError => e
+ Rails.logger.error "[Captain Onboarding] Failed to fetch #{@website_url}: #{e.message}"
+ false
+ end
+
+ def clean_and_truncate_content(content)
+ cleaned = content.gsub(/\s+/, ' ').strip
+ cleaned.length > MAX_CONTENT_LENGTH ? cleaned[0...MAX_CONTENT_LENGTH] : cleaned
+ end
+
+ def extract_business_info
+ prompt = build_analysis_prompt
+
+ response = client.chat(
+ parameters: {
+ model: model,
+ messages: [{ role: 'user', content: prompt }],
+ response_format: { type: 'json_object' },
+ temperature: 0.1,
+ max_tokens: 1000
+ }
+ )
+
+ parse_llm_response(response.dig('choices', 0, 'message', 'content'))
+ end
+
+ def build_analysis_prompt
+ <<~PROMPT
+ Analyze the following website content and extract business information. Return a JSON response with the following structure:
+
+ {
+ "business_name": "The company or business name",
+ "suggested_assistant_name": "A friendly assistant name (e.g., 'Captain Assistant', 'Support Genie', etc.)",
+ "description": "Persona of the assistant based on the business type"
+ }
+
+ Guidelines:
+ - business_name: Extract the actual company/brand name from the content
+ - suggested_assistant_name: Create a friendly, professional name that customers would want to interact with
+ - description: Provide context about the business and what the assistant can help with. Keep it general and adaptable rather than overly specific. For example: "You specialize in helping customers with their orders and product questions" or "You assist customers with their account needs and general inquiries"
+
+ Website content:
+ #{@website_content}
+
+ Return only valid JSON, no additional text.
+ PROMPT
+ end
+
+ def parse_llm_response(response_text)
+ parsed_response = JSON.parse(response_text)
+
+ {
+ success: true,
+ data: {
+ business_name: parsed_response['business_name'],
+ suggested_assistant_name: parsed_response['suggested_assistant_name'],
+ description: parsed_response['description'],
+ website_url: @website_url,
+ favicon_url: @favicon_url
+ }
+ }
+ rescue JSON::ParserError => e
+ Rails.logger.error "[Captain Onboarding] JSON parsing error: #{e.message}"
+ Rails.logger.error "[Captain Onboarding] Raw response: #{response_text}"
+ error_response('Failed to parse business information from website')
+ end
+
+ def error_response(message)
+ {
+ success: false,
+ error: message,
+ data: {
+ business_name: '',
+ suggested_assistant_name: '',
+ description: '',
+ website_url: @website_url,
+ favicon_url: nil
+ }
+ }
+ end
+end
diff --git a/enterprise/app/services/captain/tools/simple_page_crawl_service.rb b/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
index d6baa1ebe..65731ad90 100644
--- a/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
+++ b/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
@@ -19,6 +19,20 @@ class Captain::Tools::SimplePageCrawlService
ReverseMarkdown.convert @doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true
end
+ def meta_description
+ meta_desc = @doc.at_css('meta[name="description"]')
+ return nil unless meta_desc && meta_desc['content']
+
+ meta_desc['content'].strip
+ end
+
+ def favicon_url
+ favicon_link = @doc.at_css('link[rel*="icon"]')
+ return nil unless favicon_link && favicon_link['href']
+
+ resolve_url(favicon_link['href'])
+ end
+
private
def sitemap?
@@ -35,4 +49,12 @@ class Captain::Tools::SimplePageCrawlService
absolute_url
end
end
+
+ def resolve_url(url)
+ return url if url.start_with?('http')
+
+ URI.join(@external_link, url).to_s
+ rescue StandardError
+ url
+ end
end
diff --git a/enterprise/app/services/companies/business_email_detector_service.rb b/enterprise/app/services/companies/business_email_detector_service.rb
new file mode 100644
index 000000000..422ef41c8
--- /dev/null
+++ b/enterprise/app/services/companies/business_email_detector_service.rb
@@ -0,0 +1,19 @@
+class Companies::BusinessEmailDetectorService
+ attr_reader :email
+
+ def initialize(email)
+ @email = email
+ end
+
+ def perform
+ return false if email.blank?
+
+ address = ValidEmail2::Address.new(email)
+ return false unless address.valid?
+ return false if address.disposable_domain?
+
+ provider = EmailProviderInfo.call(email)
+
+ provider.nil?
+ end
+end
diff --git a/enterprise/app/services/contacts/company_association_service.rb b/enterprise/app/services/contacts/company_association_service.rb
new file mode 100644
index 000000000..f2e2ffdd2
--- /dev/null
+++ b/enterprise/app/services/contacts/company_association_service.rb
@@ -0,0 +1,46 @@
+class Contacts::CompanyAssociationService
+ def associate_company_from_email(contact)
+ return nil if skip_association?(contact)
+
+ company = find_or_create_company(contact)
+ # rubocop:disable Rails/SkipsModelValidations
+ # Intentionally using update_column here to:
+ # 1. Avoid triggering callbacks
+ # 2. Improve performance (We're only setting company_id, no need for validation)
+ contact.update_column(:company_id, company.id) if company
+ # rubocop:enable Rails/SkipsModelValidations
+ company
+ end
+
+ private
+
+ def skip_association?(contact)
+ return true if contact.company_id.present?
+ return true if contact.email.blank?
+
+ detector = Companies::BusinessEmailDetectorService.new(contact.email)
+ return true unless detector.perform
+
+ false
+ end
+
+ def find_or_create_company(contact)
+ domain = extract_domain(contact.email)
+ company_name = derive_company_name(contact, domain)
+
+ Company.find_or_create_by!(account: contact.account, domain: domain) do |company|
+ company.name = company_name
+ end
+ rescue ActiveRecord::RecordNotUnique
+ # If another process created it first, just find that
+ Company.find_by(account: contact.account, domain: domain)
+ end
+
+ def extract_domain(email)
+ email.split('@').last&.downcase
+ end
+
+ def derive_company_name(contact, domain)
+ contact.additional_attributes&.dig('company_name') || domain.split('.').first.tr('-_', ' ').titleize
+ end
+end
diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
new file mode 100644
index 000000000..4f3da08ee
--- /dev/null
+++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
@@ -0,0 +1,94 @@
+module Enterprise::AutoAssignment::AssignmentService
+ private
+
+ # Override assignment config to use policy if available
+ def assignment_config
+ return super unless policy
+
+ {
+ 'conversation_priority' => policy.conversation_priority,
+ 'fair_distribution_limit' => policy.fair_distribution_limit,
+ 'fair_distribution_window' => policy.fair_distribution_window,
+ 'balanced' => policy.balanced?
+ }.compact
+ end
+
+ # Extend agent finding to add capacity checks
+ def find_available_agent
+ agents = filter_agents_by_rate_limit(inbox.available_agents)
+ agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
+ return nil if agents.empty?
+
+ selector = policy&.balanced? ? balanced_selector : round_robin_selector
+ selector.select_agent(agents)
+ end
+
+ def filter_agents_by_capacity(agents)
+ return agents unless capacity_filtering_enabled?
+
+ capacity_service = Enterprise::AutoAssignment::CapacityService.new
+ agents.select { |agent_member| capacity_service.agent_has_capacity?(agent_member.user, inbox) }
+ end
+
+ def capacity_filtering_enabled?
+ account.feature_enabled?('assignment_v2') &&
+ account.account_users.joins(:agent_capacity_policy).exists?
+ end
+
+ def round_robin_selector
+ @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
+ end
+
+ def balanced_selector
+ @balanced_selector ||= Enterprise::AutoAssignment::BalancedSelector.new(inbox: inbox)
+ end
+
+ def policy
+ @policy ||= inbox.assignment_policy
+ end
+
+ def account
+ inbox.account
+ end
+
+ # Override to apply exclusion rules
+ def unassigned_conversations(limit)
+ scope = inbox.conversations.unassigned.open
+
+ # Apply exclusion rules from capacity policy or assignment policy
+ scope = apply_exclusion_rules(scope)
+
+ # Apply conversation priority using enum methods if policy exists
+ scope = if policy&.longest_waiting?
+ scope.reorder(last_activity_at: :asc, created_at: :asc)
+ else
+ scope.reorder(created_at: :asc)
+ end
+
+ scope.limit(limit)
+ end
+
+ def apply_exclusion_rules(scope)
+ capacity_policy = inbox.inbox_capacity_limits.first&.agent_capacity_policy
+ return scope unless capacity_policy
+
+ exclusion_rules = capacity_policy.exclusion_rules || {}
+ scope = apply_label_exclusions(scope, exclusion_rules['excluded_labels'])
+ apply_age_exclusions(scope, exclusion_rules['exclude_older_than_hours'])
+ end
+
+ def apply_label_exclusions(scope, excluded_labels)
+ return scope if excluded_labels.blank?
+
+ scope.tagged_with(excluded_labels, exclude: true, on: :labels)
+ end
+
+ def apply_age_exclusions(scope, hours_threshold)
+ return scope if hours_threshold.blank?
+
+ hours = hours_threshold.to_i
+ return scope unless hours.positive?
+
+ scope.where('conversations.created_at >= ?', hours.hours.ago)
+ end
+end
diff --git a/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb b/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb
new file mode 100644
index 000000000..57017b6bb
--- /dev/null
+++ b/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb
@@ -0,0 +1,26 @@
+class Enterprise::AutoAssignment::BalancedSelector
+ pattr_initialize [:inbox!]
+
+ def select_agent(available_agents)
+ return nil if available_agents.empty?
+
+ agent_users = available_agents.map(&:user)
+ assignment_counts = fetch_assignment_counts(agent_users)
+
+ agent_users.min_by { |user| assignment_counts[user.id] || 0 }
+ end
+
+ private
+
+ def fetch_assignment_counts(users)
+ user_ids = users.map(&:id)
+
+ counts = inbox.conversations
+ .open
+ .where(assignee_id: user_ids)
+ .group(:assignee_id)
+ .count
+
+ Hash.new(0).merge(counts)
+ end
+end
diff --git a/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb b/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb
new file mode 100644
index 000000000..f82da9267
--- /dev/null
+++ b/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb
@@ -0,0 +1,25 @@
+class Enterprise::AutoAssignment::CapacityService
+ def agent_has_capacity?(user, inbox)
+ # Get the account_user for this specific account
+ account_user = user.account_users.find_by(account: inbox.account)
+
+ # If no account_user or no capacity policy, agent has unlimited capacity
+ return true unless account_user&.agent_capacity_policy
+
+ policy = account_user.agent_capacity_policy
+
+ # Check if there's a specific limit for this inbox
+ inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
+
+ # If no specific limit for this inbox, agent has unlimited capacity for this inbox
+ return true unless inbox_limit
+
+ # Count current open conversations for this agent in this inbox
+ current_count = user.assigned_conversations
+ .where(inbox: inbox, status: :open)
+ .count
+
+ # Agent has capacity if current count is below the limit
+ current_count < inbox_limit.conversation_limit
+ end
+end
diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
index d0d2f73d8..5364409a1 100644
--- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
+++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
@@ -16,6 +16,7 @@ class Enterprise::Billing::HandleStripeEventService
channel_instagram
captain_integration
advanced_search_indexing
+ advanced_search
].freeze
# Additional features available starting with the Business plan
diff --git a/enterprise/app/services/llm/base_open_ai_service.rb b/enterprise/app/services/llm/base_open_ai_service.rb
index 2d3932246..e3e88453c 100644
--- a/enterprise/app/services/llm/base_open_ai_service.rb
+++ b/enterprise/app/services/llm/base_open_ai_service.rb
@@ -1,5 +1,6 @@
class Llm::BaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
+ attr_reader :client, :model
def initialize
@client = OpenAI::Client.new(
diff --git a/enterprise/app/views/api/v1/accounts/conversations/reporting_events.json.jbuilder b/enterprise/app/views/api/v1/accounts/conversations/reporting_events.json.jbuilder
new file mode 100644
index 000000000..691296522
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/conversations/reporting_events.json.jbuilder
@@ -0,0 +1,3 @@
+json.array! @reporting_events do |reporting_event|
+ json.partial! 'api/v1/models/reporting_event', formats: [:json], reporting_event: reporting_event
+end
diff --git a/enterprise/app/views/api/v1/accounts/reporting_events/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/reporting_events/index.json.jbuilder
new file mode 100644
index 000000000..ae8531955
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/reporting_events/index.json.jbuilder
@@ -0,0 +1,11 @@
+json.payload do
+ json.array! @reporting_events do |reporting_event|
+ json.partial! 'api/v1/models/reporting_event', formats: [:json], reporting_event: reporting_event
+ end
+end
+
+json.meta do
+ json.count @total_count
+ json.current_page @current_page
+ json.total_pages @reporting_events.total_pages
+end
diff --git a/enterprise/app/views/api/v1/models/_reporting_event.json.jbuilder b/enterprise/app/views/api/v1/models/_reporting_event.json.jbuilder
new file mode 100644
index 000000000..f8e5f08ba
--- /dev/null
+++ b/enterprise/app/views/api/v1/models/_reporting_event.json.jbuilder
@@ -0,0 +1,12 @@
+json.id reporting_event.id
+json.name reporting_event.name
+json.value reporting_event.value
+json.value_in_business_hours reporting_event.value_in_business_hours
+json.event_start_time reporting_event.event_start_time
+json.event_end_time reporting_event.event_end_time
+json.account_id reporting_event.account_id
+json.inbox_id reporting_event.inbox_id
+json.user_id reporting_event.user_id
+json.conversation_id reporting_event.conversation_id
+json.created_at reporting_event.created_at
+json.updated_at reporting_event.updated_at
diff --git a/enterprise/lib/captain/tools/faq_lookup_tool.rb b/enterprise/lib/captain/tools/faq_lookup_tool.rb
index d09b7c2d7..93dd90259 100644
--- a/enterprise/lib/captain/tools/faq_lookup_tool.rb
+++ b/enterprise/lib/captain/tools/faq_lookup_tool.rb
@@ -28,7 +28,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
Question: #{response.question}
Answer: #{response.answer}
"
- if response.documentable.present? && response.documentable.try(:external_link)
+ if should_show_source?(response)
formatted_response += "
Source: #{response.documentable.external_link}
"
@@ -36,4 +36,13 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
formatted_response
end
+
+ def should_show_source?(response)
+ return false if response.documentable.blank?
+ return false unless response.documentable.try(:external_link)
+
+ # Don't show source if it's a PDF placeholder
+ external_link = response.documentable.external_link
+ !external_link.start_with?('PDF:')
+ end
end
diff --git a/lib/redis/alfred.rb b/lib/redis/alfred.rb
index b3e156420..e7e04ed60 100644
--- a/lib/redis/alfred.rb
+++ b/lib/redis/alfred.rb
@@ -35,6 +35,25 @@ module Redis::Alfred
$alfred.with { |conn| conn.exists?(key) }
end
+ # set expiry on a key in seconds
+ def expire(key, seconds)
+ $alfred.with { |conn| conn.expire(key, seconds) }
+ end
+
+ # scan keys matching a pattern
+ def scan_each(match: nil, count: 100, &)
+ $alfred.with do |conn|
+ conn.scan_each(match: match, count: count, &)
+ end
+ end
+
+ # count keys matching a pattern
+ def keys_count(pattern)
+ count = 0
+ scan_each(match: pattern) { count += 1 }
+ count
+ end
+
# list operations
def llen(key)
@@ -81,8 +100,15 @@ module Redis::Alfred
# sorted set operations
# add score and value for a key
- def zadd(key, score, value)
- $alfred.with { |conn| conn.zadd(key, score, value) }
+ # 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
end
# get score of a value for key
@@ -90,9 +116,22 @@ module Redis::Alfred
$alfred.with { |conn| conn.zscore(key, value) }
end
+ # count members in a sorted set with scores within the given range
+ def zcount(key, min_score, max_score)
+ $alfred.with { |conn| conn.zcount(key, min_score, max_score) }
+ end
+
+ # get the number of members in a sorted set
+ def zcard(key)
+ $alfred.with { |conn| conn.zcard(key) }
+ end
+
# get values by score
- def zrangebyscore(key, range_start, range_end)
- $alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end) }
+ def zrangebyscore(key, range_start, range_end, with_scores: false, limit: nil)
+ options = {}
+ options[:with_scores] = with_scores if with_scores
+ options[:limit] = limit if limit
+ $alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end, **options) }
end
# remove values by score
diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb
index fa64d1043..f5d297e5f 100644
--- a/lib/redis/redis_keys.rb
+++ b/lib/redis/redis_keys.rb
@@ -42,4 +42,9 @@ module Redis::RedisKeys
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%s::%s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%s'.freeze
+
+ ## Auto Assignment Keys
+ # 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
end
diff --git a/lib/seeders/reports/conversation_creator.rb b/lib/seeders/reports/conversation_creator.rb
index b6259de7d..1cd11ef33 100644
--- a/lib/seeders/reports/conversation_creator.rb
+++ b/lib/seeders/reports/conversation_creator.rb
@@ -16,8 +16,11 @@ class Seeders::Reports::ConversationCreator
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
end
+ # rubocop:disable Metrics/MethodLength
def create_conversation(created_at:)
conversation = nil
+ should_resolve = false
+ resolution_time = nil
ActiveRecord::Base.transaction do
travel_to(created_at) do
@@ -26,14 +29,35 @@ class Seeders::Reports::ConversationCreator
add_labels_to_conversation(conversation)
create_messages_for_conversation(conversation)
- resolve_conversation_if_needed(conversation)
+
+ # Determine if should resolve but don't update yet
+ should_resolve = rand > 0.3
+ if should_resolve
+ resolution_delay = rand((30.minutes)..(24.hours))
+ resolution_time = created_at + resolution_delay
+ end
end
travel_back
end
+ # Now resolve outside of time travel if needed
+ if should_resolve && resolution_time
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:status, :resolved)
+ conversation.update_column(:updated_at, resolution_time)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ # Trigger the event with proper timestamp
+ travel_to(resolution_time) do
+ trigger_conversation_resolved_event(conversation)
+ end
+ travel_back
+ end
+
conversation
end
+ # rubocop:enable Metrics/MethodLength
private
@@ -85,16 +109,6 @@ class Seeders::Reports::ConversationCreator
message_creator.create_messages
end
- def resolve_conversation_if_needed(conversation)
- return unless rand < 0.7
-
- resolution_delay = rand((30.minutes)..(24.hours))
- travel(resolution_delay)
- conversation.update!(status: :resolved)
-
- trigger_conversation_resolved_event(conversation)
- end
-
def trigger_conversation_resolved_event(conversation)
event_data = { conversation: conversation }
diff --git a/lib/tasks/companies.rake b/lib/tasks/companies.rake
new file mode 100644
index 000000000..11fb5dc10
--- /dev/null
+++ b/lib/tasks/companies.rake
@@ -0,0 +1,12 @@
+namespace :companies do
+ desc 'Backfill companies from existing contact email domains'
+ task backfill: :environment do
+ puts 'Starting company backfill migration...'
+ puts 'This will process all accounts and create companies from contact email domains.'
+ puts 'The job will run in the background via Sidekiq'
+ puts ''
+ Migration::CompanyBackfillJob.perform_later
+ puts 'Company backfill job has been enqueued.'
+ puts 'Monitor progress in logs or Sidekiq dashboard.'
+ end
+end
diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb
index 41b3a415d..95c399d54 100644
--- a/lib/webhooks/trigger.rb
+++ b/lib/webhooks/trigger.rb
@@ -31,14 +31,33 @@ class Webhooks::Trigger
end
def handle_error(error)
- return unless should_handle_error?
+ return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless message
- update_message_status(error)
+ case @webhook_type
+ when :agent_bot_webhook
+ conversation = message.conversation
+ return unless conversation&.pending?
+
+ conversation.open!
+ create_agent_bot_error_activity(conversation)
+ when :api_inbox_webhook
+ update_message_status(error)
+ end
end
- def should_handle_error?
- @webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
+ def create_agent_bot_error_activity(conversation)
+ content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
+ Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
+ end
+
+ def activity_message_params(conversation, content)
+ {
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ message_type: :activity,
+ content: content
+ }
end
def update_message_status(error)
diff --git a/package.json b/package.json
index 38053b2c3..4660b44fa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.6.0",
+ "version": "4.7.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -33,7 +33,7 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.2.1",
+ "@chatwoot/prosemirror-schema": "1.2.3",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
@@ -139,7 +139,7 @@
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.13",
- "vite": "^5.4.20",
+ "vite": "^5.4.21",
"vite-plugin-ruby": "^5.0.0",
"vitest": "3.0.5"
},
@@ -155,7 +155,7 @@
"pnpm": {
"overrides": {
"vite-node": "2.0.1",
- "vite": "5.4.20",
+ "vite": "5.4.21",
"vitest": "3.0.5"
}
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f4ae568c5..e5629af3b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,7 +6,7 @@ settings:
overrides:
vite-node: 2.0.1
- vite: 5.4.20
+ vite: 5.4.21
vitest: 3.0.5
importers:
@@ -20,8 +20,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.2.1
- version: 1.2.1
+ specifier: 1.2.3
+ version: 1.2.3
'@chatwoot/utils':
specifier: ^0.0.51
version: 0.0.51
@@ -69,7 +69,7 @@ importers:
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
'@vitejs/plugin-vue':
specifier: ^5.1.4
- version: 5.1.4(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
+ version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@vue/compiler-sfc':
specifier: ^3.5.8
version: 3.5.8
@@ -241,7 +241,7 @@ importers:
version: 1.8.1(tailwindcss@3.4.13)
'@histoire/plugin-vue':
specifier: 0.17.15
- version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
+ version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@iconify-json/logos':
specifier: ^1.2.3
version: 1.2.3
@@ -304,7 +304,7 @@ importers:
version: 6.0.0
histoire:
specifier: 0.17.15
- version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
husky:
specifier: ^7.0.0
version: 7.0.4
@@ -333,11 +333,11 @@ importers:
specifier: ^3.4.13
version: 3.4.13
vite:
- specifier: 5.4.20
- version: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ specifier: 5.4.21
+ version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-plugin-ruby:
specifier: ^5.0.0
- version: 5.0.0(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ version: 5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
vitest:
specifier: 3.0.5
version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)
@@ -406,8 +406,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.2.1':
- resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
+ '@chatwoot/prosemirror-schema@1.2.3':
+ resolution: {integrity: sha512-q/EfirVK9jt8FJAx3Gf6y3LoVadmYVLknbYvPrkUe81WO0f2mkZ/kY2UQgpUISVvOGEkCH4bkfYMp5UQ+Buz3g==}
'@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@@ -863,7 +863,7 @@ packages:
'@histoire/shared@0.17.17':
resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==}
peerDependencies:
- vite: 5.4.20
+ vite: 5.4.21
'@histoire/vendors@0.17.17':
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
@@ -978,8 +978,8 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
- '@jridgewell/trace-mapping@0.3.30':
- resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/color@0.3.2':
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
@@ -1047,108 +1047,113 @@ packages:
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
- '@rollup/rollup-android-arm-eabi@4.50.1':
- resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==}
+ '@rollup/rollup-android-arm-eabi@4.52.5':
+ resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==}
cpu: [arm]
os: [android]
- '@rollup/rollup-android-arm64@4.50.1':
- resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==}
+ '@rollup/rollup-android-arm64@4.52.5':
+ resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.50.1':
- resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==}
+ '@rollup/rollup-darwin-arm64@4.52.5':
+ resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.50.1':
- resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==}
+ '@rollup/rollup-darwin-x64@4.52.5':
+ resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-freebsd-arm64@4.50.1':
- resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==}
+ '@rollup/rollup-freebsd-arm64@4.52.5':
+ resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==}
cpu: [arm64]
os: [freebsd]
- '@rollup/rollup-freebsd-x64@4.50.1':
- resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==}
+ '@rollup/rollup-freebsd-x64@4.52.5':
+ resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==}
cpu: [x64]
os: [freebsd]
- '@rollup/rollup-linux-arm-gnueabihf@4.50.1':
- resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==}
+ '@rollup/rollup-linux-arm-gnueabihf@4.52.5':
+ resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm-musleabihf@4.50.1':
- resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==}
+ '@rollup/rollup-linux-arm-musleabihf@4.52.5':
+ resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm64-gnu@4.50.1':
- resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==}
+ '@rollup/rollup-linux-arm64-gnu@4.52.5':
+ resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-arm64-musl@4.50.1':
- resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==}
+ '@rollup/rollup-linux-arm64-musl@4.52.5':
+ resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-loongarch64-gnu@4.50.1':
- resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==}
+ '@rollup/rollup-linux-loong64-gnu@4.52.5':
+ resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
cpu: [loong64]
os: [linux]
- '@rollup/rollup-linux-ppc64-gnu@4.50.1':
- resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==}
+ '@rollup/rollup-linux-ppc64-gnu@4.52.5':
+ resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
cpu: [ppc64]
os: [linux]
- '@rollup/rollup-linux-riscv64-gnu@4.50.1':
- resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==}
+ '@rollup/rollup-linux-riscv64-gnu@4.52.5':
+ resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-riscv64-musl@4.50.1':
- resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==}
+ '@rollup/rollup-linux-riscv64-musl@4.52.5':
+ resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-s390x-gnu@4.50.1':
- resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==}
+ '@rollup/rollup-linux-s390x-gnu@4.52.5':
+ resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
cpu: [s390x]
os: [linux]
- '@rollup/rollup-linux-x64-gnu@4.50.1':
- resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==}
+ '@rollup/rollup-linux-x64-gnu@4.52.5':
+ resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-linux-x64-musl@4.50.1':
- resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==}
+ '@rollup/rollup-linux-x64-musl@4.52.5':
+ resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-openharmony-arm64@4.50.1':
- resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==}
+ '@rollup/rollup-openharmony-arm64@4.52.5':
+ resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
cpu: [arm64]
os: [openharmony]
- '@rollup/rollup-win32-arm64-msvc@4.50.1':
- resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==}
+ '@rollup/rollup-win32-arm64-msvc@4.52.5':
+ resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.50.1':
- resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==}
+ '@rollup/rollup-win32-ia32-msvc@4.52.5':
+ resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==}
cpu: [ia32]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.50.1':
- resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==}
+ '@rollup/rollup-win32-x64-gnu@4.52.5':
+ resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.52.5':
+ resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==}
cpu: [x64]
os: [win32]
@@ -1283,7 +1288,7 @@ packages:
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
- vite: 5.4.20
+ vite: 5.4.21
vue: ^3.2.25
'@vitest/coverage-v8@3.0.5':
@@ -1302,7 +1307,7 @@ packages:
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
peerDependencies:
msw: ^2.4.9
- vite: 5.4.20
+ vite: 5.4.21
peerDependenciesMeta:
msw:
optional: true
@@ -2600,7 +2605,7 @@ packages:
resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==}
hasBin: true
peerDependencies:
- vite: 5.4.20
+ vite: 5.4.21
hotkeys-js@3.8.7:
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
@@ -3872,8 +3877,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
- rollup@4.50.1:
- resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==}
+ rollup@4.52.5:
+ resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -4374,10 +4379,10 @@ packages:
vite-plugin-ruby@5.0.0:
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
peerDependencies:
- vite: 5.4.20
+ vite: 5.4.21
- vite@5.4.20:
- resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==}
+ vite@5.4.21:
+ resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -4763,7 +4768,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.2.1':
+ '@chatwoot/prosemirror-schema@1.2.3':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -5198,10 +5203,10 @@ snapshots:
highlight.js: 11.10.0
vue: 3.5.12(typescript@5.6.2)
- '@histoire/app@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
+ '@histoire/app@0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
- '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
- '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/controls': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/shared': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
flexsearch: 0.7.21
@@ -5209,7 +5214,7 @@ snapshots:
transitivePeerDependencies:
- vite
- '@histoire/controls@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
+ '@histoire/controls@0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@codemirror/commands': 6.7.0
'@codemirror/lang-json': 6.0.1
@@ -5218,26 +5223,26 @@ snapshots:
'@codemirror/state': 6.4.1
'@codemirror/theme-one-dark': 6.1.2
'@codemirror/view': 6.34.1
- '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/shared': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
transitivePeerDependencies:
- vite
- '@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
+ '@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
- '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
- '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/controls': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/shared': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
change-case: 4.1.2
globby: 13.2.2
- histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
launch-editor: 2.9.1
pathe: 1.1.2
vue: 3.5.12(typescript@5.6.2)
transitivePeerDependencies:
- vite
- '@histoire/shared@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
+ '@histoire/shared@0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@histoire/vendors': 0.17.17
'@types/fs-extra': 9.0.13
@@ -5245,7 +5250,7 @@ snapshots:
chokidar: 3.6.0
pathe: 1.1.2
picocolors: 1.1.0
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
'@histoire/vendors@0.17.17': {}
@@ -5362,7 +5367,7 @@ snapshots:
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.31
optional: true
'@jridgewell/gen-mapping@0.3.5':
@@ -5387,7 +5392,7 @@ snapshots:
'@jridgewell/source-map@0.3.11':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.31
optional: true
'@jridgewell/sourcemap-codec@1.5.0': {}
@@ -5400,7 +5405,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.5.0
- '@jridgewell/trace-mapping@0.3.30':
+ '@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5466,67 +5471,70 @@ snapshots:
'@rails/ujs@7.1.400': {}
- '@rollup/rollup-android-arm-eabi@4.50.1':
+ '@rollup/rollup-android-arm-eabi@4.52.5':
optional: true
- '@rollup/rollup-android-arm64@4.50.1':
+ '@rollup/rollup-android-arm64@4.52.5':
optional: true
- '@rollup/rollup-darwin-arm64@4.50.1':
+ '@rollup/rollup-darwin-arm64@4.52.5':
optional: true
- '@rollup/rollup-darwin-x64@4.50.1':
+ '@rollup/rollup-darwin-x64@4.52.5':
optional: true
- '@rollup/rollup-freebsd-arm64@4.50.1':
+ '@rollup/rollup-freebsd-arm64@4.52.5':
optional: true
- '@rollup/rollup-freebsd-x64@4.50.1':
+ '@rollup/rollup-freebsd-x64@4.52.5':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.50.1':
+ '@rollup/rollup-linux-arm-gnueabihf@4.52.5':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.50.1':
+ '@rollup/rollup-linux-arm-musleabihf@4.52.5':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.50.1':
+ '@rollup/rollup-linux-arm64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.50.1':
+ '@rollup/rollup-linux-arm64-musl@4.52.5':
optional: true
- '@rollup/rollup-linux-loongarch64-gnu@4.50.1':
+ '@rollup/rollup-linux-loong64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-ppc64-gnu@4.50.1':
+ '@rollup/rollup-linux-ppc64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.50.1':
+ '@rollup/rollup-linux-riscv64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-riscv64-musl@4.50.1':
+ '@rollup/rollup-linux-riscv64-musl@4.52.5':
optional: true
- '@rollup/rollup-linux-s390x-gnu@4.50.1':
+ '@rollup/rollup-linux-s390x-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.50.1':
+ '@rollup/rollup-linux-x64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-x64-musl@4.50.1':
+ '@rollup/rollup-linux-x64-musl@4.52.5':
optional: true
- '@rollup/rollup-openharmony-arm64@4.50.1':
+ '@rollup/rollup-openharmony-arm64@4.52.5':
optional: true
- '@rollup/rollup-win32-arm64-msvc@4.50.1':
+ '@rollup/rollup-win32-arm64-msvc@4.52.5':
optional: true
- '@rollup/rollup-win32-ia32-msvc@4.50.1':
+ '@rollup/rollup-win32-ia32-msvc@4.52.5':
optional: true
- '@rollup/rollup-win32-x64-msvc@4.50.1':
+ '@rollup/rollup-win32-x64-gnu@4.52.5':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.52.5':
optional: true
'@rtsao/scc@1.1.0': {}
@@ -5677,9 +5685,9 @@ snapshots:
global: 4.4.0
is-function: 1.0.2
- '@vitejs/plugin-vue@5.1.4(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
+ '@vitejs/plugin-vue@5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vue: 3.5.12(typescript@5.6.2)
'@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))':
@@ -5707,13 +5715,13 @@ snapshots:
chai: 5.1.2
tinyrainbow: 2.0.0
- '@vitest/mocker@3.0.5(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
+ '@vitest/mocker@3.0.5(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@vitest/spy': 3.0.5
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
'@vitest/pretty-format@3.0.5':
dependencies:
@@ -7285,12 +7293,12 @@ snapshots:
highlight.js@11.10.0: {}
- histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
+ histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
'@akryum/tinypool': 0.3.1
- '@histoire/app': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
- '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
- '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/app': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/controls': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/shared': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
'@types/markdown-it': 12.2.3
@@ -7317,7 +7325,7 @@ snapshots:
sade: 1.8.1
shiki-es: 0.2.0
sirv: 2.0.4
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
@@ -8682,31 +8690,32 @@ snapshots:
dependencies:
glob: 7.2.3
- rollup@4.50.1:
+ rollup@4.52.5:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.50.1
- '@rollup/rollup-android-arm64': 4.50.1
- '@rollup/rollup-darwin-arm64': 4.50.1
- '@rollup/rollup-darwin-x64': 4.50.1
- '@rollup/rollup-freebsd-arm64': 4.50.1
- '@rollup/rollup-freebsd-x64': 4.50.1
- '@rollup/rollup-linux-arm-gnueabihf': 4.50.1
- '@rollup/rollup-linux-arm-musleabihf': 4.50.1
- '@rollup/rollup-linux-arm64-gnu': 4.50.1
- '@rollup/rollup-linux-arm64-musl': 4.50.1
- '@rollup/rollup-linux-loongarch64-gnu': 4.50.1
- '@rollup/rollup-linux-ppc64-gnu': 4.50.1
- '@rollup/rollup-linux-riscv64-gnu': 4.50.1
- '@rollup/rollup-linux-riscv64-musl': 4.50.1
- '@rollup/rollup-linux-s390x-gnu': 4.50.1
- '@rollup/rollup-linux-x64-gnu': 4.50.1
- '@rollup/rollup-linux-x64-musl': 4.50.1
- '@rollup/rollup-openharmony-arm64': 4.50.1
- '@rollup/rollup-win32-arm64-msvc': 4.50.1
- '@rollup/rollup-win32-ia32-msvc': 4.50.1
- '@rollup/rollup-win32-x64-msvc': 4.50.1
+ '@rollup/rollup-android-arm-eabi': 4.52.5
+ '@rollup/rollup-android-arm64': 4.52.5
+ '@rollup/rollup-darwin-arm64': 4.52.5
+ '@rollup/rollup-darwin-x64': 4.52.5
+ '@rollup/rollup-freebsd-arm64': 4.52.5
+ '@rollup/rollup-freebsd-x64': 4.52.5
+ '@rollup/rollup-linux-arm-gnueabihf': 4.52.5
+ '@rollup/rollup-linux-arm-musleabihf': 4.52.5
+ '@rollup/rollup-linux-arm64-gnu': 4.52.5
+ '@rollup/rollup-linux-arm64-musl': 4.52.5
+ '@rollup/rollup-linux-loong64-gnu': 4.52.5
+ '@rollup/rollup-linux-ppc64-gnu': 4.52.5
+ '@rollup/rollup-linux-riscv64-gnu': 4.52.5
+ '@rollup/rollup-linux-riscv64-musl': 4.52.5
+ '@rollup/rollup-linux-s390x-gnu': 4.52.5
+ '@rollup/rollup-linux-x64-gnu': 4.52.5
+ '@rollup/rollup-linux-x64-musl': 4.52.5
+ '@rollup/rollup-openharmony-arm64': 4.52.5
+ '@rollup/rollup-win32-arm64-msvc': 4.52.5
+ '@rollup/rollup-win32-ia32-msvc': 4.52.5
+ '@rollup/rollup-win32-x64-gnu': 4.52.5
+ '@rollup/rollup-win32-x64-msvc': 4.52.5
fsevents: 2.3.3
rope-sequence@1.3.2: {}
@@ -9285,7 +9294,7 @@ snapshots:
debug: 4.4.0
pathe: 1.1.2
picocolors: 1.1.1
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -9297,19 +9306,19 @@ snapshots:
- supports-color
- terser
- vite-plugin-ruby@5.0.0(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
+ vite-plugin-ruby@5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
debug: 4.3.5
fast-glob: 3.3.2
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- supports-color
- vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
+ vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.6
- rollup: 4.50.1
+ rollup: 4.52.5
optionalDependencies:
'@types/node': 22.7.0
fsevents: 2.3.3
@@ -9319,7 +9328,7 @@ snapshots:
vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0):
dependencies:
'@vitest/expect': 3.0.5
- '@vitest/mocker': 3.0.5(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@vitest/mocker': 3.0.5(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/pretty-format': 3.0.5
'@vitest/runner': 3.0.5
'@vitest/snapshot': 3.0.5
@@ -9335,7 +9344,7 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.0.2
tinyrainbow: 2.0.0
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
why-is-node-running: 2.3.0
optionalDependencies:
diff --git a/script/bulk_reindex_messages.rb b/script/bulk_reindex_messages.rb
new file mode 100644
index 000000000..1e19f70a7
--- /dev/null
+++ b/script/bulk_reindex_messages.rb
@@ -0,0 +1,58 @@
+# Bulk reindex all messages with throttling to prevent DB overload
+# This creates jobs slowly to avoid overwhelming the database connection pool
+# Usage: RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb
+
+JOBS_PER_MINUTE = 50 # Adjust based on your DB capacity
+BATCH_SIZE = 1000 # Messages per job
+
+batch_count = 0
+total_batches = (Message.count / BATCH_SIZE.to_f).ceil
+start_time = Time.zone.now
+
+index_name = Message.searchkick_index.name
+
+puts '=' * 80
+puts "Bulk Reindex Started at #{start_time}"
+puts '=' * 80
+puts "Total messages: #{Message.count}"
+puts "Batch size: #{BATCH_SIZE}"
+puts "Total batches: #{total_batches}"
+puts "Index name: #{index_name}"
+puts "Rate: #{JOBS_PER_MINUTE} jobs/minute (#{JOBS_PER_MINUTE * BATCH_SIZE} messages/minute)"
+puts "Estimated time: #{(total_batches / JOBS_PER_MINUTE.to_f / 60).round(2)} hours"
+puts '=' * 80
+puts ''
+
+sleep(15)
+
+Message.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, index|
+ batch_count += 1
+
+ # Enqueue to low priority queue with proper format
+ Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
+ class_name: 'Message',
+ index_name: index_name,
+ batch_id: index,
+ record_ids: batch.map(&:id) # Keep as integers like Message.reindex does
+ )
+
+ # Throttle: wait after every N jobs
+ if (batch_count % JOBS_PER_MINUTE).zero?
+ elapsed = Time.zone.now - start_time
+ progress = (batch_count.to_f / total_batches * 100).round(2)
+ queue_size = Sidekiq::Queue.new('bulk_reindex_low').size
+
+ puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}] Progress: #{batch_count}/#{total_batches} (#{progress}%)"
+ puts " Queue size: #{queue_size}"
+ puts " Elapsed: #{(elapsed / 3600).round(2)} hours"
+ puts " ETA: #{((elapsed / batch_count * (total_batches - batch_count)) / 3600).round(2)} hours remaining"
+ puts ''
+
+ sleep(60)
+ end
+end
+
+puts '=' * 80
+puts "Done! Created #{batch_count} jobs"
+puts "Total time: #{((Time.zone.now - start_time) / 3600).round(2)} hours"
+puts '=' * 80
diff --git a/script/monitor_reindex.rb b/script/monitor_reindex.rb
new file mode 100644
index 000000000..6a2c1ee6c
--- /dev/null
+++ b/script/monitor_reindex.rb
@@ -0,0 +1,19 @@
+# Monitor bulk reindex progress
+# RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb
+
+puts 'Monitoring bulk reindex progress (Ctrl+C to stop)...'
+puts ''
+
+loop do
+ bulk_queue = Sidekiq::Queue.new('bulk_reindex_low')
+ prod_queue = Sidekiq::Queue.new('async_database_migration')
+ retry_set = Sidekiq::RetrySet.new
+
+ puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}]"
+ puts " Bulk Reindex Queue: #{bulk_queue.size} jobs"
+ puts " Production Queue: #{prod_queue.size} jobs"
+ puts " Retry Queue: #{retry_set.size} jobs"
+ puts " #{('-' * 60)}"
+
+ sleep(30)
+end
diff --git a/script/reindex_single_account.rb b/script/reindex_single_account.rb
new file mode 100644
index 000000000..cb7dd8c87
--- /dev/null
+++ b/script/reindex_single_account.rb
@@ -0,0 +1,58 @@
+# Reindex messages for a single account
+# Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]
+
+#account_id = ARGV[0]&.to_i
+days_back = (ARGV[1] || 30).to_i
+
+# if account_id.nil? || account_id.zero?
+# puts "Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]"
+# puts "Example: bundle exec rails runner script/reindex_single_account.rb 93293 30"
+# exit 1
+# end
+
+# account = Account.find(account_id)
+# puts "=" * 80
+# puts "Reindexing messages for: #{account.name} (ID: #{account.id})"
+# puts "=" * 80
+
+# Enable feature if not already enabled
+# unless account.feature_enabled?('advanced_search_indexing')
+# puts "Enabling advanced_search_indexing feature..."
+# account.enable_features(:advanced_search_indexing)
+# account.save!
+# end
+
+# Get messages to index
+# messages = Message.where(account_id: account.id)
+# .where(message_type: [0, 1]) # incoming/outgoing only
+# .where('created_at >= ?', days_back.days.ago)
+
+messages = Message.where('created_at >= ?', days_back.days.ago)
+
+puts "Found #{messages.count} messages to index (last #{days_back} days)"
+puts ''
+
+sleep(15)
+
+# Create bulk reindex jobs
+index_name = Message.searchkick_index.name
+batch_count = 0
+
+messages.find_in_batches(batch_size: 1000).with_index do |batch, index|
+ Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
+ class_name: 'Message',
+ index_name: index_name,
+ batch_id: index,
+ record_ids: batch.map(&:id)
+ )
+
+ batch_count += 1
+ print '.'
+ sleep(0.5) # Small delay
+end
+
+puts ''
+puts '=' * 80
+puts "Done! Created #{batch_count} bulk reindex jobs"
+puts 'Messages will be indexed shortly via the bulk_reindex_low queue'
+puts '=' * 80
diff --git a/spec/builders/messages/message_builder_spec.rb b/spec/builders/messages/message_builder_spec.rb
index 891f8eb02..71b3cbf5a 100644
--- a/spec/builders/messages/message_builder_spec.rb
+++ b/spec/builders/messages/message_builder_spec.rb
@@ -179,6 +179,113 @@ describe Messages::MessageBuilder do
expect(message.content_attributes[:cc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
end
+
+ context 'when custom email content is provided' do
+ before do
+ account.enable_features('quoted_email_reply')
+ end
+
+ it 'creates message with custom HTML email content' do
+ params = ActionController::Parameters.new({
+ content: 'Regular message content',
+ email_html_content: 'Custom HTML content '
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content', 'full')).to eq 'Custom HTML content '
+ expect(message.content_attributes.dig('email', 'html_content', 'reply')).to eq 'Custom HTML content '
+ expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular message content'
+ expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
+ end
+
+ it 'does not process custom email content for private messages' do
+ params = ActionController::Parameters.new({
+ content: 'Regular message content',
+ email_html_content: 'Custom HTML content ',
+ private: true
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content')).to be_nil
+ expect(message.content_attributes.dig('email', 'text_content')).to be_nil
+ end
+
+ it 'falls back to default behavior when no custom email content is provided' do
+ params = ActionController::Parameters.new({
+ content: 'Regular **markdown** content'
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('markdown')
+ expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
+ end
+ end
+
+ context 'when liquid templates are present in email content' do
+ let(:contact) { create(:contact, name: 'John', email: 'john@example.com') }
+ let(:conversation) { create(:conversation, inbox: channel_email.inbox, account: account, contact: contact) }
+
+ it 'processes liquid variables in email content' do
+ params = ActionController::Parameters.new({
+ content: 'Hello {{contact.name}}, your email is {{contact.email}}'
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('Hello John')
+ expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('john@example.com')
+ expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, your email is john@example.com'
+ end
+
+ it 'does not process liquid in code blocks' do
+ params = ActionController::Parameters.new({
+ content: 'Hello {{contact.name}}, use this code: `{{contact.email}}`'
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, use this code: `{{contact.email}}`'
+ end
+
+ it 'handles broken liquid syntax gracefully' do
+ params = ActionController::Parameters.new({
+ content: 'Hello {{contact.name} {{invalid}}'
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello {{contact.name} {{invalid}}'
+ end
+
+ it 'does not process liquid for incoming messages' do
+ params = ActionController::Parameters.new({
+ content: 'Hello {{contact.name}}',
+ message_type: 'incoming'
+ })
+
+ api_channel = create(:channel_api, account: account)
+ api_conversation = create(:conversation, inbox: api_channel.inbox, account: account, contact: contact)
+
+ message = described_class.new(user, api_conversation, params).perform
+
+ expect(message.content).to eq 'Hello {{contact.name}}'
+ end
+
+ it 'does not process liquid for private messages' do
+ params = ActionController::Parameters.new({
+ content: 'Hello {{contact.name}}',
+ private: true
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content')).to be_nil
+ expect(message.content_attributes.dig('email', 'text_content')).to be_nil
+ end
+ end
end
end
end
diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb
index ac2306d14..69e4f5cae 100644
--- a/spec/controllers/api/base_controller_spec.rb
+++ b/spec/controllers/api/base_controller_spec.rb
@@ -5,6 +5,29 @@ RSpec.describe 'API Base', type: :request do
let!(:user) { create(:user, account: account) }
describe 'request with api_access_token for user' do
+ context 'when accessing an account scoped resource' do
+ let!(:admin) { create(:user, :administrator, account: account) }
+ let!(:conversation) { create(:conversation, account: account) }
+
+ it 'sets Current attributes for the request and then returns the response' do
+ # expect Current.account_user is set to the admin's account_user
+ allow(Current).to receive(:user=).and_call_original
+ allow(Current).to receive(:account=).and_call_original
+ allow(Current).to receive(:account_user=).and_call_original
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+
+ expect(Current).to have_received(:user=).with(admin).at_least(:once)
+ expect(Current).to have_received(:account=).with(account).at_least(:once)
+ expect(Current).to have_received(:account_user=).with(admin.account_users.first).at_least(:once)
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+ end
+
context 'when it is an invalid api_access_token' do
it 'returns unauthorized' do
get '/api/v1/profile',
diff --git a/spec/controllers/api/v1/accounts/articles_controller_spec.rb b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
index 42fd062dc..e74be76e2 100644
--- a/spec/controllers/api/v1/accounts/articles_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
- expect(json_response['payload']['status']).to eql('draft')
+ expect(json_response['payload']['status']).to eql('published')
expect(json_response['payload']['position']).to be(3)
end
@@ -59,10 +59,30 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
- expect(json_response['payload']['status']).to eql('draft')
+ expect(json_response['payload']['status']).to eql('published')
expect(json_response['payload']['position']).to be(3)
end
+ it 'creates article as draft when status is not provided' do
+ article_params = {
+ article: {
+ category_id: category.id,
+ description: 'test description',
+ title: 'DraftTitle',
+ slug: 'draft-title',
+ content: 'This is my draft content.',
+ author_id: agent.id
+ }
+ }
+ post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
+ params: article_params,
+ headers: admin.create_new_auth_token
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['payload']['title']).to eql('DraftTitle')
+ expect(json_response['payload']['status']).to eql('draft')
+ end
+
it 'associate to the root article' do
root_article = create(:article, category: category, slug: 'root-article', portal: portal, account_id: account.id, author_id: agent.id,
associated_article_id: nil)
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 ec6af61be..1ab490a96 100644
--- a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb
@@ -225,4 +225,49 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
end
end
end
+
+ describe 'POST /api/v1/accounts/{account.id}/bulk_actions (contacts)' do
+ context 'when it is an authenticated user' do
+ let!(:agent) { create(:user, account: account, role: :agent) }
+
+ it 'enqueues Contacts::BulkActionJob with permitted 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: { add: %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('add' => %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)
+
+ post "/api/v1/accounts/#{account.id}/bulk_actions",
+ headers: agent.create_new_auth_token,
+ params: {
+ type: 'Contact',
+ ids: [contact.id],
+ action_name: 'delete'
+ }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
end
diff --git a/spec/controllers/api/v1/accounts/webhook_controller_spec.rb b/spec/controllers/api/v1/accounts/webhook_controller_spec.rb
index 3a68d8921..86f4d4e7e 100644
--- a/spec/controllers/api/v1/accounts/webhook_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/webhook_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
RSpec.describe 'Webhooks API', type: :request do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
- let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com') }
+ let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
@@ -49,6 +49,16 @@ RSpec.describe 'Webhooks API', type: :request do
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
end
+ it 'creates webhook with name' do
+ post "/api/v1/accounts/#{account.id}/webhooks",
+ params: { account_id: account.id, inbox_id: inbox.id, url: 'https://hello.com', name: 'My Webhook' },
+ headers: administrator.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:success)
+
+ expect(response.parsed_body['payload']['webhook']['name']).to eql 'My Webhook'
+ end
+
it 'throws error when invalid url provided' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { account_id: account.id, inbox_id: inbox.id, url: 'javascript:alert(1)' },
@@ -103,11 +113,12 @@ RSpec.describe 'Webhooks API', type: :request do
context 'when it is an authenticated admin user' do
it 'updates webhook' do
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
- params: { url: 'https://hello.com' },
+ params: { url: 'https://hello.com', name: 'Another Webhook' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
+ expect(response.parsed_body['payload']['webhook']['name']).to eql 'Another Webhook'
end
end
end
diff --git a/spec/controllers/api/v2/accounts/reports_controller_spec.rb b/spec/controllers/api/v2/accounts/reports_controller_spec.rb
index 4dbbcf406..2d505822f 100644
--- a/spec/controllers/api/v2/accounts/reports_controller_spec.rb
+++ b/spec/controllers/api/v2/accounts/reports_controller_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
context 'when authenticated and authorized' do
before do
# Create conversations across 24 hours at different times
- base_time = Time.utc(2024, 1, 15, 0, 0) # Start at midnight UTC
+ base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
# Create conversations every 4 hours across 24 hours
6.times do |i|
@@ -23,36 +23,38 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
end
it 'timezone_offset affects data grouping and timestamps correctly' do
- Time.use_zone('UTC') do
- base_time = Time.utc(2024, 1, 15, 0, 0)
- base_params = {
- metric: 'conversations_count',
- type: 'account',
- since: (base_time - 1.day).to_i.to_s,
- until: (base_time + 2.days).to_i.to_s,
- group_by: 'day'
- }
+ travel_to Time.utc(2024, 1, 15, 12, 0) do
+ Time.use_zone('UTC') do
+ base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
+ base_params = {
+ metric: 'conversations_count',
+ type: 'account',
+ since: (base_time - 1.day).to_i.to_s,
+ until: (base_time + 2.days).to_i.to_s,
+ group_by: 'day'
+ }
- responses = [0, -8, 9].map do |offset|
- get "/api/v2/accounts/#{account.id}/reports",
- params: base_params.merge(timezone_offset: offset),
- headers: admin.create_new_auth_token, as: :json
- response.parsed_body
+ responses = [0, -8, 9].map do |offset|
+ get "/api/v2/accounts/#{account.id}/reports",
+ params: base_params.merge(timezone_offset: offset),
+ headers: admin.create_new_auth_token, as: :json
+ response.parsed_body
+ end
+
+ data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
+ totals = responses.map { |r| r.sum { |e| e['value'] } }
+ timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
+
+ # Data conservation and redistribution
+ expect(totals.uniq).to eq([6])
+ expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
+ expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
+ expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
+
+ # Timestamp differences
+ expect(timestamps.uniq.size).to eq(3)
+ timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
end
-
- data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
- totals = responses.map { |r| r.sum { |e| e['value'] } }
- timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
-
- # Data conservation and redistribution
- expect(totals.uniq).to eq([6])
- expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
- expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
- expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
-
- # Timestamp differences
- expect(timestamps.uniq.size).to eq(3)
- timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
index 4d5269cde..472dc959b 100644
--- a/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -30,5 +30,218 @@ RSpec.describe 'Conversations API', type: :request do
expect(response.parsed_body.keys).not_to include('applied_sla')
expect(response.parsed_body.keys).not_to include('sla_events')
end
+
+ context 'when agent has team access' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:team) { create(:team, account: account) }
+ let(:conversation) { create(:conversation, account: account, team: team) }
+
+ before do
+ create(:team_member, team: team, user: agent)
+ end
+
+ it 'allows accessing the conversation via team membership' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+ end
+
+ context 'when agent has a custom role' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:conversation) { create(:conversation, account: account) }
+
+ before do
+ create(:inbox_member, user: agent, inbox: conversation.inbox)
+ end
+
+ it 'returns unauthorized for unassigned conversation without permission' do
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
+ account.account_users.find_by(user_id: agent.id).update!(custom_role: custom_role)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'returns the conversation when permission allows managing unassigned conversations, including when assigned to agent' do
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_unassigned_manage'])
+ account_user = account.account_users.find_by(user_id: agent.id)
+ account_user.update!(custom_role: custom_role)
+ conversation.update!(assignee: agent)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+
+ it 'returns the conversation when permission allows managing assigned conversations' do
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
+ account_user = account.account_users.find_by(user_id: agent.id)
+ account_user.update!(custom_role: custom_role)
+ conversation.update!(assignee: agent)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+
+ it 'returns the conversation when permission allows managing participating conversations' do
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
+ account_user = account.account_users.find_by(user_id: agent.id)
+ account_user.update!(custom_role: custom_role)
+ create(:conversation_participant, conversation: conversation, account: account, user: agent)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/conversations/:id/reporting_events' do
+ let(:conversation) { create(:conversation, account: account) }
+ let(:inbox) { conversation.inbox }
+ let(:agent) { administrator }
+
+ before do
+ # Create reporting events for this conversation
+ @event1 = create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ inbox: inbox,
+ user: agent,
+ name: 'first_response',
+ value: 120,
+ created_at: 3.hours.ago)
+
+ @event2 = create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ inbox: inbox,
+ user: agent,
+ name: 'reply_time',
+ value: 45,
+ created_at: 2.hours.ago)
+
+ @event3 = create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ inbox: inbox,
+ user: agent,
+ name: 'resolution',
+ value: 300,
+ created_at: 1.hour.ago)
+
+ # Create an event for a different conversation (should not be included)
+ other_conversation = create(:conversation, account: account)
+ create(:reporting_event,
+ account: account,
+ conversation: other_conversation,
+ inbox: other_conversation.inbox,
+ user: agent,
+ name: 'other_conversation_event',
+ value: 60)
+ end
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user with conversation access' do
+ it 'returns all reporting events for the conversation' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
+ headers: administrator.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ # Should return array directly (no pagination)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(3)
+
+ # Check they are sorted by created_at asc (oldest first)
+ expect(json_response.first['name']).to eq('first_response')
+ expect(json_response.last['name']).to eq('resolution')
+
+ # Verify it doesn't include events from other conversations
+ event_names = json_response.map { |e| e['name'] }
+ expect(event_names).not_to include('other_conversation_event')
+ end
+
+ it 'returns empty array when conversation has no reporting events' do
+ conversation_without_events = create(:conversation, account: account)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation_without_events.display_id}/reporting_events",
+ headers: administrator.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response).to be_an(Array)
+ expect(json_response).to be_empty
+ end
+ end
+
+ context 'when agent has limited access' do
+ let(:limited_agent) { create(:user, account: account, role: :agent) }
+
+ it 'returns unauthorized for unassigned conversation without permission' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
+ headers: limited_agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'returns reporting events when agent is assigned to the conversation' do
+ conversation.update!(assignee: limited_agent)
+ # Also create inbox member for the agent
+ create(:inbox_member, user: limited_agent, inbox: conversation.inbox)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
+ headers: limited_agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(3)
+ end
+ end
+
+ context 'when agent has team access' do
+ let(:team_agent) { create(:user, account: account, role: :agent) }
+ let(:team) { create(:team, account: account) }
+
+ before do
+ create(:team_member, team: team, user: team_agent)
+ conversation.update!(team: team)
+ end
+
+ it 'allows accessing conversation reporting events via team membership' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
+ headers: team_agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(3)
+ end
+ end
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/reporting_events_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/reporting_events_controller_spec.rb
new file mode 100644
index 000000000..6b3ffc4ba
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/reporting_events_controller_spec.rb
@@ -0,0 +1,217 @@
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Reporting Events API', type: :request do
+ let!(:account) { create(:account) }
+ let!(:admin) { create(:user, account: account, role: :administrator) }
+ let!(:agent) { create(:user, account: account, role: :agent) }
+ let!(:inbox) { create(:inbox, account: account) }
+ let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
+
+ describe 'GET /api/v1/accounts/{account.id}/reporting_events' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated normal agent user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated admin user' do
+ before do
+ create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ inbox: inbox,
+ user: agent,
+ name: 'first_response',
+ value: 120,
+ created_at: 3.days.ago)
+ create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ inbox: inbox,
+ user: agent,
+ name: 'resolution',
+ value: 300,
+ created_at: 2.days.ago)
+ create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ inbox: inbox,
+ user: agent,
+ name: 'reply_time',
+ value: 45,
+ created_at: 1.day.ago)
+ end
+
+ it 'fetches reporting events with pagination' do
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ # Check structure and pagination
+ expect(json_response).to have_key('payload')
+ expect(json_response).to have_key('meta')
+ expect(json_response['meta']['count']).to eq(3)
+
+ # Check events are sorted by created_at desc (newest first)
+ events = json_response['payload']
+ expect(events.size).to eq(3)
+ expect(events.first['name']).to eq('reply_time')
+ expect(events.last['name']).to eq('first_response')
+ end
+
+ it 'filters reporting events by date range using since and until' do
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ params: { since: 2.5.days.ago.to_time.to_i.to_s, until: 1.5.days.ago.to_time.to_i.to_s },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response['meta']['count']).to eq(1)
+ expect(json_response['payload'].first['name']).to eq('resolution')
+ end
+
+ it 'filters reporting events by inbox_id' do
+ other_inbox = create(:inbox, account: account)
+ other_conversation = create(:conversation, account: account, inbox: other_inbox)
+ create(:reporting_event,
+ account: account,
+ conversation: other_conversation,
+ inbox: other_inbox,
+ user: agent,
+ name: 'other_inbox_event')
+
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ params: { inbox_id: inbox.id },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response['meta']['count']).to eq(3)
+ expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_inbox_event')
+ end
+
+ it 'filters reporting events by user_id (agent)' do
+ other_agent = create(:user, account: account, role: :agent)
+ create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ inbox: inbox,
+ user: other_agent,
+ name: 'other_agent_event')
+
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ params: { user_id: agent.id },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response['meta']['count']).to eq(3)
+ expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_agent_event')
+ end
+
+ it 'filters reporting events by name' do
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ params: { name: 'first_response' },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response['meta']['count']).to eq(1)
+ expect(json_response['payload'].first['name']).to eq('first_response')
+ end
+
+ it 'supports combining multiple filters' do
+ # Create more test data
+ other_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
+ create(:reporting_event,
+ account: account,
+ conversation: other_conversation,
+ inbox: inbox,
+ user: agent,
+ name: 'first_response',
+ value: 90,
+ created_at: 2.days.ago)
+
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ params: {
+ inbox_id: inbox.id,
+ user_id: agent.id,
+ name: 'first_response',
+ since: 4.days.ago.to_time.to_i.to_s,
+ until: Time.zone.now.to_time.to_i.to_s
+ },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response['meta']['count']).to eq(2)
+ expect(json_response['payload'].map { |e| e['name'] }).to all(eq('first_response'))
+ end
+
+ context 'with pagination' do
+ before do
+ # Create more events to test pagination
+ 30.times do |i|
+ create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ inbox: inbox,
+ user: agent,
+ name: "event_#{i}",
+ created_at: i.hours.ago)
+ end
+ end
+
+ it 'returns 25 events per page by default' do
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response['payload'].size).to eq(25)
+ expect(json_response['meta']['count']).to eq(33) # 30 + 3 original events
+ expect(json_response['meta']['current_page']).to eq(1)
+ end
+
+ it 'supports page navigation' do
+ get "/api/v1/accounts/#{account.id}/reporting_events",
+ params: { page: 2 },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+
+ expect(json_response['payload'].size).to eq(8) # Remaining events
+ expect(json_response['meta']['current_page']).to eq(2)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
index 40f1ea294..1a8a5a342 100644
--- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
+++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
@@ -1,8 +1,6 @@
require 'rails_helper'
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
- include ActiveJob::TestHelper
-
let!(:inbox) { create(:inbox) }
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
@@ -14,6 +12,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
before do
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
+ inbox.reload
end
it 'queues the job' do
@@ -22,7 +21,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
end
it 'resolves only the eligible pending conversations' do
- perform_enqueued_jobs { described_class.perform_later(inbox) }
+ described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
expect(recent_pending_conversation.reload.status).to eq('pending')
@@ -34,7 +33,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
captain_assistant.update!(config: { 'resolution_message' => custom_message })
expect do
- perform_enqueued_jobs { described_class.perform_later(inbox) }
+ described_class.perform_now(inbox)
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
@@ -44,7 +43,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
it 'creates an outgoing message with default auto resolution message if not configured' do
captain_assistant.update!(config: {})
- perform_enqueued_jobs { described_class.perform_later(inbox) }
+ described_class.perform_now(inbox)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
expect(outgoing_message.content).to eq(
I18n.t('conversations.activity.auto_resolution_message')
@@ -52,11 +51,17 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
end
it 'adds the correct activity message after resolution by Captain' do
- perform_enqueued_jobs { described_class.perform_later(inbox) }
- activity_message = resolvable_pending_conversation.messages.activity.last
- expect(activity_message).not_to be_nil
- expect(activity_message.content).to eq(
- I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
- )
+ described_class.perform_now(inbox)
+ expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
+ expect(Conversations::ActivityMessageJob)
+ .to have_been_enqueued.with(
+ resolvable_pending_conversation,
+ {
+ account_id: resolvable_pending_conversation.account_id,
+ inbox_id: resolvable_pending_conversation.inbox_id,
+ message_type: :activity,
+ content: expected_content
+ }
+ )
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 f2a42c7a2..bfffe7894 100644
--- a/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb
+++ b/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
expect(document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata]['title'],
- external_link: payload[:metadata]['url'],
+ external_link: 'https://www.firecrawl.dev',
status: 'available'
)
end
@@ -32,7 +32,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
existing_document = create(:captain_document,
assistant: assistant,
account: assistant.account,
- external_link: payload[:metadata]['url'],
+ external_link: 'https://www.firecrawl.dev',
content: 'old content',
name: 'old title',
status: :in_progress)
@@ -42,7 +42,9 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
end.not_to change(assistant.documents, :count)
existing_document.reload
+ # Payload URL ends with '/', but we persist the canonical URL without it.
expect(existing_document).to have_attributes(
+ external_link: 'https://www.firecrawl.dev',
content: payload[:markdown],
name: payload[:metadata]['title'],
status: 'available'
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 f563ba44d..3872629e5 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
@@ -3,7 +3,7 @@ require 'rails_helper'
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
describe '#perform' do
let(:assistant) { create(:captain_assistant) }
- let(:page_link) { 'https://example.com/page' }
+ let(:page_link) { 'https://example.com/page/' }
let(:page_title) { 'Example Page Title' }
let(:content) { 'Some page content here' }
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
@@ -24,7 +24,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
- expect(document.external_link).to eq(page_link)
+ expect(document.external_link).to eq('https://example.com/page')
expect(document.name).to eq(page_title)
expect(document.content).to eq(content)
expect(document.status).to eq('available')
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
it 'updates existing document if one exists' do
existing_document = create(:captain_document,
assistant: assistant,
- external_link: page_link,
+ external_link: 'https://example.com/page',
name: 'Old Title',
content: 'Old content')
diff --git a/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb b/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb
new file mode 100644
index 000000000..499b96287
--- /dev/null
+++ b/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb
@@ -0,0 +1,133 @@
+require 'rails_helper'
+
+RSpec.describe Migration::CompanyAccountBatchJob, type: :job do
+ let(:account) { create(:account) }
+
+ describe '#perform' do
+ before do
+ # Stub EmailProvideInfo to control behavior in tests
+ allow(EmailProviderInfo).to receive(:call) do |email|
+ domain = email.split('@').last&.downcase
+ case domain
+ when 'gmail.com', 'yahoo.com', 'hotmail.com', 'uol.com.br'
+ 'free_provider' # generic free provider name
+ end
+ end
+ end
+
+ context 'when contact has business email' do
+ let!(:contact) { create(:contact, account: account, email: 'user@acme.com') }
+
+ it 'creates a company and associates the contact' do
+ # Clean up companies created by Part 2's callback
+ Company.delete_all
+ # rubocop:disable Rails/SkipsModelValidations
+ contact.update_column(:company_id, nil)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ expect do
+ described_class.perform_now(account)
+ end.to change(Company, :count).by(1)
+ contact.reload
+ expect(contact.company).to be_present
+ expect(contact.company.domain).to eq('acme.com')
+ expect(contact.company.name).to eq('Acme')
+ end
+ end
+
+ context 'when contact has free email' do
+ let!(:contact) { create(:contact, account: account, email: 'user@gmail.com') }
+
+ it 'does not create a company' do
+ expect do
+ described_class.perform_now(account)
+ end.not_to change(Company, :count)
+ contact.reload
+ expect(contact.company_id).to be_nil
+ end
+ end
+
+ context 'when contact has company_name in additional_attributes' do
+ let!(:contact) do
+ create(:contact, account: account, email: 'user@acme.com', additional_attributes: { 'company_name' => 'Acme Corporation' })
+ end
+
+ it 'uses the saved company name' do
+ described_class.perform_now(account)
+ contact.reload
+ expect(contact.company.name).to eq('Acme Corporation')
+ end
+ end
+
+ context 'when contact already has a company' do
+ let!(:existing_company) { create(:company, account: account, domain: 'existing.com') }
+ let!(:contact) do
+ create(:contact, account: account, email: 'user@acme.com', company: existing_company)
+ end
+
+ it 'does not change the existing company' do
+ described_class.perform_now(account)
+ contact.reload
+ expect(contact.company_id).to eq(existing_company.id)
+ end
+ end
+
+ context 'when multiple contacts have the same domain' do
+ let!(:contact1) { create(:contact, account: account, email: 'user1@acme.com') }
+ let!(:contact2) { create(:contact, account: account, email: 'user2@acme.com') }
+
+ it 'creates only one company for the domain' do
+ # Clean up companies created by Part 2's callback
+ Company.delete_all
+ # rubocop:disable Rails/SkipsModelValidations
+ contact1.update_column(:company_id, nil)
+ contact2.update_column(:company_id, nil)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ expect do
+ described_class.perform_now(account)
+ end.to change(Company, :count).by(1)
+ contact1.reload
+ contact2.reload
+ expect(contact1.company_id).to eq(contact2.company_id)
+ expect(contact1.company.domain).to eq('acme.com')
+ end
+ end
+
+ context 'when contact has no email' do
+ let!(:contact) { create(:contact, account: account, email: nil) }
+
+ it 'skips the contact' do
+ expect do
+ described_class.perform_now(account)
+ end.not_to change(Company, :count)
+ contact.reload
+ expect(contact.company_id).to be_nil
+ end
+ end
+
+ context 'when processing large batch' do
+ before do
+ contacts_data = Array.new(2000) do |i|
+ {
+ account_id: account.id,
+ email: "user#{i}@company#{i % 100}.com",
+ name: "User #{i}",
+ created_at: Time.current,
+ updated_at: Time.current
+ }
+ end
+ # rubocop:disable Rails/SkipsModelValidations
+ Contact.insert_all(contacts_data)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
+ it 'processes all contacts in batches' do
+ expect do
+ described_class.perform_now(account)
+ end.to change(Company, :count).by(100)
+ expect(account.contacts.where.not(company_id: nil).count).to eq(2000)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/migration/company_backfill_job_spec.rb b/spec/enterprise/jobs/migration/company_backfill_job_spec.rb
new file mode 100644
index 000000000..e60b84487
--- /dev/null
+++ b/spec/enterprise/jobs/migration/company_backfill_job_spec.rb
@@ -0,0 +1,31 @@
+require 'rails_helper'
+
+RSpec.describe Migration::CompanyBackfillJob, type: :job do
+ describe '#perform' do
+ it 'enqueues the job' do
+ expect { described_class.perform_later }
+ .to have_enqueued_job(described_class)
+ .on_queue('low')
+ end
+
+ context 'when accounts exist' do
+ let!(:account1) { create(:account) }
+ let!(:account2) { create(:account) }
+
+ it 'enqueues CompanyAccountBatchJob for each account' do
+ expect do
+ described_class.perform_now
+ end.to have_enqueued_job(Migration::CompanyAccountBatchJob)
+ .with(account1)
+ .and have_enqueued_job(Migration::CompanyAccountBatchJob)
+ .with(account2)
+ end
+ end
+
+ context 'when no accounts exist' do
+ it 'completes without error' do
+ expect { described_class.perform_now }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/captain/document_spec.rb b/spec/enterprise/models/captain/document_spec.rb
index 56dc1727c..e6543e0d7 100644
--- a/spec/enterprise/models/captain/document_spec.rb
+++ b/spec/enterprise/models/captain/document_spec.rb
@@ -4,6 +4,17 @@ RSpec.describe Captain::Document, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
+ describe 'URL normalization' do
+ it 'removes a trailing slash before validation' do
+ document = create(:captain_document,
+ assistant: assistant,
+ account: account,
+ external_link: 'https://example.com/path/')
+
+ expect(document.external_link).to eq('https://example.com/path')
+ end
+ end
+
describe 'PDF support' do
let(:pdf_document) do
doc = build(:captain_document, assistant: assistant, account: account)
@@ -82,4 +93,161 @@ RSpec.describe Captain::Document, type: :model do
end
end
end
+
+ describe 'response builder job callback' do
+ before { clear_enqueued_jobs }
+
+ describe 'non-PDF documents' do
+ it 'enqueues when created with available status and content' do
+ expect do
+ create(:captain_document, assistant: assistant, account: account, status: :available)
+ end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'does not enqueue when created available without content' do
+ expect do
+ create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
+ end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'enqueues when status transitions to available with existing content' do
+ document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
+
+ expect do
+ document.update!(status: :available)
+ end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'does not enqueue when status transitions to available without content' do
+ document = create(
+ :captain_document,
+ assistant: assistant,
+ account: account,
+ status: :in_progress,
+ content: nil
+ )
+
+ expect do
+ document.update!(status: :available)
+ end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'enqueues when content is populated on an available document' do
+ document = create(
+ :captain_document,
+ assistant: assistant,
+ account: account,
+ status: :available,
+ content: nil
+ )
+ clear_enqueued_jobs
+
+ expect do
+ document.update!(content: 'Fresh content from crawl')
+ end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'enqueues when content changes on an available document' do
+ document = create(
+ :captain_document,
+ assistant: assistant,
+ account: account,
+ status: :available,
+ content: 'Initial content'
+ )
+ clear_enqueued_jobs
+
+ expect do
+ document.update!(content: 'Updated crawl content')
+ end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'does not enqueue when content is cleared on an available document' do
+ document = create(
+ :captain_document,
+ assistant: assistant,
+ account: account,
+ status: :available,
+ content: 'Initial content'
+ )
+ clear_enqueued_jobs
+
+ expect do
+ document.update!(content: nil)
+ end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'does not enqueue for metadata-only updates' do
+ document = create(:captain_document, assistant: assistant, account: account, status: :available)
+ clear_enqueued_jobs
+
+ expect do
+ document.update!(metadata: { 'title' => 'Updated Again' })
+ end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'does not enqueue while document remains in progress' do
+ document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
+
+ expect do
+ document.update!(metadata: { 'title' => 'Updated' })
+ end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+ end
+
+ describe 'PDF documents' do
+ def build_pdf_document(status:, content:)
+ build(
+ :captain_document,
+ assistant: assistant,
+ account: account,
+ status: status,
+ content: content
+ ).tap do |doc|
+ doc.pdf_file.attach(
+ io: StringIO.new('PDF content'),
+ filename: 'sample.pdf',
+ content_type: 'application/pdf'
+ )
+ end
+ end
+
+ it 'enqueues when created available without content' do
+ document = build_pdf_document(status: :available, content: nil)
+
+ expect do
+ document.save!
+ end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'enqueues when status transitions to available' do
+ document = build_pdf_document(status: :in_progress, content: nil)
+ document.save!
+ clear_enqueued_jobs
+
+ expect do
+ document.update!(status: :available)
+ end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+
+ it 'does not enqueue when content updates without status change' do
+ document = build_pdf_document(status: :available, content: nil)
+ document.save!
+ clear_enqueued_jobs
+
+ expect do
+ document.update!(content: 'Extracted PDF text')
+ end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+ end
+
+ it 'does not enqueue when the document is destroyed' do
+ document = create(:captain_document, assistant: assistant, account: account, status: :available)
+ clear_enqueued_jobs
+
+ expect do
+ document.destroy!
+ end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
+ end
+ end
end
diff --git a/spec/enterprise/models/contact_company_association_spec.rb b/spec/enterprise/models/contact_company_association_spec.rb
new file mode 100644
index 000000000..065d5bc97
--- /dev/null
+++ b/spec/enterprise/models/contact_company_association_spec.rb
@@ -0,0 +1,61 @@
+require 'rails_helper'
+
+RSpec.describe Contact, type: :model do
+ describe 'company auto-association' do
+ let(:account) { create(:account) }
+
+ context 'when creating a new contact with business email' do
+ it 'automatically creates and associates a company' do
+ expect do
+ create(:contact, email: 'john@acme.com', account: account)
+ end.to change(Company, :count).by(1)
+ contact = described_class.last
+ expect(contact.company).to be_present
+ expect(contact.company.domain).to eq('acme.com')
+ end
+
+ it 'does not create company for free email providers' do
+ expect do
+ create(:contact, email: 'john@gmail.com', account: account)
+ end.not_to change(Company, :count)
+ end
+ end
+
+ context 'when updating a contact to add email for first time' do
+ it 'creates and associates company' do
+ contact = create(:contact, email: nil, account: account)
+ expect do
+ contact.update(email: 'john@acme.com')
+ end.to change(Company, :count).by(1)
+ contact.reload
+ expect(contact.company.domain).to eq('acme.com')
+ end
+ end
+
+ context 'when updating a contact that already has a company' do
+ it 'does not change company when email changes' do
+ existing_company = create(:company, domain: 'oldcompany.com', account: account)
+ contact = create(:contact, email: 'john@oldcompany.com', company: existing_company, account: account)
+
+ expect do
+ contact.update(email: 'john@new_company.com')
+ end.not_to change(Company, :count)
+ contact.reload
+ expect(contact.company).to eq(existing_company)
+ end
+ end
+
+ context 'when multiple contacts share the same domain' do
+ it 'associates all contacts with the same company' do
+ contacts = ['john@acme.com', 'jane@acme.com', 'bob@acme.com']
+ contacts.each do |contact|
+ create(:contact, email: contact, account: account)
+ end
+
+ expect(Company.where(domain: 'acme.com', account: account).count).to eq(1)
+ company = Company.find_by(domain: 'acme.com', account: account)
+ expect(company.contacts.count).to eq(contacts.length)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/policies/conversation_policy_spec.rb b/spec/enterprise/policies/conversation_policy_spec.rb
new file mode 100644
index 000000000..e48a84852
--- /dev/null
+++ b/spec/enterprise/policies/conversation_policy_spec.rb
@@ -0,0 +1,65 @@
+require 'rails_helper'
+
+RSpec.describe ConversationPolicy, type: :policy do
+ subject { described_class }
+
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:agent_account_user) { agent.account_users.find_by(account: account) }
+ let(:context) { { user: agent, account: account, account_user: agent_account_user } }
+
+ before do
+ create(:inbox_member, user: agent, inbox: inbox)
+ end
+
+ permissions :show? do
+ context 'when role grants conversation_unassigned_manage' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
+
+ before do
+ agent_account_user.update!(role: :agent, custom_role: custom_role)
+ end
+
+ it 'allows access to conversations assigned to the agent' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
+
+ expect(subject).to permit(context, conversation)
+ end
+
+ it 'denies access to conversations assigned to someone else' do
+ other_agent = create(:user, account: account, role: :agent)
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
+
+ expect(subject).not_to permit(context, conversation)
+ end
+ end
+
+ context 'when role grants conversation_participating_manage' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
+
+ before do
+ agent_account_user.update!(role: :agent, custom_role: custom_role)
+ end
+
+ it 'allows access to conversations assigned to the agent' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
+
+ expect(subject).to permit(context, conversation)
+ end
+
+ it 'allows access to conversations where the agent is a participant' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
+ create(:conversation_participant, conversation: conversation, account: account, user: agent)
+
+ expect(subject).to permit(context, conversation)
+ end
+
+ it 'denies access to unrelated conversations' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
+
+ expect(subject).not_to permit(context, conversation)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb b/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb
new file mode 100644
index 000000000..a2735bd69
--- /dev/null
+++ b/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
+ let(:website_url) { 'https://example.com' }
+ let(:service) { described_class.new(website_url) }
+ let(:mock_crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
+ let(:mock_client) { instance_double(OpenAI::Client) }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(Captain::Tools::SimplePageCrawlService).to receive(:new).and_return(mock_crawler)
+ allow(service).to receive(:client).and_return(mock_client)
+ allow(service).to receive(:model).and_return('gpt-3.5-turbo')
+ end
+
+ describe '#analyze' do
+ context 'when website content is available and OpenAI call is successful' do
+ let(:openai_response) do
+ {
+ 'choices' => [{
+ 'message' => {
+ 'content' => {
+ 'business_name' => 'Example Corp',
+ 'suggested_assistant_name' => 'Alex from Example Corp',
+ 'description' => 'You specialize in helping customers with business solutions and support'
+ }.to_json
+ }
+ }]
+ }
+ end
+
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
+ allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
+ allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
+ allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
+ allow(mock_client).to receive(:chat).and_return(openai_response)
+ end
+
+ it 'returns success' do
+ result = service.analyze
+
+ expect(result[:success]).to be true
+ expect(result[:data]).to include(
+ business_name: 'Example Corp',
+ suggested_assistant_name: 'Alex from Example Corp',
+ description: 'You specialize in helping customers with business solutions and support',
+ website_url: website_url,
+ favicon_url: 'https://example.com/favicon.ico'
+ )
+ end
+ end
+
+ context 'when website content is errored' do
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
+ end
+
+ it 'returns error' do
+ result = service.analyze
+
+ expect(result[:success]).to be false
+ expect(result[:error]).to eq('Failed to fetch website content')
+ end
+ end
+
+ context 'when website content is unavailable' do
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_return('')
+ allow(mock_crawler).to receive(:page_title).and_return('')
+ allow(mock_crawler).to receive(:meta_description).and_return('')
+ end
+
+ it 'returns error' do
+ result = service.analyze
+
+ expect(result[:success]).to be false
+ expect(result[:error]).to eq('Failed to fetch website content')
+ end
+ end
+
+ context 'when OpenAI error' do
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
+ allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
+ allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
+ allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
+ allow(mock_client).to receive(:chat).and_raise(StandardError, 'API error')
+ end
+
+ it 'returns error' do
+ result = service.analyze
+
+ expect(result[:success]).to be false
+ expect(result[:error]).to eq('API error')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb b/spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb
index 5868c0e22..5dfe7177d 100644
--- a/spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb
@@ -125,4 +125,63 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
)
end
end
+
+ describe '#meta_description' do
+ context 'when meta description exists' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: '')
+ end
+
+ it 'returns the meta description content' do
+ expect(service.meta_description).to eq('This is a test page description')
+ end
+ end
+
+ context 'when meta description does not exist' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: 'Test')
+ end
+
+ it 'returns nil' do
+ expect(service.meta_description).to be_nil
+ end
+ end
+ end
+
+ describe '#favicon_url' do
+ context 'when favicon exists with relative URL' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: '')
+ end
+
+ it 'returns the resolved absolute favicon URL' do
+ expect(service.favicon_url).to eq('https://example.com/favicon.ico')
+ end
+ end
+
+ context 'when favicon exists with absolute URL' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: '')
+ end
+
+ it 'returns the absolute favicon URL' do
+ expect(service.favicon_url).to eq('https://cdn.example.com/favicon.ico')
+ end
+ end
+
+ context 'when favicon does not exist' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: 'Test')
+ end
+
+ it 'returns nil' do
+ expect(service.favicon_url).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/enterprise/services/companies/business_email_detector_service_spec.rb b/spec/enterprise/services/companies/business_email_detector_service_spec.rb
new file mode 100644
index 000000000..ceabfa905
--- /dev/null
+++ b/spec/enterprise/services/companies/business_email_detector_service_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+RSpec.describe Companies::BusinessEmailDetectorService, type: :service do
+ let(:service) { described_class.new(email) }
+
+ describe '#perform' do
+ context 'when email is from a business domain' do
+ let(:email) { 'user@acme.com' }
+ let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
+
+ before do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with(email).and_return(nil)
+ end
+
+ it 'returns true' do
+ expect(service.perform).to be(true)
+ end
+ end
+
+ context 'when email is from gmail' do
+ let(:email) { 'user@gmail.com' }
+ let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
+
+ before do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with(email).and_return('gmail')
+ end
+
+ it 'returns false' do
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is from Brazilian free provider' do
+ let(:email) { 'user@uol.com.br' }
+ let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
+
+ before do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with(email).and_return('uol')
+ end
+
+ it 'returns false' do
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is disposable' do
+ let(:email) { 'user@mailinator.com' }
+ let(:disposable_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: true) }
+
+ it 'returns false' do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is invalid format' do
+ let(:email) { 'invalid-email' }
+ let(:invalid_email_address) { instance_double(ValidEmail2::Address, valid?: false) }
+
+ it 'returns false' do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is nil' do
+ let(:email) { nil }
+
+ it 'remains false' do
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is empty string' do
+ let(:email) { '' }
+
+ it 'returns false' do
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email domain is uppercase' do
+ let(:email) { 'user@GMAIL.COM' }
+ let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
+
+ before do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with(email).and_return('gmail')
+ end
+
+ it 'returns false (case insensitive)' do
+ expect(service.perform).to be(false)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/contacts/company_association_service_spec.rb b/spec/enterprise/services/contacts/company_association_service_spec.rb
new file mode 100644
index 000000000..ea9363bad
--- /dev/null
+++ b/spec/enterprise/services/contacts/company_association_service_spec.rb
@@ -0,0 +1,83 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::CompanyAssociationService, type: :service do
+ let(:account) { create(:account) }
+ let(:service) { described_class.new }
+
+ describe '#associate_company_from_email' do
+ context 'when contact has business email and no company' do
+ it 'creates a new company and associates it' do
+ contact = create(:contact, email: 'john@acme.com', account: account, company_id: nil)
+ Company.delete_all # Delete any companies created by the callback
+ # rubocop:disable Rails/SkipsModelValidations
+ contact.update_column(:company_id, nil) # Delete the company association created by the callback
+ # rubocop:enable Rails/SkipsModelValidations
+
+ valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
+ allow(ValidEmail2::Address).to receive(:new).with('john@acme.com').and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with('john@acme.com').and_return(nil)
+
+ expect do
+ service.associate_company_from_email(contact)
+ end.to change(Company, :count).by(1)
+
+ contact.reload
+ expect(contact.company).to be_present
+ expect(contact.company.domain).to eq('acme.com')
+ expect(contact.company.name).to eq('Acme')
+ end
+
+ it 'reuses existing company with same domain' do
+ existing_company = create(:company, domain: 'acme.com', account: account)
+ contact = create(:contact, email: 'john@acme.com', account: account, company_id: nil)
+ # rubocop:disable Rails/SkipsModelValidations
+ contact.update_column(:company_id, nil) # Delete the company association created by the callback
+ # rubocop:enable Rails/SkipsModelValidations
+
+ valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
+ allow(ValidEmail2::Address).to receive(:new).with('john@acme.com').and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with('john@acme.com').and_return(nil)
+
+ expect do
+ service.associate_company_from_email(contact)
+ end.not_to change(Company, :count)
+
+ contact.reload
+ expect(contact.company).to eq(existing_company)
+ end
+ end
+
+ context 'when contact already has a company' do
+ it 'skips association and returns nil' do
+ existing_company = create(:company, account: account)
+ contact = create(:contact, email: 'john@acme.com', account: account, company_id: existing_company.id)
+ result = service.associate_company_from_email(contact)
+
+ expect(result).to be_nil
+ contact.reload
+ expect(contact.company).to eq(existing_company)
+ end
+ end
+
+ context 'when contact has free email provider' do
+ it 'skips association for email' do
+ contact = create(:contact, email: 'john@gmail.com', account: account, company_id: nil)
+ expect do
+ service.associate_company_from_email(contact)
+ end.not_to change(Company, :count)
+ contact.reload
+ expect(contact.company).to be_nil
+ end
+ end
+
+ context 'when contact has no email' do
+ it 'skips association' do
+ contact = create(:contact, email: nil, account: account, company_id: nil)
+
+ result = service.associate_company_from_email(contact)
+ expect(result).to be_nil
+ expect(contact.reload.company).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb
new file mode 100644
index 000000000..432f397be
--- /dev/null
+++ b/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb
@@ -0,0 +1,185 @@
+require 'rails_helper'
+
+RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
+ let(:account) { create(:account) }
+ let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:agent1) { create(:user, account: account, name: 'Agent 1') }
+ let(:agent2) { create(:user, account: account, name: 'Agent 2') }
+ let(:assignment_service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
+
+ before do
+ # Create inbox members
+ create(:inbox_member, inbox: inbox, user: agent1)
+ create(:inbox_member, inbox: inbox, user: agent2)
+
+ # Link inbox to assignment policy
+ create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
+
+ allow(account).to receive(:feature_enabled?).and_return(false)
+ allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
+
+ # Set agents as online
+ OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
+ OnlineStatusTracker.set_status(account.id, agent1.id, 'online')
+ OnlineStatusTracker.update_presence(account.id, 'User', agent2.id)
+ OnlineStatusTracker.set_status(account.id, agent2.id, 'online')
+ end
+
+ describe 'exclusion rules' do
+ let(:capacity_policy) { create(:agent_capacity_policy, account: account) }
+ let(:label1) { create(:label, account: account, title: 'high-priority') }
+ let(:label2) { create(:label, account: account, title: 'vip') }
+
+ before do
+ create(:inbox_capacity_limit, inbox: inbox, agent_capacity_policy: capacity_policy, conversation_limit: 10)
+ inbox.enable_auto_assignment = true
+ inbox.save!
+ end
+
+ context 'when excluding conversations by label' do
+ let!(:conversation_with_label) { create(:conversation, inbox: inbox, assignee: nil) }
+ let!(:conversation_without_label) { create(:conversation, inbox: inbox, assignee: nil) }
+
+ before do
+ conversation_with_label.update_labels([label1.title])
+
+ capacity_policy.update!(exclusion_rules: {
+ 'excluded_labels' => [label1.title]
+ })
+ end
+
+ it 'excludes conversations with specified labels' do
+ # First check conversations are unassigned
+ expect(conversation_with_label.assignee).to be_nil
+ expect(conversation_without_label.assignee).to be_nil
+
+ # Run bulk assignment
+ assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
+
+ # Only the conversation without label should be assigned
+ expect(assigned_count).to eq(1)
+ expect(conversation_with_label.reload.assignee).to be_nil
+ expect(conversation_without_label.reload.assignee).to be_present
+ end
+
+ it 'handles bulk assignment correctly' do
+ assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
+
+ # Only 1 conversation should be assigned (the one without label)
+ expect(assigned_count).to eq(1)
+ expect(conversation_with_label.reload.assignee).to be_nil
+ expect(conversation_without_label.reload.assignee).to be_present
+ end
+
+ it 'excludes conversations with multiple labels' do
+ conversation_without_label.update_labels([label2.title])
+
+ capacity_policy.update!(exclusion_rules: {
+ 'excluded_labels' => [label1.title, label2.title]
+ })
+
+ assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
+
+ # Both conversations should be excluded
+ expect(assigned_count).to eq(0)
+ expect(conversation_with_label.reload.assignee).to be_nil
+ expect(conversation_without_label.reload.assignee).to be_nil
+ end
+ end
+
+ context 'when excluding conversations by age' do
+ let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) }
+ let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) }
+
+ before do
+ capacity_policy.update!(exclusion_rules: {
+ 'exclude_older_than_hours' => 24
+ })
+ end
+
+ it 'excludes conversations older than specified hours' do
+ assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
+
+ # Only recent conversation should be assigned
+ expect(assigned_count).to eq(1)
+ expect(old_conversation.reload.assignee).to be_nil
+ expect(recent_conversation.reload.assignee).to be_present
+ end
+
+ it 'handles different time thresholds' do
+ capacity_policy.update!(exclusion_rules: {
+ 'exclude_older_than_hours' => 2
+ })
+
+ assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
+
+ # Only conversation created within 2 hours should be assigned
+ expect(assigned_count).to eq(1)
+ expect(recent_conversation.reload.assignee).to be_present
+ end
+ end
+
+ context 'when combining exclusion rules' do
+ it 'applies both exclusion rules' do
+ # Create conversations
+ old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
+ old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
+ recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
+ recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
+
+ # Add labels
+ old_conversation_with_label.update_labels([label1.title])
+ recent_conversation_with_label.update_labels([label1.title])
+
+ capacity_policy.update!(exclusion_rules: {
+ 'excluded_labels' => [label1.title],
+ 'exclude_older_than_hours' => 24
+ })
+
+ assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
+
+ # Only recent conversation without label should be assigned
+ expect(assigned_count).to eq(1)
+ expect(old_conversation_with_label.reload.assignee).to be_nil
+ expect(old_conversation_without_label.reload.assignee).to be_nil
+ expect(recent_conversation_with_label.reload.assignee).to be_nil
+ expect(recent_conversation_without_label.reload.assignee).to be_present
+ end
+ end
+
+ context 'when exclusion rules are empty' do
+ let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
+ let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
+
+ before do
+ capacity_policy.update!(exclusion_rules: {})
+ end
+
+ it 'assigns all eligible conversations' do
+ assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
+
+ expect(assigned_count).to eq(2)
+ expect(conversation1.reload.assignee).to be_present
+ expect(conversation2.reload.assignee).to be_present
+ end
+ end
+
+ context 'when no capacity policy exists' do
+ let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
+ let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
+
+ before do
+ InboxCapacityLimit.destroy_all
+ end
+
+ it 'assigns all eligible conversations without exclusions' do
+ assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
+
+ expect(assigned_count).to eq(2)
+ expect(conversation1.reload.assignee).to be_present
+ expect(conversation2.reload.assignee).to be_present
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb
new file mode 100644
index 000000000..a2340ab0e
--- /dev/null
+++ b/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb
@@ -0,0 +1,88 @@
+require 'rails_helper'
+
+RSpec.describe Enterprise::AutoAssignment::BalancedSelector do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:selector) { described_class.new(inbox: inbox) }
+ let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:member1) { create(:inbox_member, inbox: inbox, user: agent1) }
+ let(:member2) { create(:inbox_member, inbox: inbox, user: agent2) }
+ let(:member3) { create(:inbox_member, inbox: inbox, user: agent3) }
+
+ describe '#select_agent' do
+ context 'when selecting based on workload' do
+ let(:available_agents) { [member1, member2, member3] }
+
+ it 'selects the agent with least open conversations' do
+ # Agent1 has 3 open conversations
+ 3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'open') }
+
+ # Agent2 has 1 open conversation
+ create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
+
+ # Agent3 has 2 open conversations
+ 2.times { create(:conversation, inbox: inbox, assignee: agent3, status: 'open') }
+
+ selected_agent = selector.select_agent(available_agents)
+
+ # Should select agent2 as they have the least conversations
+ expect(selected_agent).to eq(agent2)
+ end
+
+ it 'considers only open conversations' do
+ # Agent1 has 1 open and 3 resolved conversations
+ create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
+ 3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'resolved') }
+
+ # Agent2 has 2 open conversations
+ 2.times { create(:conversation, inbox: inbox, assignee: agent2, status: 'open') }
+
+ selected_agent = selector.select_agent([member1, member2])
+
+ # Should select agent1 as they have fewer open conversations
+ expect(selected_agent).to eq(agent1)
+ end
+
+ it 'selects any agent when agents have equal workload' do
+ # All agents have same number of conversations
+ [member1, member2, member3].each do |member|
+ create(:conversation, inbox: inbox, assignee: member.user, status: 'open')
+ end
+
+ selected_agent = selector.select_agent(available_agents)
+
+ # Should select one of the agents (when equal, min_by returns the first one it finds)
+ expect([agent1, agent2, agent3]).to include(selected_agent)
+ end
+ end
+
+ context 'when no agents are available' do
+ it 'returns nil' do
+ selected_agent = selector.select_agent([])
+ expect(selected_agent).to be_nil
+ end
+ end
+
+ context 'when one agent is available' do
+ it 'returns that agent' do
+ selected_agent = selector.select_agent([member1])
+ expect(selected_agent).to eq(agent1)
+ end
+ end
+
+ context 'with new agents (no conversations)' do
+ it 'prioritizes agents with no conversations' do
+ # Agent1 and 2 have conversations
+ create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
+ create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
+
+ # Agent3 is new with no conversations
+ selected_agent = selector.select_agent([member1, member2, member3])
+
+ expect(selected_agent).to eq(agent3)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb
new file mode 100644
index 000000000..98d7c2172
--- /dev/null
+++ b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb
@@ -0,0 +1,119 @@
+require 'rails_helper'
+
+RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
+
+ # Assignment policy with rate limiting
+ let(:assignment_policy) do
+ create(:assignment_policy,
+ account: account,
+ enabled: true,
+ fair_distribution_limit: 5,
+ fair_distribution_window: 3600)
+ end
+
+ # Agent capacity policy
+ let(:agent_capacity_policy) do
+ create(:agent_capacity_policy, account: account, name: 'Limited Capacity')
+ end
+
+ # Agents with different capacity settings
+ let(:agent_with_capacity) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:agent_without_capacity) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:agent_at_capacity) { create(:user, account: account, role: :agent, availability: :online) }
+
+ before do
+ # Create inbox assignment policy
+ create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
+
+ # Set inbox capacity limit
+ create(:inbox_capacity_limit,
+ agent_capacity_policy: agent_capacity_policy,
+ inbox: inbox,
+ conversation_limit: 3)
+
+ # Assign capacity policy to specific agents
+ agent_with_capacity.account_users.find_by(account: account)
+ .update!(agent_capacity_policy: agent_capacity_policy)
+
+ agent_at_capacity.account_users.find_by(account: account)
+ .update!(agent_capacity_policy: agent_capacity_policy)
+
+ # Create inbox members
+ create(:inbox_member, inbox: inbox, user: agent_with_capacity)
+ create(:inbox_member, inbox: inbox, user: agent_without_capacity)
+ create(:inbox_member, inbox: inbox, user: agent_at_capacity)
+
+ # Mock online status
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
+ agent_with_capacity.id.to_s => 'online',
+ agent_without_capacity.id.to_s => 'online',
+ agent_at_capacity.id.to_s => 'online'
+ })
+
+ # Enable assignment_v2 feature
+ allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
+
+ # Create existing assignments for agent_at_capacity (at limit)
+ 3.times do
+ create(:conversation, inbox: inbox, assignee: agent_at_capacity, status: :open)
+ end
+ end
+
+ describe 'capacity filtering' do
+ it 'excludes agents at capacity' do
+ # Get available agents respecting capacity
+ capacity_service = described_class.new
+ online_agents = inbox.available_agents
+ filtered_agents = online_agents.select do |inbox_member|
+ capacity_service.agent_has_capacity?(inbox_member.user, inbox)
+ end
+ available_users = filtered_agents.map(&:user)
+
+ expect(available_users).to include(agent_with_capacity)
+ expect(available_users).to include(agent_without_capacity) # No capacity policy = unlimited
+ expect(available_users).not_to include(agent_at_capacity) # At capacity limit
+ end
+
+ it 'respects inbox-specific capacity limits' do
+ capacity_service = described_class.new
+
+ expect(capacity_service.agent_has_capacity?(agent_with_capacity, inbox)).to be true
+ expect(capacity_service.agent_has_capacity?(agent_without_capacity, inbox)).to be true
+ expect(capacity_service.agent_has_capacity?(agent_at_capacity, inbox)).to be false
+ end
+ end
+
+ describe 'assignment with capacity' do
+ let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
+
+ it 'assigns to agents with available capacity' do
+ # Create conversation before assignment
+ conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open)
+
+ # Mock the selector to prefer agent_at_capacity (but should skip due to capacity)
+ selector = instance_double(AutoAssignment::RoundRobinSelector)
+ allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(selector)
+ allow(selector).to receive(:select_agent) do |agents|
+ agents.map(&:user).find { |u| [agent_with_capacity, agent_without_capacity].include?(u) }
+ end
+
+ assigned_count = service.perform_bulk_assignment(limit: 1)
+ expect(assigned_count).to eq(1)
+ expect(conversation.reload.assignee).to be_in([agent_with_capacity, agent_without_capacity])
+ expect(conversation.reload.assignee).not_to eq(agent_at_capacity)
+ end
+
+ it 'returns false when all agents are at capacity' do
+ # Fill up remaining agents
+ 3.times { create(:conversation, inbox: inbox, assignee: agent_with_capacity, status: :open) }
+
+ # agent_without_capacity has no limit, so should still be available
+ conversation2 = create(:conversation, inbox: inbox, assignee: nil, status: :open)
+ assigned_count = service.perform_bulk_assignment(limit: 1)
+ expect(assigned_count).to eq(1)
+ expect(conversation2.reload.assignee).to eq(agent_without_capacity)
+ end
+ end
+end
diff --git a/spec/factories/channel/twilio_sms.rb b/spec/factories/channel/twilio_sms.rb
index 94f632efd..1963a4f28 100644
--- a/spec/factories/channel/twilio_sms.rb
+++ b/spec/factories/channel/twilio_sms.rb
@@ -13,5 +13,9 @@ FactoryBot.define do
sequence(:phone_number) { |n| "+123456789#{n}1" }
messaging_service_sid { nil }
end
+
+ trait :whatsapp do
+ medium { :whatsapp }
+ end
end
end
diff --git a/spec/factories/messages.rb b/spec/factories/messages.rb
index b2ae41c5e..99a2c7cd8 100644
--- a/spec/factories/messages.rb
+++ b/spec/factories/messages.rb
@@ -27,6 +27,13 @@ FactoryBot.define do
end
end
+ trait :bot_message do
+ message_type { 'outgoing' }
+ after(:build) do |message|
+ message.sender = nil
+ end
+ end
+
after(:build) do |message|
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)
diff --git a/spec/factories/reporting_events.rb b/spec/factories/reporting_events.rb
index 1db6a87df..aff731ba1 100644
--- a/spec/factories/reporting_events.rb
+++ b/spec/factories/reporting_events.rb
@@ -1,11 +1,13 @@
FactoryBot.define do
factory :reporting_event do
- name { 'MyString' }
+ name { 'first_response' }
value { 1.5 }
value_in_business_hours { 1 }
- account_id { 1 }
- inbox_id { 1 }
- user_id { 1 }
- conversation_id { 1 }
+ account
+ inbox { association :inbox, account: account }
+ user { association :user, account: account }
+ conversation { association :conversation, account: account, inbox: inbox }
+ event_start_time { 2.hours.ago }
+ event_end_time { 1.hour.ago }
end
end
diff --git a/spec/factories/webhooks.rb b/spec/factories/webhooks.rb
index 42acc2be6..a4a875c84 100644
--- a/spec/factories/webhooks.rb
+++ b/spec/factories/webhooks.rb
@@ -3,6 +3,7 @@ FactoryBot.define do
account_id { 1 }
inbox_id { 1 }
url { 'https://api.chatwoot.com' }
+ name { 'My Webhook' }
subscriptions do
%w[
conversation_status_changed
diff --git a/spec/jobs/auto_assignment/assignment_job_spec.rb b/spec/jobs/auto_assignment/assignment_job_spec.rb
new file mode 100644
index 000000000..d13f9fef8
--- /dev/null
+++ b/spec/jobs/auto_assignment/assignment_job_spec.rb
@@ -0,0 +1,85 @@
+require 'rails_helper'
+
+RSpec.describe AutoAssignment::AssignmentJob, type: :job do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
+ let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
+
+ before do
+ create(:inbox_member, inbox: inbox, user: agent)
+ end
+
+ describe '#perform' do
+ context 'when inbox exists' do
+ context 'when auto assignment is enabled' do
+ it 'calls the assignment service' do
+ service = instance_double(AutoAssignment::AssignmentService)
+ allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
+ expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(5)
+
+ described_class.new.perform(inbox_id: inbox.id)
+ end
+
+ it 'logs the assignment count' 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}")
+
+ described_class.new.perform(inbox_id: inbox.id)
+ end
+
+ it 'uses custom bulk limit from environment' do
+ allow(ENV).to receive(:fetch).with('AUTO_ASSIGNMENT_BULK_LIMIT', 100).and_return('50')
+
+ service = instance_double(AutoAssignment::AssignmentService)
+ allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
+ expect(service).to receive(:perform_bulk_assignment).with(limit: 50).and_return(2)
+
+ described_class.new.perform(inbox_id: inbox.id)
+ end
+ end
+
+ context 'when auto assignment is disabled' do
+ before { inbox.update!(enable_auto_assignment: false) }
+
+ it 'calls the service which handles the disabled state' do
+ service = instance_double(AutoAssignment::AssignmentService)
+ allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
+ expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(0)
+
+ described_class.new.perform(inbox_id: inbox.id)
+ end
+ end
+ end
+
+ context 'when inbox does not exist' do
+ it 'returns early without processing' do
+ expect(AutoAssignment::AssignmentService).not_to receive(:new)
+
+ described_class.new.perform(inbox_id: 999_999)
+ end
+ end
+
+ context 'when an error occurs' do
+ it 'logs the error and re-raises in test environment' 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")
+
+ expect do
+ described_class.new.perform(inbox_id: inbox.id)
+ end.to raise_error(StandardError, 'Something went wrong')
+ end
+ end
+ end
+
+ describe 'job configuration' do
+ it 'is queued in the default queue' do
+ expect(described_class.queue_name).to eq('default')
+ end
+ end
+end
diff --git a/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb b/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
new file mode 100644
index 000000000..4b06decd0
--- /dev/null
+++ b/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
@@ -0,0 +1,123 @@
+require 'rails_helper'
+
+RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
+ let(:assignment_policy) { create(:assignment_policy, account: account) }
+ let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ before do
+ create(:inbox_member, inbox: inbox, user: agent)
+ end
+
+ describe '#perform' do
+ context 'when account has assignment_v2 feature enabled' do
+ before do
+ allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
+ allow(Account).to receive(:find_in_batches).and_yield([account])
+ end
+
+ context 'when inbox has auto_assignment_v2 enabled' do
+ before do
+ allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
+ inbox_relation = instance_double(ActiveRecord::Relation)
+ allow(account).to receive(:inboxes).and_return(inbox_relation)
+ allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
+ allow(inbox_relation).to receive(:find_in_batches).and_yield([inbox])
+ end
+
+ 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)
+
+ described_class.new.perform
+ end
+
+ it 'processes multiple accounts' do
+ inbox_assignment_policy # ensure it exists
+ account2 = create(:account)
+ inbox2 = create(:inbox, account: account2, enable_auto_assignment: true)
+ policy2 = create(:assignment_policy, account: account2)
+ create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
+
+ allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
+ allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
+
+ inbox_relation2 = instance_double(ActiveRecord::Relation)
+ allow(account2).to receive(:inboxes).and_return(inbox_relation2)
+ allow(inbox_relation2).to receive(:joins).with(:assignment_policy).and_return(inbox_relation2)
+ allow(inbox_relation2).to receive(:find_in_batches).and_yield([inbox2])
+
+ 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)
+
+ described_class.new.perform
+ end
+ end
+
+ context 'when inbox does not have auto_assignment_v2 enabled' do
+ before do
+ allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false)
+ end
+
+ it 'does not queue assignment job' do
+ expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
+
+ described_class.new.perform
+ end
+ end
+ end
+
+ context 'when account does not have assignment_v2 feature enabled' do
+ before do
+ allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(false)
+ allow(Account).to receive(:find_in_batches).and_yield([account])
+ end
+
+ it 'does not process the account' do
+ expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
+
+ described_class.new.perform
+ end
+ end
+
+ context 'with batch processing' do
+ it 'processes accounts in batches' do
+ accounts = []
+ # Create multiple accounts
+ 5.times do |_i|
+ acc = create(:account)
+ inb = create(:inbox, account: acc, enable_auto_assignment: true)
+ policy = create(:assignment_policy, account: acc)
+ create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy)
+ allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
+ allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true)
+
+ inbox_relation = instance_double(ActiveRecord::Relation)
+ allow(acc).to receive(:inboxes).and_return(inbox_relation)
+ allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
+ allow(inbox_relation).to receive(:find_in_batches).and_yield([inb])
+
+ accounts << acc
+ end
+
+ allow(Account).to receive(:find_in_batches) do |&block|
+ accounts.each { |acc| block.call([acc]) }
+ end
+
+ expect(Account).to receive(:find_in_batches).and_call_original
+
+ described_class.new.perform
+ end
+ end
+ end
+
+ describe 'job configuration' do
+ it 'is queued in the scheduled_jobs queue' do
+ expect(described_class.queue_name).to eq('scheduled_jobs')
+ end
+ end
+end
diff --git a/spec/jobs/contacts/bulk_action_job_spec.rb b/spec/jobs/contacts/bulk_action_job_spec.rb
new file mode 100644
index 000000000..12a727000
--- /dev/null
+++ b/spec/jobs/contacts/bulk_action_job_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::BulkActionJob, type: :job do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:params) { { 'ids' => [1], 'labels' => { 'add' => ['vip'] } } }
+
+ it 'invokes the bulk action service with account and user' do
+ service_instance = instance_double(Contacts::BulkActionService, perform: true)
+
+ allow(Contacts::BulkActionService).to receive(:new).and_return(service_instance)
+
+ described_class.perform_now(account.id, user.id, params)
+
+ expect(Contacts::BulkActionService).to have_received(:new).with(
+ account: account,
+ user: user,
+ params: params
+ )
+ expect(service_instance).to have_received(:perform)
+ end
+end
diff --git a/spec/jobs/conversation_reply_email_job_spec.rb b/spec/jobs/conversation_reply_email_job_spec.rb
new file mode 100644
index 000000000..32758c48e
--- /dev/null
+++ b/spec/jobs/conversation_reply_email_job_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+RSpec.describe ConversationReplyEmailJob, type: :job do
+ let(:conversation) { create(:conversation) }
+ let(:mailer) { double }
+ let(:mailer_action) { double }
+
+ before do
+ allow(Conversation).to receive(:find).and_return(conversation)
+ allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
+ allow(mailer).to receive(:reply_with_summary).and_return(mailer_action)
+ allow(mailer).to receive(:reply_without_summary).and_return(mailer_action)
+ allow(mailer_action).to receive(:deliver_later).and_return(true)
+ end
+
+ it 'enqueues on mailers queue' do
+ ActiveJob::Base.queue_adapter = :test
+ expect do
+ described_class.perform_later(conversation.id, 123)
+ end.to have_enqueued_job(described_class).on_queue('mailers')
+ end
+
+ it 'calls reply_with_summary when last incoming message was not email' do
+ described_class.perform_now(conversation.id, 123)
+ expect(mailer).to have_received(:reply_with_summary)
+ end
+
+ it 'calls reply_without_summary when last incoming message was email' do
+ create(:message, conversation: conversation, message_type: :incoming, content_type: 'incoming_email')
+ described_class.perform_now(conversation.id, 123)
+ expect(mailer).to have_received(:reply_without_summary)
+ end
+end
diff --git a/spec/jobs/send_reply_job_spec.rb b/spec/jobs/send_reply_job_spec.rb
index f3c19c2f9..11d3f6b04 100644
--- a/spec/jobs/send_reply_job_spec.rb
+++ b/spec/jobs/send_reply_job_spec.rb
@@ -108,5 +108,32 @@ RSpec.describe SendReplyJob do
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
+
+ it 'calls ::Email::SendOnEmailService when its email message' do
+ email_channel = create(:channel_email)
+ message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox))
+ allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service)
+ expect(Email::SendOnEmailService).to receive(:new).with(message: message)
+ expect(process_service).to receive(:perform)
+ described_class.perform_now(message.id)
+ end
+
+ it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do
+ webwidget_channel = create(:channel_widget)
+ message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox))
+ allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
+ expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
+ expect(process_service).to receive(:perform)
+ described_class.perform_now(message.id)
+ end
+
+ it 'calls ::Messages::SendEmailNotificationService when its api channel message' do
+ api_channel = create(:channel_api)
+ message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox))
+ allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
+ expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
+ expect(process_service).to receive(:perform)
+ described_class.perform_now(message.id)
+ end
end
end
diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb
index 8ff2a21a5..224a35e07 100644
--- a/spec/lib/webhooks/trigger_spec.rb
+++ b/spec/lib/webhooks/trigger_spec.rb
@@ -1,6 +1,8 @@
require 'rails_helper'
describe Webhooks::Trigger do
+ include ActiveJob::TestHelper
+
subject(:trigger) { described_class }
let!(:account) { create(:account) }
@@ -8,8 +10,18 @@ describe Webhooks::Trigger do
let!(:conversation) { create(:conversation, inbox: inbox) }
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
- let!(:webhook_type) { :api_inbox_webhook }
+ let(:webhook_type) { :api_inbox_webhook }
let!(:url) { 'https://test.com' }
+ let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
+
+ before do
+ ActiveJob::Base.queue_adapter = :test
+ end
+
+ after do
+ clear_enqueued_jobs
+ clear_performed_jobs
+ end
describe '#execute' do
it 'triggers webhook' do
@@ -54,6 +66,57 @@ describe Webhooks::Trigger do
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
end
+
+ context 'when webhook type is agent bot' do
+ let(:webhook_type) { :agent_bot_webhook }
+
+ it 'reopens conversation and enqueues activity message if pending' do
+ conversation.update(status: :pending)
+ payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
+
+ expect(RestClient::Request).to receive(:execute)
+ .with(
+ method: :post,
+ url: url,
+ payload: payload.to_json,
+ headers: { content_type: :json, accept: :json },
+ timeout: 5
+ ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
+
+ expect do
+ perform_enqueued_jobs do
+ trigger.execute(url, payload, webhook_type)
+ end
+ end.not_to(change { message.reload.status })
+
+ expect(conversation.reload.status).to eq('open')
+
+ activity_message = conversation.reload.messages.order(:created_at).last
+ expect(activity_message.message_type).to eq('activity')
+ expect(activity_message.content).to eq(agent_bot_error_content)
+ end
+
+ it 'does not change message status or enqueue activity when conversation is not pending' do
+ payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
+
+ expect(RestClient::Request).to receive(:execute)
+ .with(
+ method: :post,
+ url: url,
+ payload: payload.to_json,
+ headers: { content_type: :json, accept: :json },
+ timeout: 5
+ ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
+
+ expect do
+ trigger.execute(url, payload, webhook_type)
+ end.not_to(change { message.reload.status })
+
+ expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
+
+ expect(conversation.reload.status).to eq('open')
+ end
+ end
end
it 'does not update message status if webhook fails for other events' do
diff --git a/spec/mailboxes/application_mailbox_spec.rb b/spec/mailboxes/application_mailbox_spec.rb
index 33bbf9de8..1bc4d0fd1 100644
--- a/spec/mailboxes/application_mailbox_spec.rb
+++ b/spec/mailboxes/application_mailbox_spec.rb
@@ -41,28 +41,31 @@ RSpec.describe ApplicationMailbox do
describe 'Support' do
let!(:channel_email) { create(:channel_email) }
- it 'routes support emails to Support Mailbox when mail is to channel email' do
+ it 'routes support emails to Reply Mailbox when mail is to channel email' do
# this email is hardcoded in the support.eml, that's why we are updating this
+ # With NewConversationStrategy, all channel emails route to ReplyMailbox
channel_email.update(email: 'care@example.com')
dbl = double
- expect(SupportMailbox).to receive(:new).and_return(dbl)
+ expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route support_mail
end
- it 'routes support emails to Support Mailbox when mail is to channel forward to email' do
+ it 'routes support emails to Reply Mailbox when mail is to channel forward to email' do
# this email is hardcoded in the support.eml, that's why we are updating this
+ # With NewConversationStrategy, all channel emails route to ReplyMailbox
channel_email.update(forward_to_email: 'care@example.com')
dbl = double
- expect(SupportMailbox).to receive(:new).and_return(dbl)
+ expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route support_mail
end
- it 'routes support emails to Support Mailbox with cc email' do
+ it 'routes support emails to Reply Mailbox with cc email' do
+ # With NewConversationStrategy, all channel emails route to ReplyMailbox
channel_email.update(email: 'test@example.com')
dbl = double
- expect(SupportMailbox).to receive(:new).and_return(dbl)
+ expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route reply_cc_mail
end
diff --git a/spec/mailboxes/imap/imap_mailbox_spec.rb b/spec/mailboxes/imap/imap_mailbox_spec.rb
index 4535899cf..309a38a65 100644
--- a/spec/mailboxes/imap/imap_mailbox_spec.rb
+++ b/spec/mailboxes/imap/imap_mailbox_spec.rb
@@ -264,5 +264,54 @@ RSpec.describe Imap::ImapMailbox do
expect(conversation.additional_attributes['in_reply_to']).to eq(multiple_in_reply_to_mail.in_reply_to.first)
end
end
+
+ context 'when a reply to a conversation started by an agent' do
+ let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
+ let(:reply_mail_with_fallback_reference) do
+ # Simulate an email reply with a reference that matches FALLBACK_PATTERN
+ reference_id = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
+ create_inbound_email_from_mail(
+ from: 'email@gmail.com',
+ to: 'imap@gmail.com',
+ subject: 'Re: Agent started conversation',
+ references: [reference_id]
+ )
+ end
+
+ it 'appends email to the existing conversation using FALLBACK_PATTERN' do
+ expect(agent_conversation.messages.size).to eq(0)
+
+ class_instance.process(reply_mail_with_fallback_reference.mail, channel)
+
+ agent_conversation.reload
+ expect(agent_conversation.messages.size).to eq(1)
+ expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_fallback_reference.mail.from)
+ end
+ end
+
+ context 'when references contain both message and fallback patterns' do
+ let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
+ let(:reply_mail_with_multiple_references) do
+ # Multiple references including both patterns
+ fallback_reference = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
+ other_reference = 'some-other-message-id@example.com'
+ create_inbound_email_from_mail(
+ from: 'email@gmail.com',
+ to: 'imap@gmail.com',
+ subject: 'Re: Multiple references',
+ references: [other_reference, fallback_reference]
+ )
+ end
+
+ it 'finds conversation using fallback pattern when message lookup fails' do
+ expect(agent_conversation.messages.size).to eq(0)
+
+ class_instance.process(reply_mail_with_multiple_references.mail, channel)
+
+ agent_conversation.reload
+ expect(agent_conversation.messages.size).to eq(1)
+ expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_multiple_references.mail.from)
+ end
+ end
end
end
diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb
index 167777218..2658731bc 100644
--- a/spec/mailboxes/reply_mailbox_spec.rb
+++ b/spec/mailboxes/reply_mailbox_spec.rb
@@ -248,5 +248,448 @@ RSpec.describe ReplyMailbox do
)
end
end
+
+ context 'with references header' do
+ let(:reply_mail_with_references) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+ let(:conversation_1) do
+ create(
+ :conversation,
+ assignee: agent,
+ inbox: email_channel.inbox,
+ account: account,
+ additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" }
+ )
+ end
+
+ before do
+ conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489')
+ end
+
+ context 'with message-specific pattern in references' do
+ before do
+ reply_mail_with_references.mail['References'] = ''
+ end
+
+ it 'finds conversation from references header with message pattern' do
+ described_class.receive reply_mail_with_references
+ expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
+ end
+ end
+
+ context 'with conversation fallback pattern in references' do
+ before do
+ reply_mail_with_references.mail['References'] = ""
+ end
+
+ it 'finds conversation from references header with fallback pattern' do
+ described_class.receive reply_mail_with_references
+ expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
+ end
+ end
+
+ context 'with multiple references including conversation pattern' do
+ before do
+ reply_mail_with_references.mail['References'] = [
+ '',
+ "",
+ ''
+ ].join("\r\n ")
+ end
+
+ it 'finds conversation from any reference in the chain' do
+ described_class.receive reply_mail_with_references
+ expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
+ end
+ end
+
+ context 'with message source_id in references' do
+ before do
+ conversation_1.messages.create!(
+ source_id: 'original-message-id@test.com',
+ account_id: account.id,
+ message_type: 'outgoing',
+ inbox_id: email_channel.inbox.id,
+ content: 'Original message'
+ )
+ reply_mail_with_references.mail['References'] = ''
+ end
+
+ it 'finds conversation from message source_id in references' do
+ described_class.receive reply_mail_with_references
+ expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
+ end
+ end
+
+ context 'with conversation from different channel in references' do
+ let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
+ let(:other_conversation) do
+ create(
+ :conversation,
+ assignee: agent,
+ inbox: other_email_channel.inbox,
+ account: account
+ )
+ end
+
+ before do
+ other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
+ reply_mail_with_references.mail['References'] = ''
+ end
+
+ it 'does not use conversation from different channel' do
+ described_class.receive reply_mail_with_references
+ expect(other_conversation.messages.count).to eq(0)
+ end
+ end
+ end
+ end
+
+ describe 'when a chatwoot notification email is received' do
+ let(:account) { create(:account) }
+ let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
+ let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
+ let(:described_subject) { described_class.receive notification_mail }
+ let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
+
+ it 'shouldnt create a conversation in the channel' do
+ described_subject
+ expect(conversation.present?).to be(false)
+ end
+ end
+
+ describe 'when bounced email with out a sender is recieved' do
+ let(:account) { create(:account) }
+ let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
+ let(:described_subject) { described_class.receive bounced_email }
+
+ it 'shouldnt throw an error' do
+ create(:channel_email, email: 'support@example.com', account: account)
+ expect { described_subject }.not_to raise_error
+ end
+ end
+
+ describe 'when an account is suspended' do
+ let(:account) { create(:account, status: :suspended) }
+ let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
+ let!(:channel_email) { create(:channel_email, account: account) }
+ let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
+ let(:described_subject) { described_class.receive support_mail }
+ let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
+
+ before do
+ # this email is hardcoded in the support.eml, that's why we are updating this
+ channel_email.email = 'care@example.com'
+ channel_email.save!
+ end
+
+ it 'shouldnt create a conversation in the channel' do
+ described_subject
+ expect(conversation.present?).to be(false)
+ end
+ end
+
+ describe 'add mail as a new ticket in the email inbox' do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
+ let!(:channel_email) { create(:channel_email, account: account) }
+ let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
+ let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
+ let(:described_subject) { described_class.receive support_mail }
+ let(:serialized_attributes) do
+ %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
+ text_content to auto_reply]
+ end
+ let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
+
+ before do
+ # this email is hardcoded in the support.eml, that's why we are updating this
+ channel_email.email = 'care@example.com'
+ channel_email.save!
+ end
+
+ describe 'covers email address format' do
+ before do
+ described_class.receive support_in_reply_to_mail
+ end
+
+ it 'creates contact with proper email address' do
+ expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew ')
+ expect(conversation.contact.email).to eq('sony@chatwoot.com')
+ end
+ end
+
+ describe 'covers basic ticket creation' do
+ before do
+ described_subject
+ end
+
+ it 'create the conversation in the inbox of the email channel' do
+ expect(conversation.inbox.id).to eq(channel_email.inbox.id)
+ expect(conversation.additional_attributes['source']).to eq('email')
+ expect(conversation.contact.email).to eq(support_mail.mail.from.first)
+ end
+
+ it 'create a new contact as the sender of the email' do
+ email_sender = Mail::Address.new(support_mail.mail[:from].value).name
+ expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
+ expect(conversation.contact.name).to eq(email_sender)
+ end
+
+ it 'add the mail content as new message on the conversation' do
+ expect(conversation.messages.last.content).to eq("Let's talk about these images:")
+ end
+
+ it 'add the attachments' do
+ expect(conversation.messages.last.attachments.count).to eq(2)
+ end
+
+ it 'have proper content_attributes with details of email' do
+ expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
+ end
+
+ it 'set proper content_type' do
+ expect(conversation.messages.last.content_type).to eq('incoming_email')
+ end
+ end
+
+ describe 'email with references header' do
+ let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
+ let(:described_subject) { described_class.receive mail_with_references }
+
+ before do
+ # reuse the existing channel_email that's already set to 'care@example.com'
+ described_subject
+ end
+
+ it 'includes references in the message content_attributes' do
+ message = conversation.messages.last
+ email_attributes = message.content_attributes['email']
+
+ expect(email_attributes['references']).to be_present
+ expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
+ end
+
+ it 'includes references in serialized email attributes' do
+ message = conversation.messages.last
+ expect(message.content_attributes['email'].keys).to include('references')
+ end
+ end
+
+ describe 'Sender without name' do
+ let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
+ let(:described_subject) { described_class.receive support_mail_without_sender_name }
+
+ it 'create a new contact with the email' do
+ described_subject
+ email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
+ expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
+ expect(conversation.contact.name).to eq(email_sender)
+ end
+ end
+
+ describe 'Sender with upcase mail address' do
+ let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
+ let(:described_subject) { described_class.receive support_mail_without_sender_name }
+
+ it 'create a new inbox with the email case insensitive' do
+ described_subject
+ expect(conversation.inbox.id).to eq(channel_email.inbox.id)
+ end
+ end
+
+ describe 'handle inbox contacts' do
+ let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
+ let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
+
+ it 'does not create new contact if that contact exists in the inbox' do
+ expect do
+ described_subject
+ end
+ .to(not_change { Contact.count }
+ .and(not_change { ContactInbox.count }))
+
+ expect(conversation.messages.last.sender.id).to eq(contact.id)
+ expect(conversation.contact_inbox).to eq(contact_inbox)
+ end
+
+ context 'with uppercase reply-to' do
+ let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
+ let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
+ let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
+
+ it 'does not create new contact if that contact exists in the inbox' do
+ expect do
+ described_subject
+ end
+ .to(not_change { Contact.count }
+ .and(not_change { ContactInbox.count }))
+
+ expect(conversation.messages.last.sender.id).to eq(contact.id)
+ expect(conversation.contact_inbox).to eq(contact_inbox)
+ end
+ end
+ end
+
+ describe 'group email sender' do
+ let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
+ let(:described_subject) { described_class.receive group_sender_support_mail }
+
+ before do
+ # this email is hardcoded eml fixture file that's why we are updating this
+ channel_email.email = 'support@chatwoot.com'
+ channel_email.save!
+ end
+
+ it 'create new contact with original sender' do
+ described_subject
+ email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
+
+ expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
+ expect(conversation.contact.name).to eq(email_sender)
+ end
+ end
+
+ describe 'when mail has in reply to email' do
+ let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
+ let(:described_subject) { described_class.receive reply_mail_without_uuid }
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+
+ before do
+ email_channel
+ reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
+ end
+
+ it 'create channel with reply to mail' do
+ described_subject
+ conversation_1 = Conversation.last
+
+ expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
+ expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
+ end
+
+ it 'append message to email conversation with same in reply to' do
+ described_subject
+ conversation_1 = Conversation.last
+
+ expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
+ expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
+ expect(conversation_1.messages.count).to eq(1)
+
+ reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
+ reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
+
+ described_class.receive reply_mail_without_uuid
+
+ expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
+ expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
+ expect(conversation_1.messages.count).to eq(2)
+ end
+ end
+
+ describe 'Sender with reply_to email address' do
+ let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+
+ it 'prefer reply-to over from address' do
+ email_channel
+ described_class.receive reply_to_mail
+
+ conversation_1 = Conversation.last
+ email = conversation_1.messages.last.content_attributes['email']
+
+ expect(reply_to_mail.mail['From'].value).to be_present
+ expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
+ expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
+ expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
+ expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
+ end
+ end
+
+ describe 'when mail part is not present' do
+ let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
+ let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
+ let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
+ let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
+ let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
+ let(:described_subject) { described_class.receive support_mail }
+
+ it 'Considers raw html mail body' do
+ described_subject
+ expect(conversation.inbox.id).to eq(channel_email.inbox.id)
+
+ expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
+ <<~BODY.chomp
+ Hi,
+
+ We are providing you platform from here you can sell paid posts on your website.
+
+ Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
+
+ Skype: live:.cid.something
+
+ []
+ BODY
+ )
+ expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
+ end
+
+ it 'Considers only text body' do
+ described_class.receive only_text
+
+ expect(conversation.inbox.id).to eq(channel_email.inbox.id)
+
+ expect(conversation.messages.last.content).to eq('text only mail')
+ expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
+ end
+
+ it 'Considers only html body' do
+ described_class.receive only_html
+
+ expect(conversation.inbox.id).to eq(channel_email.inbox.id)
+
+ expect(conversation.messages.last.content).to eq(
+ <<~BODY.chomp
+ This is html only mail
+ BODY
+ )
+ expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
+ end
+
+ it 'Considers only attachments' do
+ described_class.receive only_attachments
+
+ expect(conversation.inbox.id).to eq(channel_email.inbox.id)
+
+ expect(conversation.messages.last.content).to be_nil
+ expect(conversation.messages.last.attachments.count).to eq(1)
+ expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
+ end
+
+ it 'Considers html and attachments' do
+ described_class.receive html_and_attachments
+
+ expect(conversation.inbox.id).to eq(channel_email.inbox.id)
+
+ expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
+ expect(conversation.messages.last.attachments.count).to eq(1)
+ expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
+ end
+ end
+
+ describe 'when BCC processing is disabled for account' do
+ before do
+ allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
+ end
+
+ it 'does not process BCC-only emails' do
+ bcc_mail = create_inbound_email_from_fixture('support.eml')
+ bcc_mail.mail['to'] = nil
+ bcc_mail.mail['bcc'] = 'care@example.com'
+
+ described_class.receive bcc_mail
+ expect(conversation.present?).to be(false)
+ end
+ end
end
end
diff --git a/spec/mailboxes/support_mailbox_spec.rb b/spec/mailboxes/support_mailbox_spec.rb
deleted file mode 100644
index 0dbfbbe3b..000000000
--- a/spec/mailboxes/support_mailbox_spec.rb
+++ /dev/null
@@ -1,352 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe SupportMailbox do
- include ActionMailbox::TestHelper
-
- describe 'when a chatwoot notification email is received' do
- let(:account) { create(:account) }
- let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
- let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
- let(:described_subject) { described_class.receive notification_mail }
- let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
-
- it 'shouldnt create a conversation in the channel' do
- described_subject
- expect(conversation.present?).to be(false)
- end
- end
-
- describe 'when bounced email with out a sender is recieved' do
- let(:account) { create(:account) }
- let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
- let(:described_subject) { described_class.receive bounced_email }
-
- it 'shouldnt throw an error' do
- create(:channel_email, email: 'support@example.com', account: account)
- expect { described_subject }.not_to raise_error
- end
- end
-
- describe 'when an account is suspended' do
- let(:account) { create(:account, status: :suspended) }
- let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
- let!(:channel_email) { create(:channel_email, account: account) }
- let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
- let(:described_subject) { described_class.receive support_mail }
- let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
-
- before do
- # this email is hardcoded in the support.eml, that's why we are updating this
- channel_email.email = 'care@example.com'
- channel_email.save!
- end
-
- it 'shouldnt create a conversation in the channel' do
- described_subject
- expect(conversation.present?).to be(false)
- end
- end
-
- describe 'add mail as a new ticket in the email inbox' do
- let(:account) { create(:account) }
- let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
- let!(:channel_email) { create(:channel_email, account: account) }
- let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
- let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
- let(:described_subject) { described_class.receive support_mail }
- let(:serialized_attributes) do
- %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
- text_content to auto_reply]
- end
- let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
-
- before do
- # this email is hardcoded in the support.eml, that's why we are updating this
- channel_email.email = 'care@example.com'
- channel_email.save!
- end
-
- describe 'covers email address format' do
- before do
- described_class.receive support_in_reply_to_mail
- end
-
- it 'creates contact with proper email address' do
- expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew ')
- expect(conversation.contact.email).to eq('sony@chatwoot.com')
- end
- end
-
- describe 'covers basic ticket creation' do
- before do
- described_subject
- end
-
- it 'create the conversation in the inbox of the email channel' do
- expect(conversation.inbox.id).to eq(channel_email.inbox.id)
- expect(conversation.additional_attributes['source']).to eq('email')
- expect(conversation.contact.email).to eq(support_mail.mail.from.first)
- end
-
- it 'create a new contact as the sender of the email' do
- email_sender = Mail::Address.new(support_mail.mail[:from].value).name
- expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
- expect(conversation.contact.name).to eq(email_sender)
- end
-
- it 'add the mail content as new message on the conversation' do
- expect(conversation.messages.last.content).to eq("Let's talk about these images:")
- end
-
- it 'add the attachments' do
- expect(conversation.messages.last.attachments.count).to eq(2)
- end
-
- it 'have proper content_attributes with details of email' do
- expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
- end
-
- it 'set proper content_type' do
- expect(conversation.messages.last.content_type).to eq('incoming_email')
- end
- end
-
- describe 'email with references header' do
- let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
- let(:described_subject) { described_class.receive mail_with_references }
-
- before do
- # reuse the existing channel_email that's already set to 'care@example.com'
- described_subject
- end
-
- it 'includes references in the message content_attributes' do
- message = conversation.messages.last
- email_attributes = message.content_attributes['email']
-
- expect(email_attributes['references']).to be_present
- expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
- end
-
- it 'includes references in serialized email attributes' do
- message = conversation.messages.last
- expect(message.content_attributes['email'].keys).to include('references')
- end
- end
-
- describe 'Sender without name' do
- let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
- let(:described_subject) { described_class.receive support_mail_without_sender_name }
-
- it 'create a new contact with the email' do
- described_subject
- email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
- expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
- expect(conversation.contact.name).to eq(email_sender)
- end
- end
-
- describe 'Sender with upcase mail address' do
- let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
- let(:described_subject) { described_class.receive support_mail_without_sender_name }
-
- it 'create a new inbox with the email case insensitive' do
- described_subject
- expect(conversation.inbox.id).to eq(channel_email.inbox.id)
- end
- end
-
- describe 'handle inbox contacts' do
- let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
- let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
-
- it 'does not create new contact if that contact exists in the inbox' do
- expect do
- described_subject
- end
- .to(not_change { Contact.count }
- .and(not_change { ContactInbox.count }))
-
- expect(conversation.messages.last.sender.id).to eq(contact.id)
- expect(conversation.contact_inbox).to eq(contact_inbox)
- end
-
- context 'with uppercase reply-to' do
- let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
- let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
- let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
-
- it 'does not create new contact if that contact exists in the inbox' do
- expect do
- described_subject
- end
- .to(not_change { Contact.count }
- .and(not_change { ContactInbox.count }))
-
- expect(conversation.messages.last.sender.id).to eq(contact.id)
- expect(conversation.contact_inbox).to eq(contact_inbox)
- end
- end
- end
-
- describe 'group email sender' do
- let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
- let(:described_subject) { described_class.receive group_sender_support_mail }
-
- before do
- # this email is hardcoded eml fixture file that's why we are updating this
- channel_email.email = 'support@chatwoot.com'
- channel_email.save!
- end
-
- it 'create new contact with original sender' do
- described_subject
- email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
-
- expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
- expect(conversation.contact.name).to eq(email_sender)
- end
- end
-
- describe 'when mail has in reply to email' do
- let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
- let(:described_subject) { described_class.receive reply_mail_without_uuid }
- let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
-
- before do
- email_channel
- reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
- end
-
- it 'create channel with reply to mail' do
- described_subject
- conversation_1 = Conversation.last
-
- expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
- expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
- end
-
- it 'append message to email conversation with same in reply to' do
- described_subject
- conversation_1 = Conversation.last
-
- expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
- expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
- expect(conversation_1.messages.count).to eq(1)
-
- reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
- reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
-
- described_class.receive reply_mail_without_uuid
-
- expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
- expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
- expect(conversation_1.messages.count).to eq(2)
- end
- end
-
- describe 'Sender with reply_to email address' do
- let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
- let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
-
- it 'prefer reply-to over from address' do
- email_channel
- described_class.receive reply_to_mail
-
- conversation_1 = Conversation.last
- email = conversation_1.messages.last.content_attributes['email']
-
- expect(reply_to_mail.mail['From'].value).to be_present
- expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
- expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
- expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
- expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
- end
- end
-
- describe 'when mail part is not present' do
- let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
- let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
- let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
- let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
- let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
- let(:described_subject) { described_class.receive support_mail }
-
- it 'Considers raw html mail body' do
- described_subject
- expect(conversation.inbox.id).to eq(channel_email.inbox.id)
-
- expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
- <<~BODY.chomp
- Hi,
-
- We are providing you platform from here you can sell paid posts on your website.
-
- Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
-
- Skype: live:.cid.something
-
- []
- BODY
- )
- expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
- end
-
- it 'Considers only text body' do
- described_class.receive only_text
-
- expect(conversation.inbox.id).to eq(channel_email.inbox.id)
-
- expect(conversation.messages.last.content).to eq('text only mail')
- expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
- end
-
- it 'Considers only html body' do
- described_class.receive only_html
-
- expect(conversation.inbox.id).to eq(channel_email.inbox.id)
-
- expect(conversation.messages.last.content).to eq(
- <<~BODY.chomp
- This is html only mail
- BODY
- )
- expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
- end
-
- it 'Considers only attachments' do
- described_class.receive only_attachments
-
- expect(conversation.inbox.id).to eq(channel_email.inbox.id)
-
- expect(conversation.messages.last.content).to be_nil
- expect(conversation.messages.last.attachments.count).to eq(1)
- expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
- end
-
- it 'Considers html and attachments' do
- described_class.receive html_and_attachments
-
- expect(conversation.inbox.id).to eq(channel_email.inbox.id)
-
- expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
- expect(conversation.messages.last.attachments.count).to eq(1)
- expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
- end
- end
-
- describe 'when BCC processing is disabled for account' do
- before do
- allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
- end
-
- it 'does not process BCC-only emails' do
- bcc_mail = create_inbound_email_from_fixture('support.eml')
- bcc_mail.mail['to'] = nil
- bcc_mail.mail['bcc'] = 'care@example.com'
-
- expect { described_class.receive bcc_mail }.to raise_error('Email channel/inbox not found')
- end
- end
- end
-end
diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb
index ecd97333e..2576361fa 100644
--- a/spec/mailers/conversation_reply_mailer_spec.rb
+++ b/spec/mailers/conversation_reply_mailer_spec.rb
@@ -243,8 +243,8 @@ RSpec.describe ConversationReplyMailer do
expect(mail.decoded).to include message.content
end
- it 'updates the source_id' do
- expect(mail.message_id).to eq message.source_id
+ it 'builds messageID properly' do
+ expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
end
context 'when message is a CSAT survey' do
@@ -335,6 +335,118 @@ RSpec.describe ConversationReplyMailer do
expect(mail.body.encoded).not_to match(%r{]*>avatar\.png})
end
end
+
+ context 'with custom email content' do
+ it 'uses custom HTML content when available and creates multipart email' do
+ message_with_custom_content = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular message content',
+ content_attributes: {
+ email: {
+ html_content: {
+ reply: 'Custom HTML content for email '
+ },
+ text_content: {
+ reply: 'Custom text content for email'
+ }
+ }
+ })
+
+ mail = described_class.email_reply(message_with_custom_content).deliver_now
+
+ # Check HTML part contains custom HTML content
+ html_part = mail.html_part || mail
+ expect(html_part.body.encoded).to include('Custom HTML content for email ')
+ expect(html_part.body.encoded).not_to include('Regular message content')
+
+ # Check text part contains custom text content
+ text_part = mail.text_part
+ if text_part
+ expect(text_part.body.encoded).to include('Custom text content for email')
+ expect(text_part.body.encoded).not_to include('Regular message content')
+ end
+ end
+
+ it 'falls back to markdown rendering when custom HTML content is not available' do
+ message_without_custom_content = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular **markdown** content')
+
+ mail = described_class.email_reply(message_without_custom_content).deliver_now
+
+ html_part = mail.html_part || mail
+ expect(html_part.body.encoded).to include('markdown')
+ expect(html_part.body.encoded).to include('Regular')
+ end
+
+ it 'handles empty custom HTML content gracefully' do
+ message_with_empty_content = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular **markdown** content',
+ content_attributes: {
+ email: {
+ html_content: {
+ reply: ''
+ }
+ }
+ })
+
+ mail = described_class.email_reply(message_with_empty_content).deliver_now
+
+ html_part = mail.html_part || mail
+ expect(html_part.body.encoded).to include('markdown')
+ expect(html_part.body.encoded).to include('Regular')
+ end
+
+ it 'handles nil custom HTML content gracefully' do
+ message_with_nil_content = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular **markdown** content',
+ content_attributes: {
+ email: {
+ html_content: {
+ reply: nil
+ }
+ }
+ })
+
+ mail = described_class.email_reply(message_with_nil_content).deliver_now
+
+ expect(mail.body.encoded).to include('markdown')
+ expect(mail.body.encoded).to include('Regular')
+ end
+
+ it 'uses custom text content in text part when only text is provided' do
+ message_with_text_only = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular message content',
+ content_attributes: {
+ email: {
+ text_content: {
+ reply: 'Custom text content only'
+ }
+ }
+ })
+
+ mail = described_class.email_reply(message_with_text_only).deliver_now
+
+ text_part = mail.text_part
+ if text_part
+ expect(text_part.body.encoded).to include('Custom text content only')
+ expect(text_part.body.encoded).not_to include('Regular message content')
+ end
+ end
+ end
end
context 'when smtp enabled for email channel' do
diff --git a/spec/models/application_record_external_credentials_encryption_spec.rb b/spec/models/application_record_external_credentials_encryption_spec.rb
new file mode 100644
index 000000000..65c347434
--- /dev/null
+++ b/spec/models/application_record_external_credentials_encryption_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe ApplicationRecord do
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_email,
+ attribute: :smtp_password,
+ value: 'smtp-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_email,
+ attribute: :imap_password,
+ value: 'imap-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_twilio_sms,
+ attribute: :auth_token,
+ value: 'twilio-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :integrations_hook,
+ attribute: :access_token,
+ value: 'hook-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_facebook_page,
+ attribute: :page_access_token,
+ value: 'fb-page-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_facebook_page,
+ attribute: :user_access_token,
+ value: 'fb-user-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_instagram,
+ attribute: :access_token,
+ value: 'ig-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_line,
+ attribute: :line_channel_secret,
+ value: 'line-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_line,
+ attribute: :line_channel_token,
+ value: 'line-token-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_telegram,
+ attribute: :bot_token,
+ value: 'telegram-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_twitter_profile,
+ attribute: :twitter_access_token,
+ value: 'twitter-access-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_twitter_profile,
+ attribute: :twitter_access_token_secret,
+ value: 'twitter-secret-secret'
+
+ context 'when backfilling legacy plaintext' do
+ before do
+ skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
+ end
+
+ it 'reads existing plaintext and encrypts on update' do
+ account = create(:account)
+ channel = create(:channel_email, account: account, smtp_password: nil)
+
+ # Simulate legacy plaintext by updating the DB directly
+ sql = ActiveRecord::Base.send(
+ :sanitize_sql_array,
+ ['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id]
+ )
+ ActiveRecord::Base.connection.execute(sql)
+
+ legacy_record = Channel::Email.find(channel.id)
+ expect(legacy_record.smtp_password).to eq('legacy-plain')
+
+ legacy_record.update!(smtp_password: 'encrypted-now')
+
+ stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password)
+ expect(stored_value).to be_present
+ expect(stored_value).not_to include('encrypted-now')
+ expect(legacy_record.smtp_password).to eq('encrypted-now')
+ end
+ end
+
+ context 'when looking up telegram legacy records' do
+ before do
+ skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
+ end
+
+ it 'finds plaintext records via fallback lookup' do
+ channel = create(:channel_telegram, bot_token: 'legacy-token')
+
+ # Simulate legacy plaintext by updating the DB directly
+ sql = ActiveRecord::Base.send(
+ :sanitize_sql_array,
+ ['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id]
+ )
+ ActiveRecord::Base.connection.execute(sql)
+
+ found = Channel::Telegram.find_by(bot_token: 'legacy-token')
+ expect(found).to eq(channel)
+ end
+ end
+end
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 72962ba54..51bb43384 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -576,9 +576,38 @@ RSpec.describe Conversation do
expect(conversation.status).to eq('pending')
end
- it 'returns conversation as open if campaign is present' do
- conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: create(:campaign))
- expect(conversation.status).to eq('open')
+ context 'with campaigns' do
+ let(:user) { create(:user, account: bot_inbox.inbox.account) }
+
+ it 'returns conversation as open if campaign has a sender' do
+ campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user)
+ conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
+ expect(conversation.status).to eq('open')
+ end
+
+ it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do
+ campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil)
+ conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
+ expect(conversation.status).to eq('pending')
+ end
+ end
+
+ context 'with campaigns in inbox without bot' do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:user) { create(:user, account: account) }
+
+ it 'returns conversation as open if campaign has no sender but no bot is active' do
+ campaign = create(:campaign, inbox: inbox, account: account, sender: nil)
+ conversation = create(:conversation, inbox: inbox, campaign: campaign)
+ expect(conversation.status).to eq('open')
+ end
+
+ it 'returns conversation as open if campaign has a sender' do
+ campaign = create(:campaign, inbox: inbox, account: account, sender: user)
+ conversation = create(:conversation, inbox: inbox, campaign: campaign)
+ expect(conversation.status).to eq('open')
+ end
end
end
diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb
index c5e080677..9cc43d0fd 100644
--- a/spec/models/message_spec.rb
+++ b/spec/models/message_spec.rb
@@ -316,43 +316,52 @@ RSpec.describe Message do
end
context 'with conversation continuity' do
- it 'calls notify email method on after save for outgoing messages in website channel' do
- allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
- message.message_type = 'outgoing'
- message.save!
- expect(ConversationReplyEmailWorker).to have_received(:perform_in)
+ let(:inbox_with_continuity) do
+ create(:inbox, account: message.account,
+ channel: build(:channel_widget, account: message.account, continuity_via_email: true))
end
- it 'does not call notify email for website channel if continuity is disabled' do
- message.inbox = create(:inbox, account: message.account,
- channel: build(:channel_widget, account: message.account, continuity_via_email: false))
- allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
+ it 'schedules email notification for outgoing messages in website channel' do
+ message.inbox = inbox_with_continuity
+ message.conversation.update!(inbox: inbox_with_continuity)
+ message.conversation.contact.update!(email: 'test@example.com')
message.message_type = 'outgoing'
- message.save!
- expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
+
+ ActiveJob::Base.queue_adapter = :test
+ allow(Redis::Alfred).to receive(:set).and_return(true)
+ perform_enqueued_jobs(only: SendReplyJob) do
+ expect { message.save! }.to have_enqueued_job(ConversationReplyEmailJob).with(message.conversation.id, kind_of(Integer)).on_queue('mailers')
+ end
end
- it 'wont call notify email method for private notes' do
+ it 'does not schedule email for website channel if continuity is disabled' do
+ inbox_without_continuity = create(:inbox, account: message.account,
+ channel: build(:channel_widget, account: message.account, continuity_via_email: false))
+ message.inbox = inbox_without_continuity
+ message.conversation.update!(inbox: inbox_without_continuity)
+ message.conversation.contact.update!(email: 'test@example.com')
+ message.message_type = 'outgoing'
+
+ ActiveJob::Base.queue_adapter = :test
+ expect { message.save! }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+
+ it 'does not schedule email for private notes' do
+ message.inbox = inbox_with_continuity
+ message.conversation.update!(inbox: inbox_with_continuity)
+ message.conversation.contact.update!(email: 'test@example.com')
message.private = true
- allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
- message.save!
- expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
- end
-
- it 'calls EmailReply worker if the channel is email' do
- message.inbox = create(:inbox, account: message.account, channel: build(:channel_email, account: message.account))
- allow(EmailReplyWorker).to receive(:perform_in).and_return(true)
message.message_type = 'outgoing'
- message.content_attributes = { email: { text_content: { quoted: 'quoted text' } } }
- message.save!
- expect(EmailReplyWorker).to have_received(:perform_in).with(1.second, message.id)
+
+ ActiveJob::Base.queue_adapter = :test
+ expect { message.save! }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
- it 'wont call notify email method unless its website or email channel' do
- message.inbox = create(:inbox, account: message.account, channel: build(:channel_api, account: message.account))
- allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
+ it 'calls SendReplyJob for all channels' do
+ allow(SendReplyJob).to receive(:perform_later).and_return(true)
+ message.message_type = 'outgoing'
message.save!
- expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
+ expect(SendReplyJob).to have_received(:perform_later).with(message.id)
end
end
end
diff --git a/spec/policies/conversation_policy_spec.rb b/spec/policies/conversation_policy_spec.rb
index ecc3134fc..d75a6bc3a 100644
--- a/spec/policies/conversation_policy_spec.rb
+++ b/spec/policies/conversation_policy_spec.rb
@@ -4,11 +4,12 @@ RSpec.describe ConversationPolicy, type: :policy do
subject { described_class }
let(:account) { create(:account) }
- let(:conversation) { create(:conversation, account: account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
- let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.first } }
- let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.first } }
+ let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.find_by(account: account) } }
+ let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.find_by(account: account) } }
+
+ let(:conversation) { create(:conversation, account: account) }
permissions :destroy? do
context 'when user is an administrator' do
@@ -31,4 +32,42 @@ RSpec.describe ConversationPolicy, type: :policy do
end
end
end
+
+ permissions :show? do
+ context 'when user is an administrator' do
+ it 'allows access' do
+ expect(subject).to permit(administrator_context, conversation)
+ end
+ end
+
+ context 'when agent has inbox access' do
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ it 'allows access' do
+ expect(subject).to permit(agent_context, conversation)
+ end
+ end
+
+ context 'when agent has team access' do
+ let(:team) { create(:team, account: account) }
+ let(:conversation) { create(:conversation, :with_team, account: account, team: team) }
+
+ before { create(:team_member, team: team, user: agent) }
+
+ it 'allows access' do
+ expect(subject).to permit(agent_context, conversation)
+ end
+ end
+
+ context 'when agent lacks inbox and team access' do
+ let(:conversation) { create(:conversation, account: account) }
+
+ it 'denies access' do
+ expect(subject).not_to permit(agent_context, conversation)
+ end
+ end
+ end
end
diff --git a/spec/presenters/messages/search_data_presenter_spec.rb b/spec/presenters/messages/search_data_presenter_spec.rb
new file mode 100644
index 000000000..0bbe1a812
--- /dev/null
+++ b/spec/presenters/messages/search_data_presenter_spec.rb
@@ -0,0 +1,78 @@
+require 'rails_helper'
+
+RSpec.describe Messages::SearchDataPresenter do
+ let(:presenter) { described_class.new(message) }
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let(:message) { create(:message, account: account, inbox: inbox, conversation: conversation, sender: contact) }
+
+ describe '#search_data' do
+ let(:expected_data) do
+ {
+ content: message.content,
+ account_id: message.account_id,
+ inbox_id: message.inbox_id,
+ conversation_id: message.conversation_id,
+ message_type: message.message_type,
+ private: message.private,
+ created_at: message.created_at,
+ source_id: message.source_id,
+ sender_id: message.sender_id,
+ sender_type: message.sender_type,
+ conversation: {
+ id: conversation.display_id
+ }
+ }
+ end
+
+ it 'returns search index payload with core fields' do
+ expect(presenter.search_data).to include(expected_data)
+ end
+
+ context 'with attachments' do
+ before do
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ attachment.meta = { 'transcribed_text' => 'Hello world' }
+ end
+
+ it 'includes attachment transcriptions' do
+ attachments_data = presenter.search_data[:attachments]
+ expect(attachments_data).to be_an(Array)
+ expect(attachments_data.first).to include(transcribed_text: 'Hello world')
+ end
+ end
+
+ context 'with email content attributes' do
+ before do
+ message.update(
+ content_attributes: { email: { subject: 'Test Subject' } }
+ )
+ end
+
+ it 'includes email subject' do
+ content_attrs = presenter.search_data[:content_attributes]
+ expect(content_attrs[:email][:subject]).to eq('Test Subject')
+ end
+ end
+
+ context 'with campaign and automation data' do
+ before do
+ message.update(
+ additional_attributes: { 'campaign_id' => '123' },
+ content_attributes: { 'automation_rule_id' => '456' }
+ )
+ end
+
+ it 'includes campaign_id' do
+ expect(presenter.search_data[:additional_attributes][:campaign_id]).to eq('123')
+ end
+
+ it 'includes automation_rule_id' do
+ expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456')
+ end
+ end
+ end
+end
diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb
new file mode 100644
index 000000000..535277714
--- /dev/null
+++ b/spec/services/auto_assignment/assignment_service_spec.rb
@@ -0,0 +1,310 @@
+require 'rails_helper'
+
+RSpec.describe AutoAssignment::AssignmentService do
+ let(:account) { create(:account) }
+ let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
+ let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
+ let(:service) { described_class.new(inbox: inbox) }
+ let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
+
+ before do
+ # Enable assignment_v2 feature for the account
+ allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
+ # Link inbox to assignment policy
+ create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
+ create(:inbox_member, inbox: inbox, user: agent)
+ end
+
+ describe '#perform_bulk_assignment' do
+ context 'when auto assignment is enabled' do
+ let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
+
+ before do
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
+
+ # Mock RoundRobinSelector to return the agent
+ round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
+ allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
+ allow(round_robin_selector).to receive(:select_agent).and_return(agent)
+
+ # Mock RateLimiter to allow all assignments by default
+ allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
+ allow(rate_limiter).to receive(:within_limit?).and_return(true)
+ allow(rate_limiter).to receive(:track_assignment)
+ end
+
+ it 'assigns conversations to available agents' do
+ # Create conversation and ensure it's unassigned
+ conv = create(:conversation, inbox: inbox, status: 'open')
+ conv.update!(assignee_id: nil)
+
+ assigned_count = service.perform_bulk_assignment(limit: 1)
+
+ expect(assigned_count).to eq(1)
+ expect(conv.reload.assignee).to eq(agent)
+ end
+
+ it 'returns 0 when no agents are online' do
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
+
+ assigned_count = service.perform_bulk_assignment(limit: 1)
+
+ expect(assigned_count).to eq(0)
+ expect(conversation.reload.assignee).to be_nil
+ end
+
+ it 'respects the limit parameter' do
+ 3.times do
+ conv = create(:conversation, inbox: inbox, status: 'open')
+ conv.update!(assignee_id: nil)
+ end
+
+ assigned_count = service.perform_bulk_assignment(limit: 2)
+
+ expect(assigned_count).to eq(2)
+ expect(inbox.conversations.unassigned.count).to eq(1)
+ end
+
+ it 'only assigns open conversations' do
+ conversation # ensure it exists
+ conversation.update!(assignee_id: nil)
+ resolved_conversation = create(:conversation, inbox: inbox, status: 'resolved')
+ resolved_conversation.update!(assignee_id: nil)
+
+ service.perform_bulk_assignment(limit: 10)
+
+ expect(conversation.reload.assignee).to eq(agent)
+ expect(resolved_conversation.reload.assignee).to be_nil
+ end
+
+ it 'does not reassign already assigned conversations' do
+ conversation # ensure it exists
+ conversation.update!(assignee_id: nil)
+ assigned_conversation = create(:conversation, inbox: inbox, assignee: agent)
+ unassigned_conversation = create(:conversation, inbox: inbox, status: 'open')
+ unassigned_conversation.update!(assignee_id: nil)
+
+ assigned_count = service.perform_bulk_assignment(limit: 10)
+
+ expect(assigned_count).to eq(2) # conversation + unassigned_conversation
+ expect(assigned_conversation.reload.assignee).to eq(agent)
+ expect(unassigned_conversation.reload.assignee).to eq(agent)
+ end
+
+ it 'dispatches assignee changed event' do
+ conversation # ensure it exists
+ conversation.update!(assignee_id: nil)
+
+ # The conversation model also dispatches a conversation.updated event
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
+ Events::Types::ASSIGNEE_CHANGED,
+ anything,
+ hash_including(conversation: conversation, user: agent)
+ )
+
+ service.perform_bulk_assignment(limit: 1)
+ end
+ end
+
+ context 'when auto assignment is disabled' do
+ before { assignment_policy.update!(enabled: false) }
+
+ it 'returns 0 without processing' do
+ assigned_count = service.perform_bulk_assignment(limit: 10)
+
+ expect(assigned_count).to eq(0)
+ expect(conversation.reload.assignee).to be_nil
+ end
+ end
+
+ context 'with conversation priority' do
+ let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
+
+ before do
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
+
+ # Mock RoundRobinSelector to return the agent
+ round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
+ allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
+ allow(round_robin_selector).to receive(:select_agent).and_return(agent)
+
+ # Mock RateLimiter to allow all assignments by default
+ allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
+ allow(rate_limiter).to receive(:within_limit?).and_return(true)
+ allow(rate_limiter).to receive(:track_assignment)
+ end
+
+ context 'when priority is longest_waiting' do
+ before do
+ allow(inbox).to receive(:auto_assignment_config).and_return({ 'conversation_priority' => 'longest_waiting' })
+ end
+
+ it 'assigns conversations with oldest last_activity_at first' do
+ old_conversation = create(:conversation,
+ inbox: inbox,
+ status: 'open',
+ created_at: 2.hours.ago,
+ last_activity_at: 2.hours.ago)
+ old_conversation.update!(assignee_id: nil)
+ new_conversation = create(:conversation,
+ inbox: inbox,
+ status: 'open',
+ created_at: 1.hour.ago,
+ last_activity_at: 1.hour.ago)
+ new_conversation.update!(assignee_id: nil)
+
+ service.perform_bulk_assignment(limit: 1)
+
+ expect(old_conversation.reload.assignee).to eq(agent)
+ expect(new_conversation.reload.assignee).to be_nil
+ end
+ end
+
+ context 'when priority is default' do
+ it 'assigns conversations by created_at' do
+ old_conversation = create(:conversation, inbox: inbox, status: 'open', created_at: 2.hours.ago)
+ old_conversation.update!(assignee_id: nil)
+ new_conversation = create(:conversation, inbox: inbox, status: 'open', created_at: 1.hour.ago)
+ new_conversation.update!(assignee_id: nil)
+
+ service.perform_bulk_assignment(limit: 1)
+
+ expect(old_conversation.reload.assignee).to eq(agent)
+ expect(new_conversation.reload.assignee).to be_nil
+ end
+ end
+ end
+
+ context 'with fair distribution' do
+ before do
+ create(:inbox_member, inbox: inbox, user: agent2)
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
+ agent.id.to_s => 'online',
+ agent2.id.to_s => 'online'
+ })
+ end
+
+ context 'when fair distribution is enabled' do
+ before do
+ allow(inbox).to receive(:auto_assignment_config).and_return({
+ 'fair_distribution_limit' => 2,
+ 'fair_distribution_window' => 3600
+ })
+ end
+
+ it 'respects the assignment limit per agent' do
+ # Mock RoundRobinSelector to select agent2
+ round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
+ allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
+ allow(round_robin_selector).to receive(:select_agent).and_return(agent2)
+
+ # Mock agent1 at limit, agent2 not at limit
+ agent1_limiter = instance_double(AutoAssignment::RateLimiter)
+ agent2_limiter = instance_double(AutoAssignment::RateLimiter)
+
+ allow(AutoAssignment::RateLimiter).to receive(:new).with(inbox: inbox, agent: agent).and_return(agent1_limiter)
+ allow(AutoAssignment::RateLimiter).to receive(:new).with(inbox: inbox, agent: agent2).and_return(agent2_limiter)
+
+ allow(agent1_limiter).to receive(:within_limit?).and_return(false)
+ allow(agent2_limiter).to receive(:within_limit?).and_return(true)
+ allow(agent2_limiter).to receive(:track_assignment)
+
+ unassigned_conversation = create(:conversation, inbox: inbox, status: 'open')
+ unassigned_conversation.update!(assignee_id: nil)
+
+ service.perform_bulk_assignment(limit: 1)
+
+ expect(unassigned_conversation.reload.assignee).to eq(agent2)
+ end
+
+ it 'tracks assignments in Redis' do
+ conversation # ensure it exists
+ conversation.update!(assignee_id: nil)
+
+ # Mock RoundRobinSelector
+ round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
+ allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
+ allow(round_robin_selector).to receive(:select_agent).and_return(agent)
+
+ limiter = instance_double(AutoAssignment::RateLimiter)
+ allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
+ allow(limiter).to receive(:within_limit?).and_return(true)
+ expect(limiter).to receive(:track_assignment)
+
+ service.perform_bulk_assignment(limit: 1)
+ end
+
+ it 'allows assignments after window expires' do
+ # Mock RoundRobinSelector
+ round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
+ allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
+ allow(round_robin_selector).to receive(:select_agent).and_return(agent, agent2)
+
+ # Mock RateLimiter to allow all
+ limiter = instance_double(AutoAssignment::RateLimiter)
+ allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
+ allow(limiter).to receive(:within_limit?).and_return(true)
+ allow(limiter).to receive(:track_assignment)
+
+ # Simulate time passing for rate limit window
+ freeze_time do
+ 2.times do
+ conversation_new = create(:conversation, inbox: inbox, status: 'open')
+ conversation_new.update!(assignee_id: nil)
+ service.perform_bulk_assignment(limit: 1)
+ expect(conversation_new.reload.assignee).not_to be_nil
+ end
+ end
+
+ # Move forward past the window
+ travel_to(2.hours.from_now) do
+ new_conversation = create(:conversation, inbox: inbox, status: 'open')
+ new_conversation.update!(assignee_id: nil)
+ service.perform_bulk_assignment(limit: 1)
+ expect(new_conversation.reload.assignee).not_to be_nil
+ end
+ end
+ end
+
+ context 'when fair distribution is disabled' do
+ it 'assigns without rate limiting' do
+ 5.times do
+ conv = create(:conversation, inbox: inbox, status: 'open')
+ conv.update!(assignee_id: nil)
+ end
+
+ # Mock RoundRobinSelector
+ round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
+ allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
+ allow(round_robin_selector).to receive(:select_agent).and_return(agent)
+
+ # Mock RateLimiter to allow all
+ limiter = instance_double(AutoAssignment::RateLimiter)
+ allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
+ allow(limiter).to receive(:within_limit?).and_return(true)
+ allow(limiter).to receive(:track_assignment)
+
+ assigned_count = service.perform_bulk_assignment(limit: 5)
+ expect(assigned_count).to eq(5)
+ end
+ end
+
+ context 'with round robin assignment' do
+ it 'distributes conversations evenly among agents' do
+ conversations = Array.new(4) { create(:conversation, inbox: inbox, assignee: nil) }
+
+ service.perform_bulk_assignment(limit: 4)
+
+ agent1_count = conversations.count { |c| c.reload.assignee == agent }
+ agent2_count = conversations.count { |c| c.reload.assignee == agent2 }
+
+ # Should be distributed evenly (2 each) or close to even (3 and 1)
+ expect([agent1_count, agent2_count].sort).to eq([2, 2]).or(eq([1, 3]))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/auto_assignment/rate_limiter_spec.rb b/spec/services/auto_assignment/rate_limiter_spec.rb
new file mode 100644
index 000000000..48a5367e3
--- /dev/null
+++ b/spec/services/auto_assignment/rate_limiter_spec.rb
@@ -0,0 +1,167 @@
+require 'rails_helper'
+
+RSpec.describe AutoAssignment::RateLimiter do
+ # Stub Math methods for testing when assignment_policy is nil
+ # rubocop:disable RSpec/BeforeAfterAll, RSpec/InstanceVariable
+ before(:all) do
+ @math_had_positive = Math.respond_to?(:positive?)
+ Math.define_singleton_method(:positive?) { false } unless @math_had_positive
+ end
+
+ after(:all) do
+ Math.singleton_class.send(:remove_method, :positive?) unless @math_had_positive
+ end
+ # rubocop:enable RSpec/BeforeAfterAll, RSpec/InstanceVariable
+
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:conversation) { create(:conversation, inbox: inbox) }
+ let(:rate_limiter) { described_class.new(inbox: inbox, agent: agent) }
+
+ describe '#within_limit?' do
+ context 'when rate limiting is not enabled' do
+ before do
+ allow(inbox).to receive(:assignment_policy).and_return(nil)
+ end
+
+ it 'returns true' do
+ expect(rate_limiter.within_limit?).to be true
+ end
+ end
+
+ context 'when rate limiting is enabled' do
+ let(:assignment_policy) do
+ instance_double(AssignmentPolicy,
+ fair_distribution_limit: 5,
+ fair_distribution_window: 3600)
+ end
+
+ before do
+ allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
+ end
+
+ it 'returns true when under the limit' do
+ allow(rate_limiter).to receive(:current_count).and_return(3)
+ expect(rate_limiter.within_limit?).to be true
+ end
+
+ it 'returns false when at or over the limit' do
+ allow(rate_limiter).to receive(:current_count).and_return(5)
+ expect(rate_limiter.within_limit?).to be false
+ end
+ end
+ end
+
+ describe '#track_assignment' do
+ context 'when rate limiting is not enabled' do
+ before do
+ allow(inbox).to receive(:assignment_policy).and_return(nil)
+ end
+
+ it 'does not track the assignment' do
+ expect(Redis::Alfred).not_to receive(:set)
+ rate_limiter.track_assignment(conversation)
+ end
+ end
+
+ context 'when rate limiting is enabled' do
+ let(:assignment_policy) do
+ instance_double(AssignmentPolicy,
+ fair_distribution_limit: 5,
+ fair_distribution_window: 3600)
+ end
+
+ before do
+ allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
+ end
+
+ it 'creates a Redis key with correct expiry' do
+ expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
+ expect(Redis::Alfred).to receive(:set).with(
+ expected_key,
+ conversation.id.to_s,
+ ex: 3600
+ )
+ rate_limiter.track_assignment(conversation)
+ end
+ end
+ end
+
+ describe '#current_count' do
+ context 'when rate limiting is not enabled' do
+ before do
+ allow(inbox).to receive(:assignment_policy).and_return(nil)
+ end
+
+ it 'returns 0' do
+ expect(rate_limiter.current_count).to eq(0)
+ end
+ end
+
+ context 'when rate limiting is enabled' do
+ let(:assignment_policy) do
+ instance_double(AssignmentPolicy,
+ fair_distribution_limit: 5,
+ fair_distribution_window: 3600)
+ end
+
+ before do
+ allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
+ end
+
+ it 'counts matching Redis keys' do
+ pattern = format(Redis::RedisKeys::ASSIGNMENT_KEY_PATTERN, inbox_id: inbox.id, agent_id: agent.id)
+ allow(Redis::Alfred).to receive(:keys_count).with(pattern).and_return(3)
+
+ expect(rate_limiter.current_count).to eq(3)
+ end
+ end
+ end
+
+ describe 'configuration' do
+ context 'with custom window' do
+ let(:assignment_policy) do
+ instance_double(AssignmentPolicy,
+ fair_distribution_limit: 10,
+ fair_distribution_window: 7200)
+ end
+
+ before do
+ allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
+ end
+
+ it 'uses the custom window value' do
+ expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
+ expect(Redis::Alfred).to receive(:set).with(
+ expected_key,
+ conversation.id.to_s,
+ ex: 7200
+ )
+ rate_limiter.track_assignment(conversation)
+ end
+ end
+
+ context 'without custom window' do
+ let(:assignment_policy) do
+ instance_double(AssignmentPolicy,
+ fair_distribution_limit: 10,
+ fair_distribution_window: nil)
+ end
+
+ before do
+ allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
+ end
+
+ it 'uses the default window value of 24 hours' do
+ expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
+ expect(Redis::Alfred).to receive(:set).with(
+ expected_key,
+ conversation.id.to_s,
+ ex: 86_400
+ )
+ rate_limiter.track_assignment(conversation)
+ end
+ end
+ end
+end
diff --git a/spec/services/auto_assignment/round_robin_selector_spec.rb b/spec/services/auto_assignment/round_robin_selector_spec.rb
new file mode 100644
index 000000000..05c1dcd73
--- /dev/null
+++ b/spec/services/auto_assignment/round_robin_selector_spec.rb
@@ -0,0 +1,78 @@
+require 'rails_helper'
+
+RSpec.describe AutoAssignment::RoundRobinSelector do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:selector) { described_class.new(inbox: inbox) }
+ let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
+
+ let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
+
+ let(:member1) do
+ allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
+ allow(round_robin_service).to receive(:add_agent_to_queue)
+ create(:inbox_member, inbox: inbox, user: agent1)
+ end
+
+ let(:member2) do
+ allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
+ allow(round_robin_service).to receive(:add_agent_to_queue)
+ create(:inbox_member, inbox: inbox, user: agent2)
+ end
+
+ let(:member3) do
+ allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
+ allow(round_robin_service).to receive(:add_agent_to_queue)
+ create(:inbox_member, inbox: inbox, user: agent3)
+ end
+
+ before do
+ # Mock the round robin service to avoid Redis calls
+ allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
+ allow(round_robin_service).to receive(:add_agent_to_queue)
+ allow(round_robin_service).to receive(:reset_queue)
+ allow(round_robin_service).to receive(:validate_queue?).and_return(true)
+ end
+
+ describe '#select_agent' do
+ context 'when agents are available' do
+ let(:available_agents) { [member1, member2, member3] }
+
+ it 'returns an agent from the available list' do
+ allow(round_robin_service).to receive(:available_agent).and_return(agent1)
+
+ selected_agent = selector.select_agent(available_agents)
+
+ expect(selected_agent).not_to be_nil
+ expect([agent1, agent2, agent3]).to include(selected_agent)
+ end
+
+ it 'uses round robin service for selection' do
+ expect(round_robin_service).to receive(:available_agent).with(
+ allowed_agent_ids: [agent1.id.to_s, agent2.id.to_s, agent3.id.to_s]
+ ).and_return(agent1)
+
+ selected_agent = selector.select_agent(available_agents)
+ expect(selected_agent).to eq(agent1)
+ end
+ end
+
+ context 'when no agents are available' do
+ it 'returns nil' do
+ selected_agent = selector.select_agent([])
+ expect(selected_agent).to be_nil
+ end
+ end
+
+ context 'when one agent is available' do
+ it 'returns that agent' do
+ allow(round_robin_service).to receive(:available_agent).and_return(agent1)
+
+ selected_agent = selector.select_agent([member1])
+ expect(selected_agent).to eq(agent1)
+ end
+ end
+ end
+end
diff --git a/spec/services/contacts/bulk_action_service_spec.rb b/spec/services/contacts/bulk_action_service_spec.rb
new file mode 100644
index 000000000..0411c8455
--- /dev/null
+++ b/spec/services/contacts/bulk_action_service_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::BulkActionService do
+ subject(:service) { described_class.new(account: account, user: user, params: params) }
+
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+
+ describe '#perform' do
+ context 'when delete action is requested via action_name' do
+ let(:params) { { ids: [1, 2], action_name: 'delete' } }
+
+ it 'delegates to the bulk delete service' do
+ bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true)
+
+ expect(Contacts::BulkDeleteService).to receive(:new)
+ .with(account: account, contact_ids: [1, 2])
+ .and_return(bulk_delete_service)
+
+ service.perform
+ end
+ end
+
+ context 'when labels are provided' do
+ let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } }
+
+ it 'delegates to the bulk assign labels service with permitted params' do
+ bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true)
+
+ expect(Contacts::BulkAssignLabelsService).to receive(:new)
+ .with(account: account, contact_ids: [10, 20], labels: %w[vip support])
+ .and_return(bulk_assign_service)
+
+ service.perform
+ end
+ end
+ end
+end
diff --git a/spec/services/contacts/bulk_assign_labels_service_spec.rb b/spec/services/contacts/bulk_assign_labels_service_spec.rb
new file mode 100644
index 000000000..3ae27a81b
--- /dev/null
+++ b/spec/services/contacts/bulk_assign_labels_service_spec.rb
@@ -0,0 +1,48 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::BulkAssignLabelsService 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 support] }
+
+ it 'assigns labels to the contacts that belong to the account' do
+ service.perform
+
+ expect(contact_one.reload.label_list).to include(*labels)
+ expect(contact_two.reload.label_list).to include(*labels)
+ end
+
+ it 'does not assign labels to contacts outside the account' do
+ service.perform
+
+ expect(other_contact.reload.label_list).to be_empty
+ 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 be_empty
+ end
+end
diff --git a/spec/services/contacts/bulk_delete_service_spec.rb b/spec/services/contacts/bulk_delete_service_spec.rb
new file mode 100644
index 000000000..4eaecaa0f
--- /dev/null
+++ b/spec/services/contacts/bulk_delete_service_spec.rb
@@ -0,0 +1,24 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::BulkDeleteService do
+ subject(:service) { described_class.new(account: account, contact_ids: contact_ids) }
+
+ let(:account) { create(:account) }
+ let!(:contact_one) { create(:contact, account: account) }
+ let!(:contact_two) { create(:contact, account: account) }
+ let(:contact_ids) { [contact_one.id, contact_two.id] }
+
+ describe '#perform' do
+ it 'deletes the provided contacts' do
+ expect { service.perform }
+ .to change { account.contacts.exists?(contact_one.id) }.from(true).to(false)
+ .and change { account.contacts.exists?(contact_two.id) }.from(true).to(false)
+ end
+
+ it 'returns when no contact ids are provided' do
+ empty_service = described_class.new(account: account, contact_ids: [])
+
+ expect { empty_service.perform }.not_to change(Contact, :count)
+ end
+ end
+end
diff --git a/spec/services/email/send_on_email_service_spec.rb b/spec/services/email/send_on_email_service_spec.rb
new file mode 100644
index 000000000..5a4997eb0
--- /dev/null
+++ b/spec/services/email/send_on_email_service_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+describe Email::SendOnEmailService do
+ let(:account) { create(:account) }
+ let(:email_channel) { create(:channel_email, account: account) }
+ let(:inbox) { create(:inbox, account: account, channel: email_channel) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
+ let(:service) { described_class.new(message: message) }
+
+ describe '#perform' do
+ let(:mailer_context) { instance_double(ConversationReplyMailer) }
+ let(:delivery) { instance_double(ActionMailer::MessageDelivery) }
+ let(:email_message) { instance_double(Mail::Message) }
+
+ before do
+ allow(ConversationReplyMailer).to receive(:with).with(account: message.account).and_return(mailer_context)
+ end
+
+ context 'when message is email notifiable' do
+ before do
+ allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
+ allow(delivery).to receive(:deliver_now).and_return(email_message)
+ allow(email_message).to receive(:message_id).and_return(
+ "conversation/#{conversation.uuid}/messages/" \
+ "#{message.id}@#{conversation.account.domain}"
+ )
+ end
+
+ it 'sends email via ConversationReplyMailer' do
+ service.perform
+
+ expect(ConversationReplyMailer).to have_received(:with).with(account: message.account)
+ expect(mailer_context).to have_received(:email_reply).with(message)
+ expect(delivery).to have_received(:deliver_now)
+ end
+
+ it 'updates message source id on success' do
+ service.perform
+
+ expect(message.reload.source_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
+ end
+ end
+
+ context 'when message is not email notifiable' do
+ let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
+
+ before do
+ allow(mailer_context).to receive(:email_reply)
+ end
+
+ it 'does not send email' do
+ service.perform
+
+ expect(ConversationReplyMailer).not_to have_received(:with)
+ expect(mailer_context).not_to have_received(:email_reply)
+ end
+ end
+
+ context 'when an error occurs' do
+ let(:error_message) { 'SMTP connection failed' }
+ let(:error) { StandardError.new(error_message) }
+ let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
+ let(:status_service) { instance_double(Messages::StatusUpdateService, perform: true) }
+
+ before do
+ allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
+ allow(delivery).to receive(:deliver_now).and_raise(error)
+ allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
+ end
+
+ it 'captures the exception' do
+ expect(ChatwootExceptionTracker).to receive(:new).with(error, account: message.account)
+
+ service.perform
+ end
+
+ it 'updates message status to failed' do
+ service.perform
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.reload.external_error).to eq(error_message)
+ end
+ end
+ end
+end
diff --git a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
index 49fcc1a18..b79ee6d79 100644
--- a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
+++ b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
@@ -28,6 +28,14 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
content: 'Hello, I need help'
)
+ create(
+ :message,
+ :bot_message,
+ conversation: conversation,
+ message_type: 'outgoing',
+ content: 'Thanks for reaching out, an agent will reach out to you soon'
+ )
+
create(
:message,
conversation: conversation,
@@ -40,7 +48,8 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
'User: Hello, I need help',
- 'Support agent: How can I assist you today?',
+ 'Bot: Thanks for reaching out, an agent will reach out to you soon',
+ 'Support Agent: How can I assist you today?',
''
].join("\n")
diff --git a/spec/services/mailbox/conversation_finder_spec.rb b/spec/services/mailbox/conversation_finder_spec.rb
new file mode 100644
index 000000000..313ca409a
--- /dev/null
+++ b/spec/services/mailbox/conversation_finder_spec.rb
@@ -0,0 +1,133 @@
+require 'rails_helper'
+
+RSpec.describe Mailbox::ConversationFinder do
+ let(:account) { create(:account) }
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+ let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
+ let(:mail) { Mail.new }
+
+ describe '#find' do
+ context 'when receiver uuid strategy finds conversation' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
+ end
+
+ it 'returns the conversation' do
+ finder = described_class.new(mail)
+ expect(finder.find).to eq(conversation)
+ end
+
+ it 'logs which strategy succeeded' do
+ allow(Rails.logger).to receive(:info)
+ finder = described_class.new(mail)
+ finder.find
+
+ expect(Rails.logger).to have_received(:info).with('Conversation found via receiver_uuid_strategy strategy')
+ end
+ end
+
+ context 'when in_reply_to strategy finds conversation' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
+ end
+
+ it 'returns the conversation' do
+ finder = described_class.new(mail)
+ expect(finder.find).to eq(conversation)
+ end
+
+ it 'logs which strategy succeeded' do
+ allow(Rails.logger).to receive(:info)
+ finder = described_class.new(mail)
+ finder.find
+
+ expect(Rails.logger).to have_received(:info).with('Conversation found via in_reply_to_strategy strategy')
+ end
+ end
+
+ context 'when references strategy finds conversation' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'test@example.com'
+ mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
+ end
+
+ it 'returns the conversation' do
+ finder = described_class.new(mail)
+ expect(finder.find).to eq(conversation)
+ end
+
+ it 'logs which strategy succeeded' do
+ allow(Rails.logger).to receive(:info)
+ finder = described_class.new(mail)
+ finder.find
+
+ expect(Rails.logger).to have_received(:info).with('Conversation found via references_strategy strategy')
+ end
+ end
+
+ context 'when no strategy finds conversation' do
+ # With NewConversationStrategy in default strategies, this scenario only happens
+ # when using custom strategies that exclude NewConversationStrategy
+ let(:finding_strategies) do
+ [
+ Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
+ Mailbox::ConversationFinderStrategies::InReplyToStrategy,
+ Mailbox::ConversationFinderStrategies::ReferencesStrategy
+ ]
+ end
+
+ it 'returns nil' do
+ finder = described_class.new(mail, strategies: finding_strategies)
+ expect(finder.find).to be_nil
+ end
+
+ it 'logs that no conversation was found' do
+ allow(Rails.logger).to receive(:error)
+ finder = described_class.new(mail, strategies: finding_strategies)
+ finder.find
+
+ expect(Rails.logger).to have_received(:error).with('No conversation found via any strategy (NewConversationStrategy missing?)')
+ end
+ end
+
+ context 'with custom strategies' do
+ let(:custom_strategy_class) do
+ Class.new(Mailbox::ConversationFinderStrategies::BaseStrategy) do
+ def find
+ # Always return nil for testing
+ nil
+ end
+ end
+ end
+
+ it 'uses provided strategies instead of defaults' do
+ finder = described_class.new(mail, strategies: [custom_strategy_class])
+ expect(finder.find).to be_nil
+ end
+ end
+
+ context 'with strategy execution order' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+
+ # Set up mail so all strategies could match
+ mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
+ mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
+ mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/456@example.com'
+ end
+
+ it 'returns conversation from first matching strategy' do
+ allow(Rails.logger).to receive(:info)
+ finder = described_class.new(mail)
+ result = finder.find
+
+ expect(result).to eq(conversation)
+ # Should only log the first strategy that succeeded (ReceiverUuidStrategy)
+ expect(Rails.logger).to have_received(:info).once.with('Conversation found via receiver_uuid_strategy strategy')
+ end
+ end
+ end
+end
diff --git a/spec/services/mailbox/conversation_finder_strategies/in_reply_to_strategy_spec.rb b/spec/services/mailbox/conversation_finder_strategies/in_reply_to_strategy_spec.rb
new file mode 100644
index 000000000..3fca8c3c9
--- /dev/null
+++ b/spec/services/mailbox/conversation_finder_strategies/in_reply_to_strategy_spec.rb
@@ -0,0 +1,118 @@
+require 'rails_helper'
+
+RSpec.describe Mailbox::ConversationFinderStrategies::InReplyToStrategy do
+ let(:account) { create(:account) }
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+ let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
+ let(:mail) { Mail.new }
+
+ describe '#find' do
+ context 'when in_reply_to has message-specific pattern' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
+ end
+
+ it 'extracts UUID and returns conversation' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when in_reply_to has conversation fallback pattern' do
+ before do
+ conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
+ mail.in_reply_to = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
+ end
+
+ it 'extracts UUID and returns conversation' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when in_reply_to matches message source_id' do
+ let(:message) do
+ conversation.messages.create!(
+ source_id: 'original-message-id@example.com',
+ account_id: account.id,
+ message_type: 'outgoing',
+ inbox_id: email_channel.inbox.id,
+ content: 'Original message'
+ )
+ end
+
+ before do
+ message # Create the message
+ mail.in_reply_to = 'original-message-id@example.com'
+ end
+
+ it 'finds conversation from message source_id' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when in_reply_to has multiple values' do
+ let(:message) do
+ conversation.messages.create!(
+ source_id: 'message-123@example.com',
+ account_id: account.id,
+ message_type: 'outgoing',
+ inbox_id: email_channel.inbox.id,
+ content: 'Test message'
+ )
+ end
+
+ before do
+ message # Create the message
+ mail.in_reply_to = ['some-other-id@example.com', 'message-123@example.com']
+ end
+
+ it 'finds conversation from any in_reply_to value' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when in_reply_to is blank' do
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when in_reply_to does not match any pattern or source_id' do
+ before do
+ mail.in_reply_to = 'random-message-id@gmail.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when UUID exists but conversation does not' do
+ before do
+ mail.in_reply_to = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'with malformed in_reply_to pattern' do
+ before do
+ mail.in_reply_to = 'conversation/not-a-uuid/messages/123@example.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/mailbox/conversation_finder_strategies/new_conversation_strategy_spec.rb b/spec/services/mailbox/conversation_finder_strategies/new_conversation_strategy_spec.rb
new file mode 100644
index 000000000..8609d0133
--- /dev/null
+++ b/spec/services/mailbox/conversation_finder_strategies/new_conversation_strategy_spec.rb
@@ -0,0 +1,161 @@
+require 'rails_helper'
+
+RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
+ let(:account) { create(:account) }
+ let(:email_channel) { create(:channel_email, account: account) }
+ let(:mail) { Mail.new }
+
+ before do
+ mail.to = [email_channel.email]
+ mail.from = 'sender@example.com'
+ mail.subject = 'Test Subject'
+ mail.message_id = ''
+ end
+
+ describe '#find' do
+ context 'when channel is found' do
+ context 'with new contact' do
+ it 'builds a new conversation with new contact' do
+ strategy = described_class.new(mail)
+
+ expect do
+ conversation = strategy.find
+ expect(conversation).to be_a(Conversation)
+ expect(conversation.new_record?).to be(true) # Not persisted yet
+ expect(conversation.inbox).to eq(email_channel.inbox)
+ expect(conversation.account).to eq(account)
+ end.to not_change(Conversation, :count) # No conversation created yet
+ .and change(Contact, :count).by(1) # Contact is created
+ .and change(ContactInbox, :count).by(1)
+ end
+
+ it 'sets conversation attributes correctly' do
+ strategy = described_class.new(mail)
+ conversation = strategy.find
+
+ expect(conversation.additional_attributes['source']).to eq('email')
+ expect(conversation.additional_attributes['mail_subject']).to eq('Test Subject')
+ expect(conversation.additional_attributes['initiated_at']).to have_key('timestamp')
+ end
+
+ it 'sets contact attributes correctly' do
+ strategy = described_class.new(mail)
+ conversation = strategy.find
+
+ expect(conversation.contact.email).to eq('sender@example.com')
+ expect(conversation.contact.name).to eq('sender')
+ end
+ end
+
+ context 'with existing contact' do
+ let!(:existing_contact) { create(:contact, email: 'sender@example.com', account: account) }
+
+ before do
+ create(:contact_inbox, contact: existing_contact, inbox: email_channel.inbox)
+ end
+
+ it 'builds conversation with existing contact' do
+ strategy = described_class.new(mail)
+
+ expect do
+ conversation = strategy.find
+ expect(conversation).to be_a(Conversation)
+ expect(conversation.new_record?).to be(true) # Not persisted yet
+ expect(conversation.contact).to eq(existing_contact)
+ end.to not_change(Conversation, :count) # No conversation created yet
+ .and not_change(Contact, :count)
+ .and not_change(ContactInbox, :count)
+ end
+ end
+
+ context 'when mail has In-Reply-To header' do
+ before do
+ mail['In-Reply-To'] = ''
+ end
+
+ it 'stores in_reply_to in additional_attributes' do
+ strategy = described_class.new(mail)
+ conversation = strategy.find
+
+ expect(conversation.additional_attributes['in_reply_to']).to eq('')
+ end
+ end
+
+ context 'when mail is auto reply' do
+ before do
+ mail['X-Autoreply'] = 'yes'
+ end
+
+ it 'marks conversation as auto_reply' do
+ strategy = described_class.new(mail)
+ conversation = strategy.find
+
+ expect(conversation.additional_attributes['auto_reply']).to be true
+ end
+ end
+
+ context 'when sender has name in From header' do
+ before do
+ mail.from = 'John Doe '
+ end
+
+ it 'uses sender name from mail' do
+ strategy = described_class.new(mail)
+ conversation = strategy.find
+
+ expect(conversation.contact.name).to eq('John Doe')
+ end
+ end
+ end
+
+ context 'when channel is not found' do
+ before do
+ mail.to = ['nonexistent@example.com']
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+
+ expect do
+ result = strategy.find
+ expect(result).to be_nil
+ end.not_to change(Conversation, :count)
+ end
+ end
+
+ context 'when contact creation fails' do
+ before do
+ builder = instance_double(ContactInboxWithContactBuilder)
+ allow(ContactInboxWithContactBuilder).to receive(:new).and_return(builder)
+ allow(builder).to receive(:perform).and_raise(ActiveRecord::RecordInvalid)
+ end
+
+ it 'rolls back the transaction' do
+ strategy = described_class.new(mail)
+
+ expect do
+ strategy.find
+ end.to raise_error(ActiveRecord::RecordInvalid)
+ .and not_change(Conversation, :count)
+ .and not_change(Contact, :count)
+ .and not_change(ContactInbox, :count)
+ end
+ end
+
+ context 'when conversation creation fails' do
+ before do
+ # Make conversation build fail with invalid attributes
+ allow(Conversation).to receive(:new).and_return(Conversation.new)
+ end
+
+ it 'returns invalid conversation object' do
+ strategy = described_class.new(mail)
+
+ conversation = strategy.find
+ expect(conversation).to be_a(Conversation)
+ expect(conversation.new_record?).to be(true)
+ expect(conversation.valid?).to be(false)
+ end
+ end
+ end
+end
diff --git a/spec/services/mailbox/conversation_finder_strategies/receiver_uuid_strategy_spec.rb b/spec/services/mailbox/conversation_finder_strategies/receiver_uuid_strategy_spec.rb
new file mode 100644
index 000000000..d45f6f4fe
--- /dev/null
+++ b/spec/services/mailbox/conversation_finder_strategies/receiver_uuid_strategy_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+RSpec.describe Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy do
+ let(:account) { create(:account) }
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+ let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
+ let(:mail) { Mail.new }
+
+ describe '#find' do
+ context 'when mail has valid reply+uuid format' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
+ end
+
+ it 'returns the conversation' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when mail has uppercase UUID' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'reply+12345678-1234-1234-1234-123456789012@EXAMPLE.COM'
+ end
+
+ it 'returns the conversation (case-insensitive matching)' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when mail has multiple recipients with valid UUID' do
+ before do
+ conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
+ mail.to = ['other@example.com', 'reply+aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com']
+ end
+
+ it 'extracts UUID from any recipient' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when UUID does not exist in database' do
+ before do
+ mail.to = 'reply+99999999-9999-9999-9999-999999999999@example.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when mail has no recipients' do
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when mail recipient has malformed UUID' do
+ before do
+ mail.to = 'reply+not-a-valid-uuid@example.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when mail recipient has no reply+ prefix' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'test+12345678-1234-1234-1234-123456789012@example.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when mail recipient has additional text after UUID' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'reply+12345678-1234-1234-1234-123456789012-extra@example.com'
+ end
+
+ it 'returns nil (UUID must be exact)' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/mailbox/conversation_finder_strategies/references_strategy_spec.rb b/spec/services/mailbox/conversation_finder_strategies/references_strategy_spec.rb
new file mode 100644
index 000000000..6ef61a58f
--- /dev/null
+++ b/spec/services/mailbox/conversation_finder_strategies/references_strategy_spec.rb
@@ -0,0 +1,208 @@
+require 'rails_helper'
+
+RSpec.describe Mailbox::ConversationFinderStrategies::ReferencesStrategy do
+ let(:account) { create(:account) }
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+ let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
+ let(:mail) { Mail.new }
+
+ describe '#find' do
+ context 'when references has message-specific pattern' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'test@example.com'
+ mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
+ end
+
+ it 'extracts UUID and returns conversation' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when references has conversation fallback pattern' do
+ before do
+ conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
+ mail.to = 'test@example.com'
+ mail.references = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
+ end
+
+ it 'extracts UUID and returns conversation' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when references matches message source_id' do
+ let(:message) do
+ conversation.messages.create!(
+ source_id: 'original-message-id@example.com',
+ account_id: account.id,
+ message_type: 'outgoing',
+ inbox_id: email_channel.inbox.id,
+ content: 'Original message'
+ )
+ end
+
+ before do
+ message # Create the message
+ mail.to = 'test@example.com'
+ mail.references = 'original-message-id@example.com'
+ end
+
+ it 'finds conversation from message source_id' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when references has multiple values' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'test@example.com'
+ mail.references = [
+ 'some-random-message@gmail.com',
+ 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com',
+ 'another-message@outlook.com'
+ ]
+ end
+
+ it 'finds conversation from any reference' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when references is blank' do
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when references does not match any pattern or source_id' do
+ before do
+ mail.references = 'random-message-id@gmail.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'with channel validation' do
+ context 'when conversation belongs to the correct channel' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'test@example.com'
+ mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
+ end
+
+ it 'returns the conversation' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+
+ context 'when conversation belongs to a different channel' do
+ let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
+ let(:other_conversation) do
+ create(
+ :conversation,
+ inbox: other_email_channel.inbox,
+ account: account
+ )
+ end
+
+ before do
+ other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
+ # Mail is addressed to test@example.com but references conversation from other@example.com
+ mail.to = 'test@example.com'
+ mail.references = 'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com'
+ end
+
+ it 'returns nil (prevents cross-channel hijacking)' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when channel cannot be determined from mail' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = 'unknown@example.com' # Email not associated with any channel
+ mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when mail has multiple recipients including correct channel' do
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ mail.to = ['other@example.com', 'test@example.com']
+ mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
+ end
+
+ it 'finds the correct channel and returns conversation' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+ end
+
+ context 'when UUID exists but conversation does not' do
+ before do
+ mail.to = 'test@example.com'
+ mail.references = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'with malformed references pattern' do
+ before do
+ mail.references = 'conversation/not-a-uuid/messages/123@example.com'
+ end
+
+ it 'returns nil' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to be_nil
+ end
+ end
+
+ context 'when first reference fails channel validation but second succeeds' do
+ let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
+ let(:other_conversation) do
+ create(
+ :conversation,
+ inbox: other_email_channel.inbox,
+ account: account
+ )
+ end
+
+ before do
+ conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
+ other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
+
+ mail.to = 'test@example.com'
+ mail.references = [
+ 'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com', # Wrong channel
+ 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com' # Correct channel
+ ]
+ end
+
+ it 'skips invalid reference and returns conversation from valid reference' do
+ strategy = described_class.new(mail)
+ expect(strategy.find).to eq(conversation)
+ end
+ end
+ end
+end
diff --git a/spec/services/messages/send_email_notification_service_spec.rb b/spec/services/messages/send_email_notification_service_spec.rb
new file mode 100644
index 000000000..7c0970fe1
--- /dev/null
+++ b/spec/services/messages/send_email_notification_service_spec.rb
@@ -0,0 +1,178 @@
+require 'rails_helper'
+
+describe Messages::SendEmailNotificationService do
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
+ let(:service) { described_class.new(message: message) }
+
+ describe '#perform' do
+ context 'when email notification should be sent' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ allow(Redis::Alfred).to receive(:set).and_return(true)
+ ActiveJob::Base.queue_adapter = :test
+ end
+
+ it 'enqueues ConversationReplyEmailJob' do
+ expect { service.perform }.to have_enqueued_job(ConversationReplyEmailJob).with(conversation.id, message.id).on_queue('mailers')
+ end
+
+ it 'atomically sets redis key to prevent duplicate emails' do
+ expected_key = format(Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
+
+ service.perform
+
+ expect(Redis::Alfred).to have_received(:set).with(expected_key, message.id, nx: true, ex: 1.hour.to_i)
+ end
+
+ context 'when redis key already exists' do
+ before do
+ allow(Redis::Alfred).to receive(:set).and_return(false)
+ end
+
+ it 'does not enqueue job' do
+ expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+
+ it 'attempts atomic set once' do
+ service.perform
+
+ expect(Redis::Alfred).to have_received(:set).once
+ end
+ end
+ end
+
+ context 'when handling concurrent requests' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ end
+
+ it 'prevents duplicate jobs under race conditions' do
+ # Create 5 threads that simultaneously try to enqueue workers for the same conversation
+ threads = Array.new(5) do
+ Thread.new do
+ msg = create(:message, conversation: conversation, message_type: 'outgoing')
+ described_class.new(message: msg).perform
+ end
+ end
+
+ threads.each(&:join)
+
+ # Only ONE job should be scheduled despite 5 concurrent attempts
+ jobs_for_conversation = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
+ job[:job] == ConversationReplyEmailJob && job[:args].first == conversation.id
+ end
+ expect(jobs_for_conversation.size).to eq(1)
+ end
+ end
+
+ context 'when email notification should not be sent' do
+ before do
+ ActiveJob::Base.queue_adapter = :test
+ end
+
+ context 'when message is not email notifiable' do
+ let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
+
+ it 'does not enqueue job' do
+ expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+ end
+
+ context 'when contact has no email' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: nil)
+ end
+
+ it 'does not enqueue job' do
+ expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+ end
+
+ context 'when channel does not support email notifications' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ end
+
+ it 'does not enqueue job' do
+ expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+ end
+ end
+ end
+
+ describe '#should_send_email_notification?' do
+ context 'with WebWidget channel' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ end
+
+ it 'returns true when continuity_via_email is enabled' do
+ expect(service.send(:should_send_email_notification?)).to be true
+ end
+
+ context 'when continuity_via_email is disabled' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: false)) }
+
+ it 'returns false' do
+ expect(service.send(:should_send_email_notification?)).to be false
+ end
+ end
+ end
+
+ context 'with API channel' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_api, account: account)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ allow(account).to receive(:feature_enabled?).and_return(false)
+ allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(true)
+ end
+
+ it 'returns true when email_continuity_on_api_channel feature is enabled' do
+ expect(service.send(:should_send_email_notification?)).to be true
+ end
+
+ context 'when email_continuity_on_api_channel feature is disabled' do
+ before do
+ allow(account).to receive(:feature_enabled?).and_return(false)
+ allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(false)
+ end
+
+ it 'returns false' do
+ expect(service.send(:should_send_email_notification?)).to be false
+ end
+ end
+ end
+
+ context 'with other channels' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ end
+
+ it 'returns false' do
+ expect(service.send(:should_send_email_notification?)).to be false
+ end
+ end
+ end
+end
diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb
index d32ef59bb..190a6c45a 100644
--- a/spec/services/twilio/incoming_message_service_spec.rb
+++ b/spec/services/twilio/incoming_message_service_spec.rb
@@ -402,6 +402,230 @@ describe Twilio::IncomingMessageService do
existing_contact.reload
expect(existing_contact.name).to eq('Alice Johnson')
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',
+ inbox: create(:inbox, account: account, greeting_enabled: false))
+ end
+
+ it 'creates appropriate conversations, message and contacts if contact does not exist' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+5541988887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Brazil',
+ ProfileName: 'João Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva')
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
+ end
+
+ it 'appends to existing contact if contact inbox exists' do
+ # Create existing contact with same format
+ normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
+ inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+5541988887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Another message from Brazil',
+ ProfileName: 'João Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ # No new conversation should be created
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq('Another message from Brazil')
+ end
+ end
+
+ describe 'When incoming number is a Brazilian number in old format without the 9 included' 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 'appends to existing contact when contact inbox exists in old format' do
+ # Create existing contact with old format (12 digits)
+ old_contact = create(:contact, account: account, phone_number: '+554188887777')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+554188887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Brazil old format',
+ ProfileName: 'Maria Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ # No new conversation should be created
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format')
+ end
+
+ it 'appends to existing contact when contact inbox exists in new format' do
+ # Create existing contact with new format (13 digits)
+ normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
+ inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ # Incoming message with old format (12 digits)
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+554188887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Brazil',
+ ProfileName: 'João Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ # Should find and use existing contact, not create duplicate
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the existing conversation
+ expect(last_conversation.messages.last.content).to eq('Test message from Brazil')
+ # Should use the existing contact's source_id (normalized format)
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
+ end
+
+ it 'creates contact inbox with incoming number when no existing contact' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+554188887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Brazil',
+ ProfileName: 'Carlos Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva')
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777')
+ end
+ end
+
+ describe 'When the incoming number is an Argentine number with 9 after country code' 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 appropriate conversations, message and contacts if contact does not exist' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+5491123456789',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Argentina',
+ ProfileName: 'Carlos Mendoza'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza')
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789')
+ end
+
+ it 'appends to existing contact if contact inbox exists with normalized format' do
+ # Create existing contact with normalized format (without 9 after country code)
+ normalized_contact = create(:contact, account: account, phone_number: '+541123456789')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact,
+ inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ # Incoming message with 9 after country code
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+5491123456789',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Argentina',
+ ProfileName: 'Carlos Mendoza'
+ }
+
+ described_class.new(params: params).perform
+
+ # Should find and use existing contact, not create duplicate
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the existing conversation
+ expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
+ # Should use the normalized source_id from existing contact
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
+ end
+ end
+
+ describe 'When incoming number is an Argentine number without 9 after country code' 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 'appends to existing contact when contact inbox exists with same format' do
+ # Create existing contact with same format (without 9)
+ contact = create(:contact, account: account, phone_number: '+541123456789')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+541123456789',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Argentina',
+ ProfileName: 'Ana García'
+ }
+
+ described_class.new(params: params).perform
+
+ # No new conversation should be created
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
+ end
+
+ it 'creates contact inbox with incoming number when no existing contact' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+541123456789',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Argentina',
+ ProfileName: 'Diego López'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López')
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
+ end
+ end
end
end
end
diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb
index 403fb71b8..983af6c78 100644
--- a/spec/services/whatsapp/channel_creation_service_spec.rb
+++ b/spec/services/whatsapp/channel_creation_service_spec.rb
@@ -62,14 +62,19 @@ describe Whatsapp::ChannelCreationService do
end
end
- context 'when channel already exists' do
+ context 'when channel already exists for the phone number' do
+ let(:different_account) { create(:account) }
+
before do
- create(:channel_whatsapp, account: account, phone_number: '+1234567890',
+ create(:channel_whatsapp, account: different_account, phone_number: '+1234567890',
provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
- it 'raises an error' do
- expect { service.perform }.to raise_error(/Channel already exists/)
+ it 'raises an error even if the channel belongs to a different account' do
+ expect { service.perform }.to raise_error(
+ RuntimeError,
+ I18n.t('errors.whatsapp.phone_number_already_exists', phone_number: '+1234567890')
+ )
end
end
diff --git a/spec/services/widget/token_service_expiry_spec.rb b/spec/services/widget/token_service_expiry_spec.rb
index 051a757a5..50e104c65 100644
--- a/spec/services/widget/token_service_expiry_spec.rb
+++ b/spec/services/widget/token_service_expiry_spec.rb
@@ -15,11 +15,11 @@ RSpec.describe Widget::TokenService, type: :service do
end
it 'uses the configured value for token expiry' do
- freeze_time do
+ travel_to '2025-01-01' do
token = service.generate_token
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
- expect(decoded['iat']).to eq(Time.now.to_i)
- expect(decoded['exp']).to eq(30.days.from_now.to_i)
+ expect(decoded['iat']).to eq(Time.zone.now.to_i)
+ expect(decoded['exp']).to eq(Time.zone.now.to_i + 30.days.to_i)
end
end
end
@@ -30,11 +30,11 @@ RSpec.describe Widget::TokenService, type: :service do
end
it 'uses the default expiry' do
- freeze_time do
+ travel_to '2025-01-01' do
token = service.generate_token
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
- expect(decoded['iat']).to eq(Time.now.to_i)
- expect(decoded['exp']).to eq(180.days.from_now.to_i)
+ expect(decoded['iat']).to eq(Time.zone.now.to_i)
+ expect(decoded['exp']).to eq(Time.zone.now.to_i + 180.days.to_i)
end
end
end
diff --git a/spec/support/examples/encrypted_external_credential_examples.rb b/spec/support/examples/encrypted_external_credential_examples.rb
new file mode 100644
index 000000000..c67d814a9
--- /dev/null
+++ b/spec/support/examples/encrypted_external_credential_examples.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'|
+ before do
+ skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
+ if defined?(Facebook::Messenger::Subscriptions)
+ allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true)
+ allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true)
+ end
+ end
+
+ it "encrypts #{attribute} at rest" do
+ record = create(factory, attribute => value)
+
+ raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s
+ expect(raw_stored_value).to be_present
+ expect(raw_stored_value).not_to include(value)
+ expect(record.public_send(attribute)).to eq(value)
+ expect(record.encrypted_attribute?(attribute)).to be(true)
+ end
+end
diff --git a/spec/workers/conversation_reply_email_worker_spec.rb b/spec/workers/conversation_reply_email_worker_spec.rb
deleted file mode 100644
index 7b28eedde..000000000
--- a/spec/workers/conversation_reply_email_worker_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-require 'rails_helper'
-
-Sidekiq::Testing.fake!
-RSpec.describe ConversationReplyEmailWorker, type: :worker do
- let(:conversation) { build(:conversation, display_id: nil) }
- let(:message) { build(:message, conversation: conversation, content_type: 'incoming_email', inbox: conversation.inbox) }
- let(:mailer) { double }
- let(:mailer_action) { double }
-
- describe 'testing ConversationSummaryEmailWorker' do
- before do
- conversation.save!
- allow(Conversation).to receive(:find).and_return(conversation)
- allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
- allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
- allow(mailer).to receive(:reply_with_summary).and_return(mailer_action)
- allow(mailer).to receive(:reply_without_summary).and_return(mailer_action)
- allow(mailer_action).to receive(:deliver_later).and_return(true)
- end
-
- it 'worker jobs are enqueued in the mailers queue' do
- described_class.perform_async
- expect(described_class.queue).to eq(:mailers)
- end
-
- it 'goes into the jobs array for testing environment' do
- expect do
- described_class.perform_async
- end.to change(described_class.jobs, :size).by(1)
- described_class.new.perform(1, message.id)
- end
-
- context 'with actions performed by the worker' do
- it 'calls ConversationSummaryMailer#reply_with_summary when last incoming message was not email' do
- described_class.new.perform(1, message.id)
- expect(mailer).to have_received(:reply_with_summary)
- end
-
- it 'calls ConversationSummaryMailer#reply_without_summary when last incoming message was from email' do
- message.save!
- described_class.new.perform(1, message.id)
- expect(mailer).to have_received(:reply_without_summary)
- end
- end
- end
-end
diff --git a/spec/workers/email_reply_worker_spec.rb b/spec/workers/email_reply_worker_spec.rb
deleted file mode 100644
index 579378918..000000000
--- a/spec/workers/email_reply_worker_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe EmailReplyWorker, type: :worker do
- let(:account) { create(:account) }
- let(:channel) { create(:channel_email, account: account) }
- let(:message) { create(:message, message_type: :outgoing, inbox: channel.inbox, account: account) }
- let(:private_message) { create(:message, private: true, message_type: :outgoing, inbox: channel.inbox, account: account) }
- let(:incoming_message) { create(:message, message_type: :incoming, inbox: channel.inbox, account: account) }
- let(:template_message) { create(:message, message_type: :template, content_type: :input_csat, inbox: channel.inbox, account: account) }
- let(:mailer) { double }
- let(:mailer_action) { double }
-
- describe '#perform' do
- context 'when emails are successfully sent' do
- before do
- allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
- allow(mailer).to receive(:email_reply).and_return(mailer_action)
- allow(mailer_action).to receive(:deliver_now).and_return(true)
- end
-
- it 'calls mailer action with message' do
- described_class.new.perform(message.id)
- expect(mailer).to have_received(:email_reply).with(message)
- expect(mailer_action).to have_received(:deliver_now)
- end
-
- it 'does not call mailer action with a private message' do
- described_class.new.perform(private_message.id)
- expect(mailer).not_to have_received(:email_reply)
- expect(mailer_action).not_to have_received(:deliver_now)
- end
-
- it 'calls mailer action with a CSAT message' do
- described_class.new.perform(template_message.id)
- expect(mailer).to have_received(:email_reply).with(template_message)
- expect(mailer_action).to have_received(:deliver_now)
- end
-
- it 'does not call mailer action with an incoming message' do
- described_class.new.perform(incoming_message.id)
- expect(mailer).not_to have_received(:email_reply)
- expect(mailer_action).not_to have_received(:deliver_now)
- end
- end
-
- context 'when emails are not sent' do
- before do
- allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
- allow(mailer).to receive(:email_reply).and_return(mailer_action)
- allow(mailer_action).to receive(:deliver_now).and_raise(ArgumentError)
- end
-
- it 'mark message as failed' do
- expect { described_class.new.perform(message.id) }.to change { message.reload.status }.from('sent').to('failed')
- end
- end
- end
-end
diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml
index b244deb7f..c24831f84 100644
--- a/swagger/definitions/index.yml
+++ b/swagger/definitions/index.yml
@@ -248,3 +248,9 @@ contact_conversations_response:
$ref: ./resource/contact_conversations_response.yml
contactable_inboxes_response:
$ref: ./resource/contactable_inboxes_response.yml
+reporting_event:
+ $ref: ./resource/reporting_event.yml
+reporting_event_meta:
+ $ref: ./resource/reporting_event_meta.yml
+reporting_events_list_response:
+ $ref: ./resource/reporting_events_list_response.yml
diff --git a/swagger/definitions/request/webhooks/create_update_payload.yml b/swagger/definitions/request/webhooks/create_update_payload.yml
index 2f4a718e4..485d532e4 100644
--- a/swagger/definitions/request/webhooks/create_update_payload.yml
+++ b/swagger/definitions/request/webhooks/create_update_payload.yml
@@ -4,6 +4,9 @@ properties:
type: string
description: The url where the events should be sent
example: https://example.com/webhook
+ name:
+ type: string
+ description: The name of the webhook
subscriptions:
type: array
items:
diff --git a/swagger/definitions/resource/reporting_event.yml b/swagger/definitions/resource/reporting_event.yml
new file mode 100644
index 000000000..85cf71b8c
--- /dev/null
+++ b/swagger/definitions/resource/reporting_event.yml
@@ -0,0 +1,47 @@
+type: object
+properties:
+ id:
+ type: number
+ description: ID of the reporting event
+ name:
+ type: string
+ description: Name of the event (e.g., first_response, resolution, reply_time)
+ value:
+ type: number
+ format: double
+ description: Value of the metric in seconds
+ value_in_business_hours:
+ type: number
+ format: double
+ description: Value of the metric in seconds, calculated only for business hours
+ event_start_time:
+ type: string
+ format: date-time
+ description: The timestamp when the event started
+ event_end_time:
+ type: string
+ format: date-time
+ description: The timestamp when the event ended
+ account_id:
+ type: number
+ description: ID of the account
+ conversation_id:
+ type: number
+ nullable: true
+ description: ID of the conversation
+ inbox_id:
+ type: number
+ nullable: true
+ description: ID of the inbox
+ user_id:
+ type: number
+ nullable: true
+ description: ID of the user/agent
+ created_at:
+ type: string
+ format: date-time
+ description: The timestamp when the reporting event was created
+ updated_at:
+ type: string
+ format: date-time
+ description: The timestamp when the reporting event was last updated
diff --git a/swagger/definitions/resource/reporting_event_meta.yml b/swagger/definitions/resource/reporting_event_meta.yml
new file mode 100644
index 000000000..5d2646c51
--- /dev/null
+++ b/swagger/definitions/resource/reporting_event_meta.yml
@@ -0,0 +1,11 @@
+type: object
+properties:
+ count:
+ type: integer
+ description: Total number of reporting events
+ current_page:
+ type: integer
+ description: Current page number
+ total_pages:
+ type: integer
+ description: Total number of pages
diff --git a/swagger/definitions/resource/reporting_events_list_response.yml b/swagger/definitions/resource/reporting_events_list_response.yml
new file mode 100644
index 000000000..847c5336a
--- /dev/null
+++ b/swagger/definitions/resource/reporting_events_list_response.yml
@@ -0,0 +1,10 @@
+type: object
+properties:
+ meta:
+ $ref: '#/components/schemas/reporting_event_meta'
+ description: Metadata about the reporting events list response
+ payload:
+ type: array
+ items:
+ $ref: '#/components/schemas/reporting_event'
+ description: List of reporting events
diff --git a/swagger/definitions/resource/webhook.yml b/swagger/definitions/resource/webhook.yml
index a9881b8e4..da5cb112a 100644
--- a/swagger/definitions/resource/webhook.yml
+++ b/swagger/definitions/resource/webhook.yml
@@ -6,6 +6,9 @@ properties:
url:
type: string
description: The url to which the events will be send
+ name:
+ type: string
+ description: The name of the webhook
subscriptions:
type: array
items:
diff --git a/swagger/paths/application/contact_inboxes/create.yml b/swagger/paths/application/contact_inboxes/create.yml
index 64ebeb21c..80984c49f 100644
--- a/swagger/paths/application/contact_inboxes/create.yml
+++ b/swagger/paths/application/contact_inboxes/create.yml
@@ -1,6 +1,6 @@
post:
tags:
- - Contact
+ - Contacts
operationId: contactInboxCreation
description: Create a contact inbox record for an inbox
summary: Create contact inbox
diff --git a/swagger/paths/application/contactable_inboxes/get.yml b/swagger/paths/application/contactable_inboxes/get.yml
index 357c84597..8eea02bbf 100644
--- a/swagger/paths/application/contactable_inboxes/get.yml
+++ b/swagger/paths/application/contactable_inboxes/get.yml
@@ -1,6 +1,6 @@
get:
tags:
- - Contact
+ - Contacts
operationId: contactableInboxesGet
description: Get List of contactable Inboxes
summary: Get Contactable Inboxes
diff --git a/swagger/paths/application/conversation/reporting_events.yml b/swagger/paths/application/conversation/reporting_events.yml
new file mode 100644
index 000000000..f19ccbe8b
--- /dev/null
+++ b/swagger/paths/application/conversation/reporting_events.yml
@@ -0,0 +1,29 @@
+tags:
+ - Conversations
+operationId: get-conversation-reporting-events
+summary: Conversation Reporting Events
+security:
+ - userApiKey: []
+description: Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.
+responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/reporting_event'
+ description: Array of reporting events for the conversation
+ '403':
+ description: Access denied
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '404':
+ description: Conversation not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/reporting_events/index.yml b/swagger/paths/application/reporting_events/index.yml
new file mode 100644
index 000000000..d55c99545
--- /dev/null
+++ b/swagger/paths/application/reporting_events/index.yml
@@ -0,0 +1,47 @@
+tags:
+ - Reports
+operationId: get-account-reporting-events
+summary: Account Reporting Events
+security:
+ - userApiKey: []
+description: Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.
+parameters:
+ - $ref: '#/components/parameters/page'
+ - in: query
+ name: since
+ schema:
+ type: string
+ description: The timestamp from where events should start (Unix timestamp in seconds)
+ - in: query
+ name: until
+ schema:
+ type: string
+ description: The timestamp from where events should stop (Unix timestamp in seconds)
+ - in: query
+ name: inbox_id
+ schema:
+ type: number
+ description: Filter events by inbox ID
+ - in: query
+ name: user_id
+ schema:
+ type: number
+ description: Filter events by user/agent ID
+ - in: query
+ name: name
+ schema:
+ type: string
+ description: Filter events by event name (e.g., first_response, resolution, reply_time)
+responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/reporting_events_list_response'
+ '403':
+ description: Access denied - Only administrators can access this endpoint
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml
index b2fcb4594..d5126a8d1 100644
--- a/swagger/paths/index.yml
+++ b/swagger/paths/index.yml
@@ -389,6 +389,15 @@
post:
$ref: ./application/conversation/labels/create.yml
+# Conversation Reporting Events
+
+/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events:
+ parameters:
+ - $ref: '#/components/parameters/account_id'
+ - $ref: '#/components/parameters/conversation_id'
+ get:
+ $ref: ./application/conversation/reporting_events.yml
+
# Inboxes
/api/v1/accounts/{account_id}/inboxes:
$ref: ./application/inboxes/index.yml
@@ -535,6 +544,13 @@
### Reports
+# Account Reporting Events
+/api/v1/accounts/{account_id}/reporting_events:
+ parameters:
+ - $ref: '#/components/parameters/account_id'
+ get:
+ $ref: ./application/reporting_events/index.yml
+
# List
/api/v2/accounts/{account_id}/reports:
parameters:
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 39022bf84..8d539c259 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -3281,7 +3281,7 @@
"/api/v1/accounts/{account_id}/contacts/{id}/contact_inboxes": {
"post": {
"tags": [
- "Contact"
+ "Contacts"
],
"operationId": "contactInboxCreation",
"description": "Create a contact inbox record for an inbox",
@@ -3366,7 +3366,7 @@
"/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes": {
"get": {
"tags": [
- "Contact"
+ "Contacts"
],
"operationId": "contactableInboxesGet",
"description": "Get List of contactable Inboxes",
@@ -5140,6 +5140,65 @@
}
}
},
+ "/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "$ref": "#/components/parameters/conversation_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Conversations"
+ ],
+ "operationId": "get-conversation-reporting-events",
+ "summary": "Conversation Reporting Events",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "description": "Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.",
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/reporting_event"
+ },
+ "description": "Array of reporting events for the conversation"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Conversation not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/inboxes": {
"get": {
"tags": [
@@ -7311,6 +7370,93 @@
}
}
},
+ "/api/v1/accounts/{account_id}/reporting_events": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Reports"
+ ],
+ "operationId": "get-account-reporting-events",
+ "summary": "Account Reporting Events",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "description": "Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/page"
+ },
+ {
+ "in": "query",
+ "name": "since",
+ "schema": {
+ "type": "string"
+ },
+ "description": "The timestamp from where events should start (Unix timestamp in seconds)"
+ },
+ {
+ "in": "query",
+ "name": "until",
+ "schema": {
+ "type": "string"
+ },
+ "description": "The timestamp from where events should stop (Unix timestamp in seconds)"
+ },
+ {
+ "in": "query",
+ "name": "inbox_id",
+ "schema": {
+ "type": "number"
+ },
+ "description": "Filter events by inbox ID"
+ },
+ {
+ "in": "query",
+ "name": "user_id",
+ "schema": {
+ "type": "number"
+ },
+ "description": "Filter events by user/agent ID"
+ },
+ {
+ "in": "query",
+ "name": "name",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Filter events by event name (e.g., first_response, resolution, reply_time)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/reporting_events_list_response"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied - Only administrators can access this endpoint",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v2/accounts/{account_id}/reports": {
"parameters": [
{
@@ -8981,6 +9127,10 @@
"type": "string",
"description": "The url to which the events will be send"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -10560,6 +10710,10 @@
"description": "The url where the events should be sent",
"example": "https://example.com/webhook"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -12066,6 +12220,100 @@
"description": "List of contactable inboxes for the contact"
}
}
+ },
+ "reporting_event": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the reporting event"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the event (e.g., first_response, resolution, reply_time)"
+ },
+ "value": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds"
+ },
+ "value_in_business_hours": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds, calculated only for business hours"
+ },
+ "event_start_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event started"
+ },
+ "event_end_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event ended"
+ },
+ "account_id": {
+ "type": "number",
+ "description": "ID of the account"
+ },
+ "conversation_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the conversation"
+ },
+ "inbox_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the inbox"
+ },
+ "user_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the user/agent"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was created"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was last updated"
+ }
+ }
+ },
+ "reporting_event_meta": {
+ "type": "object",
+ "properties": {
+ "count": {
+ "type": "integer",
+ "description": "Total number of reporting events"
+ },
+ "current_page": {
+ "type": "integer",
+ "description": "Current page number"
+ },
+ "total_pages": {
+ "type": "integer",
+ "description": "Total number of pages"
+ }
+ }
+ },
+ "reporting_events_list_response": {
+ "type": "object",
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/reporting_event_meta"
+ },
+ "payload": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/reporting_event"
+ },
+ "description": "List of reporting events"
+ }
+ }
}
},
"parameters": {
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index b26b913bc..2fd0cbdee 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -1821,6 +1821,152 @@
}
}
},
+ "/api/v1/accounts/{account_id}/contacts/{id}/contact_inboxes": {
+ "post": {
+ "tags": [
+ "Contacts"
+ ],
+ "operationId": "contactInboxCreation",
+ "description": "Create a contact inbox record for an inbox",
+ "summary": "Create contact inbox",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the contact",
+ "required": true
+ }
+ ],
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "inbox_id"
+ ],
+ "properties": {
+ "inbox_id": {
+ "type": "number",
+ "description": "The ID of the inbox",
+ "example": 1
+ },
+ "source_id": {
+ "type": "string",
+ "description": "Contact Inbox Source Id"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/contact_inboxes"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authentication error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Incorrect payload",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes": {
+ "get": {
+ "tags": [
+ "Contacts"
+ ],
+ "operationId": "contactableInboxesGet",
+ "description": "Get List of contactable Inboxes",
+ "summary": "Get Contactable Inboxes",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the contact",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/contactable_inboxes_response"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authentication error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Incorrect payload",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/automation_rules": {
"parameters": [
{
@@ -3537,6 +3683,65 @@
}
}
},
+ "/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "$ref": "#/components/parameters/conversation_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Conversations"
+ ],
+ "operationId": "get-conversation-reporting-events",
+ "summary": "Conversation Reporting Events",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "description": "Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.",
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/reporting_event"
+ },
+ "description": "Array of reporting events for the conversation"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Conversation not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/inboxes": {
"get": {
"tags": [
@@ -5708,6 +5913,93 @@
}
}
},
+ "/api/v1/accounts/{account_id}/reporting_events": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Reports"
+ ],
+ "operationId": "get-account-reporting-events",
+ "summary": "Account Reporting Events",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "description": "Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/page"
+ },
+ {
+ "in": "query",
+ "name": "since",
+ "schema": {
+ "type": "string"
+ },
+ "description": "The timestamp from where events should start (Unix timestamp in seconds)"
+ },
+ {
+ "in": "query",
+ "name": "until",
+ "schema": {
+ "type": "string"
+ },
+ "description": "The timestamp from where events should stop (Unix timestamp in seconds)"
+ },
+ {
+ "in": "query",
+ "name": "inbox_id",
+ "schema": {
+ "type": "number"
+ },
+ "description": "Filter events by inbox ID"
+ },
+ {
+ "in": "query",
+ "name": "user_id",
+ "schema": {
+ "type": "number"
+ },
+ "description": "Filter events by user/agent ID"
+ },
+ {
+ "in": "query",
+ "name": "name",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Filter events by event name (e.g., first_response, resolution, reply_time)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/reporting_events_list_response"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied - Only administrators can access this endpoint",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v2/accounts/{account_id}/reports": {
"parameters": [
{
@@ -7342,6 +7634,10 @@
"type": "string",
"description": "The url to which the events will be send"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -8921,6 +9217,10 @@
"description": "The url where the events should be sent",
"example": "https://example.com/webhook"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -10427,6 +10727,100 @@
"description": "List of contactable inboxes for the contact"
}
}
+ },
+ "reporting_event": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the reporting event"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the event (e.g., first_response, resolution, reply_time)"
+ },
+ "value": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds"
+ },
+ "value_in_business_hours": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds, calculated only for business hours"
+ },
+ "event_start_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event started"
+ },
+ "event_end_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event ended"
+ },
+ "account_id": {
+ "type": "number",
+ "description": "ID of the account"
+ },
+ "conversation_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the conversation"
+ },
+ "inbox_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the inbox"
+ },
+ "user_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the user/agent"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was created"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was last updated"
+ }
+ }
+ },
+ "reporting_event_meta": {
+ "type": "object",
+ "properties": {
+ "count": {
+ "type": "integer",
+ "description": "Total number of reporting events"
+ },
+ "current_page": {
+ "type": "integer",
+ "description": "Current page number"
+ },
+ "total_pages": {
+ "type": "integer",
+ "description": "Total number of pages"
+ }
+ }
+ },
+ "reporting_events_list_response": {
+ "type": "object",
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/reporting_event_meta"
+ },
+ "payload": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/reporting_event"
+ },
+ "description": "List of reporting events"
+ }
+ }
}
},
"parameters": {
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index 16c1d5bc7..cf356e609 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -1934,6 +1934,10 @@
"type": "string",
"description": "The url to which the events will be send"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -3513,6 +3517,10 @@
"description": "The url where the events should be sent",
"example": "https://example.com/webhook"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -5019,6 +5027,100 @@
"description": "List of contactable inboxes for the contact"
}
}
+ },
+ "reporting_event": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the reporting event"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the event (e.g., first_response, resolution, reply_time)"
+ },
+ "value": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds"
+ },
+ "value_in_business_hours": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds, calculated only for business hours"
+ },
+ "event_start_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event started"
+ },
+ "event_end_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event ended"
+ },
+ "account_id": {
+ "type": "number",
+ "description": "ID of the account"
+ },
+ "conversation_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the conversation"
+ },
+ "inbox_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the inbox"
+ },
+ "user_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the user/agent"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was created"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was last updated"
+ }
+ }
+ },
+ "reporting_event_meta": {
+ "type": "object",
+ "properties": {
+ "count": {
+ "type": "integer",
+ "description": "Total number of reporting events"
+ },
+ "current_page": {
+ "type": "integer",
+ "description": "Current page number"
+ },
+ "total_pages": {
+ "type": "integer",
+ "description": "Total number of pages"
+ }
+ }
+ },
+ "reporting_events_list_response": {
+ "type": "object",
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/reporting_event_meta"
+ },
+ "payload": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/reporting_event"
+ },
+ "description": "List of reporting events"
+ }
+ }
}
},
"parameters": {
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index c8a5294d3..ed1073d0b 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -1349,6 +1349,10 @@
"type": "string",
"description": "The url to which the events will be send"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -2928,6 +2932,10 @@
"description": "The url where the events should be sent",
"example": "https://example.com/webhook"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -4434,6 +4442,100 @@
"description": "List of contactable inboxes for the contact"
}
}
+ },
+ "reporting_event": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the reporting event"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the event (e.g., first_response, resolution, reply_time)"
+ },
+ "value": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds"
+ },
+ "value_in_business_hours": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds, calculated only for business hours"
+ },
+ "event_start_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event started"
+ },
+ "event_end_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event ended"
+ },
+ "account_id": {
+ "type": "number",
+ "description": "ID of the account"
+ },
+ "conversation_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the conversation"
+ },
+ "inbox_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the inbox"
+ },
+ "user_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the user/agent"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was created"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was last updated"
+ }
+ }
+ },
+ "reporting_event_meta": {
+ "type": "object",
+ "properties": {
+ "count": {
+ "type": "integer",
+ "description": "Total number of reporting events"
+ },
+ "current_page": {
+ "type": "integer",
+ "description": "Current page number"
+ },
+ "total_pages": {
+ "type": "integer",
+ "description": "Total number of pages"
+ }
+ }
+ },
+ "reporting_events_list_response": {
+ "type": "object",
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/reporting_event_meta"
+ },
+ "payload": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/reporting_event"
+ },
+ "description": "List of reporting events"
+ }
+ }
}
},
"parameters": {
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index f816b8c94..1bf31a212 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -2110,6 +2110,10 @@
"type": "string",
"description": "The url to which the events will be send"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -3689,6 +3693,10 @@
"description": "The url where the events should be sent",
"example": "https://example.com/webhook"
},
+ "name": {
+ "type": "string",
+ "description": "The name of the webhook"
+ },
"subscriptions": {
"type": "array",
"items": {
@@ -5195,6 +5203,100 @@
"description": "List of contactable inboxes for the contact"
}
}
+ },
+ "reporting_event": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the reporting event"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the event (e.g., first_response, resolution, reply_time)"
+ },
+ "value": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds"
+ },
+ "value_in_business_hours": {
+ "type": "number",
+ "format": "double",
+ "description": "Value of the metric in seconds, calculated only for business hours"
+ },
+ "event_start_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event started"
+ },
+ "event_end_time": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the event ended"
+ },
+ "account_id": {
+ "type": "number",
+ "description": "ID of the account"
+ },
+ "conversation_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the conversation"
+ },
+ "inbox_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the inbox"
+ },
+ "user_id": {
+ "type": "number",
+ "nullable": true,
+ "description": "ID of the user/agent"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was created"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The timestamp when the reporting event was last updated"
+ }
+ }
+ },
+ "reporting_event_meta": {
+ "type": "object",
+ "properties": {
+ "count": {
+ "type": "integer",
+ "description": "Total number of reporting events"
+ },
+ "current_page": {
+ "type": "integer",
+ "description": "Current page number"
+ },
+ "total_pages": {
+ "type": "integer",
+ "description": "Total number of pages"
+ }
+ }
+ },
+ "reporting_events_list_response": {
+ "type": "object",
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/reporting_event_meta"
+ },
+ "payload": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/reporting_event"
+ },
+ "description": "List of reporting events"
+ }
+ }
}
},
"parameters": {
|