diff --git a/.scss-lint.yml b/.scss-lint.yml index 1cc029441bb..2477dfffbc8 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 937aef4af45..b9452251359 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 74a59167a1d..07aa2d63882 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 00000000000..7fe31889b26 --- /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 00000000000..7fb8b5a47ad --- /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 43157fa0e74..3e17a7369da 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 00000000000..6aad93211c5 --- /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 2b816e6037b..d4ffcbca34b 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 00000000000..0b6ce8ec1a3 --- /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 287dfb18813..42325b0c5ec 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 00000000000..ed15ff47d6d --- /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 00000000000..81912b86409 --- /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 f3a7cb4dc49..f67f58ebc99 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 2847f482133..6cfe9d082c2 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 c6583d7f86a..5105513d586 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 a1c4ac66df0..9d1471ec5a6 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 bf3974a6819..2ad0c2c0b87 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 00000000000..a249aee22ea --- /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 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