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..1e8605379 100644 --- a/Gemfile +++ b/Gemfile @@ -173,8 +173,11 @@ gem 'pgvector' # Convert Website HTML to Markdown gem 'reverse_markdown' +gem 'iso-639' gem 'ruby-openai' +gem 'shopify_api' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 74a59167a..bfe4b1970 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) @@ -377,6 +378,8 @@ GEM io-console (0.6.0) irb (1.7.2) reline (>= 0.3.6) + iso-639 (0.3.8) + csv jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) @@ -520,6 +523,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 +717,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 +732,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 +775,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) @@ -896,6 +915,7 @@ DEPENDENCIES hashie html2text image_processing + iso-639 jbuilder json_refs json_schemer @@ -950,6 +970,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-next/message/bubbles/Audio.vue b/app/javascript/dashboard/components-next/message/bubbles/Audio.vue index 6b36459a4..f50597fee 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Audio.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Audio.vue @@ -13,6 +13,9 @@ const attachment = computed(() => { diff --git a/app/javascript/dashboard/components-next/message/bubbles/Image.vue b/app/javascript/dashboard/components-next/message/bubbles/Image.vue index 4015bc248..2484bb06c 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Image.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Image.vue @@ -56,6 +56,7 @@ const downloadAttachment = async () => {
{ @error="handleError" />