|
-
|
@@ -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 93f515c10..d4bf3bd1e 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -317,6 +317,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/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/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/services/search_service.rb b/app/services/search_service.rb
index 7fce0a7dd..30d9a186c 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,9 @@ 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 account_user.administrator?
+ query
end
def use_gin_search
@@ -90,7 +95,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/installation_config.yml b/config/installation_config.yml
index 2e8d94a96..fcf4ade2f 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -416,3 +416,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/routes.rb b/config/routes.rb
index 11acc5477..ca6f59dbd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -196,6 +196,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
# Voice call management - using resource to avoid plural/singular confusion
@@ -555,6 +556,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/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..41c8411b1 100644
--- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
+++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
@@ -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/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/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/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/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/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/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/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index 92c397ab7..bbab5e05d 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -185,6 +185,46 @@ 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' 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 = 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
+ 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
|