-
+
+
![]()
{
-
-
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 00000000000..a249aee22ea
--- /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 f302f36ab5b..e50eccb3a43 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 84844b38a3e..1f88cdd4db3 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 4472701eec1..37f6a77ff68 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 dda797fceee..70d6c9fcf7c 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 422ecea9c35..15b81549663 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 b625e83b58e..b4d0d8394be 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 4183c873d05..4a66a4bc2a4 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 56704d9aa19..5bc965337a7 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 5aa5fc160b3..a54aaf6eb2c 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 00000000000..8f973938d74
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 00000000000..cfc5036f7b9
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 00000000000..ef6c4d3677c
--- /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 00000000000..cb75e23b4db
--- /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 498fb9f9a9f..f154d684de4 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 00000000000..15b7120d4cb
--- /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 b9652ff89cb..fc64e1cc618 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