-
+
+
![]()
{
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue
new file mode 100644
index 000000000..a249aee22
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
index f302f36ab..e50eccb3a 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
@@ -8,6 +8,8 @@ import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
+import Shopify from './Shopify.vue';
+
export default {
routes: [
{
@@ -88,6 +90,16 @@ export default {
},
props: route => ({ code: route.query.code }),
},
+ {
+ path: 'shopify',
+ name: 'settings_integrations_shopify',
+ component: Shopify,
+ meta: {
+ featureFlag: FEATURE_FLAGS.INTEGRATIONS,
+ permissions: ['administrator'],
+ },
+ props: route => ({ error: route.query.error }),
+ },
{
path: ':integration_id',
name: 'settings_applications_integration',
diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb
index 84844b38a..1f88cdd4d 100644
--- a/app/models/integrations/app.rb
+++ b/app/models/integrations/app.rb
@@ -48,7 +48,9 @@ class Integrations::App
when 'slack'
ENV['SLACK_CLIENT_SECRET'].present?
when 'linear'
- account.feature_enabled?('linear_integration')
+ GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
+ when 'shopify'
+ account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
else
true
end
diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb
index 4472701ee..37f6a77ff 100644
--- a/app/views/super_admin/application/_icons.html.erb
+++ b/app/views/super_admin/application/_icons.html.erb
@@ -149,6 +149,10 @@
-
-
+
+
+
+
+
+
diff --git a/config/features.yml b/config/features.yml
index dda797fce..70d6c9fcf 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -154,6 +154,10 @@
display_name: Contact Chatwoot Support Team
enabled: true
chatwoot_internal: true
+- name: shopify_integration
+ display_name: Shopify Integration
+ enabled: false
+ chatwoot_internal: true
- name: search_with_gin
display_name: Search messages with GIN
enabled: false
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 422ecea9c..15b815496 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -279,3 +279,16 @@
description: 'Linear client secret'
type: secret
## ------ End of Configs added for Linear ------ ##
+
+# ------- Shopify Integration Config ------- #
+- name: SHOPIFY_CLIENT_ID
+ display_title: 'Shopify Client ID'
+ description: 'The Client ID (API Key) from your Shopify Partner account'
+ locked: false
+ type: secret
+- name: SHOPIFY_CLIENT_SECRET
+ display_title: 'Shopify Client Secret'
+ description: 'The Client Secret (API Secret Key) from your Shopify Partner account'
+ locked: false
+ type: secret
+# ------- End of Shopify Related Config ------- #
diff --git a/config/integration/apps.yml b/config/integration/apps.yml
index b625e83b5..b4d0d8394 100644
--- a/config/integration/apps.yml
+++ b/config/integration/apps.yml
@@ -179,3 +179,10 @@ dyte:
},
]
visible_properties: ['organization_id']
+
+shopify:
+ id: shopify
+ logo: shopify.png
+ i18n_key: shopify
+ hook_type: account
+ allow_multiple_hooks: false
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4183c873d..4a66a4bc2 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -231,6 +231,9 @@ en:
linear:
name: 'Linear'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
+ shopify:
+ name: 'Shopify'
+ description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
captain:
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
diff --git a/config/routes.rb b/config/routes.rb
index 56704d9aa..5bc965337 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -233,6 +233,12 @@ Rails.application.routes.draw do
post :add_participant_to_meeting
end
end
+ resource :shopify, controller: 'shopify', only: [:destroy] do
+ collection do
+ post :auth
+ get :orders
+ end
+ end
resource :linear, controller: 'linear', only: [] do
collection do
delete :destroy
@@ -457,6 +463,10 @@ Rails.application.routes.draw do
resource :callback, only: [:show]
end
+ namespace :shopify do
+ resource :callback, only: [:show]
+ end
+
namespace :twilio do
resources :callback, only: [:create]
resources :delivery_status, only: [:create]
diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml
index 5aa5fc160..a54aaf6eb 100644
--- a/enterprise/app/helpers/super_admin/features.yml
+++ b/enterprise/app/helpers/super_admin/features.yml
@@ -85,3 +85,9 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
+shopify:
+ name: 'Shopify'
+ description: 'Configuration for setting up Shopify'
+ enabled: true
+ icon: 'icon-shopify'
+ config_key: 'shopify'
diff --git a/public/dashboard/images/integrations/shopify-dark.png b/public/dashboard/images/integrations/shopify-dark.png
new file mode 100644
index 000000000..8f973938d
Binary files /dev/null and b/public/dashboard/images/integrations/shopify-dark.png differ
diff --git a/public/dashboard/images/integrations/shopify.png b/public/dashboard/images/integrations/shopify.png
new file mode 100644
index 000000000..cfc5036f7
Binary files /dev/null and b/public/dashboard/images/integrations/shopify.png differ
diff --git a/spec/controllers/api/v1/accounts/integrations/shopify_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/shopify_controller_spec.rb
new file mode 100644
index 000000000..ef6c4d367
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/integrations/shopify_controller_spec.rb
@@ -0,0 +1,187 @@
+require 'rails_helper'
+
+# Stub class for ShopifyAPI response
+class ShopifyAPIResponse
+ attr_reader :body
+
+ def initialize(body)
+ @body = body
+ end
+end
+
+RSpec.describe 'Shopify Integration API', type: :request do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:unauthorized_agent) { create(:user, account: account, role: :agent) }
+ let(:contact) { create(:contact, account: account, email: 'test@example.com', phone_number: '+1234567890') }
+
+ describe 'POST /api/v1/accounts/:account_id/integrations/shopify/auth' do
+ let(:shop_domain) { 'test-store.myshopify.com' }
+
+ context 'when it is an authenticated user' do
+ it 'returns a redirect URL for Shopify OAuth' do
+ post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
+ params: { shop_domain: shop_domain },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body).to have_key('redirect_url')
+ expect(response.parsed_body['redirect_url']).to include(shop_domain)
+ end
+
+ it 'returns error when shop domain is missing' do
+ post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Shop domain is required')
+ end
+ end
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
+ params: { shop_domain: shop_domain },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/integrations/shopify/orders' do
+ before do
+ create(:integrations_hook, :shopify, account: account)
+ end
+
+ context 'when it is an authenticated user' do
+ # rubocop:disable RSpec/AnyInstance
+ let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
+
+ let(:customers_response) do
+ instance_double(
+ ShopifyAPIResponse,
+ body: { 'customers' => [{ 'id' => '123' }] }
+ )
+ end
+
+ let(:orders_response) do
+ instance_double(
+ ShopifyAPIResponse,
+ body: {
+ 'orders' => [{
+ 'id' => '456',
+ 'email' => 'test@example.com',
+ 'created_at' => Time.now.iso8601,
+ 'total_price' => '100.00',
+ 'currency' => 'USD',
+ 'fulfillment_status' => 'fulfilled',
+ 'financial_status' => 'paid'
+ }]
+ }
+ )
+ end
+
+ before do
+ allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:shopify_client).and_return(shopify_client)
+
+ allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:client_id).and_return('test_client_id')
+ allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:client_secret).and_return('test_client_secret')
+
+ allow(shopify_client).to receive(:get).with(
+ path: 'customers/search.json',
+ query: { query: "email:#{contact.email} OR phone:#{contact.phone_number}", fields: 'id,email,phone' }
+ ).and_return(customers_response)
+
+ allow(shopify_client).to receive(:get).with(
+ path: 'orders.json',
+ query: { customer_id: '123', status: 'any', fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status' }
+ ).and_return(orders_response)
+ end
+
+ it 'returns orders for the contact' do
+ get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
+ params: { contact_id: contact.id },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body).to have_key('orders')
+ expect(response.parsed_body['orders'].length).to eq(1)
+ expect(response.parsed_body['orders'][0]['id']).to eq('456')
+ end
+
+ it 'returns error when contact has no email or phone' do
+ contact_without_info = create(:contact, account: account)
+
+ get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
+ params: { contact_id: contact_without_info.id },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Contact information missing')
+ end
+
+ it 'returns empty array when no customers found' do
+ empty_customers_response = instance_double(
+ ShopifyAPIResponse,
+ body: { 'customers' => [] }
+ )
+
+ allow(shopify_client).to receive(:get).with(
+ path: 'customers/search.json',
+ query: { query: "email:#{contact.email} OR phone:#{contact.phone_number}", fields: 'id,email,phone' }
+ ).and_return(empty_customers_response)
+
+ get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
+ params: { contact_id: contact.id },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['orders']).to eq([])
+ end
+ # rubocop:enable RSpec/AnyInstance
+ end
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
+ params: { contact_id: contact.id },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
+ before do
+ create(:integrations_hook, :shopify, account: account)
+ end
+
+ context 'when it is an authenticated user' do
+ it 'deletes the shopify integration' do
+ expect do
+ delete "/api/v1/accounts/#{account.id}/integrations/shopify",
+ headers: agent.create_new_auth_token,
+ as: :json
+ end.to change { account.hooks.count }.by(-1)
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ delete "/api/v1/accounts/#{account.id}/integrations/shopify",
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/shopify/callbacks_controller_spec.rb b/spec/controllers/shopify/callbacks_controller_spec.rb
new file mode 100644
index 000000000..cb75e23b4
--- /dev/null
+++ b/spec/controllers/shopify/callbacks_controller_spec.rb
@@ -0,0 +1,109 @@
+require 'rails_helper'
+
+RSpec.describe Shopify::CallbacksController, type: :request do
+ let(:account) { create(:account) }
+ let(:code) { SecureRandom.hex(10) }
+ let(:state) { SecureRandom.hex(10) }
+ let(:shop) { 'my-store.myshopify.com' }
+ let(:frontend_url) { 'http://www.example.com' }
+ let(:shopify_redirect_uri) { "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify" }
+ let(:oauth_client) { instance_double(OAuth2::Client) }
+ let(:auth_code_strategy) { instance_double(OAuth2::Strategy::AuthCode) }
+
+ describe 'GET /shopify/callback' do
+ let(:access_token) { SecureRandom.hex(10) }
+ let(:response_body) do
+ {
+ 'access_token' => access_token,
+ 'scope' => 'read_products,write_products'
+ }
+ end
+
+ before do
+ stub_const('ENV', ENV.to_hash.merge('FRONTEND_URL' => frontend_url))
+ end
+
+ context 'when successful' do
+ before do
+ controller = described_class.new
+ allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
+ allow(described_class).to receive(:new).and_return(controller)
+
+ stub_request(:post, "https://#{shop}/admin/oauth/access_token")
+ .to_return(
+ status: 200,
+ body: response_body.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'creates a new integration hook' do
+ expect do
+ get shopify_callback_path, params: { code: code, state: state, shop: shop }
+ end.to change(Integrations::Hook, :count).by(1)
+
+ hook = Integrations::Hook.last
+ expect(hook.access_token).to eq(access_token)
+ expect(hook.app_id).to eq('shopify')
+ expect(hook.status).to eq('enabled')
+ expect(hook.reference_id).to eq(shop)
+ expect(hook.settings).to eq(
+ 'scope' => 'read_products,write_products'
+ )
+ expect(response).to redirect_to(shopify_redirect_uri)
+ end
+ end
+
+ context 'when the code is missing' do
+ before do
+ controller = described_class.new
+ allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
+ allow(controller).to receive(:oauth_client).and_return(oauth_client)
+ allow(oauth_client).to receive(:auth_code).and_raise(StandardError)
+ allow(described_class).to receive(:new).and_return(controller)
+ end
+
+ it 'redirects to the shopify_redirect_uri with error' do
+ get shopify_callback_path, params: { state: state, shop: shop }
+ expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
+ end
+ end
+
+ context 'when the token is invalid' do
+ before do
+ controller = described_class.new
+ allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
+ allow(controller).to receive(:oauth_client).and_return(oauth_client)
+ allow(oauth_client).to receive(:auth_code).and_return(auth_code_strategy)
+ allow(auth_code_strategy).to receive(:get_token).and_raise(
+ OAuth2::Error.new(
+ OpenStruct.new(
+ parsed: { 'error' => 'invalid_grant' },
+ status: 400
+ )
+ )
+ )
+ allow(described_class).to receive(:new).and_return(controller)
+ end
+
+ it 'redirects to the shopify_redirect_uri with error' do
+ get shopify_callback_path, params: { code: code, state: state, shop: shop }
+ expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
+ end
+ end
+
+ context 'when state parameter is invalid' do
+ before do
+ controller = described_class.new
+ allow(controller).to receive(:verify_shopify_token).with(state).and_return(nil)
+ allow(controller).to receive(:account).and_return(nil)
+ allow(described_class).to receive(:new).and_return(controller)
+ end
+
+ it 'redirects to the frontend URL with error' do
+ get shopify_callback_path, params: { code: code, state: state, shop: shop }
+ expect(response).to redirect_to("#{frontend_url}?error=true")
+ end
+ end
+ end
+end
diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb
index 498fb9f9a..f154d684d 100644
--- a/spec/factories/integrations/hooks.rb
+++ b/spec/factories/integrations/hooks.rb
@@ -31,5 +31,11 @@ FactoryBot.define do
app_id { 'linear' }
access_token { SecureRandom.hex }
end
+
+ trait :shopify do
+ app_id { 'shopify' }
+ access_token { SecureRandom.hex }
+ reference_id { 'test-store.myshopify.com' }
+ end
end
end
diff --git a/spec/helpers/shopify/integration_helper_spec.rb b/spec/helpers/shopify/integration_helper_spec.rb
new file mode 100644
index 000000000..15b7120d4
--- /dev/null
+++ b/spec/helpers/shopify/integration_helper_spec.rb
@@ -0,0 +1,95 @@
+require 'rails_helper'
+
+RSpec.describe Shopify::IntegrationHelper do
+ include described_class
+
+ describe '#generate_shopify_token' do
+ let(:account_id) { 1 }
+ let(:client_secret) { 'test_secret' }
+ let(:current_time) { Time.current }
+
+ before do
+ allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil).and_return(client_secret)
+ allow(Time).to receive(:current).and_return(current_time)
+ end
+
+ it 'generates a valid JWT token with correct payload' do
+ token = generate_shopify_token(account_id)
+ decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first
+
+ expect(decoded_token['sub']).to eq(account_id)
+ expect(decoded_token['iat']).to eq(current_time.to_i)
+ end
+
+ context 'when client secret is not configured' do
+ let(:client_secret) { nil }
+
+ it 'returns nil' do
+ expect(generate_shopify_token(account_id)).to be_nil
+ end
+ end
+
+ context 'when an error occurs' do
+ before do
+ allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error'))
+ end
+
+ it 'logs the error and returns nil' do
+ expect(Rails.logger).to receive(:error).with('Failed to generate Shopify token: Test error')
+ expect(generate_shopify_token(account_id)).to be_nil
+ end
+ end
+ end
+
+ describe '#verify_shopify_token' do
+ let(:account_id) { 1 }
+ let(:client_secret) { 'test_secret' }
+ let(:valid_token) do
+ JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256')
+ end
+
+ before do
+ allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil).and_return(client_secret)
+ end
+
+ it 'successfully verifies and returns account_id from valid token' do
+ expect(verify_shopify_token(valid_token)).to eq(account_id)
+ end
+
+ context 'when token is blank' do
+ it 'returns nil' do
+ expect(verify_shopify_token('')).to be_nil
+ expect(verify_shopify_token(nil)).to be_nil
+ end
+ end
+
+ context 'when client secret is not configured' do
+ let(:client_secret) { nil }
+
+ it 'returns nil' do
+ expect(verify_shopify_token(valid_token)).to be_nil
+ end
+ end
+
+ context 'when token is invalid' do
+ it 'logs the error and returns nil' do
+ expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Shopify token:/)
+ expect(verify_shopify_token('invalid_token')).to be_nil
+ end
+ end
+ end
+
+ describe '#client_id' do
+ it 'loads client_id from GlobalConfigService' do
+ expect(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil)
+ client_id
+ end
+ end
+
+ describe '#client_secret' do
+ it 'loads client_secret from GlobalConfigService' do
+ expect(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil)
+ client_secret
+ end
+ end
+end
diff --git a/spec/models/integrations/app_spec.rb b/spec/models/integrations/app_spec.rb
index b9652ff89..fc64e1cc6 100644
--- a/spec/models/integrations/app_spec.rb
+++ b/spec/models/integrations/app_spec.rb
@@ -51,17 +51,38 @@ RSpec.describe Integrations::App do
end
end
- context 'when the app is linear' do
- let(:app_name) { 'linear' }
+ context 'when the app is shopify' do
+ let(:app_name) { 'shopify' }
- it 'returns true if the linear integration feature is disabled' do
+ it 'returns true if the shopify integration feature is enabled' do
+ account.enable_features('shopify_integration')
+ allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
+ expect(app.active?(account)).to be true
+ end
+
+ it 'returns false if the shopify integration feature is disabled' do
+ allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be false
end
- it 'returns false if the linear integration feature is enabled' do
+ it 'returns false if SHOPIFY_CLIENT_ID is not present, even if feature is enabled' do
+ account.enable_features('shopify_integration')
+ allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return(nil)
+ expect(app.active?(account)).to be false
+ end
+ end
+
+ context 'when the app is linear' do
+ let(:app_name) { 'linear' }
+
+ it 'returns false if the linear integration feature is disabled' do
+ expect(app.active?(account)).to be false
+ end
+
+ it 'returns true if the linear integration feature is enabled' do
account.enable_features('linear_integration')
account.save!
-
+ allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be true
end
end