Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63abd18bde | ||
|
|
c3606d22ea | ||
|
|
db96d0a34c | ||
|
|
7f5d9f0f76 | ||
|
|
3281f43629 | ||
|
|
97fb06dba3 | ||
|
|
eec285e41d | ||
|
|
bfc6cd1c0f | ||
|
|
09812b9343 | ||
|
|
00956845bc | ||
|
|
228992549b | ||
|
|
e496bd9cb1 | ||
|
|
4791cef4d7 | ||
|
|
cfafab0f08 | ||
|
|
24b0241ba5 | ||
|
|
81afdf0703 | ||
|
|
d4e82e6c4c | ||
|
|
d3bf813067 | ||
|
|
5c58cd5e89 | ||
|
|
547d58fefb | ||
|
|
a8367a969f | ||
|
|
ab3236148a | ||
|
|
7143482a87 | ||
|
|
bd59e7ec04 | ||
|
|
434d621dc4 |
@@ -1,26 +1,9 @@
|
|||||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||||
include Shopify::IntegrationHelper
|
include Shopify::IntegrationHelper
|
||||||
before_action :setup_shopify_context, only: [:orders]
|
before_action :setup_shopify_context, only: [:orders]
|
||||||
before_action :fetch_hook, except: [:auth]
|
before_action :fetch_hook, except: [:complete_install]
|
||||||
before_action :validate_contact, only: [:orders]
|
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
|
def orders
|
||||||
customers = fetch_customers
|
customers = fetch_customers
|
||||||
return render json: { orders: [] } if customers.empty?
|
return render json: { orders: [] } if customers.empty?
|
||||||
@@ -31,6 +14,23 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
|||||||
render json: { error: e.message }, status: :unprocessable_entity
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def complete_install
|
||||||
|
token_key = "shopify_pending_install:#{params[:pending_install_token]}"
|
||||||
|
data = claim_pending_install_token(token_key, Current.account.id)
|
||||||
|
return render json: { error: data[:error] }, status: :unprocessable_entity if data[:error]
|
||||||
|
|
||||||
|
Current.account.hooks.create!(
|
||||||
|
app_id: 'shopify',
|
||||||
|
access_token: data['access_token'],
|
||||||
|
status: 'enabled',
|
||||||
|
reference_id: data['shop'],
|
||||||
|
settings: { scope: data['scope'] }
|
||||||
|
)
|
||||||
|
|
||||||
|
::Redis::Alfred.delete(token_key)
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@hook.destroy!
|
@hook.destroy!
|
||||||
head :ok
|
head :ok
|
||||||
@@ -40,10 +40,6 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_uri
|
|
||||||
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
|
|
||||||
end
|
|
||||||
|
|
||||||
def contact
|
def contact
|
||||||
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
|
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
|
||||||
end
|
end
|
||||||
@@ -108,4 +104,31 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
|
|||||||
render json: { error: 'Contact information missing' },
|
render json: { error: 'Contact information missing' },
|
||||||
status: :unprocessable_entity
|
status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def claim_pending_install_token(token_key, account_id)
|
||||||
|
json_data = Redis::SecureStorage.get(token_key)
|
||||||
|
return { error: 'Invalid or expired install token' } if json_data.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
data = JSON.parse(json_data)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
return { error: 'Invalid or corrupted install token' }
|
||||||
|
end
|
||||||
|
|
||||||
|
if data['claimed']
|
||||||
|
Redis::SecureStorage.delete(token_key)
|
||||||
|
return { error: 'Install token already used' }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if already bound to a different account (prevents token theft)
|
||||||
|
return { error: 'Install token cannot be used by this account' } if data['account_id'].present? && data['account_id'] != account_id
|
||||||
|
|
||||||
|
# Bind to this account and mark as claimed to prevent replay
|
||||||
|
data['claimed'] = true
|
||||||
|
data['account_id'] = account_id
|
||||||
|
ttl = ::Redis::Alfred.ttl(token_key)
|
||||||
|
Redis::SecureStorage.set(token_key, data, [ttl, 60].max) if ttl.positive?
|
||||||
|
|
||||||
|
data
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,42 +2,74 @@ class Shopify::CallbacksController < ApplicationController
|
|||||||
include Shopify::IntegrationHelper
|
include Shopify::IntegrationHelper
|
||||||
|
|
||||||
def show
|
def show
|
||||||
verify_account!
|
if chatwoot_initiated?
|
||||||
|
handle_chatwoot_initiated_flow
|
||||||
@response = oauth_client.auth_code.get_token(
|
else
|
||||||
params[:code],
|
handle_shopify_initiated_flow
|
||||||
redirect_uri: '/shopify/callback'
|
end
|
||||||
)
|
|
||||||
|
|
||||||
handle_response
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error("Shopify callback error: #{e.message}")
|
Rails.logger.error("Shopify callback error: #{e.message}")
|
||||||
redirect_to "#{redirect_uri}?error=true"
|
redirect_to error_redirect_url
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def verify_account!
|
def chatwoot_initiated?
|
||||||
@account_id = verify_shopify_token(params[:state])
|
verify_shopify_token(params[:state]).present?
|
||||||
raise StandardError, 'Invalid state parameter' if account.blank?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_response
|
def handle_chatwoot_initiated_flow
|
||||||
|
@account_id = verify_shopify_token(params[:state])
|
||||||
|
raise StandardError, 'Invalid state parameter' if account.blank?
|
||||||
|
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
|
||||||
|
|
||||||
|
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri)
|
||||||
|
create_hook
|
||||||
|
redirect_to shopify_integration_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_shopify_initiated_flow
|
||||||
|
raise StandardError, 'Invalid shop domain' unless valid_shop_domain?
|
||||||
|
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
|
||||||
|
|
||||||
|
# Security: HMAC validation ensures params (including shop) haven't been tampered with.
|
||||||
|
# Additionally, the OAuth code is cryptographically bound to the shop that issued it.
|
||||||
|
# Shopify will reject any attempt to exchange a code at a different shop's endpoint.
|
||||||
|
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri)
|
||||||
|
|
||||||
|
token_key = SecureRandom.hex(16)
|
||||||
|
pending_data = {
|
||||||
|
access_token: parsed_body['access_token'],
|
||||||
|
shop: params[:shop],
|
||||||
|
scope: parsed_body['scope'],
|
||||||
|
claimed: false
|
||||||
|
}
|
||||||
|
|
||||||
|
Redis::SecureStorage.set("shopify_pending_install:#{token_key}", pending_data, 10.minutes)
|
||||||
|
|
||||||
|
redirect_url = "settings/integrations/shopify?shopify_pending_install=#{CGI.escape(token_key)}"
|
||||||
|
redirect_to "#{frontend_url}/app/login?redirect_url=#{CGI.escape(redirect_url)}", allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_hook
|
||||||
account.hooks.create!(
|
account.hooks.create!(
|
||||||
app_id: 'shopify',
|
app_id: 'shopify',
|
||||||
access_token: parsed_body['access_token'],
|
access_token: parsed_body['access_token'],
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
reference_id: params[:shop],
|
reference_id: params[:shop],
|
||||||
settings: {
|
settings: { scope: parsed_body['scope'] }
|
||||||
scope: parsed_body['scope']
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
redirect_to shopify_integration_url
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def parsed_body
|
def parsed_body
|
||||||
@parsed_body ||= @response.response.parsed
|
@parsed_body ||= begin
|
||||||
|
parsed = @response.response.parsed
|
||||||
|
# Handle both SnakyHash (production) and regular Hash (tests)
|
||||||
|
{
|
||||||
|
'access_token' => parsed.respond_to?(:access_token) ? parsed.access_token : parsed['access_token'],
|
||||||
|
'scope' => parsed.respond_to?(:scope) ? parsed.scope : parsed['scope']
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def oauth_client
|
def oauth_client
|
||||||
@@ -56,17 +88,51 @@ class Shopify::CallbacksController < ApplicationController
|
|||||||
@account ||= Account.find(@account_id)
|
@account ||= Account.find(@account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_id
|
def redirect_callback_uri
|
||||||
@account_id ||= params[:state].split('_').first
|
"#{frontend_url}/shopify/callback"
|
||||||
end
|
end
|
||||||
|
|
||||||
def shopify_integration_url
|
def shopify_integration_url
|
||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
|
"#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify"
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect_uri
|
def error_redirect_url
|
||||||
return shopify_integration_url if account
|
if @account_id
|
||||||
|
begin
|
||||||
|
"#{shopify_integration_url}?error=true"
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
"#{frontend_url}?error=true"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
"#{frontend_url}?error=true"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
ENV.fetch('FRONTEND_URL', nil)
|
def frontend_url
|
||||||
|
ENV.fetch('FRONTEND_URL', '')
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_shop_domain?
|
||||||
|
return false if params[:shop].blank?
|
||||||
|
|
||||||
|
# Shopify shop domains must match: *.myshopify.com or *.myshopify.io (for dev shops)
|
||||||
|
params[:shop].match?(/\A[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.(com|io)\z/)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_hmac?
|
||||||
|
return false if params[:hmac].blank?
|
||||||
|
|
||||||
|
# Shopify HMAC validation
|
||||||
|
# Reference: https://shopify.dev/docs/apps/build/authentication-authorization/get-access-tokens
|
||||||
|
hmac = params[:hmac]
|
||||||
|
|
||||||
|
# Build query string from params, excluding hmac and Rails-added params
|
||||||
|
query_params = params.except(:hmac, :controller, :action).to_unsafe_h
|
||||||
|
query_string = query_params.sort.map { |k, v| "#{k}=#{v}" }.join('&')
|
||||||
|
|
||||||
|
# Compute HMAC-SHA256
|
||||||
|
computed_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA256'), client_secret, query_string)
|
||||||
|
|
||||||
|
ActiveSupport::SecurityUtils.secure_compare(computed_hmac, hmac)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
def allowed_configs
|
def allowed_configs
|
||||||
mapping = {
|
mapping = {
|
||||||
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
|
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
|
||||||
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET SHOPIFY_APP_STORE_URL],
|
||||||
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||||
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
|
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
|
||||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
module Shopify::IntegrationHelper
|
module Shopify::IntegrationHelper
|
||||||
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
|
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments read_products].freeze
|
||||||
|
|
||||||
# Generates a signed JWT token for Shopify integration
|
# Generates a signed JWT token for Shopify integration
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -32,12 +32,6 @@ class IntegrationsAPI extends ApiClient {
|
|||||||
deleteHook(hookId) {
|
deleteHook(hookId) {
|
||||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${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();
|
export default new IntegrationsAPI();
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ class ShopifyAPI extends ApiClient {
|
|||||||
params: { contact_id: contactId },
|
params: { contact_id: contactId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completeInstall(pendingInstallToken) {
|
||||||
|
return axios.post(`${this.url}/complete_install`, {
|
||||||
|
pending_install_token: pendingInstallToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ShopifyAPI();
|
export default new ShopifyAPI();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
|
|||||||
billing: 'https://chwt.app/pricing',
|
billing: 'https://chwt.app/pricing',
|
||||||
saml: 'https://chwt.app/hc/saml',
|
saml: 'https://chwt.app/hc/saml',
|
||||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||||
|
shopify: 'https://chwt.app/hc/shopify',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getHelpUrlForFeature(featureName) {
|
export function getHelpUrlForFeature(featureName) {
|
||||||
|
|||||||
@@ -11,9 +11,18 @@
|
|||||||
"LABEL": "Store URL",
|
"LABEL": "Store URL",
|
||||||
"PLACEHOLDER": "your-store.myshopify.com",
|
"PLACEHOLDER": "your-store.myshopify.com",
|
||||||
"HELP": "Enter your Shopify store's myshopify.com URL",
|
"HELP": "Enter your Shopify store's myshopify.com URL",
|
||||||
|
"INVALID_URL": "Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)",
|
||||||
"CANCEL": "Cancel",
|
"CANCEL": "Cancel",
|
||||||
"SUBMIT": "Connect Store"
|
"SUBMIT": "Connect Store"
|
||||||
},
|
},
|
||||||
|
"PENDING_INSTALL": {
|
||||||
|
"SUCCESS": "Shopify integration connected successfully.",
|
||||||
|
"ERROR": "Failed to complete Shopify installation. The link may have expired."
|
||||||
|
},
|
||||||
|
"HELP_TEXT": {
|
||||||
|
"TITLE": "How to use the Shopify Integration?",
|
||||||
|
"BODY": "With this integration, your Shopify store ***{storeDomain}*** is connected to your Chatwoot workspace. Here's what you can do:\n\n**Track orders in conversations:** When you open a conversation, the Shopify sidebar will automatically display recent orders for the customer based on their email address. This gives your support team instant context without switching tabs.\n\n**Access order details:** View order status, fulfillment status, total amount, and individual line items directly within the conversation panel."
|
||||||
|
},
|
||||||
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
|
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
|
||||||
},
|
},
|
||||||
"HEADER": "Integrations",
|
"HEADER": "Integrations",
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
useFunctionGetter,
|
useFunctionGetter,
|
||||||
useMapGetter,
|
useMapGetter,
|
||||||
useStore,
|
useStore,
|
||||||
} from 'dashboard/composables/store';
|
} from 'dashboard/composables/store';
|
||||||
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
import Integration from './Integration.vue';
|
import Integration from './Integration.vue';
|
||||||
import integrationAPI from 'dashboard/api/integrations';
|
import shopifyAPI from 'dashboard/api/integrations/shopify';
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import SettingsLayout from '../SettingsLayout.vue';
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
@@ -23,12 +24,11 @@ defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
|
||||||
const dialogRef = ref(null);
|
|
||||||
const integrationLoaded = ref(false);
|
const integrationLoaded = ref(false);
|
||||||
const storeUrl = ref('');
|
const route = useRoute();
|
||||||
const isSubmitting = ref(false);
|
const router = useRouter();
|
||||||
const storeUrlError = ref('');
|
const { t } = useI18n();
|
||||||
|
const { formatMessage } = useMessageFormatter();
|
||||||
const integration = useFunctionGetter('integrations/getIntegration', 'shopify');
|
const integration = useFunctionGetter('integrations/getIntegration', 'shopify');
|
||||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||||
|
|
||||||
@@ -39,50 +39,43 @@ const integrationAction = computed(() => {
|
|||||||
return 'connect';
|
return 'connect';
|
||||||
});
|
});
|
||||||
|
|
||||||
const hideStoreUrlModal = () => {
|
const hook = computed(() => {
|
||||||
storeUrl.value = '';
|
const { hooks = [] } = integration.value || {};
|
||||||
storeUrlError.value = '';
|
const [firstHook] = hooks;
|
||||||
isSubmitting.value = false;
|
return firstHook || {};
|
||||||
};
|
});
|
||||||
|
|
||||||
const validateStoreUrl = url => {
|
const storeDomain = computed(() => hook.value.reference_id || '');
|
||||||
const pattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
|
|
||||||
return pattern.test(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openStoreUrlDialog = () => {
|
const formattedHelpText = computed(() => {
|
||||||
if (dialogRef.value) {
|
return formatMessage(
|
||||||
dialogRef.value.open();
|
t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.BODY', {
|
||||||
}
|
storeDomain: storeDomain.value,
|
||||||
};
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const handleStoreUrlSubmit = async () => {
|
const completePendingInstall = async token => {
|
||||||
try {
|
try {
|
||||||
storeUrlError.value = '';
|
await shopifyAPI.completeInstall(token);
|
||||||
if (!validateStoreUrl(storeUrl.value)) {
|
await store.dispatch('integrations/get', 'shopify');
|
||||||
storeUrlError.value =
|
useAlert(t('INTEGRATION_SETTINGS.SHOPIFY.PENDING_INSTALL.SUCCESS'));
|
||||||
'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)';
|
} catch {
|
||||||
return;
|
useAlert(t('INTEGRATION_SETTINGS.SHOPIFY.PENDING_INSTALL.ERROR'));
|
||||||
}
|
|
||||||
|
|
||||||
isSubmitting.value = true;
|
|
||||||
const { data } = await integrationAPI.connectShopify({
|
|
||||||
shopDomain: storeUrl.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.redirect_url) {
|
|
||||||
window.location.href = data.redirect_url;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
storeUrlError.value = error.message;
|
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false;
|
router.replace({ query: {} });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initializeShopifyIntegration = async () => {
|
const initializeShopifyIntegration = async () => {
|
||||||
await store.dispatch('integrations/get', 'shopify');
|
await store.dispatch('integrations/get', 'shopify');
|
||||||
integrationLoaded.value = true;
|
integrationLoaded.value = true;
|
||||||
|
|
||||||
|
const pendingInstallToken = route.query.shopify_pending_install;
|
||||||
|
if (pendingInstallToken) {
|
||||||
|
await completePendingInstall(pendingInstallToken);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -122,35 +115,27 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Integration>
|
</Integration>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="integration.enabled"
|
||||||
|
class="flex-1 w-full px-6 py-5 rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
|
||||||
|
>
|
||||||
|
<div class="max-w-5xl prose-lg">
|
||||||
|
<h5 class="tracking-tight text-n-slate-12">
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.TITLE') }}
|
||||||
|
</h5>
|
||||||
|
<div v-dompurify-html="formattedHelpText" class="text-n-slate-11" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="error"
|
v-if="error"
|
||||||
class="flex items-center justify-center flex-1 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-6"
|
class="flex items-center justify-center flex-1 p-6 rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
|
||||||
>
|
>
|
||||||
<p class="text-n-ruby-9">
|
<p class="text-n-ruby-9">
|
||||||
{{ t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
|
{{ t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog
|
|
||||||
ref="dialogRef"
|
|
||||||
:title="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.TITLE')"
|
|
||||||
:is-loading="isSubmitting"
|
|
||||||
@confirm="handleStoreUrlSubmit"
|
|
||||||
@close="hideStoreUrlModal"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
v-model="storeUrl"
|
|
||||||
:label="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.LABEL')"
|
|
||||||
:placeholder="
|
|
||||||
t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
:message="
|
|
||||||
!storeUrlError
|
|
||||||
? t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.HELP')
|
|
||||||
: storeUrlError
|
|
||||||
"
|
|
||||||
:message-type="storeUrlError ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const validateAuthenticateRoutePermission = (to, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (to.name === 'no_accounts' || !to.name) {
|
if (to.name === 'no_accounts' || !to.name) {
|
||||||
|
const { redirect_url: redirectUrl } = to.query || {};
|
||||||
|
if (redirectUrl) {
|
||||||
|
return next(frontendURL(`accounts/${accountId}/${redirectUrl}`));
|
||||||
|
}
|
||||||
return next(frontendURL(`accounts/${accountId}/dashboard`));
|
return next(frontendURL(`accounts/${accountId}/dashboard`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
export const login = async ({
|
export const login = async ({
|
||||||
ssoAccountId,
|
ssoAccountId,
|
||||||
ssoConversationId,
|
ssoConversationId,
|
||||||
|
redirectUrl,
|
||||||
...credentials
|
...credentials
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
@@ -31,6 +32,7 @@ export const login = async ({
|
|||||||
window.location = getLoginRedirectURL({
|
window.location = getLoginRedirectURL({
|
||||||
ssoAccountId,
|
ssoAccountId,
|
||||||
ssoConversationId,
|
ssoConversationId,
|
||||||
|
redirectUrl,
|
||||||
user: response.data.data,
|
user: response.data.data,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -40,8 +40,16 @@ export const getCredentialsFromEmail = email => {
|
|||||||
export const getLoginRedirectURL = ({
|
export const getLoginRedirectURL = ({
|
||||||
ssoAccountId,
|
ssoAccountId,
|
||||||
ssoConversationId,
|
ssoConversationId,
|
||||||
|
redirectUrl,
|
||||||
user,
|
user,
|
||||||
}) => {
|
}) => {
|
||||||
|
if (redirectUrl) {
|
||||||
|
const { accounts = [], account_id = null } = user || {};
|
||||||
|
const accountId = account_id || accounts[0]?.id;
|
||||||
|
if (accountId) {
|
||||||
|
return frontendURL(`accounts/${accountId}/${redirectUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const accountPath = getSSOAccountPath({ ssoAccountId, user });
|
const accountPath = getSSOAccountPath({ ssoAccountId, user });
|
||||||
if (accountPath) {
|
if (accountPath) {
|
||||||
if (ssoConversationId) {
|
if (ssoConversationId) {
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
|
|||||||
// Redirect to dashboard if a cookie is present, the cookie
|
// Redirect to dashboard if a cookie is present, the cookie
|
||||||
// cleanup and token validation happens in the application pack.
|
// cleanup and token validation happens in the application pack.
|
||||||
if (hasAuthCookie()) {
|
if (hasAuthCookie()) {
|
||||||
replaceRouteWithReload(DEFAULT_REDIRECT_URL);
|
const { redirect_url: redirectUrl } = to.query || {};
|
||||||
|
const redirectTarget = redirectUrl
|
||||||
|
? `${DEFAULT_REDIRECT_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`
|
||||||
|
: DEFAULT_REDIRECT_URL;
|
||||||
|
replaceRouteWithReload(redirectTarget);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default {
|
|||||||
ssoConversationId: { type: String, default: '' },
|
ssoConversationId: { type: String, default: '' },
|
||||||
email: { type: String, default: '' },
|
email: { type: String, default: '' },
|
||||||
authError: { type: String, default: '' },
|
authError: { type: String, default: '' },
|
||||||
|
redirectUrl: { type: String, default: '' },
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { replaceInstallationName } = useBranding();
|
const { replaceInstallationName } = useBranding();
|
||||||
@@ -169,6 +170,7 @@ export default {
|
|||||||
sso_auth_token: this.ssoAuthToken,
|
sso_auth_token: this.ssoAuthToken,
|
||||||
ssoAccountId: this.ssoAccountId,
|
ssoAccountId: this.ssoAccountId,
|
||||||
ssoConversationId: this.ssoConversationId,
|
ssoConversationId: this.ssoConversationId,
|
||||||
|
redirectUrl: this.redirectUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
login(credentials)
|
login(credentials)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default [
|
|||||||
ssoAccountId: route.query.sso_account_id,
|
ssoAccountId: route.query.sso_account_id,
|
||||||
ssoConversationId: route.query.sso_conversation_id,
|
ssoConversationId: route.query.sso_conversation_id,
|
||||||
authError: route.query.error,
|
authError: route.query.error,
|
||||||
|
redirectUrl: route.query.redirect_url,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class Integrations::App
|
|||||||
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
||||||
when 'linear'
|
when 'linear'
|
||||||
build_linear_action
|
build_linear_action
|
||||||
|
when 'shopify'
|
||||||
|
GlobalConfigService.load('SHOPIFY_APP_STORE_URL', nil)
|
||||||
else
|
else
|
||||||
params[:action]
|
params[:action]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
class Integrations::Shopify::ClientService
|
||||||
|
include Shopify::IntegrationHelper
|
||||||
|
|
||||||
|
API_VERSION = '2025-01'.freeze
|
||||||
|
|
||||||
|
def initialize(account:)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize
|
||||||
|
def fetch_client
|
||||||
|
Rails.logger.info("[Integrations::Shopify::ClientService] fetch_client account_id=#{@account.id}")
|
||||||
|
unless hook
|
||||||
|
Rails.logger.warn("[Integrations::Shopify::ClientService] not_connected account_id=#{@account.id}")
|
||||||
|
return failure(:not_connected, 'Shopify integration is not connected.')
|
||||||
|
end
|
||||||
|
|
||||||
|
setup_shopify_context
|
||||||
|
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Integrations::Shopify::ClientService] connected account_id=#{@account.id} " \
|
||||||
|
"shop=#{hook.reference_id} scopes=#{granted_scopes.join(',')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
success(
|
||||||
|
client: ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session),
|
||||||
|
hook: hook,
|
||||||
|
scopes: granted_scopes
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Integrations::Shopify::ClientService] #{e.class}: #{e.message}")
|
||||||
|
failure(:provider_error, 'Unable to communicate with Shopify.')
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize
|
||||||
|
|
||||||
|
def scopes_include?(*required_scopes)
|
||||||
|
required_scopes.flatten.all? { |scope| granted_scopes.include?(scope) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def granted_scopes
|
||||||
|
parse_scopes(scope_value)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def hook
|
||||||
|
@hook ||= Integrations::Hook.find_by(account: @account, app_id: 'shopify', status: :enabled)
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup_shopify_context
|
||||||
|
raise 'Shopify client credentials are missing.' if client_id.blank? || client_secret.blank?
|
||||||
|
|
||||||
|
ShopifyAPI::Context.setup(
|
||||||
|
api_key: client_id,
|
||||||
|
api_secret_key: client_secret,
|
||||||
|
api_version: API_VERSION,
|
||||||
|
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 scope_value
|
||||||
|
settings = hook&.settings || {}
|
||||||
|
settings['scope'] || settings[:scope]
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_scopes(raw_scope)
|
||||||
|
raw_scope.to_s.split(',').map(&:strip).reject(&:blank?).uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def success(data)
|
||||||
|
{ ok: true, data: data }
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(code, message)
|
||||||
|
{ ok: false, error: { code: code, message: message } }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
class Integrations::Shopify::OrdersService
|
||||||
|
REQUIRED_SCOPES = %w[read_customers read_orders].freeze
|
||||||
|
MAX_LIMIT = 10
|
||||||
|
MAX_LINE_ITEMS = 5
|
||||||
|
|
||||||
|
def initialize(account:)
|
||||||
|
@account = account
|
||||||
|
@client_service = Integrations::Shopify::ClientService.new(account: account)
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||||
|
def orders_for_contact(email:, phone_number:, limit: MAX_LIMIT)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Integrations::Shopify::OrdersService] orders_for_contact account_id=#{@account.id} " \
|
||||||
|
"email_present=#{email.present?} phone_present=#{phone_number.present?} limit=#{limit}"
|
||||||
|
)
|
||||||
|
|
||||||
|
identifiers = normalized_identifiers(email, phone_number)
|
||||||
|
return missing_identifier_result if identifiers[:email].blank? && identifiers[:phone_number].blank?
|
||||||
|
|
||||||
|
client_result = @client_service.fetch_client
|
||||||
|
return client_result unless client_result[:ok]
|
||||||
|
|
||||||
|
unless orders_scopes_granted?
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[Integrations::Shopify::OrdersService] missing_scope account_id=#{@account.id} " \
|
||||||
|
"required=#{REQUIRED_SCOPES.join(',')} granted=#{@client_service.granted_scopes.join(',')}"
|
||||||
|
)
|
||||||
|
return insufficient_scope_result
|
||||||
|
end
|
||||||
|
|
||||||
|
customers = fetch_customers(client_result[:data][:client], identifiers[:email], identifiers[:phone_number])
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Integrations::Shopify::OrdersService] customer_lookup account_id=#{@account.id} " \
|
||||||
|
"customers_count=#{customers.length}"
|
||||||
|
)
|
||||||
|
return no_customer_result if customers.empty?
|
||||||
|
|
||||||
|
orders = fetch_orders(client_result[:data][:client], customers.first['id'], normalized_limit(limit))
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Integrations::Shopify::OrdersService] orders_lookup account_id=#{@account.id} " \
|
||||||
|
"customer_id=#{customers.first['id']} orders_count=#{orders.length}"
|
||||||
|
)
|
||||||
|
return failure(:no_results, 'No orders found for the customer.') if orders.empty?
|
||||||
|
|
||||||
|
success(
|
||||||
|
orders: orders.map { |order| normalize_order(order, client_result[:data][:hook].reference_id) }
|
||||||
|
)
|
||||||
|
rescue ShopifyAPI::Errors::HttpResponseError => e
|
||||||
|
Rails.logger.error("[Integrations::Shopify::OrdersService] Shopify error: #{e.message}")
|
||||||
|
failure(:provider_error, 'Shopify order lookup failed.')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Integrations::Shopify::OrdersService] #{e.class}: #{e.message}")
|
||||||
|
failure(:provider_error, 'Shopify order lookup failed.')
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalized_identifiers(email, phone_number)
|
||||||
|
{
|
||||||
|
email: email.to_s.strip,
|
||||||
|
phone_number: phone_number.to_s.strip
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def missing_identifier_result
|
||||||
|
Rails.logger.info("[Integrations::Shopify::OrdersService] missing_identifier account_id=#{@account.id}")
|
||||||
|
failure(:missing_identifier, 'A contact email or phone number is required.')
|
||||||
|
end
|
||||||
|
|
||||||
|
def no_customer_result
|
||||||
|
Rails.logger.info("[Integrations::Shopify::OrdersService] no_customer_result account_id=#{@account.id}")
|
||||||
|
failure(:no_results, 'No matching Shopify customer found.')
|
||||||
|
end
|
||||||
|
|
||||||
|
def orders_scopes_granted?
|
||||||
|
@client_service.scopes_include?(REQUIRED_SCOPES)
|
||||||
|
end
|
||||||
|
|
||||||
|
def insufficient_scope_result
|
||||||
|
failure(
|
||||||
|
:insufficient_scope,
|
||||||
|
'Shopify integration is missing customer/order read scopes.'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_customers(client, email, phone_number)
|
||||||
|
query_parts = []
|
||||||
|
query_parts << "email:#{email}" if email.present?
|
||||||
|
query_parts << "phone:#{phone_number}" if phone_number.present?
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
path: 'customers/search.json',
|
||||||
|
query: {
|
||||||
|
query: query_parts.join(' OR '),
|
||||||
|
fields: 'id,email,phone'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.body['customers'] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_orders(client, customer_id, limit)
|
||||||
|
response = client.get(
|
||||||
|
path: 'orders.json',
|
||||||
|
query: {
|
||||||
|
customer_id: customer_id,
|
||||||
|
status: 'any',
|
||||||
|
limit: limit,
|
||||||
|
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.body['orders'] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_order(order, shop_domain)
|
||||||
|
{
|
||||||
|
id: order['id'],
|
||||||
|
name: order['name'],
|
||||||
|
created_at: order['created_at'],
|
||||||
|
total_price: order['total_price'],
|
||||||
|
currency: order['currency'],
|
||||||
|
financial_status: order['financial_status'],
|
||||||
|
fulfillment_status: order['fulfillment_status'],
|
||||||
|
line_items: normalize_line_items(order['line_items']),
|
||||||
|
admin_url: admin_url(shop_domain, order['id'])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_line_items(line_items)
|
||||||
|
Array(line_items).first(MAX_LINE_ITEMS).map do |line_item|
|
||||||
|
{
|
||||||
|
title: line_item['title'],
|
||||||
|
quantity: line_item['quantity'],
|
||||||
|
price: line_item['price']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_url(shop_domain, order_id)
|
||||||
|
return nil if shop_domain.blank? || order_id.blank?
|
||||||
|
|
||||||
|
"https://#{shop_domain}/admin/orders/#{order_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_limit(limit)
|
||||||
|
value = limit.to_i
|
||||||
|
return MAX_LIMIT if value <= 0
|
||||||
|
|
||||||
|
[value, MAX_LIMIT].min
|
||||||
|
end
|
||||||
|
|
||||||
|
def success(data)
|
||||||
|
{ ok: true, data: data }
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(code, message)
|
||||||
|
{ ok: false, error: { code: code, message: message } }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
class Integrations::Shopify::ProductsService
|
||||||
|
REQUIRED_SCOPE = 'read_products'.freeze
|
||||||
|
MAX_LIMIT = 10
|
||||||
|
FALLBACK_MULTIPLIER = 5
|
||||||
|
MAX_FALLBACK_LIMIT = 50
|
||||||
|
|
||||||
|
def initialize(account:)
|
||||||
|
@account = account
|
||||||
|
@client_service = Integrations::Shopify::ClientService.new(account: account)
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
def search_products(query:, limit: MAX_LIMIT)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Integrations::Shopify::ProductsService] search_products account_id=#{@account.id} " \
|
||||||
|
"query=#{query.inspect} limit=#{limit}"
|
||||||
|
)
|
||||||
|
return no_products_result(query) if query.blank?
|
||||||
|
|
||||||
|
client_result = @client_service.fetch_client
|
||||||
|
return client_result unless client_result[:ok]
|
||||||
|
|
||||||
|
unless product_scope_granted?
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[Integrations::Shopify::ProductsService] missing_scope account_id=#{@account.id} " \
|
||||||
|
"required=#{REQUIRED_SCOPE} granted=#{@client_service.granted_scopes.join(',')}"
|
||||||
|
)
|
||||||
|
return failure(:insufficient_scope, 'Shopify integration is missing read_products scope.')
|
||||||
|
end
|
||||||
|
|
||||||
|
products = fetch_products(client_result[:data][:client], query, normalized_limit(limit))
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Integrations::Shopify::ProductsService] shopify_response account_id=#{@account.id} " \
|
||||||
|
"query=#{query.inspect} products_count=#{products.length}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return no_products_result(query) if products.empty?
|
||||||
|
|
||||||
|
success(
|
||||||
|
products: products.map { |product| normalize_product(product, client_result[:data][:hook].reference_id) }
|
||||||
|
)
|
||||||
|
rescue ShopifyAPI::Errors::HttpResponseError => e
|
||||||
|
Rails.logger.error("[Integrations::Shopify::ProductsService] Shopify error: #{e.message}")
|
||||||
|
failure(:provider_error, 'Shopify product search failed.')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Integrations::Shopify::ProductsService] #{e.class}: #{e.message}")
|
||||||
|
failure(:provider_error, 'Shopify product search failed.')
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def product_scope_granted?
|
||||||
|
@client_service.scopes_include?(REQUIRED_SCOPE)
|
||||||
|
end
|
||||||
|
|
||||||
|
def no_products_result(query)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Integrations::Shopify::ProductsService] no_results account_id=#{@account.id} query=#{query.inspect}"
|
||||||
|
)
|
||||||
|
failure(:no_results, 'No products found for the provided query.')
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_products(client, query, limit)
|
||||||
|
products = fetch_products_by_title(client, query, limit)
|
||||||
|
return products if products.any?
|
||||||
|
|
||||||
|
fallback_limit = [(limit * FALLBACK_MULTIPLIER), MAX_FALLBACK_LIMIT].min
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Integrations::Shopify::ProductsService] fallback_keyword_search account_id=#{@account.id} " \
|
||||||
|
"query=#{query.inspect} fallback_limit=#{fallback_limit}"
|
||||||
|
)
|
||||||
|
|
||||||
|
fallback_products = fetch_active_products(client, fallback_limit)
|
||||||
|
filter_products_by_keyword(fallback_products, query).first(limit)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_products_by_title(client, query, limit)
|
||||||
|
response = client.get(
|
||||||
|
path: 'products.json',
|
||||||
|
query: product_query(limit: limit, title: query)
|
||||||
|
)
|
||||||
|
response.body['products'] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_active_products(client, limit)
|
||||||
|
response = client.get(
|
||||||
|
path: 'products.json',
|
||||||
|
query: product_query(limit: limit)
|
||||||
|
)
|
||||||
|
response.body['products'] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def product_query(limit:, title: nil)
|
||||||
|
{
|
||||||
|
title: title,
|
||||||
|
status: 'active',
|
||||||
|
limit: limit,
|
||||||
|
fields: 'id,title,vendor,product_type,handle,variants'
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_products_by_keyword(products, query)
|
||||||
|
keyword = query.to_s.downcase.strip
|
||||||
|
return [] if keyword.blank?
|
||||||
|
|
||||||
|
products.select do |product|
|
||||||
|
searchable_text = [product['title'], product['vendor'], product['product_type']].compact.join(' ').downcase
|
||||||
|
searchable_text.include?(keyword)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_product(product, shop_domain)
|
||||||
|
variants = Array(product['variants'])
|
||||||
|
first_variant = variants.first || {}
|
||||||
|
|
||||||
|
{
|
||||||
|
id: product['id'],
|
||||||
|
title: product['title'],
|
||||||
|
vendor: product['vendor'],
|
||||||
|
product_type: product['product_type'],
|
||||||
|
handle: product['handle'],
|
||||||
|
storefront_url: storefront_url(shop_domain, product['handle']),
|
||||||
|
price: first_variant['price'],
|
||||||
|
availability: availability_summary(variants)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def storefront_url(shop_domain, handle)
|
||||||
|
return nil if shop_domain.blank? || handle.blank?
|
||||||
|
|
||||||
|
"https://#{shop_domain}/products/#{handle}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def availability_summary(variants)
|
||||||
|
return 'Out of stock' if variants.empty?
|
||||||
|
|
||||||
|
available_count = variants.count { |variant| variant_available?(variant) }
|
||||||
|
return 'Out of stock' if available_count.zero?
|
||||||
|
return 'In stock' if available_count == variants.size
|
||||||
|
|
||||||
|
"Partially in stock (#{available_count}/#{variants.size} variants)"
|
||||||
|
end
|
||||||
|
|
||||||
|
def variant_available?(variant)
|
||||||
|
return variant['available'] if [true, false].include?(variant['available'])
|
||||||
|
|
||||||
|
quantity = variant['inventory_quantity']
|
||||||
|
return quantity.to_i.positive? unless quantity.nil?
|
||||||
|
|
||||||
|
variant['inventory_policy'] == 'continue'
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_limit(limit)
|
||||||
|
value = limit.to_i
|
||||||
|
return MAX_LIMIT if value <= 0
|
||||||
|
|
||||||
|
[value, MAX_LIMIT].min
|
||||||
|
end
|
||||||
|
|
||||||
|
def success(data)
|
||||||
|
{ ok: true, data: data }
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(code, message)
|
||||||
|
{ ok: false, error: { code: code, message: message } }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -30,6 +30,16 @@
|
|||||||
description: 'Search FAQ responses using semantic similarity'
|
description: 'Search FAQ responses using semantic similarity'
|
||||||
icon: 'search'
|
icon: 'search'
|
||||||
|
|
||||||
|
- id: shopify_search_products
|
||||||
|
title: 'Shopify: Search Products'
|
||||||
|
description: 'Search products in the connected Shopify store'
|
||||||
|
icon: 'shopping-bag'
|
||||||
|
|
||||||
|
- id: shopify_get_orders
|
||||||
|
title: 'Shopify: Get Orders'
|
||||||
|
description: "Look up a customer's orders from Shopify"
|
||||||
|
icon: 'shopping-cart'
|
||||||
|
|
||||||
- id: resolve_conversation
|
- id: resolve_conversation
|
||||||
title: 'Resolve Conversation'
|
title: 'Resolve Conversation'
|
||||||
description: 'Resolve a conversation when the issue has been addressed'
|
description: 'Resolve a conversation when the issue has been addressed'
|
||||||
|
|||||||
@@ -387,6 +387,10 @@
|
|||||||
description: 'The Client Secret (API Secret Key) from your Shopify Partner account'
|
description: 'The Client Secret (API Secret Key) from your Shopify Partner account'
|
||||||
locked: false
|
locked: false
|
||||||
type: secret
|
type: secret
|
||||||
|
- name: SHOPIFY_APP_STORE_URL
|
||||||
|
display_title: 'Shopify App Store URL'
|
||||||
|
description: 'The Shopify App Store listing URL (e.g., https://apps.shopify.com/your-app)'
|
||||||
|
locked: false
|
||||||
# ------- End of Shopify Related Config ------- #
|
# ------- End of Shopify Related Config ------- #
|
||||||
|
|
||||||
# ------- Instagram Channel Related Config ------- #
|
# ------- Instagram Channel Related Config ------- #
|
||||||
|
|||||||
+1
-1
@@ -317,8 +317,8 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
resource :shopify, controller: 'shopify', only: [:destroy] do
|
resource :shopify, controller: 'shopify', only: [:destroy] do
|
||||||
collection do
|
collection do
|
||||||
post :auth
|
|
||||||
get :orders
|
get :orders
|
||||||
|
post :complete_install
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resource :linear, controller: 'linear', only: [] do
|
resource :linear, controller: 'linear', only: [] do
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Captain::Assistant < ApplicationRecord
|
|||||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||||
|
|
||||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||||
|
SHOPIFY_TOOL_IDS = %w[shopify_search_products shopify_get_orders].freeze
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :description, presence: true
|
validates :description, presence: true
|
||||||
@@ -52,6 +53,7 @@ class Captain::Assistant < ApplicationRecord
|
|||||||
|
|
||||||
def available_agent_tools
|
def available_agent_tools
|
||||||
tools = self.class.built_in_agent_tools.dup
|
tools = self.class.built_in_agent_tools.dup
|
||||||
|
tools = filter_shopify_tool_metadata(tools)
|
||||||
|
|
||||||
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
|
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
|
||||||
tools.concat(custom_tools)
|
tools.concat(custom_tools)
|
||||||
@@ -92,10 +94,25 @@ class Captain::Assistant < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def agent_tools
|
def agent_tools
|
||||||
[
|
tools = [
|
||||||
self.class.resolve_tool_class('faq_lookup').new(self),
|
self.class.resolve_tool_class('faq_lookup').new(self),
|
||||||
self.class.resolve_tool_class('handoff').new(self)
|
self.class.resolve_tool_class('handoff').new(self)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if shopify_tools_enabled_for_v2?
|
||||||
|
tools.concat(
|
||||||
|
SHOPIFY_TOOL_IDS.filter_map do |tool_id|
|
||||||
|
tool_class = self.class.resolve_tool_class(tool_id)
|
||||||
|
tool_class&.new(self)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
tools
|
||||||
|
end
|
||||||
|
|
||||||
|
def shopify_tools_enabled_for_v2?
|
||||||
|
account.feature_enabled?('captain_integration_v2') && shopify_connected?
|
||||||
end
|
end
|
||||||
|
|
||||||
def prompt_context
|
def prompt_context
|
||||||
@@ -118,4 +135,14 @@ class Captain::Assistant < ApplicationRecord
|
|||||||
def default_avatar_url
|
def default_avatar_url
|
||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
|
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_shopify_tool_metadata(tools)
|
||||||
|
return tools if shopify_tools_enabled_for_v2?
|
||||||
|
|
||||||
|
tools.reject { |tool| SHOPIFY_TOOL_IDS.include?(tool[:id]) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def shopify_connected?
|
||||||
|
Integrations::Hook.exists?(account_id: account_id, app_id: 'shopify', status: :enabled)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ module Concerns::CaptainToolsHelpers
|
|||||||
# @param tool_id [String] The snake_case tool identifier
|
# @param tool_id [String] The snake_case tool identifier
|
||||||
# @return [Class, nil] The tool class if found, nil if not resolvable
|
# @return [Class, nil] The tool class if found, nil if not resolvable
|
||||||
def resolve_tool_class(tool_id)
|
def resolve_tool_class(tool_id)
|
||||||
class_name = "Captain::Tools::#{tool_id.classify}Tool"
|
class_name = "Captain::Tools::#{tool_id.camelize}Tool"
|
||||||
class_name.safe_constantize
|
class_name.safe_constantize
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -66,9 +66,11 @@ If unclear, ask clarifying questions to determine if a scenario applies:
|
|||||||
If no specialized scenario clearly matches, handle it yourself in the following way
|
If no specialized scenario clearly matches, handle it yourself in the following way
|
||||||
|
|
||||||
### For Questions and Information Requests
|
### For Questions and Information Requests
|
||||||
1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
|
1. **For Shopify product queries**: Use `captain--tools--shopify_search_products` first to find matching products
|
||||||
2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
|
2. **For Shopify order status/lookup queries**: Use `captain--tools--shopify_get_orders` first to check customer orders
|
||||||
3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
|
3. **For general factual questions**: Use `captain--tools--faq_lookup` tool to search for relevant information
|
||||||
|
4. **If not found in tools**: Ask clarifying questions to gather more information
|
||||||
|
5. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
|
||||||
|
|
||||||
### For Complex or Unclear Requests
|
### For Complex or Unclear Requests
|
||||||
1. **Ask clarifying questions**: Gather more information if needed
|
1. **Ask clarifying questions**: Gather more information if needed
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
class Captain::Tools::ShopifyBaseTool < Captain::Tools::BasePublicTool
|
||||||
|
SHOPIFY_APP_ID = 'shopify'.freeze
|
||||||
|
V2_FEATURE_FLAG = 'captain_integration_v2'.freeze
|
||||||
|
EMAIL_REGEX = /\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/i
|
||||||
|
PHONE_REGEX = /(?:\+\d[\d\s().-]{7,}\d|\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4})/
|
||||||
|
|
||||||
|
def active?
|
||||||
|
v2_enabled? && shopify_connected?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def v2_enabled?
|
||||||
|
@assistant.account.feature_enabled?(V2_FEATURE_FLAG)
|
||||||
|
end
|
||||||
|
|
||||||
|
def shopify_connected?
|
||||||
|
Integrations::Hook.exists?(account_id: @assistant.account_id, app_id: SHOPIFY_APP_ID, status: :enabled)
|
||||||
|
end
|
||||||
|
|
||||||
|
def eligibility_error_message
|
||||||
|
unless v2_enabled?
|
||||||
|
log_tool_usage('shopify_tool_not_eligible', { reason: 'captain_v2_disabled' })
|
||||||
|
return 'This Shopify tool is available only for Captain V2.'
|
||||||
|
end
|
||||||
|
|
||||||
|
unless shopify_connected?
|
||||||
|
log_tool_usage('shopify_tool_not_eligible', { reason: 'shopify_not_connected' })
|
||||||
|
return 'Shopify integration is not connected for this account.'
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_contact_identity(state, override_identity = {})
|
||||||
|
inferred_identity = extract_identity_from_current_input(state)
|
||||||
|
history_identity = extract_identity_from_trace_history(state)
|
||||||
|
|
||||||
|
{
|
||||||
|
email: resolve_identity_value(:email, override_identity, state, inferred_identity, history_identity),
|
||||||
|
phone_number: resolve_identity_value(:phone_number, override_identity, state, inferred_identity, history_identity)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_domain_error(error)
|
||||||
|
case error&.dig(:code)
|
||||||
|
when :not_connected
|
||||||
|
'Shopify integration is not connected. Please connect Shopify and try again.'
|
||||||
|
when :insufficient_scope
|
||||||
|
'Shopify integration requires additional permissions. Please reauthorize Shopify integration and try again.'
|
||||||
|
when :missing_identifier
|
||||||
|
'I need the contact email or phone number to look up Shopify orders.'
|
||||||
|
when :no_results
|
||||||
|
error[:message].presence || 'No matching Shopify records were found.'
|
||||||
|
else
|
||||||
|
'I could not fetch Shopify data right now. Please try again shortly.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_identity_from_current_input(state)
|
||||||
|
current_input = extract_current_input_text(state)
|
||||||
|
return empty_identity if current_input.blank?
|
||||||
|
|
||||||
|
extract_identity_from_text(current_input)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_current_input_text(state)
|
||||||
|
raw_input = state&.dig(:captain_v2_trace_current_input).to_s
|
||||||
|
return '' if raw_input.blank?
|
||||||
|
|
||||||
|
extract_trace_content_text(parse_trace_payload(raw_input))
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_identity_from_trace_history(state)
|
||||||
|
trace_input = state&.dig(:captain_v2_trace_input).to_s
|
||||||
|
parsed_input = parse_trace_payload(trace_input)
|
||||||
|
return empty_identity unless parsed_input.is_a?(Array)
|
||||||
|
|
||||||
|
user_messages = parsed_input.reverse.filter_map { |entry| trace_user_text(entry) }
|
||||||
|
first_identity_from_messages(user_messages)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_trace_content_text(content)
|
||||||
|
return '' if content.blank?
|
||||||
|
return content.to_s unless content.is_a?(Array)
|
||||||
|
|
||||||
|
content.filter_map { |part| part['text'] if part.is_a?(Hash) && part['type'] == 'text' }.join(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def trace_user_text(entry)
|
||||||
|
return unless entry.is_a?(Hash) && entry['role'] == 'user'
|
||||||
|
|
||||||
|
extract_trace_content_text(entry['content']).presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_identity_from_messages(messages)
|
||||||
|
identity = empty_identity
|
||||||
|
|
||||||
|
messages.each do |message|
|
||||||
|
identity[:email] ||= normalize_email(message)
|
||||||
|
identity[:phone_number] ||= normalize_phone(message[PHONE_REGEX])
|
||||||
|
break if identity.values.all?(&:present?)
|
||||||
|
end
|
||||||
|
|
||||||
|
identity
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_identity_from_text(text)
|
||||||
|
{
|
||||||
|
email: normalize_email(text),
|
||||||
|
phone_number: normalize_phone(text[PHONE_REGEX])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_identity_value(key, override_identity, state, inferred_identity, history_identity)
|
||||||
|
[
|
||||||
|
normalized_identity_value(key, override_identity[key]),
|
||||||
|
normalized_identity_value(key, state&.dig(:contact, key)),
|
||||||
|
normalized_identity_value(key, inferred_identity[key]),
|
||||||
|
normalized_identity_value(key, history_identity[key])
|
||||||
|
].find(&:present?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_identity_value(key, value)
|
||||||
|
return normalize_email(value) if key == :email
|
||||||
|
|
||||||
|
normalize_phone(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty_identity
|
||||||
|
{ email: nil, phone_number: nil }
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_trace_payload(raw_input)
|
||||||
|
return raw_input unless raw_input.strip.start_with?('[', '{')
|
||||||
|
|
||||||
|
JSON.parse(raw_input)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
raw_input
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_email(raw_value)
|
||||||
|
value = raw_value.to_s.strip
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
value[EMAIL_REGEX]
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_phone(raw_value)
|
||||||
|
value = raw_value.to_s.strip
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
normalized = value.gsub(/[^\d+]/, '')
|
||||||
|
return nil unless normalized.match?(/\A\+?\d{7,15}\z/)
|
||||||
|
|
||||||
|
normalized
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
class Captain::Tools::ShopifyGetOrdersTool < Captain::Tools::ShopifyBaseTool
|
||||||
|
description "Look up a customer's orders from Shopify"
|
||||||
|
param :email, type: 'string', desc: 'Customer email used for order lookup', required: false
|
||||||
|
param :phone_number, type: 'string', desc: 'Customer phone number used for order lookup', required: false
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize
|
||||||
|
def perform(tool_context, email: nil, phone_number: nil)
|
||||||
|
log_tool_usage('shopify_get_orders_requested', { email_present: email.present?, phone_present: phone_number.present? })
|
||||||
|
return eligibility_error_message if eligibility_error_message
|
||||||
|
|
||||||
|
identity = resolve_contact_identity(tool_context&.state, { email: email, phone_number: phone_number })
|
||||||
|
log_tool_usage(
|
||||||
|
'shopify_get_orders_identity_resolved',
|
||||||
|
{ email_present: identity[:email].present?, phone_present: identity[:phone_number].present? }
|
||||||
|
)
|
||||||
|
|
||||||
|
result = orders_service.orders_for_contact(email: identity[:email], phone_number: identity[:phone_number], limit: 10)
|
||||||
|
unless result[:ok]
|
||||||
|
log_tool_usage('shopify_get_orders_failed', { error: result[:error] })
|
||||||
|
return format_domain_error(result[:error])
|
||||||
|
end
|
||||||
|
|
||||||
|
log_tool_usage('shopify_get_orders_success', { orders_count: result[:data][:orders].length })
|
||||||
|
|
||||||
|
format_orders(result[:data][:orders])
|
||||||
|
rescue StandardError => e
|
||||||
|
ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception
|
||||||
|
'I could not fetch Shopify orders right now. Please try again shortly.'
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def orders_service
|
||||||
|
@orders_service ||= Integrations::Shopify::OrdersService.new(account: @assistant.account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_orders(orders)
|
||||||
|
lines = ["Found #{orders.length} Shopify orders:"]
|
||||||
|
|
||||||
|
orders.each_with_index do |order, index|
|
||||||
|
lines << order_line(index, order)
|
||||||
|
lines << " Items: #{line_items_summary(order[:line_items])}"
|
||||||
|
end
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def order_line(index, order)
|
||||||
|
[
|
||||||
|
"#{index + 1}. #{order[:name]}",
|
||||||
|
"Date: #{order[:created_at]}",
|
||||||
|
"Total: #{formatted_total(order)}",
|
||||||
|
"Payment: #{order[:financial_status]}",
|
||||||
|
"Fulfillment: #{order[:fulfillment_status]}",
|
||||||
|
"Admin: #{order[:admin_url]}"
|
||||||
|
].join(' | ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def line_items_summary(line_items)
|
||||||
|
items = Array(line_items)
|
||||||
|
return 'N/A' if items.empty?
|
||||||
|
|
||||||
|
items.map { |item| "#{item[:quantity]}x #{item[:title]}" }.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def formatted_total(order)
|
||||||
|
return 'N/A' if order[:total_price].blank?
|
||||||
|
|
||||||
|
[order[:currency], order[:total_price]].compact.join(' ')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
class Captain::Tools::ShopifySearchProductsTool < Captain::Tools::ShopifyBaseTool
|
||||||
|
description 'Search products in the connected Shopify store'
|
||||||
|
param :query, type: 'string', desc: 'Search query for product name or keywords'
|
||||||
|
|
||||||
|
def perform(_tool_context, query:)
|
||||||
|
log_tool_usage('shopify_search_products_requested', { query: query })
|
||||||
|
return eligibility_error_message if eligibility_error_message
|
||||||
|
|
||||||
|
result = products_service.search_products(query: query, limit: 10)
|
||||||
|
unless result[:ok]
|
||||||
|
log_tool_usage('shopify_search_products_failed', { query: query, error: result[:error] })
|
||||||
|
return format_domain_error(result[:error])
|
||||||
|
end
|
||||||
|
|
||||||
|
log_tool_usage('shopify_search_products_success', { query: query, products_count: result[:data][:products].length })
|
||||||
|
|
||||||
|
format_products(result[:data][:products])
|
||||||
|
rescue StandardError => e
|
||||||
|
ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception
|
||||||
|
'I could not fetch Shopify products right now. Please try again shortly.'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def products_service
|
||||||
|
@products_service ||= Integrations::Shopify::ProductsService.new(account: @assistant.account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_products(products)
|
||||||
|
lines = ["Found #{products.length} matching Shopify products:"]
|
||||||
|
|
||||||
|
products.each_with_index do |product, index|
|
||||||
|
lines << product_line(index, product)
|
||||||
|
end
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def product_line(index, product)
|
||||||
|
[
|
||||||
|
"#{index + 1}. #{product[:title]}",
|
||||||
|
"Price: #{formatted_price(product[:price])}",
|
||||||
|
"Availability: #{product[:availability]}",
|
||||||
|
"URL: #{product[:storefront_url]}"
|
||||||
|
].join(' | ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def formatted_price(price)
|
||||||
|
return 'N/A' if price.blank?
|
||||||
|
|
||||||
|
price.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -40,6 +40,11 @@ module Redis::Alfred
|
|||||||
$alfred.with { |conn| conn.expire(key, seconds) }
|
$alfred.with { |conn| conn.expire(key, seconds) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# get time to live for a key in seconds
|
||||||
|
def ttl(key)
|
||||||
|
$alfred.with { |conn| conn.ttl(key) }
|
||||||
|
end
|
||||||
|
|
||||||
# scan keys matching a pattern
|
# scan keys matching a pattern
|
||||||
def scan_each(match: nil, count: 100, &)
|
def scan_each(match: nil, count: 100, &)
|
||||||
$alfred.with do |conn|
|
$alfred.with do |conn|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Redis::SecureStorage provides encrypted storage for sensitive temporary data in Redis.
|
||||||
|
# Uses ActiveSupport::MessageEncryptor with AES-256-GCM for authenticated encryption.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# Redis::SecureStorage.set('session:token', { access_token: 'secret' }, 10.minutes)
|
||||||
|
# data = Redis::SecureStorage.get('session:token')
|
||||||
|
#
|
||||||
|
module Redis::SecureStorage
|
||||||
|
class << self
|
||||||
|
# Store data in Redis with encryption
|
||||||
|
# @param key [String] Redis key
|
||||||
|
# @param data [Hash, String] Data to store (will be converted to JSON)
|
||||||
|
# @param expiry [Integer, ActiveSupport::Duration] TTL in seconds
|
||||||
|
def set(key, data, expiry)
|
||||||
|
encrypted = encrypt(data)
|
||||||
|
Redis::Alfred.setex(key, encrypted, expiry)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Retrieve and decrypt data from Redis
|
||||||
|
# @param key [String] Redis key
|
||||||
|
# @return [Hash, nil] Decrypted data or nil if not found/invalid
|
||||||
|
def get(key)
|
||||||
|
encrypted = Redis::Alfred.get(key)
|
||||||
|
return nil if encrypted.blank?
|
||||||
|
|
||||||
|
decrypt(encrypted)
|
||||||
|
rescue ActiveSupport::MessageEncryptor::InvalidMessage, JSON::ParserError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete data from Redis
|
||||||
|
# @param key [String] Redis key
|
||||||
|
def delete(key)
|
||||||
|
Redis::Alfred.delete(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def encryptor
|
||||||
|
@encryptor ||= begin
|
||||||
|
# Derive a proper 32-byte key from secret_key_base
|
||||||
|
key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secret_key_base)
|
||||||
|
key = key_generator.generate_key('redis_secure_storage', 32)
|
||||||
|
ActiveSupport::MessageEncryptor.new(key, cipher: 'aes-256-gcm')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def encrypt(data)
|
||||||
|
json_data = data.is_a?(String) ? data : data.to_json
|
||||||
|
return json_data unless Chatwoot.encryption_configured?
|
||||||
|
|
||||||
|
encryptor.encrypt_and_sign(json_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def decrypt(encrypted_data)
|
||||||
|
return encrypted_data unless Chatwoot.encryption_configured?
|
||||||
|
|
||||||
|
encryptor.decrypt_and_verify(encrypted_data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,42 +15,6 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
|||||||
let(:unauthorized_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') }
|
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
|
describe 'GET /api/v1/accounts/:account_id/integrations/shopify/orders' do
|
||||||
before do
|
before do
|
||||||
create(:integrations_hook, :shopify, account: account)
|
create(:integrations_hook, :shopify, account: account)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
|||||||
let(:code) { SecureRandom.hex(10) }
|
let(:code) { SecureRandom.hex(10) }
|
||||||
let(:state) { SecureRandom.hex(10) }
|
let(:state) { SecureRandom.hex(10) }
|
||||||
let(:shop) { 'my-store.myshopify.com' }
|
let(:shop) { 'my-store.myshopify.com' }
|
||||||
|
let(:client_secret) { 'test_secret_key_1234567890' }
|
||||||
let(:frontend_url) { 'http://www.example.com' }
|
let(:frontend_url) { 'http://www.example.com' }
|
||||||
let(:shopify_redirect_uri) { "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify" }
|
let(:shopify_redirect_uri) { "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify" }
|
||||||
let(:oauth_client) { instance_double(OAuth2::Client) }
|
let(:oauth_client) { instance_double(OAuth2::Client) }
|
||||||
@@ -17,6 +18,12 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper to compute HMAC for test requests (matches Shopify's algorithm)
|
||||||
|
def compute_hmac(params, secret)
|
||||||
|
query_string = params.except(:hmac).sort.map { |k, v| "#{k}=#{v}" }.join('&')
|
||||||
|
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA256'), secret, query_string)
|
||||||
|
end
|
||||||
|
|
||||||
describe 'GET /shopify/callback' do
|
describe 'GET /shopify/callback' do
|
||||||
let(:access_token) { SecureRandom.hex(10) }
|
let(:access_token) { SecureRandom.hex(10) }
|
||||||
let(:response_body) do
|
let(:response_body) do
|
||||||
@@ -36,6 +43,7 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
|||||||
controller = original.call(*args)
|
controller = original.call(*args)
|
||||||
allow(controller).to receive(:verify_shopify_token).and_return(account.id)
|
allow(controller).to receive(:verify_shopify_token).and_return(account.id)
|
||||||
allow(controller).to receive(:oauth_client).and_return(oauth_client)
|
allow(controller).to receive(:oauth_client).and_return(oauth_client)
|
||||||
|
allow(controller).to receive(:client_secret).and_return(client_secret)
|
||||||
controller
|
controller
|
||||||
end
|
end
|
||||||
allow(Account).to receive(:find).and_return(account)
|
allow(Account).to receive(:find).and_return(account)
|
||||||
@@ -57,8 +65,11 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new integration hook' do
|
it 'creates a new integration hook' do
|
||||||
|
params = { code: code, state: state, shop: shop }
|
||||||
|
params[:hmac] = compute_hmac(params, client_secret)
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
get shopify_callback_path, params: { code: code, state: state, shop: shop }
|
get shopify_callback_path, params: params
|
||||||
end.to change(Integrations::Hook, :count).by(1)
|
end.to change(Integrations::Hook, :count).by(1)
|
||||||
|
|
||||||
hook = Integrations::Hook.last
|
hook = Integrations::Hook.last
|
||||||
@@ -82,7 +93,10 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'redirects to the shopify_redirect_uri with error' do
|
it 'redirects to the shopify_redirect_uri with error' do
|
||||||
get shopify_callback_path, params: { state: state, shop: shop }
|
params = { state: state, shop: shop }
|
||||||
|
params[:hmac] = compute_hmac(params, client_secret)
|
||||||
|
|
||||||
|
get shopify_callback_path, params: params
|
||||||
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
|
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -104,7 +118,10 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'redirects to the shopify_redirect_uri with error' do
|
it 'redirects to the shopify_redirect_uri with error' do
|
||||||
get shopify_callback_path, params: { code: code, state: state, shop: shop }
|
params = { code: code, state: state, shop: shop }
|
||||||
|
params[:hmac] = compute_hmac(params, client_secret)
|
||||||
|
|
||||||
|
get shopify_callback_path, params: params
|
||||||
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
|
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -114,13 +131,20 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
|||||||
# rubocop:disable RSpec/AnyInstance, RSpec/DescribedClass
|
# rubocop:disable RSpec/AnyInstance, RSpec/DescribedClass
|
||||||
# Explicit class name and any_instance required for parallel CI stability
|
# Explicit class name and any_instance required for parallel CI stability
|
||||||
allow_any_instance_of(Shopify::CallbacksController).to receive(:verify_shopify_token).and_return(nil)
|
allow_any_instance_of(Shopify::CallbacksController).to receive(:verify_shopify_token).and_return(nil)
|
||||||
allow_any_instance_of(Shopify::CallbacksController).to receive(:account).and_return(nil)
|
allow_any_instance_of(Shopify::CallbacksController).to receive(:oauth_client).and_return(oauth_client)
|
||||||
|
allow_any_instance_of(Shopify::CallbacksController).to receive(:client_secret).and_return(client_secret)
|
||||||
# rubocop:enable RSpec/AnyInstance, RSpec/DescribedClass
|
# rubocop:enable RSpec/AnyInstance, RSpec/DescribedClass
|
||||||
|
allow(oauth_client).to receive(:auth_code).and_return(auth_code_strategy)
|
||||||
|
allow(auth_code_strategy).to receive(:get_token).and_return(token_response)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'redirects to the frontend URL with error' do
|
it 'handles as Shopify-initiated install and redirects to login with pending install token' do
|
||||||
get shopify_callback_path, params: { code: code, state: state, shop: shop }
|
params = { code: code, state: state, shop: shop }
|
||||||
expect(response).to redirect_to("#{frontend_url}?error=true")
|
params[:hmac] = compute_hmac(params, client_secret)
|
||||||
|
|
||||||
|
get shopify_callback_path, params: params
|
||||||
|
expect(response).to redirect_to(%r{#{Regexp.escape(frontend_url)}/app/login\?redirect_url=})
|
||||||
|
expect(CGI.unescape(response.location)).to include('shopify_pending_install=')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Tools::ShopifyBaseTool, type: :model do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:tool_class) do
|
||||||
|
Class.new(described_class) do
|
||||||
|
def perform(*)
|
||||||
|
'ok'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
let(:tool) { tool_class.new(assistant) }
|
||||||
|
|
||||||
|
describe '#active?' do
|
||||||
|
it 'returns false when captain v2 is disabled' do
|
||||||
|
create(:integrations_hook, :shopify, account: account)
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||||
|
|
||||||
|
expect(tool.active?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when shopify is disconnected' do
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||||
|
|
||||||
|
expect(tool.active?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true when v2 is enabled and shopify is connected' do
|
||||||
|
create(:integrations_hook, :shopify, account: account)
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||||
|
|
||||||
|
expect(tool.active?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#resolve_contact_identity' do
|
||||||
|
it 'returns email and phone from state' do
|
||||||
|
identity = tool.send(:resolve_contact_identity, { contact: { email: 'john@example.com', phone_number: '+15551234567' } })
|
||||||
|
|
||||||
|
expect(identity).to eq({ email: 'john@example.com', phone_number: '+15551234567' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to extracting email from current input when contact identity is missing' do
|
||||||
|
identity = tool.send(
|
||||||
|
:resolve_contact_identity,
|
||||||
|
{ contact: {}, captain_v2_trace_current_input: 'Where is my order Russel.winfield@example.com?' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(identity).to eq({ email: 'Russel.winfield@example.com', phone_number: nil })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to extracting email from trace history when current input has no identity' do
|
||||||
|
trace_input = [
|
||||||
|
{ role: 'user', content: 'Where is my order Russel.winfield@example.com?' },
|
||||||
|
{ role: 'assistant', content: 'Please confirm your email' },
|
||||||
|
{ role: 'user', content: 'Yes' }
|
||||||
|
].to_json
|
||||||
|
|
||||||
|
identity = tool.send(
|
||||||
|
:resolve_contact_identity,
|
||||||
|
{ contact: {}, captain_v2_trace_current_input: 'Yes', captain_v2_trace_input: trace_input }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(identity).to eq({ email: 'Russel.winfield@example.com', phone_number: nil })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prioritizes explicit identity over contact and inferred identity' do
|
||||||
|
state = {
|
||||||
|
contact: { email: 'contact@example.com', phone_number: '+15550001111' },
|
||||||
|
captain_v2_trace_current_input: 'my alternate is inferred@example.com'
|
||||||
|
}
|
||||||
|
overrides = { email: 'explicit@example.com', phone_number: '+15551234567' }
|
||||||
|
|
||||||
|
identity = tool.send(:resolve_contact_identity, state, overrides)
|
||||||
|
|
||||||
|
expect(identity).to eq({ email: 'explicit@example.com', phone_number: '+15551234567' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Tools::ShopifyGetOrdersTool, type: :model do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:tool) { described_class.new(assistant) }
|
||||||
|
let(:tool_context) { Struct.new(:state).new({ contact: { email: 'john@example.com', phone_number: '+15551234567' } }) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:integrations_hook, :shopify, account: account)
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
it 'formats successful order results' do
|
||||||
|
service = instance_double(Integrations::Shopify::OrdersService)
|
||||||
|
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:orders_for_contact).with(email: 'john@example.com', phone_number: '+15551234567', limit: 10).and_return(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
name: '#1001',
|
||||||
|
created_at: '2026-01-10T10:00:00Z',
|
||||||
|
currency: 'USD',
|
||||||
|
total_price: '89.50',
|
||||||
|
financial_status: 'paid',
|
||||||
|
fulfillment_status: 'fulfilled',
|
||||||
|
admin_url: 'https://store/admin/orders/91',
|
||||||
|
line_items: [{ title: 'Red Sneakers', quantity: 1 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.perform(tool_context)
|
||||||
|
|
||||||
|
expect(result).to include('Found 1 Shopify orders:')
|
||||||
|
expect(result).to include('#1001')
|
||||||
|
expect(result).to include('https://store/admin/orders/91')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns missing identity message' do
|
||||||
|
service = instance_double(Integrations::Shopify::OrdersService)
|
||||||
|
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:orders_for_contact).and_return(
|
||||||
|
{ ok: false, error: { code: :missing_identifier, message: 'missing' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.perform(Struct.new(:state).new({ contact: {} }))
|
||||||
|
|
||||||
|
expect(result).to eq('I need the contact email or phone number to look up Shopify orders.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to extracting email from current input text when contact identity is missing' do
|
||||||
|
service = instance_double(Integrations::Shopify::OrdersService)
|
||||||
|
context = Struct.new(:state).new(
|
||||||
|
{ contact: {}, captain_v2_trace_current_input: 'Where is my order Russel.winfield@example.com?' }
|
||||||
|
)
|
||||||
|
|
||||||
|
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:orders_for_contact).with(email: 'Russel.winfield@example.com', phone_number: nil, limit: 10).and_return(
|
||||||
|
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.perform(context)
|
||||||
|
|
||||||
|
expect(result).to eq('No orders found for the customer.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses explicit arguments over inferred state identity' do
|
||||||
|
service = instance_double(Integrations::Shopify::OrdersService)
|
||||||
|
context = Struct.new(:state).new(
|
||||||
|
{ contact: {}, captain_v2_trace_current_input: 'Where is my order inferred@example.com?' }
|
||||||
|
)
|
||||||
|
|
||||||
|
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:orders_for_contact).with(email: 'explicit@example.com', phone_number: nil, limit: 10).and_return(
|
||||||
|
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.perform(context, email: 'explicit@example.com')
|
||||||
|
|
||||||
|
expect(result).to eq('No orders found for the customer.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses trace history identity when current input is a plain confirmation' do
|
||||||
|
service = instance_double(Integrations::Shopify::OrdersService)
|
||||||
|
trace_input = [
|
||||||
|
{ role: 'user', content: 'Where is my order Russel.winfield@example.com?' },
|
||||||
|
{ role: 'assistant', content: 'Please confirm your email' },
|
||||||
|
{ role: 'user', content: 'Yes' }
|
||||||
|
].to_json
|
||||||
|
context = Struct.new(:state).new(
|
||||||
|
{ contact: {}, captain_v2_trace_current_input: 'Yes', captain_v2_trace_input: trace_input }
|
||||||
|
)
|
||||||
|
|
||||||
|
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:orders_for_contact).with(email: 'Russel.winfield@example.com', phone_number: nil, limit: 10).and_return(
|
||||||
|
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.perform(context)
|
||||||
|
|
||||||
|
expect(result).to eq('No orders found for the customer.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns no result message from service' do
|
||||||
|
service = instance_double(Integrations::Shopify::OrdersService)
|
||||||
|
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:orders_for_contact).and_return(
|
||||||
|
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.perform(tool_context)
|
||||||
|
|
||||||
|
expect(result).to eq('No orders found for the customer.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns safe provider error message' do
|
||||||
|
service = instance_double(Integrations::Shopify::OrdersService)
|
||||||
|
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:orders_for_contact).and_raise(StandardError, 'boom')
|
||||||
|
|
||||||
|
result = tool.perform(tool_context)
|
||||||
|
|
||||||
|
expect(result).to eq('I could not fetch Shopify orders right now. Please try again shortly.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Tools::ShopifySearchProductsTool, type: :model do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:tool) { described_class.new(assistant) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:integrations_hook, :shopify, account: account)
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
it 'formats successful product search results' do
|
||||||
|
service = instance_double(Integrations::Shopify::ProductsService)
|
||||||
|
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:search_products).with(query: 'sneakers', limit: 10).and_return(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
title: 'Red Sneakers',
|
||||||
|
price: '120.00',
|
||||||
|
availability: 'In stock',
|
||||||
|
storefront_url: 'https://store/products/red-sneakers'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.perform(nil, query: 'sneakers')
|
||||||
|
|
||||||
|
expect(result).to include('Found 1 matching Shopify products:')
|
||||||
|
expect(result).to include('Red Sneakers')
|
||||||
|
expect(result).to include('https://store/products/red-sneakers')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns reconnect guidance for missing scope' do
|
||||||
|
service = instance_double(Integrations::Shopify::ProductsService)
|
||||||
|
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:search_products).and_return(
|
||||||
|
{ ok: false, error: { code: :insufficient_scope, message: 'missing scope' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.perform(nil, query: 'sneakers')
|
||||||
|
|
||||||
|
expect(result).to eq('Shopify integration requires additional permissions. Please reauthorize Shopify integration and try again.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns safe provider error message' do
|
||||||
|
service = instance_double(Integrations::Shopify::ProductsService)
|
||||||
|
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
|
||||||
|
allow(service).to receive(:search_products).and_raise(StandardError, 'boom')
|
||||||
|
|
||||||
|
result = tool.perform(nil, query: 'sneakers')
|
||||||
|
|
||||||
|
expect(result).to eq('I could not fetch Shopify products right now. Please try again shortly.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Assistant, type: :model do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
|
||||||
|
describe '#available_agent_tools' do
|
||||||
|
before do
|
||||||
|
allow(described_class).to receive(:built_in_agent_tools).and_return([
|
||||||
|
{ id: 'faq_lookup', title: 'FAQ' },
|
||||||
|
{ id: 'shopify_search_products', title: 'Products' },
|
||||||
|
{ id: 'shopify_get_orders', title: 'Orders' }
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'hides shopify tools when account is not eligible for v2 shopify' do
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||||
|
|
||||||
|
expect(assistant.available_agent_tools.pluck(:id)).to eq(['faq_lookup'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows shopify tools when v2 is enabled and shopify is connected' do
|
||||||
|
create(:integrations_hook, :shopify, account: account)
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||||
|
|
||||||
|
expect(assistant.available_agent_tools.pluck(:id)).to contain_exactly('faq_lookup', 'shopify_search_products', 'shopify_get_orders')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#agent_tools' do
|
||||||
|
let(:faq_tool_class) do
|
||||||
|
Class.new do
|
||||||
|
def initialize(_assistant); end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:handoff_tool_class) do
|
||||||
|
Class.new do
|
||||||
|
def initialize(_assistant); end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:search_tool_class) do
|
||||||
|
Class.new do
|
||||||
|
def initialize(_assistant); end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:orders_tool_class) do
|
||||||
|
Class.new do
|
||||||
|
def initialize(_assistant); end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(described_class).to receive(:resolve_tool_class).with('faq_lookup').and_return(faq_tool_class)
|
||||||
|
allow(described_class).to receive(:resolve_tool_class).with('handoff').and_return(handoff_tool_class)
|
||||||
|
allow(described_class).to receive(:resolve_tool_class).with('shopify_search_products').and_return(search_tool_class)
|
||||||
|
allow(described_class).to receive(:resolve_tool_class).with('shopify_get_orders').and_return(orders_tool_class)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes only default tools when account is ineligible' do
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||||
|
|
||||||
|
expect(assistant.send(:agent_tools).length).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes shopify tools when account is eligible' do
|
||||||
|
create(:integrations_hook, :shopify, account: account)
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||||
|
|
||||||
|
expect(assistant.send(:agent_tools).length).to eq(4)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Integrations::Shopify::OrdersService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:service) { described_class.new(account: account) }
|
||||||
|
let(:shopify_hook) do
|
||||||
|
create(
|
||||||
|
:integrations_hook,
|
||||||
|
:shopify,
|
||||||
|
account: account,
|
||||||
|
settings: { scope: 'read_customers,read_orders,read_fulfillments,read_products' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
|
||||||
|
let(:client_service) { instance_double(Integrations::Shopify::ClientService) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Integrations::Shopify::ClientService).to receive(:new).with(account: account).and_return(client_service)
|
||||||
|
allow(client_service).to receive(:fetch_client).and_return(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
client: shopify_client,
|
||||||
|
hook: shopify_hook,
|
||||||
|
scopes: %w[read_customers read_orders read_fulfillments read_products]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
allow(client_service).to receive(:scopes_include?).with(%w[read_customers read_orders]).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#orders_for_contact' do
|
||||||
|
let(:customer_response) do
|
||||||
|
Struct.new(:body).new({ 'customers' => [{ 'id' => 'cust_1' }] })
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:orders_response) do
|
||||||
|
Struct.new(:body).new(
|
||||||
|
{
|
||||||
|
'orders' => [
|
||||||
|
{
|
||||||
|
'id' => 91,
|
||||||
|
'name' => '#1001',
|
||||||
|
'created_at' => '2026-01-10T10:00:00Z',
|
||||||
|
'total_price' => '89.50',
|
||||||
|
'currency' => 'USD',
|
||||||
|
'financial_status' => 'paid',
|
||||||
|
'fulfillment_status' => 'fulfilled',
|
||||||
|
'line_items' => [{ 'title' => 'Red Sneakers', 'quantity' => 1, 'price' => '89.50' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns normalized orders for email lookup' do
|
||||||
|
allow(shopify_client).to receive(:get).with(
|
||||||
|
path: 'customers/search.json',
|
||||||
|
query: { query: 'email:john@example.com', fields: 'id,email,phone' }
|
||||||
|
).and_return(customer_response)
|
||||||
|
allow(shopify_client).to receive(:get).with(
|
||||||
|
path: 'orders.json',
|
||||||
|
query: {
|
||||||
|
customer_id: 'cust_1',
|
||||||
|
status: 'any',
|
||||||
|
limit: 10,
|
||||||
|
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
|
||||||
|
}
|
||||||
|
).and_return(orders_response)
|
||||||
|
|
||||||
|
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
|
||||||
|
|
||||||
|
expect(result[:ok]).to be true
|
||||||
|
expect(result[:data][:orders]).to eq(
|
||||||
|
[{
|
||||||
|
id: 91,
|
||||||
|
name: '#1001',
|
||||||
|
created_at: '2026-01-10T10:00:00Z',
|
||||||
|
total_price: '89.50',
|
||||||
|
currency: 'USD',
|
||||||
|
financial_status: 'paid',
|
||||||
|
fulfillment_status: 'fulfilled',
|
||||||
|
line_items: [{ title: 'Red Sneakers', quantity: 1, price: '89.50' }],
|
||||||
|
admin_url: 'https://test-store.myshopify.com/admin/orders/91'
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns normalized orders for phone lookup' do
|
||||||
|
allow(shopify_client).to receive(:get).with(
|
||||||
|
path: 'customers/search.json',
|
||||||
|
query: { query: 'phone:+15551234567', fields: 'id,email,phone' }
|
||||||
|
).and_return(customer_response)
|
||||||
|
allow(shopify_client).to receive(:get).with(
|
||||||
|
path: 'orders.json',
|
||||||
|
query: {
|
||||||
|
customer_id: 'cust_1',
|
||||||
|
status: 'any',
|
||||||
|
limit: 10,
|
||||||
|
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
|
||||||
|
}
|
||||||
|
).and_return(orders_response)
|
||||||
|
|
||||||
|
result = service.orders_for_contact(email: nil, phone_number: '+15551234567')
|
||||||
|
|
||||||
|
expect(result[:ok]).to be true
|
||||||
|
expect(result[:data][:orders].first[:id]).to eq(91)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns missing_identifier when no identity is available' do
|
||||||
|
result = service.orders_for_contact(email: nil, phone_number: nil)
|
||||||
|
|
||||||
|
expect(result[:ok]).to be false
|
||||||
|
expect(result.dig(:error, :code)).to eq(:missing_identifier)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns no_results when customer is not found' do
|
||||||
|
allow(shopify_client).to receive(:get).with(
|
||||||
|
path: 'customers/search.json',
|
||||||
|
query: { query: 'email:john@example.com', fields: 'id,email,phone' }
|
||||||
|
).and_return(Struct.new(:body).new({ 'customers' => [] }))
|
||||||
|
|
||||||
|
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
|
||||||
|
|
||||||
|
expect(result[:ok]).to be false
|
||||||
|
expect(result.dig(:error, :code)).to eq(:no_results)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'maps provider exceptions to provider_error' do
|
||||||
|
allow(shopify_client).to receive(:get).and_raise(StandardError, 'provider down')
|
||||||
|
|
||||||
|
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
|
||||||
|
|
||||||
|
expect(result[:ok]).to be false
|
||||||
|
expect(result.dig(:error, :code)).to eq(:provider_error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Integrations::Shopify::ProductsService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:service) { described_class.new(account: account) }
|
||||||
|
let(:shopify_hook) do
|
||||||
|
create(
|
||||||
|
:integrations_hook,
|
||||||
|
:shopify,
|
||||||
|
account: account,
|
||||||
|
settings: { scope: 'read_customers,read_orders,read_fulfillments,read_products' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
|
||||||
|
let(:client_service) { instance_double(Integrations::Shopify::ClientService) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Integrations::Shopify::ClientService).to receive(:new).with(account: account).and_return(client_service)
|
||||||
|
allow(client_service).to receive(:fetch_client).and_return(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
client: shopify_client,
|
||||||
|
hook: shopify_hook,
|
||||||
|
scopes: %w[read_customers read_orders read_fulfillments read_products]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
allow(client_service).to receive(:granted_scopes).and_return(%w[read_customers read_orders read_fulfillments])
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#search_products' do
|
||||||
|
it 'returns normalized product fields on success' do
|
||||||
|
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
|
||||||
|
allow(shopify_client).to receive(:get).and_return(
|
||||||
|
Struct.new(:body).new(
|
||||||
|
{
|
||||||
|
'products' => [
|
||||||
|
{
|
||||||
|
'id' => 11,
|
||||||
|
'title' => 'Red Sneakers',
|
||||||
|
'vendor' => 'Acme',
|
||||||
|
'product_type' => 'Shoes',
|
||||||
|
'handle' => 'red-sneakers',
|
||||||
|
'variants' => [{ 'price' => '129.99', 'available' => true }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.search_products(query: 'sneakers')
|
||||||
|
|
||||||
|
expect(result[:ok]).to be true
|
||||||
|
expect(result[:data][:products]).to eq(
|
||||||
|
[{
|
||||||
|
id: 11,
|
||||||
|
title: 'Red Sneakers',
|
||||||
|
vendor: 'Acme',
|
||||||
|
product_type: 'Shoes',
|
||||||
|
handle: 'red-sneakers',
|
||||||
|
storefront_url: 'https://test-store.myshopify.com/products/red-sneakers',
|
||||||
|
price: '129.99',
|
||||||
|
availability: 'In stock'
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns no_results when products are empty' do
|
||||||
|
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
|
||||||
|
allow(shopify_client).to receive(:get).and_return(Struct.new(:body).new({ 'products' => [] }))
|
||||||
|
|
||||||
|
result = service.search_products(query: 'sneakers')
|
||||||
|
|
||||||
|
expect(result[:ok]).to be false
|
||||||
|
expect(result.dig(:error, :code)).to eq(:no_results)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to keyword filtering when title search is empty' do
|
||||||
|
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
|
||||||
|
title_search_empty = Struct.new(:body).new({ 'products' => [] })
|
||||||
|
active_products = Struct.new(:body).new(
|
||||||
|
{
|
||||||
|
'products' => [
|
||||||
|
{
|
||||||
|
'id' => 22,
|
||||||
|
'title' => 'The Collection Snowboard: Liquid',
|
||||||
|
'vendor' => 'Snow',
|
||||||
|
'product_type' => 'Snowboard',
|
||||||
|
'handle' => 'collection-snowboard-liquid',
|
||||||
|
'variants' => [{ 'price' => '150.00', 'available' => true }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
allow(shopify_client).to receive(:get).and_return(title_search_empty, active_products)
|
||||||
|
|
||||||
|
result = service.search_products(query: 'snowboard')
|
||||||
|
|
||||||
|
expect(result[:ok]).to be true
|
||||||
|
expect(result[:data][:products].first[:title]).to eq('The Collection Snowboard: Liquid')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns insufficient_scope when read_products is missing' do
|
||||||
|
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(false)
|
||||||
|
|
||||||
|
result = service.search_products(query: 'sneakers')
|
||||||
|
|
||||||
|
expect(result[:ok]).to be false
|
||||||
|
expect(result.dig(:error, :code)).to eq(:insufficient_scope)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'maps provider exceptions to provider_error' do
|
||||||
|
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
|
||||||
|
allow(shopify_client).to receive(:get).and_raise(StandardError, 'provider down')
|
||||||
|
|
||||||
|
result = service.search_products(query: 'sneakers')
|
||||||
|
|
||||||
|
expect(result[:ok]).to be false
|
||||||
|
expect(result.dig(:error, :code)).to eq(:provider_error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user