|
-
|
@@ -137,7 +151,7 @@ export default {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
>
-
+
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue
index 3c239a3c8..9aab4a5be 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationPreferences.vue
@@ -9,13 +9,13 @@ import {
verifyServiceWorkerExistence,
} from 'dashboard/helper/pushHelper.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
-import FormSwitch from 'v3/components/Form/Switch.vue';
+import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { NOTIFICATION_TYPES } from './constants';
export default {
components: {
TableHeaderCell,
- FormSwitch,
+ ToggleSwitch,
CheckBox,
},
data() {
@@ -284,9 +284,9 @@ export default {
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.BROWSER_PERMISSION') }}
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue
index 4e66f6350..7454c46d4 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue
@@ -10,6 +10,7 @@ import ReportsFiltersRatings from './Filters/Ratings.vue';
import subDays from 'date-fns/subDays';
import { DATE_RANGE_OPTIONS } from '../constants';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
+import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
export default {
components: {
@@ -21,6 +22,7 @@ export default {
ReportsFiltersInboxes,
ReportsFiltersTeams,
ReportsFiltersRatings,
+ ToggleSwitch,
},
props: {
showGroupByFilter: {
@@ -106,11 +108,6 @@ export default {
return this.validGroupOptions[0];
},
},
- watch: {
- businessHoursSelected() {
- this.emitChange();
- },
- },
mounted() {
this.emitChange();
},
@@ -224,7 +221,7 @@ export default {
{{ $t('REPORT.BUSINESS_HOURS') }}
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue
index d7409dc22..fe348417d 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue
@@ -5,6 +5,7 @@ import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
+import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { GROUP_BY_FILTER } from '../constants';
const CUSTOM_DATE_RANGE_ID = 5;
@@ -13,6 +14,7 @@ export default {
components: {
WootDateRangePicker,
Thumbnail,
+ ToggleSwitch,
},
props: {
currentFilter: {
@@ -125,9 +127,6 @@ export default {
groupByFilterItemsList() {
this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
},
- businessHoursSelected() {
- this.$emit('businessHoursToggle', this.businessHoursSelected);
- },
},
mounted() {
this.onDateRangeChange();
@@ -140,6 +139,9 @@ export default {
groupBy: this.groupBy,
});
},
+ onBusinessHoursToggle() {
+ this.$emit('businessHoursToggle', this.businessHoursSelected);
+ },
fromCustomDate(date) {
return getUnixTime(startOfDay(date));
},
@@ -303,7 +305,10 @@ export default {
{{ $t('REPORT.BUSINESS_HOURS') }}
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/SlaForm.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/SlaForm.vue
index 5dc6e16f4..8dd30a99c 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/sla/SlaForm.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/SlaForm.vue
@@ -5,11 +5,13 @@ import validations from './validations';
import SlaTimeInput from './SlaTimeInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useVuelidate } from '@vuelidate/core';
+import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
export default {
components: {
SlaTimeInput,
NextButton,
+ ToggleSwitch,
},
props: {
selectedResponse: {
@@ -203,7 +205,7 @@ export default {
{{ $t('SLA.FORM.BUSINESS_HOURS.PLACEHOLDER') }}
-
+
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 14bd4c2a9..a6b93c923 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -285,6 +285,13 @@ export const actions = {
throw new Error(error);
}
},
+ syncTemplates: async (_, inboxId) => {
+ try {
+ await InboxesAPI.syncTemplates(inboxId);
+ } catch (error) {
+ throw new Error(error);
+ }
+ },
};
export const mutations = {
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
index a91addaa3..edbc3f764 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
@@ -231,4 +231,28 @@ describe('#actions', () => {
).rejects.toThrow(Error);
});
});
+
+ describe('#syncTemplates', () => {
+ it('sends correct API call when sync is successful', async () => {
+ axios.post.mockResolvedValue({
+ data: { message: 'Template sync initiated successfully' },
+ });
+
+ await actions.syncTemplates({ commit }, 123);
+
+ expect(axios.post).toHaveBeenCalledWith(
+ '/api/v1/inboxes/123/sync_templates'
+ );
+ });
+
+ it('throws error when API call fails', async () => {
+ const errorMessage =
+ 'Template sync is only available for WhatsApp channels';
+ axios.post.mockRejectedValue(new Error(errorMessage));
+
+ await expect(actions.syncTemplates({ commit }, 123)).rejects.toThrow(
+ errorMessage
+ );
+ });
+ });
});
diff --git a/app/javascript/entrypoints/dashboard.js b/app/javascript/entrypoints/dashboard.js
index a2184d074..88bd35926 100644
--- a/app/javascript/entrypoints/dashboard.js
+++ b/app/javascript/entrypoints/dashboard.js
@@ -7,7 +7,6 @@ import hljsVuePlugin from '@highlightjs/vue-plugin';
import Multiselect from 'vue-multiselect';
import { plugin, defaultConfig } from '@formkit/vue';
-import WootSwitch from 'components/ui/Switch.vue';
import WootWizard from 'components/ui/Wizard.vue';
import FloatingVue from 'floating-vue';
import WootUiKit from 'dashboard/components';
@@ -90,7 +89,6 @@ app.use(FloatingVue, {
app.use(hljsVuePlugin);
app.component('multiselect', Multiselect);
-app.component('woot-switch', WootSwitch);
app.component('woot-wizard', WootWizard);
app.component('fluent-icon', FluentIcon);
diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js
index f5fa834fc..7b7b4f331 100644
--- a/app/javascript/shared/constants/messages.js
+++ b/app/javascript/shared/constants/messages.js
@@ -157,6 +157,14 @@ export const MESSAGE_VARIABLES = [
label: 'Agent email',
key: 'agent.email',
},
+ {
+ key: 'inbox.name',
+ label: 'Inbox name',
+ },
+ {
+ label: 'Inbox id',
+ key: 'inbox.id',
+ },
];
export const ATTACHMENT_ICONS = {
diff --git a/app/javascript/v3/components/Form/Switch.vue b/app/javascript/v3/components/Form/Switch.vue
deleted file mode 100644
index c7e9e93fb..000000000
--- a/app/javascript/v3/components/Form/Switch.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb
index 33edcaa34..952795604 100644
--- a/app/jobs/account/contacts_export_job.rb
+++ b/app/jobs/account/contacts_export_job.rb
@@ -29,9 +29,9 @@ class Account::ContactsExportJob < ApplicationJob
result = ::Contacts::FilterService.new(@account, @account_user, @params).perform
result[:contacts]
elsif @params[:label].present?
- @account.contacts.resolved_contacts.tagged_with(@params[:label], any: true)
+ @account.contacts.resolved_contacts(use_crm_v2: @account.feature_enabled?('crm_v2')).tagged_with(@params[:label], any: true)
else
- @account.contacts.resolved_contacts
+ @account.contacts.resolved_contacts(use_crm_v2: @account.feature_enabled?('crm_v2'))
end
end
diff --git a/app/jobs/avatar/avatar_from_gravatar_job.rb b/app/jobs/avatar/avatar_from_gravatar_job.rb
index 4a967d74f..46efc3dd8 100644
--- a/app/jobs/avatar/avatar_from_gravatar_job.rb
+++ b/app/jobs/avatar/avatar_from_gravatar_job.rb
@@ -1,5 +1,5 @@
class Avatar::AvatarFromGravatarJob < ApplicationJob
- queue_as :low
+ queue_as :purgable
def perform(avatarable, email)
return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present?
diff --git a/app/jobs/avatar/avatar_from_url_job.rb b/app/jobs/avatar/avatar_from_url_job.rb
index f0d877b54..9996cf3eb 100644
--- a/app/jobs/avatar/avatar_from_url_job.rb
+++ b/app/jobs/avatar/avatar_from_url_job.rb
@@ -1,5 +1,5 @@
class Avatar::AvatarFromUrlJob < ApplicationJob
- queue_as :low
+ queue_as :purgable
def perform(avatarable, avatar_url)
return unless avatarable.respond_to?(:avatar)
diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb
index ba46a5fed..360b227cb 100644
--- a/app/mailers/conversation_reply_mailer.rb
+++ b/app/mailers/conversation_reply_mailer.rb
@@ -4,6 +4,7 @@ class ConversationReplyMailer < ApplicationMailer
attr_reader :large_attachments
include ConversationReplyMailerHelper
+ include ReferencesHeaderBuilder
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot ')
layout :choose_layout
@@ -160,6 +161,7 @@ class ConversationReplyMailer < ApplicationMailer
end
def conversation_reply_email_id
+ # Find the last incoming message's message_id to reply to
content_attributes = @conversation.messages.incoming.last&.content_attributes
if content_attributes && content_attributes['email'] && content_attributes['email']['message_id']
@@ -169,6 +171,10 @@ class ConversationReplyMailer < ApplicationMailer
nil
end
+ def references_header
+ build_references_header(@conversation, in_reply_to_email)
+ end
+
def cc_bcc_emails
content_attributes = @conversation.messages.outgoing.last&.content_attributes
diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb
index d34369832..55f7fe12b 100644
--- a/app/mailers/conversation_reply_mailer_helper.rb
+++ b/app/mailers/conversation_reply_mailer_helper.rb
@@ -6,15 +6,15 @@ module ConversationReplyMailerHelper
reply_to: email_reply_to,
subject: mail_subject,
message_id: custom_message_id,
- in_reply_to: in_reply_to_email
+ in_reply_to: in_reply_to_email,
+ references: references_header
}
if cc_bcc_enabled
@options[:cc] = cc_bcc_emails[0]
@options[:bcc] = cc_bcc_emails[1]
end
- ms_smtp_settings
- google_smtp_settings
+ oauth_smtp_settings
set_delivery_method
# Email type detection logic:
@@ -57,22 +57,17 @@ module ConversationReplyMailerHelper
private
- def google_smtp_settings
- return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.google?
-
- smtp_settings = base_smtp_settings('smtp.gmail.com')
+ def oauth_smtp_settings
+ return unless @inbox.email? && @channel.imap_enabled
+ return unless oauth_provider_domain
@options[:delivery_method] = :smtp
- @options[:delivery_method_options] = smtp_settings
+ @options[:delivery_method_options] = base_smtp_settings(oauth_provider_domain)
end
- def ms_smtp_settings
- return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.microsoft?
-
- smtp_settings = base_smtp_settings('smtp.office365.com')
-
- @options[:delivery_method] = :smtp
- @options[:delivery_method_options] = smtp_settings
+ def oauth_provider_domain
+ return 'smtp.gmail.com' if @inbox.channel.google?
+ return 'smtp.office365.com' if @inbox.channel.microsoft?
end
def base_smtp_settings(domain)
diff --git a/app/mailers/references_header_builder.rb b/app/mailers/references_header_builder.rb
new file mode 100644
index 000000000..a48d7c9c1
--- /dev/null
+++ b/app/mailers/references_header_builder.rb
@@ -0,0 +1,101 @@
+# Builds RFC 5322 compliant References headers for email threading
+#
+# This module provides functionality to construct proper References headers
+# that maintain email conversation threading according to RFC 5322 standards.
+module ReferencesHeaderBuilder
+ # Builds a complete References header for an email reply
+ #
+ # According to RFC 5322, the References header should contain:
+ # - References from the message being replied to (if available)
+ # - The In-Reply-To message ID as the final element
+ # - Proper line folding if the header exceeds 998 characters
+ #
+ # If the message being replied to has no stored References, we use a minimal
+ # approach with only the In-Reply-To message ID rather than rebuilding.
+ #
+ # @param conversation [Conversation] The conversation containing the message thread
+ # @param in_reply_to_message_id [String] The message ID being replied to
+ # @return [String] A properly formatted and folded References header value
+ def build_references_header(conversation, in_reply_to_message_id)
+ references = get_references_from_replied_message(conversation, in_reply_to_message_id)
+ references << in_reply_to_message_id
+
+ references = references.compact.uniq
+ fold_references_header(references)
+ rescue StandardError => e
+ Rails.logger.error("Error building references header for ##{conversation.id}: #{e.message}")
+ ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
+ ''
+ end
+
+ private
+
+ # Gets References header from the message being replied to
+ #
+ # Finds the message by its source_id matching the in_reply_to_message_id
+ # and extracts its stored References header. If no References are found,
+ # we return an empty array (minimal approach - no rebuilding).
+ #
+ # @param conversation [Conversation] The conversation containing the message thread
+ # @param in_reply_to_message_id [String] The message ID being replied to
+ # @return [Array] Array of properly formatted message IDs with angle brackets
+ def get_references_from_replied_message(conversation, in_reply_to_message_id)
+ return [] if in_reply_to_message_id.blank?
+
+ replied_to_message = find_replied_to_message(conversation, in_reply_to_message_id)
+ return [] unless replied_to_message
+
+ extract_references_from_message(replied_to_message)
+ end
+
+ # Finds the message being replied to based on its source_id
+ #
+ # @param conversation [Conversation] The conversation containing the message thread
+ # @param in_reply_to_message_id [String] The message ID to search for
+ # @return [Message, nil] The message being replied to
+ def find_replied_to_message(conversation, in_reply_to_message_id)
+ return nil if in_reply_to_message_id.blank?
+
+ # Remove angle brackets if present for comparison
+ normalized_id = in_reply_to_message_id.gsub(/[<>]/, '')
+
+ # Use database query to find the message efficiently
+ # Search for exact match or with angle brackets
+ conversation.messages
+ .where.not(source_id: nil)
+ .where('source_id = ? OR source_id = ? OR source_id = ?',
+ normalized_id,
+ "<#{normalized_id}>",
+ in_reply_to_message_id)
+ .first
+ end
+
+ # Extracts References header from a message's content_attributes
+ #
+ # @param message [Message] The message to extract References from
+ # @return [Array] Array of properly formatted message IDs with angle brackets
+ def extract_references_from_message(message)
+ return [] unless message.content_attributes&.dig('email', 'references')
+
+ references = message.content_attributes['email']['references']
+ Array.wrap(references).map do |ref|
+ ref.start_with?('<') ? ref : "<#{ref}>"
+ end
+ end
+
+ # Folds References header to comply with RFC 5322 line folding requirements
+ #
+ # RFC 5322 requires that continuation lines in folded headers start with
+ # whitespace (space or tab). This method joins message IDs with CRLF + space,
+ # ensuring the first line has no leading space and all continuation lines
+ # start with a space as required by the RFC.
+ #
+ # @param references_array [Array] Array of message IDs to be folded
+ # @return [String] A properly folded header value with CRLF line endings
+ def fold_references_header(references_array)
+ return '' if references_array.empty?
+ return references_array.first if references_array.size == 1
+
+ references_array.join("\r\n ")
+ end
+end
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index b7bfd0da7..8d054a586 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord
after_create :sync_templates
after_create_commit :setup_webhooks
+ before_destroy :teardown_webhooks
def name
'Whatsapp'
@@ -105,4 +106,8 @@ class Channel::Whatsapp < ApplicationRecord
# Don't raise the error to prevent channel creation from failing
# Webhooks can be retried later
end
+
+ def teardown_webhooks
+ Whatsapp::WebhookTeardownService.new(self).perform
+ end
end
diff --git a/app/models/contact.rb b/app/models/contact.rb
index 83920d9fc..a3570b2af 100644
--- a/app/models/contact.rb
+++ b/app/models/contact.rb
@@ -25,6 +25,7 @@
# Indexes
#
# index_contacts_on_account_id (account_id)
+# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
@@ -175,8 +176,12 @@ class Contact < ApplicationRecord
}
end
- def self.resolved_contacts
- where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
+ def self.resolved_contacts(use_crm_v2: false)
+ if use_crm_v2
+ where(contact_type: 'lead')
+ else
+ where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
+ end
end
def discard_invalid_attrs
diff --git a/app/models/portal.rb b/app/models/portal.rb
index cb87929f5..a62ba8e4d 100644
--- a/app/models/portal.rb
+++ b/app/models/portal.rb
@@ -12,6 +12,7 @@
# name :string not null
# page_title :string
# slug :string not null
+# ssl_settings :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@@ -69,3 +70,5 @@ class Portal < ApplicationRecord
errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
end
end
+
+Portal.include_mod_with('Concerns::Portal')
diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb
index 9c2d3c094..8a6f81484 100644
--- a/app/policies/inbox_policy.rb
+++ b/app/policies/inbox_policy.rb
@@ -57,4 +57,8 @@ class InboxPolicy < ApplicationPolicy
def avatar?
@account_user.administrator?
end
+
+ def sync_templates?
+ @account_user.administrator?
+ end
end
diff --git a/app/presenters/mail_presenter.rb b/app/presenters/mail_presenter.rb
index 1c951cbe3..890e97a78 100644
--- a/app/presenters/mail_presenter.rb
+++ b/app/presenters/mail_presenter.rb
@@ -100,6 +100,7 @@ class MailPresenter < SimpleDelegator
message_id: message_id,
multipart: multipart?,
number_of_attachments: number_of_attachments,
+ references: references,
subject: subject,
text_content: text_content,
to: to
@@ -115,6 +116,12 @@ class MailPresenter < SimpleDelegator
@mail.in_reply_to.is_a?(Array) ? @mail.in_reply_to.first : @mail.in_reply_to
end
+ def references
+ return [] if @mail.references.blank?
+
+ Array.wrap(@mail.references)
+ end
+
def from
# changing to downcase to avoid case mismatch while finding contact
(@mail.reply_to.presence || @mail.from).map(&:downcase)
diff --git a/app/services/instagram/message_text.rb b/app/services/instagram/message_text.rb
index e4b88c64d..3b371a2f4 100644
--- a/app/services/instagram/message_text.rb
+++ b/app/services/instagram/message_text.rb
@@ -14,10 +14,11 @@ class Instagram::MessageText < Instagram::BaseMessageText
return process_successful_response(response) if response.success?
- handle_error_response(response)
- {}
+ handle_error_response(response, ig_scope_id) || {}
end
+ private
+
def process_successful_response(response)
result = JSON.parse(response.body).with_indifferent_access
{
@@ -32,8 +33,9 @@ class Instagram::MessageText < Instagram::BaseMessageText
}.with_indifferent_access
end
- def handle_error_response(response)
+ def handle_error_response(response, ig_scope_id)
parsed_response = response.parsed_response
+ parsed_response = JSON.parse(parsed_response) if parsed_response.is_a?(String)
error_message = parsed_response.dig('error', 'message')
error_code = parsed_response.dig('error', 'code')
@@ -50,10 +52,17 @@ class Instagram::MessageText < Instagram::BaseMessageText
# We can safely ignore this error.
return if error_code == 230
- Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
+ # The error occurs when Facebook tries to validate the Facebook App created to authorize Instagram integration.
+ # The Facebook's agent uses a Bot to make tests on the App where is not a valid user via API,
+ # returning `{"error"=>{"message"=>"No matching Instagram user", "type"=>"IGApiException", "code"=>9010}}`.
+ # Then Facebook rejects the request saying this app is still not ready once the integration with Instagram didn't work.
+ # We can safely create an unknown contact, making this integration work.
+ return unknown_user(ig_scope_id) if error_code == 9010
+
+ Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id} ig_scope_id #{ig_scope_id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
- exception = StandardError.new("#{error_message} (Code: #{error_code})")
+ exception = StandardError.new("#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})")
ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception
end
@@ -66,4 +75,11 @@ class Instagram::MessageText < Instagram::BaseMessageText
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
+
+ def unknown_user(ig_scope_id)
+ {
+ 'name' => "Unknown (IG: #{ig_scope_id})",
+ 'id' => ig_scope_id
+ }.with_indifferent_access
+ end
end
diff --git a/app/services/instagram/messenger/message_text.rb b/app/services/instagram/messenger/message_text.rb
index ed544263e..f383bcdca 100644
--- a/app/services/instagram/messenger/message_text.rb
+++ b/app/services/instagram/messenger/message_text.rb
@@ -33,6 +33,16 @@ class Instagram::Messenger::MessageText < Instagram::BaseMessageText
return
end
+ # Handle error code 9010: No matching Instagram user
+ # This occurs when trying to fetch an Instagram user that doesn't exist or is not accessible
+ # We can safely ignore this error and return empty result
+ if error.message.include?('9010')
+ Rails.logger.warn("[Instagram User Not Found]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
+ Rails.logger.warn("[Instagram User Not Found]: #{error.message}")
+ Rails.logger.warn("[Instagram User Not Found]: #{error}")
+ return
+ end
+
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 7fce0a7dd..56c15b2f3 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -1,6 +1,10 @@
class SearchService
pattr_initialize [:current_user!, :current_account!, :params!, :search_type!]
+ def account_user
+ @account_user ||= current_account.account_users.find_by(user: current_user)
+ end
+
def perform
case search_type
when 'Message'
@@ -78,8 +82,17 @@ class SearchService
end
def message_base_query
- current_account.messages.where(inbox_id: accessable_inbox_ids)
- .where('created_at >= ?', 3.months.ago)
+ query = current_account.messages.where('created_at >= ?', 3.months.ago)
+ query = query.where(inbox_id: accessable_inbox_ids) unless should_skip_inbox_filtering?
+ query
+ end
+
+ def should_skip_inbox_filtering?
+ account_user.administrator? || user_has_access_to_all_inboxes?
+ end
+
+ def user_has_access_to_all_inboxes?
+ accessable_inbox_ids.sort == current_account.inboxes.pluck(:id).sort
end
def use_gin_search
@@ -90,7 +103,9 @@ class SearchService
@contacts = current_account.contacts.where(
"name ILIKE :search OR email ILIKE :search OR phone_number
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
- ).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
+ ).resolved_contacts(
+ use_crm_v2: current_account.feature_enabled?('crm_v2')
+ ).order_on_last_activity_at('desc').page(params[:page]).per(15)
end
def filter_articles
diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb
index 1aebdad2a..441e41b1a 100644
--- a/app/services/whatsapp/facebook_api_client.rb
+++ b/app/services/whatsapp/facebook_api_client.rb
@@ -63,6 +63,15 @@ class Whatsapp::FacebookApiClient
handle_response(response, 'Webhook subscription failed')
end
+ def unsubscribe_waba_webhook(waba_id)
+ response = HTTParty.delete(
+ "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
+ headers: request_headers
+ )
+
+ handle_response(response, 'Webhook unsubscription failed')
+ end
+
private
def request_headers
diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb
new file mode 100644
index 000000000..c4a39a5eb
--- /dev/null
+++ b/app/services/whatsapp/webhook_teardown_service.rb
@@ -0,0 +1,47 @@
+class Whatsapp::WebhookTeardownService
+ def initialize(channel)
+ @channel = channel
+ end
+
+ def perform
+ return unless should_teardown_webhook?
+
+ teardown_webhook
+ rescue StandardError => e
+ handle_webhook_teardown_error(e)
+ end
+
+ private
+
+ def should_teardown_webhook?
+ whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
+ end
+
+ def whatsapp_cloud_provider?
+ @channel.provider == 'whatsapp_cloud'
+ end
+
+ def embedded_signup_source?
+ @channel.provider_config['source'] == 'embedded_signup'
+ end
+
+ def webhook_config_present?
+ @channel.provider_config['business_account_id'].present? &&
+ @channel.provider_config['api_key'].present?
+ end
+
+ def teardown_webhook
+ waba_id = @channel.provider_config['business_account_id']
+ access_token = @channel.provider_config['api_key']
+ api_client = Whatsapp::FacebookApiClient.new(access_token)
+
+ api_client.unsubscribe_waba_webhook(waba_id)
+ Rails.logger.info "[WHATSAPP] Webhook unsubscribed successfully for channel #{@channel.id}"
+ end
+
+ def handle_webhook_teardown_error(error)
+ Rails.logger.error "[WHATSAPP] Webhook teardown failed: #{error.message}"
+ # Don't raise the error to prevent channel deletion from failing
+ # Failed webhook teardown shouldn't block deletion
+ end
+end
diff --git a/config/features.yml b/config/features.yml
index 4706c46f0..db2d46700 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -187,3 +187,7 @@
- name: whatsapp_campaign
display_name: WhatsApp Campaign
enabled: false
+- name: crm_v2
+ display_name: CRM V2
+ enabled: false
+ chatwoot_internal: true
diff --git a/config/initializers/facebook_messenger.rb b/config/initializers/facebook_messenger.rb
index 354687cbd..f93829e65 100644
--- a/config/initializers/facebook_messenger.rb
+++ b/config/initializers/facebook_messenger.rb
@@ -39,6 +39,8 @@ Rails.application.reloader.to_prepare do
end
Facebook::Messenger::Bot.on :message_echo do |message|
- Webhooks::FacebookEventsJob.perform_later(message.to_json)
+ # Add delay to prevent race condition where echo arrives before send message API completes
+ # This avoids duplicate messages when echo comes early during API processing
+ Webhooks::FacebookEventsJob.set(wait: 2.seconds).perform_later(message.to_json)
end
end
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 2e8d94a96..53251fc3c 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -170,6 +170,10 @@
display_title: 'OpenAI Model'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini'
locked: false
+- name: CAPTAIN_OPEN_AI_ENDPOINT
+ display_title: 'OpenAI API Endpoint (optional)'
+ description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
+ locked: false
- name: CAPTAIN_FIRECRAWL_API_KEY
display_title: 'FireCrawl API Key (optional)'
description: 'The FireCrawl API key for the Captain AI service'
@@ -416,3 +420,17 @@
locked: false
description: 'The redirect URI configured in your Google OAuth app'
## ------ End of Configs added for Google OAuth ------ ##
+
+## ------ Configs added for Cloudflare ------ ##
+- name: CLOUDFLARE_API_KEY
+ display_title: 'Cloudflare API Key'
+ value:
+ locked: false
+ description: 'API key for Cloudflare account authentication'
+ type: secret
+- name: CLOUDFLARE_ZONE_ID
+ display_title: 'Cloudflare Zone ID'
+ value:
+ locked: false
+ description: 'Zone ID for the Cloudflare domain'
+## ------ End of Configs added for Cloudflare ------ ##
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index afb5800c7..437f35eeb 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -91,7 +91,7 @@ ar:
conversations_count: عدد المحادثات
avg_first_response_time: متوسط وقت الرد الأول
avg_resolution_time: متوسط وقت الحل
- avg_reply_time: Avg reply time
+ avg_reply_time: معدل وقت الرد
resolution_count: عدد مرات الإغلاق
team_csv:
team_name: اسم الفريق
@@ -135,19 +135,19 @@ ar:
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}'
status:
resolved: 'تم تحديث حالة المحادثة لـ"مغلقة" بواسطة %{user_name}'
contact_resolved: 'تم حل المحادثة بواسطة %{contact_name}'
@@ -155,8 +155,8 @@ ar:
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}'
@@ -244,7 +244,7 @@ ar:
short_description: 'Create and link Linear issues directly from conversations.'
description: 'إنشاء مشكلات في Linear مباشرة من نافذة المحادثة الخاصة بك. بدلاً من ذلك، قم بربط مشكلات Linear القائمة من أجل عملية تتبع أكثر تبسيطاً وكفاءة.'
notion:
- name: 'Notion'
+ name: 'نوشن'
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:
diff --git a/config/routes.rb b/config/routes.rb
index 681c21e44..c12aa670b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -185,6 +185,7 @@ Rails.application.routes.draw do
get :agent_bot, on: :member
post :set_agent_bot, on: :member
delete :avatar, on: :member
+ post :sync_templates, on: :member
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
@@ -516,6 +517,7 @@ Rails.application.routes.draw do
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
get '.well-known/apple-app-site-association' => 'apple_app#site_association'
get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_association'
+ get '.well-known/cf-custom-hostname-challenge/:id', to: 'custom_domains#verify'
# ----------------------------------------------------------------------
# Internal Monitoring Routes
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index d2d764382..50a47a20b 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -24,6 +24,7 @@
- low
- scheduled_jobs
- deferred
+ - purgable
- housekeeping
- async_database_migration
- active_storage_analysis
diff --git a/db/migrate/20250722083820_add_ssl_settings_to_portals.rb b/db/migrate/20250722083820_add_ssl_settings_to_portals.rb
new file mode 100644
index 000000000..dc59bf89b
--- /dev/null
+++ b/db/migrate/20250722083820_add_ssl_settings_to_portals.rb
@@ -0,0 +1,5 @@
+class AddSslSettingsToPortals < ActiveRecord::Migration[7.1]
+ def change
+ add_column :portals, :ssl_settings, :jsonb, default: {}, null: false
+ end
+end
diff --git a/db/migrate/20250722152516_add_index_on_contact_type_and_account_id_to_contacts.rb b/db/migrate/20250722152516_add_index_on_contact_type_and_account_id_to_contacts.rb
new file mode 100644
index 000000000..1233e417c
--- /dev/null
+++ b/db/migrate/20250722152516_add_index_on_contact_type_and_account_id_to_contacts.rb
@@ -0,0 +1,7 @@
+class AddIndexOnContactTypeAndAccountIdToContacts < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+
+ def change
+ add_index :contacts, [:account_id, :contact_type], name: 'index_contacts_on_account_id_and_contact_type', algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 34637315b..eb7409b8b 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_07_14_104358) do
+ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -539,6 +539,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
+ t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
t.index ["account_id"], name: "index_contacts_on_account_id"
@@ -942,6 +943,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
t.jsonb "config", default: {"allowed_locales" => ["en"]}
t.boolean "archived", default: false
t.bigint "channel_web_widget_id"
+ t.jsonb "ssl_settings", default: {}, null: false
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
t.index ["custom_domain"], name: "index_portals_on_custom_domain", unique: true
t.index ["slug"], name: "index_portals_on_slug", unique: true
diff --git a/deployment/chatwoot-web.target b/deployment/chatwoot-web.target
new file mode 100644
index 000000000..759c7c443
--- /dev/null
+++ b/deployment/chatwoot-web.target
@@ -0,0 +1,6 @@
+[Unit]
+Description=Chatwoot Web Server
+Wants=chatwoot-web.1.service
+
+[Install]
+WantedBy=multi-user.target
\ No newline at end of file
diff --git a/deployment/chatwoot-worker.target b/deployment/chatwoot-worker.target
new file mode 100644
index 000000000..265cb1156
--- /dev/null
+++ b/deployment/chatwoot-worker.target
@@ -0,0 +1,6 @@
+[Unit]
+Description=Chatwoot Background Worker
+Wants=chatwoot-worker.1.service
+
+[Install]
+WantedBy=multi-user.target
\ No newline at end of file
diff --git a/deployment/setup_20.04.sh b/deployment/setup_20.04.sh
index 75320bbd3..e904441a9 100644
--- a/deployment/setup_20.04.sh
+++ b/deployment/setup_20.04.sh
@@ -2,7 +2,7 @@
# Description: Install and manage a Chatwoot installation.
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
-# Script Version: 3.2.0
+# Script Version: 3.4.0
# Run this script as root
set -eu -o errexit -o pipefail -o noclobber -o nounset
@@ -17,9 +17,9 @@ fi
# Global variables
# option --output/-o requires 1 argument
-LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,webserver,version
-OPTIONS=cdhiI:l:rsuwv
-CWCTL_VERSION="3.2.0"
+LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,Upgrade:,webserver,version,web-only,worker-only,convert:
+OPTIONS=cdhiI:l:rsuU:wvWK
+CWCTL_VERSION="3.3.0"
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
CHATWOOT_HUB_URL="https://hub.2.chatwoot.com/events"
@@ -42,7 +42,7 @@ fi
# read getopt’s output this way to handle the quoting right:
eval set -- "$PARSED"
-c=n d=n h=n i=n I=n l=n r=n s=n u=n w=n v=n BRANCH=master SERVICE=web
+c=n d=n h=n i=n I=n l=n r=n s=n u=n U=n w=n v=n W=n K=n C=n BRANCH=master SERVICE=web DEPLOYMENT_TYPE=full CONVERT_TO=""
# Iterate options in order and nicely split until we see --
while true; do
case "$1" in
@@ -83,6 +83,12 @@ while true; do
;;
-u|--upgrade)
u=y
+ BRANCH="master"
+ break
+ ;;
+ -U|--Upgrade)
+ U=y
+ BRANCH="$2"
break
;;
-w|--webserver)
@@ -93,6 +99,36 @@ while true; do
v=y
shift
;;
+ -W|--web-only)
+ W=y
+ DEPLOYMENT_TYPE=web
+ shift
+ ;;
+ -K|--worker-only)
+ K=y
+ DEPLOYMENT_TYPE=worker
+ shift
+ ;;
+ --convert)
+ C=y
+ CONVERT_TO="$2"
+ case "$CONVERT_TO" in
+ web)
+ DEPLOYMENT_TYPE=web
+ ;;
+ worker)
+ DEPLOYMENT_TYPE=worker
+ ;;
+ full)
+ DEPLOYMENT_TYPE=full
+ ;;
+ *)
+ echo "Invalid conversion type. Use: web, worker, or full"
+ exit 3
+ ;;
+ esac
+ shift 2
+ ;;
--)
shift
break
@@ -107,7 +143,7 @@ done
# log if debug flag set
if [ "$d" == "y" ]; then
echo "console: $c, debug: $d, help: $h, install: $i, Install: $I, BRANCH: $BRANCH, \
- logs: $l, SERVICE: $SERVICE, ssl: $s, upgrade: $u, webserver: $w"
+ logs: $l, SERVICE: $SERVICE, ssl: $s, upgrade: $u, Upgrade: $U, webserver: $w, web-only: $W, worker-only: $K, convert: $C, convert-to: $CONVERT_TO, deployment-type: $DEPLOYMENT_TYPE"
fi
# exit if script is not run as root
@@ -379,23 +415,98 @@ EOF
##############################################################################
# Setup Chatwoot systemd services and cwctl CLI
# Globals:
-# None
+# DEPLOYMENT_TYPE
# Arguments:
# None
# Outputs:
# None
##############################################################################
function configure_systemd_services() {
- cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service
- cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service
- cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target
+ # Check if this is a conversion from existing deployment
+ local existing_full_deployment=false
+ if [ -f "/etc/systemd/system/chatwoot.target" ]; then
+ existing_full_deployment=true
+ fi
+
+ if [ "$DEPLOYMENT_TYPE" == "web" ]; then
+ echo "Setting up web-only deployment"
+
+ # Stop and disable existing services if converting
+ if [ "$existing_full_deployment" = true ]; then
+ echo "Converting from full deployment to web-only"
+ systemctl stop chatwoot.target || true
+ systemctl disable chatwoot.target || true
+ systemctl stop chatwoot-worker.1.service || true
+ systemctl disable chatwoot-worker.1.service || true
+ fi
+
+ # Stop and disable worker target if converting from worker-only
+ if [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then
+ echo "Converting from worker-only to web-only"
+ systemctl stop chatwoot-worker.target || true
+ systemctl disable chatwoot-worker.target || true
+ fi
+
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-web.target /etc/systemd/system/chatwoot-web.target
+
+ systemctl daemon-reload
+ systemctl enable chatwoot-web.target
+ systemctl start chatwoot-web.target
+
+ elif [ "$DEPLOYMENT_TYPE" == "worker" ]; then
+ echo "Setting up worker-only deployment"
+
+ # Stop and disable existing services if converting
+ if [ "$existing_full_deployment" = true ]; then
+ echo "Converting from full deployment to worker-only"
+ systemctl stop chatwoot.target || true
+ systemctl disable chatwoot.target || true
+ systemctl stop chatwoot-web.1.service || true
+ systemctl disable chatwoot-web.1.service || true
+ fi
+
+ # Stop and disable web target if converting from web-only
+ if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then
+ echo "Converting from web-only to worker-only"
+ systemctl stop chatwoot-web.target || true
+ systemctl disable chatwoot-web.target || true
+ fi
+
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.target /etc/systemd/system/chatwoot-worker.target
+
+ systemctl daemon-reload
+ systemctl enable chatwoot-worker.target
+ systemctl start chatwoot-worker.target
+
+ else
+ echo "Setting up full deployment (web + worker)"
+
+ # Stop existing specialized deployments if converting back to full
+ if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then
+ echo "Converting from web-only to full deployment"
+ systemctl stop chatwoot-web.target || true
+ systemctl disable chatwoot-web.target || true
+ fi
+ if [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then
+ echo "Converting from worker-only to full deployment"
+ systemctl stop chatwoot-worker.target || true
+ systemctl disable chatwoot-worker.target || true
+ fi
+
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service
+ cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target
+
+ systemctl daemon-reload
+ systemctl enable chatwoot.target
+ systemctl start chatwoot.target
+ fi
cp /home/chatwoot/chatwoot/deployment/chatwoot /etc/sudoers.d/chatwoot
cp /home/chatwoot/chatwoot/deployment/setup_20.04.sh /usr/local/bin/cwctl
chmod +x /usr/local/bin/cwctl
-
- systemctl enable chatwoot.target
- systemctl start chatwoot.target
}
##############################################################################
@@ -427,7 +538,15 @@ function setup_ssl() {
cd chatwoot
sed -i "s/http:\/\/0.0.0.0:3000/https:\/\/$domain_name/g" .env
EOF
- systemctl restart chatwoot.target
+
+ # Restart the appropriate chatwoot target
+ if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then
+ systemctl restart chatwoot-web.target
+ elif [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then
+ systemctl restart chatwoot-worker.target
+ else
+ systemctl restart chatwoot.target
+ fi
}
##############################################################################
@@ -624,17 +743,27 @@ Usage: cwctl [OPTION]...
Install and manage your Chatwoot installation.
Example: cwctl -i master
+Example: cwctl -i --web-only (for web server ASG)
+Example: cwctl -i --worker-only (for worker ASG)
+Example: cwctl --convert web (convert existing to web-only)
+Example: cwctl --convert worker (convert existing to worker-only)
+Example: cwctl --convert full (convert back to full deployment)
+Example: cwctl --upgrade (upgrade to latest master)
+Example: cwctl -U develop (upgrade to develop branch)
Example: cwctl -l web
Example: cwctl --logs worker
-Example: cwctl --upgrade
Example: cwctl -c
Installation/Upgrade:
-i, --install Install the latest stable version of Chatwoot
- -I Install Chatwoot from a git branch
+ -I BRANCH Install Chatwoot from a git branch
-u, --upgrade Upgrade Chatwoot to the latest stable version
+ -U BRANCH Upgrade Chatwoot from a git branch (EXPERIMENTAL)
-s, --ssl Fetch and install SSL certificates using LetsEncrypt
-w, --webserver Install and configure Nginx webserver with SSL
+ -W, --web-only Install only the web server (for ASG deployment)
+ -K, --worker-only Install only the background worker (for ASG deployment)
+ --convert TYPE Convert existing deployment (TYPE: web, worker, full)
Management:
-c, --console Open ruby console
@@ -831,7 +960,31 @@ EOF
function upgrade() {
cwctl_upgrade_check
get_cw_version
- echo "Upgrading Chatwoot to v$CW_VERSION"
+ echo "Upgrading Chatwoot to v$CW_VERSION (branch: $BRANCH)"
+
+ # Warning for non-master branch upgrades
+ if [ "$BRANCH" != "master" ]; then
+ cat << EOF
+
+⚠️ WARNING: Branch-specific upgrades are EXPERIMENTAL
+⚠️ Switching between different versions/branches may cause:
+ - Database migration conflicts
+ - Asset compilation errors
+ - Configuration incompatibilities
+ - Data corruption or loss
+
+⚠️ This is NOT recommended for production environments.
+⚠️ Always backup your database before proceeding.
+
+EOF
+ read -p "Do you understand the risks and want to continue? [y/N]: " user_input
+ user_input=${user_input:-N}
+ if [[ ! "$user_input" =~ ^([yY][eE][sS]|[yY])$ ]]; then
+ echo "Upgrade cancelled."
+ exit 1
+ fi
+ fi
+
sleep 3
# Check if CW_VERSION is 4.0 or above
@@ -857,8 +1010,9 @@ function upgrade() {
# Navigate to the Chatwoot directory
cd chatwoot
- # Pull the latest version of the master branch
- git checkout master && git pull
+ # Pull the latest version of the specified branch
+ git fetch
+ git checkout "$BRANCH" && git pull
# Ensure the ruby version is upto date
# Parse the latest ruby version
@@ -878,18 +1032,35 @@ function upgrade() {
EOF
- # Copy the updated targets
- cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service
- cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service
- cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target
+ # Copy the updated services and targets based on existing deployment
+ if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then
+ echo "Updating web-only deployment"
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-web.target /etc/systemd/system/chatwoot-web.target
+ elif [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then
+ echo "Updating worker-only deployment"
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.target /etc/systemd/system/chatwoot-worker.target
+ else
+ echo "Updating full deployment"
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service
+ cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service
+ cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target
+ fi
cp /home/chatwoot/chatwoot/deployment/chatwoot /etc/sudoers.d/chatwoot
# TODO:(@vn) handle cwctl updates
systemctl daemon-reload
- # Restart the chatwoot server
- systemctl restart chatwoot.target
+ # Restart the appropriate chatwoot target
+ if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then
+ systemctl restart chatwoot-web.target
+ elif [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then
+ systemctl restart chatwoot-worker.target
+ else
+ systemctl restart chatwoot.target
+ fi
}
@@ -903,8 +1074,40 @@ EOF
# None
##############################################################################
function restart() {
- systemctl restart chatwoot.target
- systemctl status chatwoot.target
+ if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then
+ systemctl restart chatwoot-web.target
+ systemctl status chatwoot-web.target
+ elif [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then
+ systemctl restart chatwoot-worker.target
+ systemctl status chatwoot-worker.target
+ else
+ systemctl restart chatwoot.target
+ systemctl status chatwoot.target
+ fi
+}
+
+##############################################################################
+# Convert existing Chatwoot deployment to different type (--convert)
+# Globals:
+# DEPLOYMENT_TYPE
+# Arguments:
+# None
+# Outputs:
+# None
+##############################################################################
+function convert_deployment() {
+ echo "Converting Chatwoot deployment to: $DEPLOYMENT_TYPE"
+
+ # Check if Chatwoot is installed
+ if [ ! -d "/home/chatwoot/chatwoot" ]; then
+ echo "Chatwoot installation not found. Use --install first."
+ exit 1
+ fi
+
+ # Run the systemd service configuration which handles conversion logic
+ configure_systemd_services
+
+ echo "Deployment converted successfully to: $DEPLOYMENT_TYPE"
}
##############################################################################
@@ -1107,7 +1310,7 @@ function main() {
ssl
fi
- if [ "$u" == "y" ]; then
+ if [ "$u" == "y" ] || [ "$U" == "y" ]; then
report_event "cwctl" "upgrade" > /dev/null 2>&1
upgrade
fi
@@ -1122,6 +1325,11 @@ function main() {
version
fi
+ if [ "$C" == "y" ]; then
+ report_event "cwctl" "convert" > /dev/null 2>&1
+ convert_deployment
+ fi
+
}
main "$@"
diff --git a/enterprise/app/controllers/custom_domains_controller.rb b/enterprise/app/controllers/custom_domains_controller.rb
new file mode 100644
index 000000000..bdba869a8
--- /dev/null
+++ b/enterprise/app/controllers/custom_domains_controller.rb
@@ -0,0 +1,22 @@
+class CustomDomainsController < ApplicationController
+ def verify
+ challenge_id = permitted_params[:id]
+
+ domain = request.host
+ portal = Portal.find_by(custom_domain: domain)
+
+ return render plain: 'Domain not found', status: :not_found unless portal
+
+ ssl_settings = portal.ssl_settings || {}
+
+ return render plain: 'Challenge ID not found', status: :not_found unless ssl_settings['cf_verification_id'] == challenge_id
+
+ render plain: ssl_settings['cf_verification_body'], status: :ok
+ end
+
+ private
+
+ def permitted_params
+ params.permit(:id)
+ end
+end
diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
index 67ce3a6a9..5466c86a4 100644
--- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
+++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
@@ -10,7 +10,7 @@ module Enterprise::SuperAdmin::AppConfigsController
when 'internal'
@allowed_configs = internal_config_options
when 'captain'
- @allowed_configs = %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_FIRECRAWL_API_KEY]
+ @allowed_configs = %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT CAPTAIN_FIRECRAWL_API_KEY]
else
super
end
@@ -34,6 +34,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
- OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF]
+ OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID]
end
end
diff --git a/enterprise/app/jobs/enterprise/cloudflare_verification_job.rb b/enterprise/app/jobs/enterprise/cloudflare_verification_job.rb
new file mode 100644
index 000000000..9b4fd64bd
--- /dev/null
+++ b/enterprise/app/jobs/enterprise/cloudflare_verification_job.rb
@@ -0,0 +1,22 @@
+class Enterprise::CloudflareVerificationJob < ApplicationJob
+ queue_as :default
+
+ def perform(portal_id)
+ portal = Portal.find(portal_id)
+ return unless portal && portal.custom_domain.present?
+
+ result = check_hostname_status(portal)
+
+ create_hostname(portal) if result[:errors].present?
+ end
+
+ private
+
+ def create_hostname(portal)
+ Cloudflare::CreateCustomHostnameService.new(portal: portal).perform
+ end
+
+ def check_hostname_status(portal)
+ Cloudflare::CheckCustomHostnameService.new(portal: portal).perform
+ end
+end
diff --git a/enterprise/app/models/enterprise/concerns/article.rb b/enterprise/app/models/enterprise/concerns/article.rb
index b7de767ad..d3a94d7b7 100644
--- a/enterprise/app/models/enterprise/concerns/article.rb
+++ b/enterprise/app/models/enterprise/concerns/article.rb
@@ -68,8 +68,16 @@ module Enterprise::Concerns::Article
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" }
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
Rails.logger.info "Requesting Chat GPT with body: #{body}"
- response = HTTParty.post('https://api.openai.com/v1/chat/completions', headers: headers, body: body)
+ response = HTTParty.post(openai_api_url, headers: headers, body: body)
Rails.logger.info "Chat GPT response: #{response.body}"
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
end
+
+ private
+
+ def openai_api_url
+ endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
+ endpoint = endpoint.chomp('/')
+ "#{endpoint}/v1/chat/completions"
+ end
end
diff --git a/enterprise/app/models/enterprise/concerns/portal.rb b/enterprise/app/models/enterprise/concerns/portal.rb
new file mode 100644
index 000000000..9ca76b7b9
--- /dev/null
+++ b/enterprise/app/models/enterprise/concerns/portal.rb
@@ -0,0 +1,14 @@
+module Enterprise::Concerns::Portal
+ extend ActiveSupport::Concern
+
+ included do
+ after_save :enqueue_cloudflare_verification, if: :saved_change_to_custom_domain?
+ end
+
+ def enqueue_cloudflare_verification
+ return if custom_domain.blank?
+ return unless ChatwootApp.chatwoot_cloud?
+
+ Enterprise::CloudflareVerificationJob.perform_later(id)
+ end
+end
diff --git a/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb
new file mode 100644
index 000000000..7fad50790
--- /dev/null
+++ b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb
@@ -0,0 +1,20 @@
+class Cloudflare::BaseCloudflareZoneService
+ BASE_URI = 'https://api.cloudflare.com/client/v4'.freeze
+
+ private
+
+ def headers
+ {
+ 'Authorization' => "Bearer #{api_token}",
+ 'Content-Type' => 'application/json'
+ }
+ end
+
+ def api_token
+ InstallationConfig.find_by(name: 'CLOUDFLARE_API_KEY')&.value
+ end
+
+ def zone_id
+ InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value
+ end
+end
diff --git a/enterprise/app/services/cloudflare/check_custom_hostname_service.rb b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb
new file mode 100644
index 000000000..588a9c4a7
--- /dev/null
+++ b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb
@@ -0,0 +1,34 @@
+class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneService
+ pattr_initialize [:portal!]
+
+ def perform
+ return { errors: ['Cloudflare API token or zone ID not found'] } if api_token.blank? || zone_id.blank?
+ return { errors: ['No custom domain found'] } if @portal.custom_domain.blank?
+
+ response = HTTParty.get(
+ "#{BASE_URI}/zones/#{zone_id}/custom_hostnames?hostname=#{@portal.custom_domain}", headers: headers
+ )
+
+ return { errors: response.parsed_response['errors'] } unless response.success?
+
+ data = response.parsed_response['result']
+
+ if data.present?
+ update_portal_ssl_settings(data.first)
+ return { data: data }
+ end
+
+ { errors: ['Hostname is missing in Cloudflare'] }
+ end
+
+ private
+
+ def update_portal_ssl_settings(data)
+ verification_record = data['ownership_verification_http']
+ ssl_settings = {
+ 'cf_verification_id': verification_record['http_url'].split('/').last,
+ 'cf_verification_body': verification_record['http_body']
+ }
+ @portal.update(ssl_settings: ssl_settings)
+ end
+end
diff --git a/enterprise/app/services/cloudflare/create_custom_hostname_service.rb b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb
new file mode 100644
index 000000000..f1546caed
--- /dev/null
+++ b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb
@@ -0,0 +1,40 @@
+class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneService
+ pattr_initialize [:portal!]
+
+ def perform
+ return { errors: ['Cloudflare API token or zone ID not found'] } if api_token.blank? || zone_id.blank?
+ return { errors: ['No hostname found'] } if @portal.custom_domain.blank?
+
+ response = create_hostname
+
+ return { errors: response.parsed_response['errors'] } unless response.success?
+
+ data = response.parsed_response['result']
+
+ if data.present?
+ update_portal_ssl_settings(data)
+ return { data: data }
+ end
+
+ { errors: ['Could not create hostname'] }
+ end
+
+ private
+
+ def create_hostname
+ HTTParty.post(
+ "#{BASE_URI}/zones/#{zone_id}/custom_hostnames",
+ headers: headers,
+ body: { hostname: @portal.custom_domain }.to_json
+ )
+ end
+
+ def update_portal_ssl_settings(data)
+ verification_record = data['ownership_verification_http']
+ ssl_settings = {
+ 'cf_verification_id': verification_record['http_url'].split('/').last,
+ 'cf_verification_body': verification_record['http_body']
+ }
+ @portal.update(ssl_settings: ssl_settings)
+ end
+end
diff --git a/enterprise/app/services/llm/base_open_ai_service.rb b/enterprise/app/services/llm/base_open_ai_service.rb
index 1f229f182..04909cbf4 100644
--- a/enterprise/app/services/llm/base_open_ai_service.rb
+++ b/enterprise/app/services/llm/base_open_ai_service.rb
@@ -4,6 +4,7 @@ class Llm::BaseOpenAiService
def initialize
@client = OpenAI::Client.new(
access_token: InstallationConfig.find_by!(name: 'CAPTAIN_OPEN_AI_API_KEY').value,
+ uri_base: uri_base,
log_errors: Rails.env.development?
)
setup_model
@@ -13,6 +14,10 @@ class Llm::BaseOpenAiService
private
+ def uri_base
+ InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
+ end
+
def setup_model
config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
@model = (config_value.presence || DEFAULT_MODEL)
diff --git a/enterprise/lib/chat_gpt.rb b/enterprise/lib/chat_gpt.rb
deleted file mode 100644
index 44afbd641..000000000
--- a/enterprise/lib/chat_gpt.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-class ChatGpt
- def self.base_uri
- 'https://api.openai.com'
- end
-
- def initialize(context_sections = '')
- @model = 'gpt-4o'
- @messages = [system_message(context_sections)]
- end
-
- def generate_response(input, previous_messages = [], role = 'user')
- @messages += previous_messages
- @messages << { 'role': role, 'content': input } if input.present?
-
- response = request_gpt
- JSON.parse(response['choices'][0]['message']['content'].strip)
- end
-
- private
-
- def system_message(context_sections)
- {
- 'role': 'system',
- 'content': system_content(context_sections)
- }
- end
-
- def system_content(context_sections)
- <<~SYSTEM_PROMPT_MESSAGE
- You are a very enthusiastic customer support representative who loves to help people.
- Your answers will always be formatted in valid JSON hash, as shown below. Never respond in non JSON format.
-
- ```
- {
- response: '' ,
- context_ids: [ids],
- }
- ```
-
- response: will be the next response to the conversation
-
- context_ids: will be an array of unique context IDs that were used to generate the answer. choose top 3.
-
- The answers will be generated using the information provided at the end of the prompt under the context sections. You will not respond outside the context of the information provided in context sections.
-
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
-
- ----------------------------------
- Context sections:
- #{context_sections}
- SYSTEM_PROMPT_MESSAGE
- end
-
- def request_gpt
- headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" }
- body = { model: @model, messages: @messages, response_format: { type: 'json_object' } }.to_json
- Rails.logger.info "Requesting Chat GPT with body: #{body}"
- response = HTTParty.post("#{self.class.base_uri}/v1/chat/completions", headers: headers, body: body)
- Rails.logger.info "Chat GPT response: #{response.body}"
- JSON.parse(response.body)
- end
-end
diff --git a/lib/integrations/openai_base_service.rb b/lib/integrations/openai_base_service.rb
index 908e496a7..f06baf5b5 100644
--- a/lib/integrations/openai_base_service.rb
+++ b/lib/integrations/openai_base_service.rb
@@ -4,7 +4,6 @@ class Integrations::OpenaiBaseService
# sticking with 120000 to be safe
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
- API_URL = 'https://api.openai.com/v1/chat/completions'.freeze
GPT_MODEL = ENV.fetch('OPENAI_GPT_MODEL', 'gpt-4o-mini').freeze
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
@@ -81,6 +80,12 @@ class Integrations::OpenaiBaseService
self.class::CACHEABLE_EVENTS.include?(event_name)
end
+ def api_url
+ endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
+ endpoint = endpoint.chomp('/')
+ "#{endpoint}/v1/chat/completions"
+ end
+
def make_api_call(body)
headers = {
'Content-Type' => 'application/json',
@@ -88,7 +93,7 @@ class Integrations::OpenaiBaseService
}
Rails.logger.info("OpenAI API request: #{body}")
- response = HTTParty.post(API_URL, headers: headers, body: body)
+ response = HTTParty.post(api_url, headers: headers, body: body)
Rails.logger.info("OpenAI API response: #{response.body}")
return { error: response.parsed_response, error_code: response.code } unless response.success?
diff --git a/package.json b/package.json
index e0fd2cf7b..668ea8b57 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.6-next",
- "@chatwoot/utils": "^0.0.47",
+ "@chatwoot/utils": "^0.0.48",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a88cfc084..16e830a87 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,8 +23,8 @@ importers:
specifier: 1.1.6-next
version: 1.1.6-next
'@chatwoot/utils':
- specifier: ^0.0.47
- version: 0.0.47
+ specifier: ^0.0.48
+ version: 0.0.48
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -406,8 +406,8 @@ packages:
'@chatwoot/prosemirror-schema@1.1.6-next':
resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==}
- '@chatwoot/utils@0.0.47':
- resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
+ '@chatwoot/utils@0.0.48':
+ resolution: {integrity: sha512-67M2lvpBp0Ciczv1uRzabOXSCGiEeJE3wYVoPAxkqI35CJSkotu4tSX2TFOwagUQoRyU6F8YV3xXGfCpDN9WAA==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -5255,7 +5255,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
- '@chatwoot/utils@0.0.47':
+ '@chatwoot/utils@0.0.48':
dependencies:
date-fns: 2.30.0
diff --git a/spec/actions/contact_identify_action_spec.rb b/spec/actions/contact_identify_action_spec.rb
index c9bae7b5a..568f4db98 100644
--- a/spec/actions/contact_identify_action_spec.rb
+++ b/spec/actions/contact_identify_action_spec.rb
@@ -36,12 +36,18 @@ describe ContactIdentifyAction do
expect(result.additional_attributes['social_profiles']).to eq({ 'linkedin' => 'saras', 'twitter' => 'saras' })
end
- it 'enques avatar job when avatar url parameter is passed' do
+ it 'enqueues avatar job when valid avatar url parameter is passed' do
params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
described_class.new(contact: contact, params: params).perform
end
+ it 'does not enqueue avatar job when invalid avatar url parameter is passed' do
+ params = { name: 'test', avatar_url: 'invalid-url' }
+ expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later)
+ described_class.new(contact: contact, params: params).perform
+ end
+
context 'when contact with same identifier exists' do
it 'merges the current contact to identified contact' do
existing_identified_contact = create(:contact, account: account, identifier: 'test_id')
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index 96272f9ac..cc235cada 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -904,4 +904,80 @@ RSpec.describe 'Inboxes API', type: :request do
end
end
end
+
+ describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/sync_templates' do
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
+ end
+ let(:whatsapp_inbox) { create(:inbox, account: account, channel: whatsapp_channel) }
+ let(:non_whatsapp_inbox) { create(:inbox, account: account) }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated agent' do
+ it 'returns unauthorized for agent' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated administrator' do
+ context 'with WhatsApp inbox' do
+ it 'successfully initiates template sync' do
+ expect(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).with(whatsapp_channel)
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['message']).to eq('Template sync initiated successfully')
+ end
+
+ it 'handles job errors gracefully' do
+ allow(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).and_raise(StandardError, 'Job failed')
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:internal_server_error)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq('Job failed')
+ end
+ end
+
+ context 'with non-WhatsApp inbox' do
+ it 'returns unprocessable entity error' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/sync_templates",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq('Template sync is only available for WhatsApp channels')
+ end
+ end
+
+ context 'with non-existent inbox' do
+ it 'returns not found error' do
+ post "/api/v1/accounts/#{account.id}/inboxes/999999/sync_templates",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+ end
end
diff --git a/spec/controllers/webhooks/instagram_controller_spec.rb b/spec/controllers/webhooks/instagram_controller_spec.rb
index df5a6596d..c31d65a2b 100644
--- a/spec/controllers/webhooks/instagram_controller_spec.rb
+++ b/spec/controllers/webhooks/instagram_controller_spec.rb
@@ -33,5 +33,21 @@ RSpec.describe 'Webhooks::InstagramController', type: :request do
post '/webhooks/instagram', params: instagram_params
expect(response).to have_http_status(:success)
end
+
+ context 'when processing echo events' do
+ let!(:echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
+
+ it 'delays processing for echo events by 2 seconds' do
+ job_double = class_double(Webhooks::InstagramEventsJob)
+ allow(Webhooks::InstagramEventsJob).to receive(:set).with(wait: 2.seconds).and_return(job_double)
+ allow(job_double).to receive(:perform_later)
+
+ instagram_params = echo_params.merge(object: 'instagram')
+ post '/webhooks/instagram', params: instagram_params
+ expect(response).to have_http_status(:success)
+ expect(Webhooks::InstagramEventsJob).to have_received(:set).with(wait: 2.seconds)
+ expect(job_double).to have_received(:perform_later)
+ end
+ end
end
end
diff --git a/spec/enterprise/jobs/enterprise/cloudflare_verification_job_spec.rb b/spec/enterprise/jobs/enterprise/cloudflare_verification_job_spec.rb
new file mode 100644
index 000000000..862479034
--- /dev/null
+++ b/spec/enterprise/jobs/enterprise/cloudflare_verification_job_spec.rb
@@ -0,0 +1,47 @@
+require 'rails_helper'
+
+RSpec.describe Enterprise::CloudflareVerificationJob do
+ let(:portal) { create(:portal, custom_domain: 'test.example.com') }
+
+ describe '#perform' do
+ context 'when portal is not found' do
+ it 'returns early' do
+ expect(Portal).to receive(:find).with(0).and_return(nil)
+ expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new)
+ expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
+
+ described_class.perform_now(0)
+ end
+ end
+
+ context 'when portal has no custom domain' do
+ it 'returns early' do
+ portal_without_domain = create(:portal, custom_domain: nil)
+ expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new)
+ expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
+
+ described_class.perform_now(portal_without_domain.id)
+ end
+ end
+
+ context 'when portal exists with custom domain' do
+ it 'checks hostname status' do
+ check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { data: 'success' })
+ expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service)
+ expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
+
+ described_class.perform_now(portal.id)
+ end
+
+ it 'creates hostname when check returns errors' do
+ check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { errors: ['Hostname is missing'] })
+ create_service = instance_double(Cloudflare::CreateCustomHostnameService, perform: { data: 'success' })
+
+ expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service)
+ expect(Cloudflare::CreateCustomHostnameService).to receive(:new).with(portal: portal).and_return(create_service)
+
+ described_class.perform_now(portal.id)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/enterprise/concerns/portal_spec.rb b/spec/enterprise/models/enterprise/concerns/portal_spec.rb
new file mode 100644
index 000000000..1dae65e86
--- /dev/null
+++ b/spec/enterprise/models/enterprise/concerns/portal_spec.rb
@@ -0,0 +1,59 @@
+require 'rails_helper'
+
+RSpec.describe Enterprise::Concerns::Portal do
+ describe '#enqueue_cloudflare_verification' do
+ let(:portal) { create(:portal, custom_domain: nil) }
+
+ context 'when custom_domain is changed' do
+ context 'when on chatwoot cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ end
+
+ it 'enqueues cloudflare verification job' do
+ expect do
+ portal.update(custom_domain: 'test.example.com')
+ end.to have_enqueued_job(Enterprise::CloudflareVerificationJob).with(portal.id)
+ end
+ end
+
+ context 'when not on chatwoot cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ end
+
+ it 'does not enqueue cloudflare verification job' do
+ expect do
+ portal.update(custom_domain: 'test.example.com')
+ end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
+ end
+ end
+ end
+
+ context 'when custom_domain is not changed' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ portal.update(custom_domain: 'test.example.com')
+ end
+
+ it 'does not enqueue cloudflare verification job' do
+ expect do
+ portal.update(name: 'New Name')
+ end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
+ end
+ end
+
+ context 'when custom_domain is set to blank' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ portal.update(custom_domain: 'test.example.com')
+ end
+
+ it 'does not enqueue cloudflare verification job' do
+ expect do
+ portal.update(custom_domain: '')
+ end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb
index a4274ec4e..a02063b5e 100644
--- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb
+++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb
@@ -22,6 +22,7 @@ RSpec.describe Captain::Copilot::ChatService do
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://api.openai.com/')
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
@@ -47,6 +48,48 @@ RSpec.describe Captain::Copilot::ChatService do
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
+
+ it 'initializes OpenAI client with configured endpoint' do
+ expect(OpenAI::Client).to receive(:new).with(
+ access_token: 'test-key',
+ uri_base: 'https://api.openai.com/',
+ log_errors: Rails.env.development?
+ )
+
+ described_class.new(assistant, config)
+ end
+
+ context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do
+ before do
+ InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
+ end
+
+ it 'uses default OpenAI endpoint' do
+ expect(OpenAI::Client).to receive(:new).with(
+ access_token: 'test-key',
+ uri_base: 'https://api.openai.com/',
+ log_errors: Rails.env.development?
+ )
+
+ described_class.new(assistant, config)
+ end
+ end
+
+ context 'when custom endpoint is configured' do
+ before do
+ InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').update!(value: 'https://custom.azure.com/')
+ end
+
+ it 'uses custom endpoint for OpenAI client' do
+ expect(OpenAI::Client).to receive(:new).with(
+ access_token: 'test-key',
+ uri_base: 'https://custom.azure.com/',
+ log_errors: Rails.env.development?
+ )
+
+ described_class.new(assistant, config)
+ end
+ end
end
describe '#generate_response' do
diff --git a/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb
new file mode 100644
index 000000000..d7ed80b90
--- /dev/null
+++ b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb
@@ -0,0 +1,111 @@
+require 'rails_helper'
+
+RSpec.describe Cloudflare::CheckCustomHostnameService do
+ let(:portal) { create(:portal, custom_domain: 'test.example.com') }
+ let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') }
+ let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') }
+
+ describe '#perform' do
+ context 'when API token or zone ID is not found' do
+ it 'returns error when API token is missing' do
+ installation_config_zone_id
+ service = described_class.new(portal: portal)
+
+ result = service.perform
+
+ expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
+ end
+
+ it 'returns error when zone ID is missing' do
+ installation_config_api_key
+ service = described_class.new(portal: portal)
+
+ result = service.perform
+
+ expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
+ end
+ end
+
+ context 'when no hostname ID is found' do
+ it 'returns error' do
+ installation_config_api_key
+ installation_config_zone_id
+ portal.update(custom_domain: nil)
+ service = described_class.new(portal: portal)
+
+ result = service.perform
+
+ expect(result).to eq(errors: ['No custom domain found'])
+ end
+ end
+
+ context 'when API request is made' do
+ before do
+ installation_config_api_key
+ installation_config_zone_id
+ end
+
+ context 'when API request fails' do
+ it 'returns error response' do
+ service = described_class.new(portal: portal)
+ error_response = {
+ 'errors' => [{ 'message' => 'API error' }]
+ }
+
+ stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
+ .to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
+
+ result = service.perform
+
+ expect(result[:errors]).to eq(error_response['errors'])
+ end
+ end
+
+ context 'when API request succeeds but no data is returned' do
+ it 'returns hostname missing error' do
+ service = described_class.new(portal: portal)
+ success_response = {
+ 'result' => []
+ }
+
+ stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
+ .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
+
+ result = service.perform
+
+ expect(result).to eq(errors: ['Hostname is missing in Cloudflare'])
+ end
+ end
+
+ context 'when API request succeeds and data is returned' do
+ it 'updates portal SSL settings and returns success' do
+ service = described_class.new(portal: portal)
+ success_response = {
+ 'result' => [
+ {
+ 'ownership_verification_http' => {
+ 'http_url' => 'http://example.com/.well-known/cf-verification/verification-id',
+ 'http_body' => 'verification-body'
+ }
+ }
+ ]
+ }
+
+ stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
+ .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
+
+ expect(portal).to receive(:update).with(
+ ssl_settings: {
+ 'cf_verification_id': 'verification-id',
+ 'cf_verification_body': 'verification-body'
+ }
+ )
+
+ result = service.perform
+
+ expect(result).to eq(data: success_response['result'])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb
new file mode 100644
index 000000000..a3ddc8273
--- /dev/null
+++ b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb
@@ -0,0 +1,111 @@
+require 'rails_helper'
+
+RSpec.describe Cloudflare::CreateCustomHostnameService do
+ let(:portal) { create(:portal, custom_domain: 'test.example.com') }
+ let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') }
+ let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') }
+
+ describe '#perform' do
+ context 'when API token or zone ID is not found' do
+ it 'returns error when API token is missing' do
+ installation_config_zone_id
+ service = described_class.new(portal: portal)
+
+ result = service.perform
+
+ expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
+ end
+
+ it 'returns error when zone ID is missing' do
+ installation_config_api_key
+ service = described_class.new(portal: portal)
+
+ result = service.perform
+
+ expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
+ end
+ end
+
+ context 'when no hostname is found' do
+ it 'returns error' do
+ installation_config_api_key
+ installation_config_zone_id
+ portal.update(custom_domain: nil)
+ service = described_class.new(portal: portal)
+
+ result = service.perform
+
+ expect(result).to eq(errors: ['No hostname found'])
+ end
+ end
+
+ context 'when API request is made' do
+ before do
+ installation_config_api_key
+ installation_config_zone_id
+ end
+
+ context 'when API request fails' do
+ it 'returns error response' do
+ service = described_class.new(portal: portal)
+ error_response = {
+ 'errors' => [{ 'message' => 'API error' }]
+ }
+
+ stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
+ .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
+ body: { hostname: 'test.example.com' }.to_json)
+ .to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
+
+ result = service.perform
+
+ expect(result[:errors]).to eq(error_response['errors'])
+ end
+ end
+
+ context 'when API request succeeds but no data is returned' do
+ it 'returns hostname creation error' do
+ service = described_class.new(portal: portal)
+ success_response = {
+ 'result' => nil
+ }
+
+ stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
+ .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
+ body: { hostname: 'test.example.com' }.to_json)
+ .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
+
+ result = service.perform
+
+ expect(result).to eq(errors: ['Could not create hostname'])
+ end
+ end
+
+ context 'when API request succeeds and data is returned' do
+ it 'updates portal SSL settings and returns success' do
+ service = described_class.new(portal: portal)
+ success_response = {
+ 'result' => {
+ 'ownership_verification_http' => {
+ 'http_url' => 'http://example.com/.well-known/cf-verification/verification-id',
+ 'http_body' => 'verification-body'
+ }
+ }
+ }
+
+ stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
+ .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
+ body: { hostname: 'test.example.com' }.to_json)
+ .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
+
+ expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id',
+ 'cf_verification_body': 'verification-body' })
+
+ result = service.perform
+
+ expect(result).to eq(data: success_response['result'])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/files/mail_with_references.eml b/spec/fixtures/files/mail_with_references.eml
new file mode 100644
index 000000000..4fca32726
--- /dev/null
+++ b/spec/fixtures/files/mail_with_references.eml
@@ -0,0 +1,17 @@
+From: Sony Mathew
+To: care@example.com
+Mime-Version: 1.0 (Apple Message framework v1244.3)
+Content-Type: multipart/alternative; boundary="Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74"
+Subject: Discussion: Let's debate these attachments
+Date: Tue, 20 Apr 2020 04:20:20 -0400
+In-Reply-To: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail>
+References: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail>
+Message-Id: <0CB459E0-0336-41DA-BC88-E6E28C697DDBF@chatwoot.com>
+X-Mailer: Apple Mail (2.1244.3)
+
+--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain;
+ charset=utf-8
+
+Email with references header
\ No newline at end of file
diff --git a/spec/jobs/avatar/avatar_from_gravatar_job_spec.rb b/spec/jobs/avatar/avatar_from_gravatar_job_spec.rb
index 4f73fd4b2..06ebe739d 100644
--- a/spec/jobs/avatar/avatar_from_gravatar_job_spec.rb
+++ b/spec/jobs/avatar/avatar_from_gravatar_job_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Avatar::AvatarFromGravatarJob do
it 'enqueues the job' do
expect { described_class.perform_later(avatarable, email) }.to have_enqueued_job(described_class)
- .on_queue('low')
+ .on_queue('purgable')
end
it 'will call AvatarFromUrlJob with gravatar url' do
diff --git a/spec/jobs/avatar/avatar_from_url_job_spec.rb b/spec/jobs/avatar/avatar_from_url_job_spec.rb
index 25276bc67..2e6d89804 100644
--- a/spec/jobs/avatar/avatar_from_url_job_spec.rb
+++ b/spec/jobs/avatar/avatar_from_url_job_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Avatar::AvatarFromUrlJob do
it 'enqueues the job' do
expect { described_class.perform_later(avatarable, avatar_url) }.to have_enqueued_job(described_class)
- .on_queue('low')
+ .on_queue('purgable')
end
it 'will attach avatar from url' do
diff --git a/spec/jobs/webhooks/instagram_events_job_spec.rb b/spec/jobs/webhooks/instagram_events_job_spec.rb
index dfd9f1aed..1962c78a5 100644
--- a/spec/jobs/webhooks/instagram_events_job_spec.rb
+++ b/spec/jobs/webhooks/instagram_events_job_spec.rb
@@ -279,11 +279,29 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.messages.count).to be 0
end
- it 'handle messaging_seen callback' do
+ it 'handles messaging_seen callback' do
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
channel: instagram_inbox.channel).and_call_original
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
end
+
+ it 'creates contact when Instagram API call returns `No matching Instagram user` (9010 error code)' do
+ stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
+ .to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json)
+
+ instagram_webhook.perform_now(message_events[:dm][:entry])
+
+ instagram_inbox.reload
+
+ expect(instagram_inbox.contacts.count).to be 1
+ expect(instagram_inbox.contacts.last.name).to eq 'Unknown (IG: Sender-id-1)'
+ expect(instagram_inbox.contacts.last.contact_inboxes.count).to be 1
+ expect(instagram_inbox.contacts.last.contact_inboxes.first.source_id).to eq 'Sender-id-1'
+
+ expect(instagram_inbox.conversations.count).to eq 1
+ expect(instagram_inbox.messages.count).to eq 1
+ expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
+ end
end
end
end
diff --git a/spec/lib/integrations/openai/processor_service_spec.rb b/spec/lib/integrations/openai/processor_service_spec.rb
index 8bbf5d5fb..a22c8e815 100644
--- a/spec/lib/integrations/openai/processor_service_spec.rb
+++ b/spec/lib/integrations/openai/processor_service_spec.rb
@@ -253,5 +253,52 @@ RSpec.describe Integrations::Openai::ProcessorService do
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
+
+ context 'when testing endpoint configuration' do
+ let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
+
+ context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do
+ it 'uses default OpenAI endpoint' do
+ InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
+
+ stub_request(:post, 'https://api.openai.com/v1/chat/completions')
+ .with(body: anything, headers: expected_headers)
+ .to_return(status: 200, body: openai_response, headers: {})
+
+ result = subject.perform
+ expect(result).to eq({ :message => 'This is a reply from openai.' })
+ end
+ end
+
+ context 'when CAPTAIN_OPEN_AI_ENDPOINT is configured' do
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
+ end
+
+ it 'uses custom endpoint' do
+ stub_request(:post, 'https://custom.azure.com/v1/chat/completions')
+ .with(body: anything, headers: expected_headers)
+ .to_return(status: 200, body: openai_response, headers: {})
+
+ result = subject.perform
+ expect(result).to eq({ :message => 'This is a reply from openai.' })
+ end
+ end
+
+ context 'when CAPTAIN_OPEN_AI_ENDPOINT has trailing slash' do
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
+ end
+
+ it 'properly handles trailing slash' do
+ stub_request(:post, 'https://custom.azure.com/v1/chat/completions')
+ .with(body: anything, headers: expected_headers)
+ .to_return(status: 200, body: openai_response, headers: {})
+
+ result = subject.perform
+ expect(result).to eq({ :message => 'This is a reply from openai.' })
+ end
+ end
+ end
end
end
diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb
index a9a802bb9..2320e3e07 100644
--- a/spec/mailboxes/reply_mailbox_spec.rb
+++ b/spec/mailboxes/reply_mailbox_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe ReplyMailbox do
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
let(:described_subject) { described_class.receive reply_mail }
let(:serialized_attributes) do
- %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments subject text_content to]
+ %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to]
end
context 'with reply uuid present' do
diff --git a/spec/mailboxes/support_mailbox_spec.rb b/spec/mailboxes/support_mailbox_spec.rb
index f9e9aa2ff..f75e3ef80 100644
--- a/spec/mailboxes/support_mailbox_spec.rb
+++ b/spec/mailboxes/support_mailbox_spec.rb
@@ -55,7 +55,7 @@ RSpec.describe SupportMailbox do
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 subject
+ %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
@@ -111,6 +111,29 @@ RSpec.describe SupportMailbox do
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 }
diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb
index 2a0d6c8b0..ecd97333e 100644
--- a/spec/mailers/conversation_reply_mailer_spec.rb
+++ b/spec/mailers/conversation_reply_mailer_spec.rb
@@ -137,6 +137,99 @@ RSpec.describe ConversationReplyMailer do
end
end
+ context 'with references header' do
+ let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
+ let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
+ let(:mail) { described_class.email_reply(message).deliver_now }
+
+ context 'when starting a new conversation' do
+ let(:first_outgoing_message) do
+ create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'First outgoing message')
+ end
+ let(:mail) { described_class.email_reply(first_outgoing_message).deliver_now }
+
+ it 'has only the conversation reference' do
+ # When starting a conversation, references will have the default conversation ID
+ # Extract domain from the actual references header to handle dynamic domain selection
+ actual_domain = mail.references.split('@').last
+ expected_reference = "account/#{account.id}/conversation/#{conversation.uuid}@#{actual_domain}"
+ expect(mail.references).to eq(expected_reference)
+ end
+ end
+
+ context 'when replying to a message with no references' do
+ let(:incoming_message) do
+ create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'incoming',
+ source_id: '',
+ content: 'Incoming message',
+ content_attributes: {
+ 'email' => {
+ 'message_id' => 'incoming-123@example.com'
+ }
+ })
+ end
+ let(:reply_message) do
+ create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Reply to incoming')
+ end
+ let(:mail) { described_class.email_reply(reply_message).deliver_now }
+
+ before do
+ incoming_message
+ end
+
+ it 'includes only the in_reply_to id in references' do
+ # References should only have the incoming message ID when no prior references exist
+ expect(mail.references).to eq('incoming-123@example.com')
+ end
+ end
+
+ context 'when replying to a message that has references' do
+ let(:incoming_message_with_refs) do
+ create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'incoming',
+ source_id: '',
+ content: 'Incoming with references',
+ content_attributes: {
+ 'email' => {
+ 'message_id' => 'incoming-456@example.com',
+ 'references' => ['', '']
+ }
+ })
+ end
+ let(:reply_message) do
+ create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Reply to message with refs')
+ end
+ let(:mail) { described_class.email_reply(reply_message).deliver_now }
+
+ before do
+ incoming_message_with_refs
+ end
+
+ it 'includes existing references plus the in_reply_to id' do
+ # Rails returns references as an array when multiple values are present
+ expected_references = ['ref-1@example.com', 'ref-2@example.com', 'incoming-456@example.com']
+ expect(mail.references).to eq(expected_references)
+ end
+ end
+ end
+
context 'with email reply' do
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
diff --git a/spec/mailers/references_header_builder_spec.rb b/spec/mailers/references_header_builder_spec.rb
new file mode 100644
index 000000000..b10d63dba
--- /dev/null
+++ b/spec/mailers/references_header_builder_spec.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe ReferencesHeaderBuilder do
+ include described_class
+
+ let(:account) { create(:account) }
+ let(:email_channel) { create(:channel_email, account: account) }
+ let(:inbox) { create(:inbox, channel: email_channel, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ describe '#build_references_header' do
+ let(:in_reply_to_message_id) { '' }
+
+ context 'when no message is found with the in_reply_to_message_id' do
+ it 'returns only the in_reply_to message ID' do
+ result = build_references_header(conversation, in_reply_to_message_id)
+ expect(result).to eq('')
+ end
+ end
+
+ context 'when a message is found with matching source_id' do
+ context 'with stored References' do
+ let(:original_message) do
+ create(:message, conversation: conversation, account: account,
+ source_id: '',
+ content_attributes: {
+ 'email' => {
+ 'references' => ['', '']
+ }
+ })
+ end
+
+ before do
+ original_message
+ end
+
+ it 'includes stored References plus in_reply_to' do
+ result = build_references_header(conversation, in_reply_to_message_id)
+ expect(result).to eq("\r\n \r\n ")
+ end
+
+ it 'removes duplicates while preserving order' do
+ # If in_reply_to is already in the References, it should appear only once at the end
+ original_message.content_attributes['email']['references'] = ['', '']
+ original_message.save!
+
+ result = build_references_header(conversation, in_reply_to_message_id)
+ message_ids = result.split("\r\n ").map(&:strip)
+ expect(message_ids).to eq(['', ''])
+ end
+ end
+
+ context 'without stored References' do
+ let(:original_message) do
+ create(:message, conversation: conversation, account: account,
+ source_id: 'reply-to-123@example.com', # without angle brackets
+ content_attributes: { 'email' => {} })
+ end
+
+ before do
+ original_message
+ end
+
+ it 'returns only the in_reply_to message ID (no rebuild)' do
+ result = build_references_header(conversation, in_reply_to_message_id)
+ expect(result).to eq('')
+ end
+ end
+ end
+
+ context 'with folding multiple References' do
+ let(:original_message) do
+ create(:message, conversation: conversation, account: account,
+ source_id: '',
+ content_attributes: {
+ 'email' => {
+ 'references' => ['', '', '']
+ }
+ })
+ end
+
+ before do
+ original_message
+ end
+
+ it 'folds the header with CRLF between message IDs' do
+ result = build_references_header(conversation, in_reply_to_message_id)
+
+ expect(result).to include("\r\n")
+ lines = result.split("\r\n")
+
+ # First line has no leading space, continuation lines do
+ expect(lines.first).not_to start_with(' ')
+ expect(lines[1..]).to all(start_with(' '))
+ end
+ end
+
+ context 'with source_id in different formats' do
+ it 'finds message with source_id without angle brackets' do
+ create(:message, conversation: conversation, account: account,
+ source_id: 'test-123@example.com',
+ content_attributes: {
+ 'email' => {
+ 'references' => ['']
+ }
+ })
+
+ result = build_references_header(conversation, '')
+ expect(result).to eq("\r\n ")
+ end
+
+ it 'finds message with source_id with angle brackets' do
+ create(:message, conversation: conversation, account: account,
+ source_id: '',
+ content_attributes: {
+ 'email' => {
+ 'references' => ['']
+ }
+ })
+
+ result = build_references_header(conversation, 'test-456@example.com')
+ expect(result).to eq("\r\n test-456@example.com")
+ end
+ end
+ end
+
+ describe '#fold_references_header' do
+ it 'returns single message ID without folding' do
+ single_array = ['']
+ result = fold_references_header(single_array)
+
+ expect(result).to eq('')
+ expect(result).not_to include("\r\n")
+ end
+
+ it 'folds multiple message IDs with CRLF + space' do
+ multiple_array = ['', '', '']
+ result = fold_references_header(multiple_array)
+
+ expect(result).to eq("\r\n \r\n ")
+ end
+
+ it 'ensures RFC 5322 compliance with continuation line spacing' do
+ multiple_array = ['', '']
+ result = fold_references_header(multiple_array)
+ lines = result.split("\r\n")
+
+ # First line has no leading space (not a continuation line)
+ expect(lines.first).to eq('')
+ expect(lines.first).not_to start_with(' ')
+
+ # Second line starts with space (continuation line)
+ expect(lines[1]).to eq(' ')
+ expect(lines[1]).to start_with(' ')
+ end
+
+ it 'handles empty array' do
+ result = fold_references_header([])
+ expect(result).to eq('')
+ end
+ end
+end
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index 29a00fd96..b46c984d1 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -122,4 +122,60 @@ RSpec.describe Channel::Whatsapp do
end
end
end
+
+ describe '#teardown_webhooks' do
+ let(:account) { create(:account) }
+
+ context 'when channel is whatsapp_cloud with embedded_signup' do
+ it 'calls WebhookTeardownService on destroy' do
+ # Mock the setup service to prevent HTTP calls during creation
+ setup_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
+ allow(setup_service).to receive(:perform)
+
+ channel = create(:channel_whatsapp,
+ account: account,
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ 'source' => 'embedded_signup',
+ 'business_account_id' => 'test_waba_id',
+ 'api_key' => 'test_access_token',
+ 'phone_number_id' => '123456789'
+ },
+ validate_provider_config: false,
+ sync_templates: false)
+
+ teardown_service = instance_double(Whatsapp::WebhookTeardownService)
+ allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
+ allow(teardown_service).to receive(:perform)
+
+ channel.destroy
+
+ expect(Whatsapp::WebhookTeardownService).to have_received(:new).with(channel)
+ expect(teardown_service).to have_received(:perform)
+ end
+ end
+
+ context 'when channel is not embedded_signup' do
+ it 'does not call WebhookTeardownService on destroy' do
+ channel = create(:channel_whatsapp,
+ account: account,
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ 'source' => 'manual',
+ 'api_key' => 'test_access_token'
+ },
+ validate_provider_config: false,
+ sync_templates: false)
+
+ teardown_service = instance_double(Whatsapp::WebhookTeardownService)
+ allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
+ allow(teardown_service).to receive(:perform)
+
+ channel.destroy
+
+ expect(teardown_service).to have_received(:perform)
+ end
+ end
+ end
end
diff --git a/spec/models/contact_spec.rb b/spec/models/contact_spec.rb
index 2ca65fa4e..f21a81978 100644
--- a/spec/models/contact_spec.rb
+++ b/spec/models/contact_spec.rb
@@ -102,4 +102,98 @@ RSpec.describe Contact do
expect(contact.contact_type).to eq 'lead'
end
end
+
+ describe '.resolved_contacts' do
+ let(:account) { create(:account) }
+
+ context 'when crm_v2 feature flag is disabled' do
+ it 'returns contacts with email, phone_number, or identifier using feature flag value' do
+ # Create contacts with different attributes
+ contact_with_email = create(:contact, account: account, email: 'test@example.com', name: 'John Doe')
+ contact_with_phone = create(:contact, account: account, phone_number: '+1234567890', name: 'Jane Smith')
+ contact_with_identifier = create(:contact, account: account, identifier: 'user123', name: 'Bob Wilson')
+ contact_without_details = create(:contact, account: account, name: 'Alice Johnson', email: nil, phone_number: nil, identifier: nil)
+
+ resolved = account.contacts.resolved_contacts(use_crm_v2: false)
+
+ expect(resolved).to include(contact_with_email, contact_with_phone, contact_with_identifier)
+ expect(resolved).not_to include(contact_without_details)
+ end
+ end
+
+ context 'when crm_v2 feature flag is enabled' do
+ it 'returns only contacts with contact_type lead' do
+ # Contact with email and phone - should be marked as lead
+ contact_with_details = create(:contact, account: account, email: 'customer@example.com', phone_number: '+1234567890', name: 'Customer One')
+ expect(contact_with_details.contact_type).to eq('lead')
+
+ # Contact without email/phone - should be marked as visitor
+ contact_without_details = create(:contact, account: account, name: 'Lead', email: nil, phone_number: nil)
+ expect(contact_without_details.contact_type).to eq('visitor')
+
+ # Force set contact_type to lead for testing
+ contact_without_details.update!(contact_type: 'lead')
+
+ resolved = account.contacts.resolved_contacts(use_crm_v2: true)
+
+ expect(resolved).to include(contact_with_details)
+ expect(resolved).to include(contact_without_details)
+ end
+
+ it 'includes all lead contacts regardless of email/phone presence' do
+ # Create a lead contact with only name
+ lead_contact = create(:contact, account: account, name: 'Test Lead')
+ lead_contact.update!(contact_type: 'lead')
+
+ # Create a customer contact
+ customer_contact = create(:contact, account: account, email: 'customer@test.com')
+ customer_contact.update!(contact_type: 'customer')
+
+ # Create a visitor contact
+ visitor_contact = create(:contact, account: account, name: 'Visitor')
+ expect(visitor_contact.contact_type).to eq('visitor')
+
+ resolved = account.contacts.resolved_contacts(use_crm_v2: true)
+
+ expect(resolved).to include(lead_contact)
+ expect(resolved).not_to include(customer_contact)
+ expect(resolved).not_to include(visitor_contact)
+ end
+
+ it 'returns contacts with email, phone_number, or identifier when explicitly passing use_crm_v2: false' do
+ # Even though feature flag is enabled, we're explicitly passing false
+ contact_with_email = create(:contact, account: account, email: 'test@example.com', name: 'John Doe')
+ contact_with_phone = create(:contact, account: account, phone_number: '+1234567890', name: 'Jane Smith')
+ contact_without_details = create(:contact, account: account, name: 'Alice Johnson', email: nil, phone_number: nil, identifier: nil)
+
+ resolved = account.contacts.resolved_contacts(use_crm_v2: false)
+
+ # Should use the old logic despite feature flag being enabled
+ expect(resolved).to include(contact_with_email, contact_with_phone)
+ expect(resolved).not_to include(contact_without_details)
+ end
+ end
+
+ context 'with mixed contact types' do
+ it 'correctly filters based on use_crm_v2 parameter regardless of feature flag' do
+ # Create different types of contacts
+ visitor_contact = create(:contact, account: account, name: 'Visitor')
+ lead_with_email = create(:contact, account: account, email: 'lead@example.com', name: 'Lead')
+ lead_without_email = create(:contact, account: account, name: 'Lead Only')
+ lead_without_email.update!(contact_type: 'lead')
+ customer_contact = create(:contact, account: account, email: 'customer@example.com', name: 'Customer')
+ customer_contact.update!(contact_type: 'customer')
+
+ # Test with use_crm_v2: false
+ resolved_old = account.contacts.resolved_contacts(use_crm_v2: false)
+ expect(resolved_old).to include(lead_with_email, customer_contact)
+ expect(resolved_old).not_to include(visitor_contact, lead_without_email)
+
+ # Test with use_crm_v2: true
+ resolved_new = account.contacts.resolved_contacts(use_crm_v2: true)
+ expect(resolved_new).to include(lead_with_email, lead_without_email)
+ expect(resolved_new).not_to include(visitor_contact, customer_contact)
+ end
+ end
+ end
end
diff --git a/spec/presenters/mail_presenter_spec.rb b/spec/presenters/mail_presenter_spec.rb
index d5b313ce9..af6768e41 100644
--- a/spec/presenters/mail_presenter_spec.rb
+++ b/spec/presenters/mail_presenter_spec.rb
@@ -46,6 +46,7 @@ RSpec.describe MailPresenter do
:message_id,
:multipart,
:number_of_attachments,
+ :references,
:subject,
:text_content,
:to
@@ -100,6 +101,31 @@ RSpec.describe MailPresenter do
end
end
+ describe '#references' do
+ let(:references_mail) { create_inbound_email_from_fixture('references.eml').mail }
+ let(:mail_presenter_with_references) { described_class.new(references_mail) }
+
+ context 'when mail has references' do
+ it 'returns an array of reference IDs' do
+ expect(mail_presenter_with_references.references).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
+ end
+ end
+
+ context 'when mail has no references' do
+ it 'returns an empty array' do
+ mail_presenter = described_class.new(mail_without_in_reply_to)
+ expect(mail_presenter.references).to eq([])
+ end
+ end
+
+ context 'when references are included in serialized_data' do
+ it 'includes references in the serialized data' do
+ data = mail_presenter_with_references.serialized_data
+ expect(data[:references]).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
+ end
+ end
+ end
+
describe 'auto_reply?' do
let(:auto_reply_mail) { create_inbound_email_from_fixture('auto_reply.eml').mail }
let(:auto_reply_with_auto_submitted_mail) { create_inbound_email_from_fixture('auto_reply_with_auto_submitted.eml').mail }
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index 92c397ab7..9ce1b0785 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -185,6 +185,63 @@ describe SearchService do
end
end
+ describe '#message_base_query' do
+ let(:params) { { q: 'test' } }
+ let(:search_type) { 'Message' }
+
+ context 'when user is admin' do
+ let(:admin_user) { create(:user) }
+ let(:admin_search) do
+ create(:account_user, account: account, user: admin_user, role: 'administrator')
+ described_class.new(current_user: admin_user, current_account: account, params: params, search_type: search_type)
+ end
+
+ it 'does not filter by inbox_id' do
+ # Testing the private method itself seems like the best way to ensure
+ # that the inboxes are not added to the search query
+ base_query = admin_search.send(:message_base_query)
+
+ # Should only have the time filter, not inbox filter
+ expect(base_query.to_sql).to include('created_at >= ')
+ expect(base_query.to_sql).not_to include('inbox_id')
+ end
+ end
+
+ context 'when user is not admin' do
+ before do
+ account_user = account.account_users.find_or_create_by(user: user)
+ account_user.update!(role: 'agent')
+ end
+
+ it 'filters by accessible inbox_id when user has limited access' do
+ # Create an additional inbox that user is NOT assigned to
+ create(:inbox, account: account)
+
+ base_query = search.send(:message_base_query)
+
+ # Should have both time and inbox filters
+ expect(base_query.to_sql).to include('created_at >= ')
+ expect(base_query.to_sql).to include('inbox_id')
+ end
+
+ context 'when user has access to all inboxes' do
+ before do
+ # Create additional inbox and assign user to all inboxes
+ other_inbox = create(:inbox, account: account)
+ create(:inbox_member, user: user, inbox: other_inbox)
+ end
+
+ it 'skips inbox filtering as optimization' do
+ base_query = search.send(:message_base_query)
+
+ # Should only have the time filter, not inbox filter
+ expect(base_query.to_sql).to include('created_at >= ')
+ expect(base_query.to_sql).not_to include('inbox_id')
+ end
+ end
+ end
+ end
+
describe '#use_gin_search' do
let(:params) { { q: 'test' } }
diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb
index 6c2af7716..308d61f62 100644
--- a/spec/services/whatsapp/facebook_api_client_spec.rb
+++ b/spec/services/whatsapp/facebook_api_client_spec.rb
@@ -194,4 +194,41 @@ describe Whatsapp::FacebookApiClient do
end
end
end
+
+ describe '#unsubscribe_waba_webhook' do
+ let(:waba_id) { 'test_waba_id' }
+
+ context 'when successful' do
+ before do
+ stub_request(:delete, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
+ )
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns success response' do
+ result = api_client.unsubscribe_waba_webhook(waba_id)
+ expect(result['success']).to be(true)
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:delete, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
+ )
+ .to_return(status: 400, body: { error: 'Webhook unsubscription failed' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.unsubscribe_waba_webhook(waba_id) }.to raise_error(/Webhook unsubscription failed/)
+ end
+ end
+ end
end
diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb
new file mode 100644
index 000000000..25d2ae374
--- /dev/null
+++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb
@@ -0,0 +1,81 @@
+require 'rails_helper'
+
+RSpec.describe Whatsapp::WebhookTeardownService do
+ describe '#perform' do
+ let(:channel) { create(:channel_whatsapp, validate_provider_config: false, sync_templates: false) }
+ let(:service) { described_class.new(channel) }
+
+ context 'when channel is whatsapp_cloud with embedded_signup' do
+ before do
+ channel.update!(
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ 'source' => 'embedded_signup',
+ 'business_account_id' => 'test_waba_id',
+ 'api_key' => 'test_api_key'
+ }
+ )
+ end
+
+ it 'calls unsubscribe_waba_webhook on Facebook API client' do
+ api_client = instance_double(Whatsapp::FacebookApiClient)
+ allow(Whatsapp::FacebookApiClient).to receive(:new).with('test_api_key').and_return(api_client)
+ allow(api_client).to receive(:unsubscribe_waba_webhook).with('test_waba_id')
+
+ service.perform
+
+ expect(api_client).to have_received(:unsubscribe_waba_webhook).with('test_waba_id')
+ end
+
+ it 'handles errors gracefully without raising' do
+ api_client = instance_double(Whatsapp::FacebookApiClient)
+ allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
+ allow(api_client).to receive(:unsubscribe_waba_webhook).and_raise(StandardError, 'API Error')
+
+ expect { service.perform }.not_to raise_error
+ end
+ end
+
+ context 'when channel is not whatsapp_cloud' do
+ before do
+ channel.update!(provider: 'default')
+ end
+
+ it 'does not attempt to unsubscribe webhook' do
+ expect(Whatsapp::FacebookApiClient).not_to receive(:new)
+
+ service.perform
+ end
+ end
+
+ context 'when channel is whatsapp_cloud but not embedded_signup' do
+ before do
+ channel.update!(
+ provider: 'whatsapp_cloud',
+ provider_config: { 'source' => 'manual' }
+ )
+ end
+
+ it 'does not attempt to unsubscribe webhook' do
+ expect(Whatsapp::FacebookApiClient).not_to receive(:new)
+
+ service.perform
+ end
+ end
+
+ context 'when required config is missing' do
+ before do
+ channel.update!(
+ provider: 'whatsapp_cloud',
+ provider_config: { 'source' => 'embedded_signup' }
+ )
+ end
+
+ it 'does not attempt to unsubscribe webhook' do
+ expect(Whatsapp::FacebookApiClient).not_to receive(:new)
+
+ service.perform
+ end
+ end
+ end
+end
diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml
index faf947217..b244deb7f 100644
--- a/swagger/definitions/index.yml
+++ b/swagger/definitions/index.yml
@@ -74,6 +74,8 @@ integrations_app:
$ref: ./resource/integrations/app.yml
integrations_hook:
$ref: ./resource/integrations/hook.yml
+audit_log:
+ $ref: ./resource/audit_log.yml
## public resources
public_contact:
diff --git a/swagger/definitions/resource/audit_log.yml b/swagger/definitions/resource/audit_log.yml
new file mode 100644
index 000000000..9dfa63263
--- /dev/null
+++ b/swagger/definitions/resource/audit_log.yml
@@ -0,0 +1,53 @@
+type: object
+properties:
+ id:
+ type: integer
+ description: Unique identifier for the audit log entry
+ auditable_id:
+ type: integer
+ description: The ID of the audited object
+ auditable_type:
+ type: string
+ description: The type of the audited object (e.g., Conversation, Contact, User)
+ auditable:
+ type: object
+ description: The audited object data
+ associated_id:
+ type: integer
+ description: The ID of the associated object (typically the account ID)
+ associated_type:
+ type: string
+ description: The type of the associated object
+ user_id:
+ type: integer
+ description: The ID of the user who performed the action
+ user_type:
+ type: string
+ description: The type of user who performed the action
+ username:
+ type: string
+ description: The email/username of the user who performed the action
+ action:
+ type: string
+ enum: ['create', 'update', 'destroy']
+ description: The action performed on the object
+ audited_changes:
+ type: object
+ description: JSON object containing the changes made to the audited object
+ version:
+ type: integer
+ description: Version number of the audit log entry
+ comment:
+ type: string
+ nullable: true
+ description: Optional comment associated with the audit log entry
+ request_uuid:
+ type: string
+ description: UUID to identify the request that generated this audit log
+ created_at:
+ type: integer
+ description: Unix timestamp when the audit log entry was created
+ remote_address:
+ type: string
+ nullable: true
+ description: IP address from which the action was performed
\ No newline at end of file
diff --git a/swagger/index.yml b/swagger/index.yml
index e27425d41..dd460d111 100644
--- a/swagger/index.yml
+++ b/swagger/index.yml
@@ -99,7 +99,9 @@ x-tagGroups:
- name: Application
tags:
- Account AgentBots
+ - Account
- Agents
+ - Audit Logs
- Canned Responses
- Contacts
- Contact Labels
diff --git a/swagger/paths/application/audit_logs/index.yml b/swagger/paths/application/audit_logs/index.yml
new file mode 100644
index 000000000..8fcd22256
--- /dev/null
+++ b/swagger/paths/application/audit_logs/index.yml
@@ -0,0 +1,52 @@
+tags:
+ - Audit Logs
+operationId: get-account-audit-logs
+summary: List Audit Logs in Account
+description: Get Details of Audit Log entries for an Account. This endpoint is only available in Enterprise editions and requires the audit_logs feature to be enabled.
+security:
+ - userApiKey: []
+parameters:
+ - name: page
+ in: query
+ description: Page number for pagination
+ required: false
+ schema:
+ type: integer
+ default: 1
+responses:
+ 200:
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ per_page:
+ type: integer
+ description: Number of items per page
+ example: 15
+ total_entries:
+ type: integer
+ description: Total number of audit log entries
+ example: 150
+ current_page:
+ type: integer
+ description: Current page number
+ example: 1
+ audit_logs:
+ type: array
+ description: Array of audit log entries
+ items:
+ $ref: '#/components/schemas/audit_log'
+ 403:
+ description: Access denied
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ 422:
+ description: Feature not enabled or not available in current plan
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
\ No newline at end of file
diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml
index a70800b89..b2fcb4594 100644
--- a/swagger/paths/index.yml
+++ b/swagger/paths/index.yml
@@ -175,6 +175,13 @@
patch:
$ref: ./application/accounts/update.yml
+# Audit Logs
+/api/v1/accounts/{account_id}/audit_logs:
+ parameters:
+ - $ref: '#/components/parameters/account_id'
+ get:
+ $ref: ./application/audit_logs/index.yml
+
# AgentBots
/api/v1/accounts/{account_id}/agent_bots:
parameters:
diff --git a/swagger/swagger.json b/swagger/swagger.json
index b9ecbcf27..e849f8119 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -1608,6 +1608,94 @@
}
}
},
+ "/api/v1/accounts/{account_id}/audit_logs": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Audit Logs"
+ ],
+ "operationId": "get-account-audit-logs",
+ "summary": "List Audit Logs in Account",
+ "description": "Get Details of Audit Log entries for an Account. This endpoint is only available in Enterprise editions and requires the audit_logs feature to be enabled.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "description": "Page number for pagination",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "per_page": {
+ "type": "integer",
+ "description": "Number of items per page",
+ "example": 15
+ },
+ "total_entries": {
+ "type": "integer",
+ "description": "Total number of audit log entries",
+ "example": 150
+ },
+ "current_page": {
+ "type": "integer",
+ "description": "Current page number",
+ "example": 1
+ },
+ "audit_logs": {
+ "type": "array",
+ "description": "Array of audit log entries",
+ "items": {
+ "$ref": "#/components/schemas/audit_log"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Feature not enabled or not available in current plan",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/agent_bots": {
"parameters": [
{
@@ -9177,6 +9265,82 @@
}
}
},
+ "audit_log": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "Unique identifier for the audit log entry"
+ },
+ "auditable_id": {
+ "type": "integer",
+ "description": "The ID of the audited object"
+ },
+ "auditable_type": {
+ "type": "string",
+ "description": "The type of the audited object (e.g., Conversation, Contact, User)"
+ },
+ "auditable": {
+ "type": "object",
+ "description": "The audited object data"
+ },
+ "associated_id": {
+ "type": "integer",
+ "description": "The ID of the associated object (typically the account ID)"
+ },
+ "associated_type": {
+ "type": "string",
+ "description": "The type of the associated object"
+ },
+ "user_id": {
+ "type": "integer",
+ "description": "The ID of the user who performed the action"
+ },
+ "user_type": {
+ "type": "string",
+ "description": "The type of user who performed the action"
+ },
+ "username": {
+ "type": "string",
+ "description": "The email/username of the user who performed the action"
+ },
+ "action": {
+ "type": "string",
+ "enum": [
+ "create",
+ "update",
+ "destroy"
+ ],
+ "description": "The action performed on the object"
+ },
+ "audited_changes": {
+ "type": "object",
+ "description": "JSON object containing the changes made to the audited object"
+ },
+ "version": {
+ "type": "integer",
+ "description": "Version number of the audit log entry"
+ },
+ "comment": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional comment associated with the audit log entry"
+ },
+ "request_uuid": {
+ "type": "string",
+ "description": "UUID to identify the request that generated this audit log"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the audit log entry was created"
+ },
+ "remote_address": {
+ "type": "string",
+ "nullable": true,
+ "description": "IP address from which the action was performed"
+ }
+ }
+ },
"public_contact": {
"type": "object",
"properties": {
@@ -12148,7 +12312,9 @@
"name": "Application",
"tags": [
"Account AgentBots",
+ "Account",
"Agents",
+ "Audit Logs",
"Canned Responses",
"Contacts",
"Contact Labels",
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index 2a8162358..f06819d1d 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -19,6 +19,226 @@
}
],
"paths": {
+ "/api/v1/accounts/{id}": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Account"
+ ],
+ "operationId": "get-account-details",
+ "summary": "Get account details",
+ "description": "Get the details of the current account",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/account_show_response"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Account not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Account"
+ ],
+ "operationId": "update-account",
+ "summary": "Update account",
+ "description": "Update account details, settings, and custom attributes",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/account_update_payload"
+ }
+ },
+ "application/x-www-form-urlencoded": {
+ "schema": {
+ "$ref": "#/components/schemas/account_update_payload"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/account_detail"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized (requires administrator role)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Account not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/audit_logs": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Audit Logs"
+ ],
+ "operationId": "get-account-audit-logs",
+ "summary": "List Audit Logs in Account",
+ "description": "Get Details of Audit Log entries for an Account. This endpoint is only available in Enterprise editions and requires the audit_logs feature to be enabled.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "description": "Page number for pagination",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "per_page": {
+ "type": "integer",
+ "description": "Number of items per page",
+ "example": 15
+ },
+ "total_entries": {
+ "type": "integer",
+ "description": "Total number of audit log entries",
+ "example": 150
+ },
+ "current_page": {
+ "type": "integer",
+ "description": "Current page number",
+ "example": 1
+ },
+ "audit_logs": {
+ "type": "array",
+ "description": "Array of audit log entries",
+ "items": {
+ "$ref": "#/components/schemas/audit_log"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Feature not enabled or not available in current plan",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/agent_bots": {
"parameters": [
{
@@ -7406,6 +7626,82 @@
}
}
},
+ "audit_log": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "Unique identifier for the audit log entry"
+ },
+ "auditable_id": {
+ "type": "integer",
+ "description": "The ID of the audited object"
+ },
+ "auditable_type": {
+ "type": "string",
+ "description": "The type of the audited object (e.g., Conversation, Contact, User)"
+ },
+ "auditable": {
+ "type": "object",
+ "description": "The audited object data"
+ },
+ "associated_id": {
+ "type": "integer",
+ "description": "The ID of the associated object (typically the account ID)"
+ },
+ "associated_type": {
+ "type": "string",
+ "description": "The type of the associated object"
+ },
+ "user_id": {
+ "type": "integer",
+ "description": "The ID of the user who performed the action"
+ },
+ "user_type": {
+ "type": "string",
+ "description": "The type of user who performed the action"
+ },
+ "username": {
+ "type": "string",
+ "description": "The email/username of the user who performed the action"
+ },
+ "action": {
+ "type": "string",
+ "enum": [
+ "create",
+ "update",
+ "destroy"
+ ],
+ "description": "The action performed on the object"
+ },
+ "audited_changes": {
+ "type": "object",
+ "description": "JSON object containing the changes made to the audited object"
+ },
+ "version": {
+ "type": "integer",
+ "description": "Version number of the audit log entry"
+ },
+ "comment": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional comment associated with the audit log entry"
+ },
+ "request_uuid": {
+ "type": "string",
+ "description": "UUID to identify the request that generated this audit log"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the audit log entry was created"
+ },
+ "remote_address": {
+ "type": "string",
+ "nullable": true,
+ "description": "IP address from which the action was performed"
+ }
+ }
+ },
"public_contact": {
"type": "object",
"properties": {
@@ -10345,7 +10641,9 @@
"name": "Application",
"tags": [
"Account AgentBots",
+ "Account",
"Agents",
+ "Audit Logs",
"Canned Responses",
"Contacts",
"Contact Labels",
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index cefc39324..c6a3ba408 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -2249,6 +2249,82 @@
}
}
},
+ "audit_log": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "Unique identifier for the audit log entry"
+ },
+ "auditable_id": {
+ "type": "integer",
+ "description": "The ID of the audited object"
+ },
+ "auditable_type": {
+ "type": "string",
+ "description": "The type of the audited object (e.g., Conversation, Contact, User)"
+ },
+ "auditable": {
+ "type": "object",
+ "description": "The audited object data"
+ },
+ "associated_id": {
+ "type": "integer",
+ "description": "The ID of the associated object (typically the account ID)"
+ },
+ "associated_type": {
+ "type": "string",
+ "description": "The type of the associated object"
+ },
+ "user_id": {
+ "type": "integer",
+ "description": "The ID of the user who performed the action"
+ },
+ "user_type": {
+ "type": "string",
+ "description": "The type of user who performed the action"
+ },
+ "username": {
+ "type": "string",
+ "description": "The email/username of the user who performed the action"
+ },
+ "action": {
+ "type": "string",
+ "enum": [
+ "create",
+ "update",
+ "destroy"
+ ],
+ "description": "The action performed on the object"
+ },
+ "audited_changes": {
+ "type": "object",
+ "description": "JSON object containing the changes made to the audited object"
+ },
+ "version": {
+ "type": "integer",
+ "description": "Version number of the audit log entry"
+ },
+ "comment": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional comment associated with the audit log entry"
+ },
+ "request_uuid": {
+ "type": "string",
+ "description": "UUID to identify the request that generated this audit log"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the audit log entry was created"
+ },
+ "remote_address": {
+ "type": "string",
+ "nullable": true,
+ "description": "IP address from which the action was performed"
+ }
+ }
+ },
"public_contact": {
"type": "object",
"properties": {
@@ -5124,7 +5200,9 @@
"name": "Application",
"tags": [
"Account AgentBots",
+ "Account",
"Agents",
+ "Audit Logs",
"Canned Responses",
"Contacts",
"Contact Labels",
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index 2cc045747..e2a16245b 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -1664,6 +1664,82 @@
}
}
},
+ "audit_log": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "Unique identifier for the audit log entry"
+ },
+ "auditable_id": {
+ "type": "integer",
+ "description": "The ID of the audited object"
+ },
+ "auditable_type": {
+ "type": "string",
+ "description": "The type of the audited object (e.g., Conversation, Contact, User)"
+ },
+ "auditable": {
+ "type": "object",
+ "description": "The audited object data"
+ },
+ "associated_id": {
+ "type": "integer",
+ "description": "The ID of the associated object (typically the account ID)"
+ },
+ "associated_type": {
+ "type": "string",
+ "description": "The type of the associated object"
+ },
+ "user_id": {
+ "type": "integer",
+ "description": "The ID of the user who performed the action"
+ },
+ "user_type": {
+ "type": "string",
+ "description": "The type of user who performed the action"
+ },
+ "username": {
+ "type": "string",
+ "description": "The email/username of the user who performed the action"
+ },
+ "action": {
+ "type": "string",
+ "enum": [
+ "create",
+ "update",
+ "destroy"
+ ],
+ "description": "The action performed on the object"
+ },
+ "audited_changes": {
+ "type": "object",
+ "description": "JSON object containing the changes made to the audited object"
+ },
+ "version": {
+ "type": "integer",
+ "description": "Version number of the audit log entry"
+ },
+ "comment": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional comment associated with the audit log entry"
+ },
+ "request_uuid": {
+ "type": "string",
+ "description": "UUID to identify the request that generated this audit log"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the audit log entry was created"
+ },
+ "remote_address": {
+ "type": "string",
+ "nullable": true,
+ "description": "IP address from which the action was performed"
+ }
+ }
+ },
"public_contact": {
"type": "object",
"properties": {
@@ -4531,7 +4607,9 @@
"name": "Application",
"tags": [
"Account AgentBots",
+ "Account",
"Agents",
+ "Audit Logs",
"Canned Responses",
"Contacts",
"Contact Labels",
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index 085e969ab..b4feb0024 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -2425,6 +2425,82 @@
}
}
},
+ "audit_log": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "Unique identifier for the audit log entry"
+ },
+ "auditable_id": {
+ "type": "integer",
+ "description": "The ID of the audited object"
+ },
+ "auditable_type": {
+ "type": "string",
+ "description": "The type of the audited object (e.g., Conversation, Contact, User)"
+ },
+ "auditable": {
+ "type": "object",
+ "description": "The audited object data"
+ },
+ "associated_id": {
+ "type": "integer",
+ "description": "The ID of the associated object (typically the account ID)"
+ },
+ "associated_type": {
+ "type": "string",
+ "description": "The type of the associated object"
+ },
+ "user_id": {
+ "type": "integer",
+ "description": "The ID of the user who performed the action"
+ },
+ "user_type": {
+ "type": "string",
+ "description": "The type of user who performed the action"
+ },
+ "username": {
+ "type": "string",
+ "description": "The email/username of the user who performed the action"
+ },
+ "action": {
+ "type": "string",
+ "enum": [
+ "create",
+ "update",
+ "destroy"
+ ],
+ "description": "The action performed on the object"
+ },
+ "audited_changes": {
+ "type": "object",
+ "description": "JSON object containing the changes made to the audited object"
+ },
+ "version": {
+ "type": "integer",
+ "description": "Version number of the audit log entry"
+ },
+ "comment": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional comment associated with the audit log entry"
+ },
+ "request_uuid": {
+ "type": "string",
+ "description": "UUID to identify the request that generated this audit log"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the audit log entry was created"
+ },
+ "remote_address": {
+ "type": "string",
+ "nullable": true,
+ "description": "IP address from which the action was performed"
+ }
+ }
+ },
"public_contact": {
"type": "object",
"properties": {
@@ -5304,7 +5380,9 @@
"name": "Application",
"tags": [
"Account AgentBots",
+ "Account",
"Agents",
+ "Audit Logs",
"Canned Responses",
"Contacts",
"Contact Labels",
|