From b34c526c513407366b3741a1c50ed8fc9d461b62 Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 19 Mar 2025 15:37:55 -0700 Subject: [PATCH] feat(apps): Shopify Integration (#11101) This PR adds native integration with Shopify. No more dashboard apps. The support agents can view the orders, their status and the link to the order page on the conversation sidebar. This PR does the following: - Create an integration with Shopify (a new app is added in the integrations tab) - Option to configure it in SuperAdmin - OAuth endpoint and the callbacks. - Frontend component to render the orders. (We might need to cache it in the future) --------- Co-authored-by: iamsivin Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth --- .scss-lint.yml | 1 + Gemfile | 2 + Gemfile.lock | 18 ++ .../integrations/shopify_controller.rb | 111 +++++++++++ .../shopify/callbacks_controller.rb | 72 +++++++ .../super_admin/app_configs_controller.rb | 2 + app/helpers/shopify/integration_helper.rb | 58 ++++++ app/javascript/dashboard/api/integrations.js | 6 + .../dashboard/api/integrations/shopify.js | 17 ++ .../components-next/dialog/Dialog.vue | 10 +- .../widgets/conversation/ShopifyOrderItem.vue | 105 ++++++++++ .../conversation/ShopifyOrdersList.vue | 71 +++++++ .../dashboard/composables/useUISettings.js | 1 + .../i18n/locale/en/conversation.json | 22 ++- .../i18n/locale/en/integrations.json | 15 ++ .../search/components/SearchHeader.vue | 2 +- .../dashboard/conversation/ContactPanel.vue | 31 ++- .../settings/integrations/Integration.vue | 58 +++--- .../settings/integrations/Shopify.vue | 151 ++++++++++++++ .../integrations/integrations.routes.js | 12 ++ app/models/integrations/app.rb | 4 +- .../super_admin/application/_icons.html.erb | 8 +- config/features.yml | 4 + config/installation_config.yml | 13 ++ config/integration/apps.yml | 7 + config/locales/en.yml | 3 + config/routes.rb | 10 + .../app/helpers/super_admin/features.yml | 6 + .../images/integrations/shopify-dark.png | Bin 0 -> 21353 bytes .../dashboard/images/integrations/shopify.png | Bin 0 -> 27340 bytes .../integrations/shopify_controller_spec.rb | 187 ++++++++++++++++++ .../shopify/callbacks_controller_spec.rb | 109 ++++++++++ spec/factories/integrations/hooks.rb | 6 + .../shopify/integration_helper_spec.rb | 95 +++++++++ spec/models/integrations/app_spec.rb | 31 ++- 35 files changed, 1211 insertions(+), 37 deletions(-) create mode 100644 app/controllers/api/v1/accounts/integrations/shopify_controller.rb create mode 100644 app/controllers/shopify/callbacks_controller.rb create mode 100644 app/helpers/shopify/integration_helper.rb create mode 100644 app/javascript/dashboard/api/integrations/shopify.js create mode 100644 app/javascript/dashboard/components/widgets/conversation/ShopifyOrderItem.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/ShopifyOrdersList.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrations/Shopify.vue create mode 100644 public/dashboard/images/integrations/shopify-dark.png create mode 100644 public/dashboard/images/integrations/shopify.png create mode 100644 spec/controllers/api/v1/accounts/integrations/shopify_controller_spec.rb create mode 100644 spec/controllers/shopify/callbacks_controller_spec.rb create mode 100644 spec/helpers/shopify/integration_helper_spec.rb diff --git a/.scss-lint.yml b/.scss-lint.yml index 1cc029441..2477dfffb 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -283,3 +283,4 @@ exclude: - 'app/javascript/widget/assets/scss/sdk.css' - 'app/assets/stylesheets/administrate/reset/_normalize.scss' - 'app/javascript/shared/assets/stylesheets/*.scss' + - 'app/javascript/dashboard/assets/scss/_woot.scss' diff --git a/Gemfile b/Gemfile index 937aef4af..b94522513 100644 --- a/Gemfile +++ b/Gemfile @@ -175,6 +175,8 @@ gem 'reverse_markdown' gem 'ruby-openai' +gem 'shopify_api' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 74a59167a..07aa2d638 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -352,6 +352,7 @@ GEM ruby2ruby (~> 2.4) ruby_parser (~> 3.10) hana (1.3.7) + hash_diff (1.1.1) hashdiff (1.1.0) hashie (5.0.0) html2text (0.4.0) @@ -520,6 +521,9 @@ GEM rack (>= 1.2, < 4) snaky_hash (~> 2.0) version_gem (~> 1.1) + oj (3.16.10) + bigdecimal (>= 3.0) + ostruct (>= 0.2) omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -711,6 +715,7 @@ GEM parser scss_lint (0.60.0) sass (~> 3.5, >= 3.5.5) + securerandom (0.4.1) seed_dump (3.3.1) activerecord (>= 4) activesupport (>= 4) @@ -725,6 +730,17 @@ GEM sentry-ruby (~> 5.19.0) sidekiq (>= 3.0) sexp_processor (4.17.0) + shopify_api (14.8.0) + activesupport + concurrent-ruby + hash_diff + httparty + jwt + oj + openssl + securerandom + sorbet-runtime + zeitwerk (~> 2.5) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) sidekiq (7.3.1) @@ -757,6 +773,7 @@ GEM snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) + sorbet-runtime (0.5.11934) spring (4.1.1) spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) @@ -950,6 +967,7 @@ DEPENDENCIES sentry-rails (>= 5.19.0) sentry-ruby sentry-sidekiq (>= 5.19.0) + shopify_api shoulda-matchers sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb new file mode 100644 index 000000000..7fe31889b --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb @@ -0,0 +1,111 @@ +class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController + include Shopify::IntegrationHelper + before_action :setup_shopify_context, only: [:orders] + before_action :fetch_hook, except: [:auth] + before_action :validate_contact, only: [:orders] + + def auth + shop_domain = params[:shop_domain] + return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank? + + state = generate_shopify_token(Current.account.id) + + auth_url = "https://#{shop_domain}/admin/oauth/authorize?" + auth_url += URI.encode_www_form( + client_id: client_id, + scope: REQUIRED_SCOPES.join(','), + redirect_uri: redirect_uri, + state: state + ) + + render json: { redirect_url: auth_url } + end + + def orders + customers = fetch_customers + return render json: { orders: [] } if customers.empty? + + orders = fetch_orders(customers.first['id']) + render json: { orders: orders } + rescue ShopifyAPI::Errors::HttpResponseError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def destroy + @hook.destroy! + head :ok + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def redirect_uri + "#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback" + end + + def contact + @contact ||= Current.account.contacts.find_by(id: params[:contact_id]) + end + + def fetch_hook + @hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify') + end + + def fetch_customers + query = [] + query << "email:#{contact.email}" if contact.email.present? + query << "phone:#{contact.phone_number}" if contact.phone_number.present? + + shopify_client.get( + path: 'customers/search.json', + query: { + query: query.join(' OR '), + fields: 'id,email,phone' + } + ).body['customers'] || [] + end + + def fetch_orders(customer_id) + orders = shopify_client.get( + path: 'orders.json', + query: { + customer_id: customer_id, + status: 'any', + fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status' + } + ).body['orders'] || [] + + orders.map do |order| + order.merge('admin_url' => "https://#{@hook.reference_id}/admin/orders/#{order['id']}") + end + end + + def setup_shopify_context + return if client_id.blank? || client_secret.blank? + + ShopifyAPI::Context.setup( + api_key: client_id, + api_secret_key: client_secret, + api_version: '2025-01'.freeze, + scope: REQUIRED_SCOPES.join(','), + is_embedded: true, + is_private: false + ) + end + + def shopify_session + ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token) + end + + def shopify_client + @shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session) + end + + def validate_contact + return unless contact.blank? || (contact.email.blank? && contact.phone_number.blank?) + + render json: { error: 'Contact information missing' }, + status: :unprocessable_entity + end +end diff --git a/app/controllers/shopify/callbacks_controller.rb b/app/controllers/shopify/callbacks_controller.rb new file mode 100644 index 000000000..7fb8b5a47 --- /dev/null +++ b/app/controllers/shopify/callbacks_controller.rb @@ -0,0 +1,72 @@ +class Shopify::CallbacksController < ApplicationController + include Shopify::IntegrationHelper + + def show + verify_account! + + @response = oauth_client.auth_code.get_token( + params[:code], + redirect_uri: '/shopify/callback' + ) + + handle_response + rescue StandardError => e + Rails.logger.error("Shopify callback error: #{e.message}") + redirect_to "#{redirect_uri}?error=true" + end + + private + + def verify_account! + @account_id = verify_shopify_token(params[:state]) + raise StandardError, 'Invalid state parameter' if account.blank? + end + + def handle_response + account.hooks.create!( + app_id: 'shopify', + access_token: parsed_body['access_token'], + status: 'enabled', + reference_id: params[:shop], + settings: { + scope: parsed_body['scope'] + } + ) + + redirect_to shopify_integration_url + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end + + def oauth_client + OAuth2::Client.new( + client_id, + client_secret, + { + site: "https://#{params[:shop]}", + authorize_url: '/admin/oauth/authorize', + token_url: '/admin/oauth/access_token' + } + ) + end + + def account + @account ||= Account.find(@account_id) + end + + def account_id + @account_id ||= params[:state].split('_').first + end + + def shopify_integration_url + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify" + end + + def redirect_uri + return shopify_integration_url if account + + ENV.fetch('FRONTEND_URL', nil) + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 43157fa0e..3e17a7369 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -35,6 +35,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController @allowed_configs = case @config when 'facebook' %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT] + when 'shopify' + %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET] when 'microsoft' %w[AZURE_APP_ID AZURE_APP_SECRET] when 'email' diff --git a/app/helpers/shopify/integration_helper.rb b/app/helpers/shopify/integration_helper.rb new file mode 100644 index 000000000..6aad93211 --- /dev/null +++ b/app/helpers/shopify/integration_helper.rb @@ -0,0 +1,58 @@ +module Shopify::IntegrationHelper + REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze + + # Generates a signed JWT token for Shopify integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_shopify_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Shopify token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Shopify JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_shopify_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_id + @client_id ||= GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil) + end + + def client_secret + @client_secret ||= GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode( + token, + secret, + true, + { + algorithm: 'HS256', + verify_expiration: true + } + ).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Shopify token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/api/integrations.js b/app/javascript/dashboard/api/integrations.js index 2b816e603..d4ffcbca3 100644 --- a/app/javascript/dashboard/api/integrations.js +++ b/app/javascript/dashboard/api/integrations.js @@ -32,6 +32,12 @@ class IntegrationsAPI extends ApiClient { deleteHook(hookId) { return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`); } + + connectShopify({ shopDomain }) { + return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, { + shop_domain: shopDomain, + }); + } } export default new IntegrationsAPI(); diff --git a/app/javascript/dashboard/api/integrations/shopify.js b/app/javascript/dashboard/api/integrations/shopify.js new file mode 100644 index 000000000..0b6ce8ec1 --- /dev/null +++ b/app/javascript/dashboard/api/integrations/shopify.js @@ -0,0 +1,17 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class ShopifyAPI extends ApiClient { + constructor() { + super('integrations/shopify', { accountScoped: true }); + } + + getOrders(contactId) { + return axios.get(`${this.url}/orders`, { + params: { contact_id: contactId }, + }); + } +} + +export default new ShopifyAPI(); diff --git a/app/javascript/dashboard/components-next/dialog/Dialog.vue b/app/javascript/dashboard/components-next/dialog/Dialog.vue index 287dfb188..42325b0c5 100644 --- a/app/javascript/dashboard/components-next/dialog/Dialog.vue +++ b/app/javascript/dashboard/components-next/dialog/Dialog.vue @@ -80,10 +80,12 @@ const maxWidthClass = computed(() => { const open = () => { dialogRef.value?.showModal(); }; + const close = () => { emit('close'); dialogRef.value?.close(); }; + const confirm = () => { emit('confirm'); }; @@ -104,9 +106,10 @@ defineExpose({ open, close }); @close="close" > -
@@ -129,6 +132,7 @@ defineExpose({ open, close }); color="slate" :label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')" class="w-full" + type="button" @click="close" />
-
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/ShopifyOrderItem.vue b/app/javascript/dashboard/components/widgets/conversation/ShopifyOrderItem.vue new file mode 100644 index 000000000..ed15ff47d --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/ShopifyOrderItem.vue @@ -0,0 +1,105 @@ + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/ShopifyOrdersList.vue b/app/javascript/dashboard/components/widgets/conversation/ShopifyOrdersList.vue new file mode 100644 index 000000000..81912b864 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/ShopifyOrdersList.vue @@ -0,0 +1,71 @@ + + + diff --git a/app/javascript/dashboard/composables/useUISettings.js b/app/javascript/dashboard/composables/useUISettings.js index f3a7cb4dc..f67f58ebc 100644 --- a/app/javascript/dashboard/composables/useUISettings.js +++ b/app/javascript/dashboard/composables/useUISettings.js @@ -8,6 +8,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([ { name: 'contact_attributes' }, { name: 'previous_conversation' }, { name: 'conversation_participants' }, + { name: 'shopify_orders' }, ]); export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = Object.freeze([ diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 2847f4821..6cfe9d082 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -295,7 +295,27 @@ "CONVERSATION_INFO": "Conversation Information", "CONTACT_ATTRIBUTES": "Contact Attributes", "PREVIOUS_CONVERSATION": "Previous Conversations", - "MACROS": "Macros" + "MACROS": "Macros", + "SHOPIFY_ORDERS": "Shopify Orders" + }, + "SHOPIFY": { + "ORDER_ID": "Order #{id}", + "ERROR": "Error loading orders", + "NO_SHOPIFY_ORDERS": "No orders found", + "FINANCIAL_STATUS": { + "PENDING": "Pending", + "AUTHORIZED": "Authorized", + "PARTIALLY_PAID": "Partially Paid", + "PAID": "Paid", + "PARTIALLY_REFUNDED": "Partially Refunded", + "REFUNDED": "Refunded", + "VOIDED": "Voided" + }, + "FULFILLMENT_STATUS": { + "FULFILLED": "Fulfilled", + "PARTIALLY_FULFILLED": "Partially Fulfilled", + "UNFULFILLED": "Unfulfilled" + } } }, "CONVERSATION_CUSTOM_ATTRIBUTES": { diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index c6583d7f8..5105513d5 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -1,5 +1,20 @@ { "INTEGRATION_SETTINGS": { + "SHOPIFY": { + "DELETE": { + "TITLE": "Delete Shopify Integration", + "MESSAGE": "Are you sure you want to delete the Shopify integration?" + }, + "STORE_URL": { + "TITLE": "Connect Shopify Store", + "LABEL": "Store URL", + "PLACEHOLDER": "your-store.myshopify.com", + "HELP": "Enter your Shopify store's myshopify.com URL", + "CANCEL": "Cancel", + "SUBMIT": "Connect Store" + }, + "ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists." + }, "HEADER": "Integrations", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "LEARN_MORE": "Learn more about integrations", diff --git a/app/javascript/dashboard/modules/search/components/SearchHeader.vue b/app/javascript/dashboard/modules/search/components/SearchHeader.vue index a1c4ac66d..9d1471ec5 100644 --- a/app/javascript/dashboard/modules/search/components/SearchHeader.vue +++ b/app/javascript/dashboard/modules/search/components/SearchHeader.vue @@ -50,7 +50,7 @@ onUnmounted(() => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Integration.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Integration.vue index bf3974a68..2ad0c2c0b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Integration.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Integration.vue @@ -6,6 +6,8 @@ import { useI18n } from 'vue-i18n'; import { frontendURL } from '../../../../helper/URLHelper'; import { useAlert } from 'dashboard/composables'; import { useInstallationName } from 'shared/mixins/globalConfigMixin'; + +import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; const props = defineProps({ @@ -25,17 +27,21 @@ const { t } = useI18n(); const store = useStore(); const router = useRouter(); -const showDeleteConfirmationPopup = ref(false); +const dialogRef = ref(null); const accountId = computed(() => store.getters.getCurrentAccountId); const globalConfig = computed(() => store.getters['globalConfig/get']); const openDeletePopup = () => { - showDeleteConfirmationPopup.value = true; + if (dialogRef.value) { + dialogRef.value.open(); + } }; const closeDeletePopup = () => { - showDeleteConfirmationPopup.value = false; + if (dialogRef.value) { + dialogRef.value.close(); + } }; const deleteIntegration = async () => { @@ -50,16 +56,18 @@ const deleteIntegration = async () => { const confirmDeletion = () => { closeDeletePopup(); deleteIntegration(); - router.push({ name: 'settings_integrations' }); + router.push({ name: 'settings_applications' }); }; 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 @@ + + + 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 0000000000000000000000000000000000000000..8f973938d74093467e28bce5a6ca5b64f660e821 GIT binary patch literal 21353 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 !A%2BN;Xt<=L1^3>d$z7 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 literal 0 HcmV?d00001 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