feat(apps): Shopify Integration (#11101)

This PR adds native integration with Shopify. No more dashboard apps.
The support agents can view the orders, their status and the link to the
order page on the conversation sidebar.

This PR does the following: 
- Create an integration with Shopify (a new app is added in the
integrations tab)
- Option to configure it in SuperAdmin
- OAuth endpoint and the callbacks.
- Frontend component to render the orders. (We might need to cache it in
the future)
---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Pranav
2025-03-19 15:37:55 -07:00
committed by GitHub
parent a60dcda301
commit b34c526c51
35 changed files with 1211 additions and 37 deletions

View File

@@ -283,3 +283,4 @@ exclude:
- 'app/javascript/widget/assets/scss/sdk.css'
- 'app/assets/stylesheets/administrate/reset/_normalize.scss'
- 'app/javascript/shared/assets/stylesheets/*.scss'
- 'app/javascript/dashboard/assets/scss/_woot.scss'

View File

@@ -175,6 +175,8 @@ gem 'reverse_markdown'
gem 'ruby-openai'
gem 'shopify_api'
### Gems required only in specific deployment environments ###
##############################################################

View File

@@ -352,6 +352,7 @@ GEM
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.0.0)
html2text (0.4.0)
@@ -520,6 +521,9 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.2)
hashie (>= 3.4.6)
rack (>= 2.2.3)
@@ -711,6 +715,7 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
securerandom (0.4.1)
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
@@ -725,6 +730,17 @@ GEM
sentry-ruby (~> 5.19.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shopify_api (14.8.0)
activesupport
concurrent-ruby
hash_diff
httparty
jwt
oj
openssl
securerandom
sorbet-runtime
zeitwerk (~> 2.5)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.1)
@@ -757,6 +773,7 @@ GEM
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0)
@@ -950,6 +967,7 @@ DEPENDENCIES
sentry-rails (>= 5.19.0)
sentry-ruby
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)

View File

@@ -0,0 +1,111 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :validate_contact, only: [:orders]
def auth
shop_domain = params[:shop_domain]
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
state = generate_shopify_token(Current.account.id)
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
auth_url += URI.encode_www_form(
client_id: client_id,
scope: REQUIRED_SCOPES.join(','),
redirect_uri: redirect_uri,
state: state
)
render json: { redirect_url: auth_url }
end
def orders
customers = fetch_customers
return render json: { orders: [] } if customers.empty?
orders = fetch_orders(customers.first['id'])
render json: { orders: orders }
rescue ShopifyAPI::Errors::HttpResponseError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
@hook.destroy!
head :ok
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def redirect_uri
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
end
def contact
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
end
def fetch_hook
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
end
def fetch_customers
query = []
query << "email:#{contact.email}" if contact.email.present?
query << "phone:#{contact.phone_number}" if contact.phone_number.present?
shopify_client.get(
path: 'customers/search.json',
query: {
query: query.join(' OR '),
fields: 'id,email,phone'
}
).body['customers'] || []
end
def fetch_orders(customer_id)
orders = shopify_client.get(
path: 'orders.json',
query: {
customer_id: customer_id,
status: 'any',
fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status'
}
).body['orders'] || []
orders.map do |order|
order.merge('admin_url' => "https://#{@hook.reference_id}/admin/orders/#{order['id']}")
end
end
def setup_shopify_context
return if client_id.blank? || client_secret.blank?
ShopifyAPI::Context.setup(
api_key: client_id,
api_secret_key: client_secret,
api_version: '2025-01'.freeze,
scope: REQUIRED_SCOPES.join(','),
is_embedded: true,
is_private: false
)
end
def shopify_session
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
end
def shopify_client
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
end
def validate_contact
return unless contact.blank? || (contact.email.blank? && contact.phone_number.blank?)
render json: { error: 'Contact information missing' },
status: :unprocessable_entity
end
end

View File

@@ -0,0 +1,72 @@
class Shopify::CallbacksController < ApplicationController
include Shopify::IntegrationHelper
def show
verify_account!
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: '/shopify/callback'
)
handle_response
rescue StandardError => e
Rails.logger.error("Shopify callback error: #{e.message}")
redirect_to "#{redirect_uri}?error=true"
end
private
def verify_account!
@account_id = verify_shopify_token(params[:state])
raise StandardError, 'Invalid state parameter' if account.blank?
end
def handle_response
account.hooks.create!(
app_id: 'shopify',
access_token: parsed_body['access_token'],
status: 'enabled',
reference_id: params[:shop],
settings: {
scope: parsed_body['scope']
}
)
redirect_to shopify_integration_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def oauth_client
OAuth2::Client.new(
client_id,
client_secret,
{
site: "https://#{params[:shop]}",
authorize_url: '/admin/oauth/authorize',
token_url: '/admin/oauth/access_token'
}
)
end
def account
@account ||= Account.find(@account_id)
end
def account_id
@account_id ||= params[:state].split('_').first
end
def shopify_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
end
def redirect_uri
return shopify_integration_url if account
ENV.fetch('FRONTEND_URL', nil)
end
end

View File

@@ -35,6 +35,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'shopify'
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'

View File

@@ -0,0 +1,58 @@
module Shopify::IntegrationHelper
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
# Generates a signed JWT token for Shopify integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_shopify_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Shopify token: #{e.message}")
nil
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Shopify JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_shopify_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_id
@client_id ||= GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil)
end
def client_secret
@client_secret ||= GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
end
def decode_token(token, secret)
JWT.decode(
token,
secret,
true,
{
algorithm: 'HS256',
verify_expiration: true
}
).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Shopify token: #{e.message}")
nil
end
end

View File

@@ -32,6 +32,12 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
connectShopify({ shopDomain }) {
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
shop_domain: shopDomain,
});
}
}
export default new IntegrationsAPI();

View File

@@ -0,0 +1,17 @@
/* global axios */
import ApiClient from '../ApiClient';
class ShopifyAPI extends ApiClient {
constructor() {
super('integrations/shopify', { accountScoped: true });
}
getOrders(contactId) {
return axios.get(`${this.url}/orders`, {
params: { contact_id: contactId },
});
}
}
export default new ShopifyAPI();

View File

@@ -80,10 +80,12 @@ const maxWidthClass = computed(() => {
const open = () => {
dialogRef.value?.showModal();
};
const close = () => {
emit('close');
dialogRef.value?.close();
};
const confirm = () => {
emit('confirm');
};
@@ -104,9 +106,10 @@ defineExpose({ open, close });
@close="close"
>
<OnClickOutside @trigger="close">
<div
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@submit.prevent="confirm"
@click.stop
>
<div v-if="title || description" class="flex flex-col gap-2">
@@ -129,6 +132,7 @@ defineExpose({ open, close });
color="slate"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
type="button"
@click="close"
/>
<Button
@@ -138,11 +142,11 @@ defineExpose({ open, close });
class="w-full"
:is-loading="isLoading"
:disabled="disableConfirmButton || isLoading"
@click="confirm"
type="submit"
/>
</div>
</slot>
</div>
</form>
</OnClickOutside>
</dialog>
</Teleport>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { format } from 'date-fns';
import { useI18n } from 'vue-i18n';
const props = defineProps({
order: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const formatDate = dateString => {
return format(new Date(dateString), 'MMM d, yyyy');
};
const formatCurrency = (amount, currency) => {
return new Intl.NumberFormat('en', {
style: 'currency',
currency: currency || 'USD',
}).format(amount);
};
const getStatusClass = status => {
const classes = {
paid: 'bg-n-teal-5 text-n-teal-12',
};
return classes[status] || 'bg-slate-50 text-slate-700';
};
const getStatusI18nKey = (type, status = '') => {
return `CONVERSATION_SIDEBAR.SHOPIFY.${type.toUpperCase()}_STATUS.${status.toUpperCase()}`;
};
const fulfillmentStatus = computed(() => {
const { fulfillment_status: status } = props.order;
if (!status) {
return '';
}
return t(getStatusI18nKey('FULFILLMENT', status));
});
const financialStatus = computed(() => {
const { financial_status: status } = props.order;
if (!status) {
return '';
}
return t(getStatusI18nKey('FINANCIAL', status));
});
const getFulfillmentClass = status => {
const classes = {
fulfilled: 'text-green-600',
partial: 'text-yellow-600',
unfulfilled: 'text-red-600',
};
return classes[status] || 'text-slate-600';
};
</script>
<template>
<div
class="py-3 border-b border-n-weak last:border-b-0 flex flex-col gap-1.5"
>
<div class="flex justify-between items-center">
<div class="font-medium flex">
<a
:href="order.admin_url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline text-n-slate-12 cursor-pointer truncate"
>
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.ORDER_ID', { id: order.id }) }}
<i class="i-lucide-external-link pl-5" />
</a>
</div>
<div
:class="getStatusClass(order.financial_status)"
class="text-xs px-2 py-1 rounded capitalize truncate"
:title="financialStatus"
>
{{ financialStatus }}
</div>
</div>
<div class="text-sm text-n-slate-12">
<span class="text-n-slate-11 border-r border-n-weak pr-2">
{{ formatDate(order.created_at) }}
</span>
<span class="text-n-slate-11 pl-2">
{{ formatCurrency(order.total_price, order.currency) }}
</span>
</div>
<div v-if="fulfillmentStatus">
<span
:class="getFulfillmentClass(order.fulfillment_status)"
class="capitalize font-medium"
:title="fulfillmentStatus"
>
{{ fulfillmentStatus }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useFunctionGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ShopifyAPI from '../../../api/integrations/shopify';
import ShopifyOrderItem from './ShopifyOrderItem.vue';
const props = defineProps({
contactId: {
type: [Number, String],
required: true,
},
});
const contact = useFunctionGetter('contacts/getContact', props.contactId);
const hasSearchableInfo = computed(
() => !!contact.value?.email || !!contact.value?.phone_number
);
const orders = ref([]);
const loading = ref(true);
const error = ref('');
const fetchOrders = async () => {
try {
loading.value = true;
const response = await ShopifyAPI.getOrders(props.contactId);
orders.value = response.data.orders;
} catch (e) {
error.value =
e.response?.data?.error || 'CONVERSATION_SIDEBAR.SHOPIFY.ERROR';
} finally {
loading.value = false;
}
};
watch(
() => props.contactId,
() => {
if (hasSearchableInfo.value) {
fetchOrders();
}
},
{ immediate: true }
);
</script>
<template>
<div class="px-4 py-2 text-n-slate-12">
<div v-if="!hasSearchableInfo" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
<div v-else-if="loading" class="flex justify-center items-center p-4">
<Spinner size="32" class="text-n-brand" />
</div>
<div v-else-if="error" class="text-center text-n-ruby-12">
{{ error }}
</div>
<div v-else-if="!orders.length" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
<div v-else>
<ShopifyOrderItem
v-for="order in orders"
:key="order.id"
:order="order"
/>
</div>
</div>
</template>

View File

@@ -8,6 +8,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'contact_attributes' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'shopify_orders' },
]);
export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = Object.freeze([

View File

@@ -295,7 +295,27 @@
"CONVERSATION_INFO": "Conversation Information",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros"
"MACROS": "Macros",
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Pending",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
}
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {

View File

@@ -1,5 +1,20 @@
{
"INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Cancel",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",

View File

@@ -50,7 +50,7 @@ onUnmounted(() => {
<template>
<div
class="input-container rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-center py-2 px-4 h-14 gap-2 border border-solid"
class="input-container rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-center py-2 px-4 h-14 gap-2 border border-solid bg-n-alpha-black2"
:class="{
'border-n-brand': isInputFocused,
'border-n-weak': !isInputFocused,

View File

@@ -1,6 +1,10 @@
<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import {
useMapGetter,
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
@@ -13,6 +17,7 @@ import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from '../../../components/widgets/conversation/ShopifyOrdersList.vue';
const props = defineProps({
conversationId: {
@@ -38,6 +43,14 @@ const {
const dragging = ref(false);
const conversationSidebarItems = ref([]);
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
'shopify'
);
const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
const store = useStore();
const currentChat = useMapGetter('getSelectedChat');
@@ -216,6 +229,22 @@ onMounted(() => {
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div
v-else-if="
element.name === 'shopify_orders' && isShopifyFeatureEnabled
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHOPIFY_ORDERS')"
:is-open="isContactSidebarItemOpen('is_shopify_orders_open')"
compact
@toggle="
value => toggleSidebarUIState('is_shopify_orders_open', value)
"
>
<ShopifyOrdersList :contact-id="contactId" />
</AccordionItem>
</div>
</div>
</template>
</Draggable>

View File

@@ -6,6 +6,8 @@ import { useI18n } from 'vue-i18n';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -25,17 +27,21 @@ const { t } = useI18n();
const store = useStore();
const router = useRouter();
const showDeleteConfirmationPopup = ref(false);
const dialogRef = ref(null);
const accountId = computed(() => store.getters.getCurrentAccountId);
const globalConfig = computed(() => store.getters['globalConfig/get']);
const openDeletePopup = () => {
showDeleteConfirmationPopup.value = true;
if (dialogRef.value) {
dialogRef.value.open();
}
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
if (dialogRef.value) {
dialogRef.value.close();
}
};
const deleteIntegration = async () => {
@@ -50,16 +56,18 @@ const deleteIntegration = async () => {
const confirmDeletion = () => {
closeDeletePopup();
deleteIntegration();
router.push({ name: 'settings_integrations' });
router.push({ name: 'settings_applications' });
};
</script>
<template>
<div
class="flex flex-col items-start justify-between md:flex-row md:items-center p-4 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow gap-6"
>
<div class="flex items-center justify-start flex-1 m-0 mx-4 gap-6">
<div class="flex h-16 w-16 items-center justify-center">
<div
class="flex items-start lg:items-center justify-start flex-1 m-0 gap-6 flex-col lg:flex-row"
>
<div class="flex h-16 w-16 items-center justify-center flex-shrink-0">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
@@ -83,7 +91,7 @@ const confirmDeletion = () => {
</p>
</div>
</div>
<div class="flex justify-center items-center mb-0 w-[15%]">
<div class="flex justify-center items-center mb-0">
<router-link
:to="
frontendURL(
@@ -105,33 +113,37 @@ const confirmDeletion = () => {
</div>
<div v-else>
<NextButton faded blue>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.CONFIGURE') }}
{{ t('INTEGRATION_SETTINGS.WEBHOOK.CONFIGURE') }}
</NextButton>
</div>
</div>
</router-link>
<div v-if="!integrationEnabled">
<a :href="integrationAction">
<NextButton faded blue>
{{ $t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT') }}
</NextButton>
</a>
<slot name="action">
<a :href="integrationAction">
<NextButton faded blue>
{{ t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT') }}
</NextButton>
</a>
</slot>
</div>
</div>
<woot-delete-modal
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
<Dialog
ref="dialogRef"
type="alert"
:title="
deleteConfirmationText.title ||
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.TITLE')
t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.TITLE')
"
:message="
:description="
deleteConfirmationText.message ||
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE')
t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE')
"
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
:confirm-button-label="
t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')
"
:cancel-button-label="t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
@confirm="confirmDeletion"
/>
</div>
</template>

View File

@@ -0,0 +1,151 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
import integrationAPI from 'dashboard/api/integrations';
import Input from 'dashboard/components-next/input/Input.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
defineProps({
error: {
type: String,
default: '',
},
});
const store = useStore();
const dialogRef = ref(null);
const integrationLoaded = ref(false);
const storeUrl = ref('');
const isSubmitting = ref(false);
const storeUrlError = ref('');
const integration = useFunctionGetter('integrations/getIntegration', 'shopify');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return 'connect';
});
const hideStoreUrlModal = () => {
storeUrl.value = '';
storeUrlError.value = '';
isSubmitting.value = false;
};
const validateStoreUrl = url => {
const pattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
return pattern.test(url);
};
const openStoreUrlDialog = () => {
if (dialogRef.value) {
dialogRef.value.open();
}
};
const handleStoreUrlSubmit = async () => {
try {
storeUrlError.value = '';
if (!validateStoreUrl(storeUrl.value)) {
storeUrlError.value =
'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)';
return;
}
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 {
isSubmitting.value = false;
}
};
const initializeShopifyIntegration = async () => {
await store.dispatch('integrations/get', 'shopify');
integrationLoaded.value = true;
};
onMounted(() => {
initializeShopifyIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto max-w-6xl mx-auto">
<div
v-if="integrationLoaded && !uiFlags.isCreatingShopify"
class="flex flex-col gap-6"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SHOPIFY.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.SHOPIFY.DELETE.MESSAGE'),
}"
>
<template #action>
<button
class="rounded button success nice"
@click="openStoreUrlDialog"
>
{{ $t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT') }}
</button>
</template>
</Integration>
<div
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"
>
<p class="text-red-500">
{{ $t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
</p>
</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 v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>

View File

@@ -8,6 +8,8 @@ import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Shopify from './Shopify.vue';
export default {
routes: [
{
@@ -88,6 +90,16 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'shopify',
name: 'settings_integrations_shopify',
component: Shopify,
meta: {
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
permissions: ['administrator'],
},
props: route => ({ error: route.query.error }),
},
{
path: ':integration_id',
name: 'settings_applications_integration',

View File

@@ -48,7 +48,9 @@ class Integrations::App
when 'slack'
ENV['SLACK_CLIENT_SECRET'].present?
when 'linear'
account.feature_enabled?('linear_integration')
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
else
true
end

View File

@@ -149,6 +149,10 @@
</symbol>
<symbol id="icon-linear" viewBox="0 0 24 24">
<path d="M0.294 14.765c-0.053-0.228 0.218-0.371 0.383-0.206l8.762 8.762c0.165 0.165 0.022 0.436-0.206 0.383C4.812 22.668 1.332 19.188 0.294 14.765zM0 11.253c-0.004 0.068 0.021 0.134 0.07 0.183l12.494 12.494c0.048 0.048 0.115 0.074 0.183 0.07c0.568-0.035 1.127-0.11 1.671-0.222c0.183-0.038 0.247-0.263 0.115-0.396l-13.847-13.847c-0.132-0.132-0.358-0.068-0.396 0.115c-0.112 0.544-0.187 1.102-0.222 1.671zM1.011 7.129c-0.04 0.09-0.02 0.195 0.05 0.264l15.546 15.546c0.069 0.069 0.174 0.09 0.264 0.05c0.429-0.191 0.844-0.406 1.244-0.644c0.133-0.079 0.153-0.261 0.044-0.37l-16.134-16.134c-0.109-0.109-0.291-0.089-0.37 0.044c-0.238 0.4-0.453 0.816-0.644 1.244zM3.038 4.338c-0.089-0.089-0.094-0.231-0.011-0.325c2.2-2.46 5.4-4.013 8.973-4.013 6.627 0 12 5.373 12 12c0 3.562-1.55 6.76-4.013 8.961c-0.094 0.084-0.236 0.078-0.325-0.011l-16.624-16.612z"/>
</symbol>
<path d="M0.294 14.765c-0.053-0.228 0.218-0.371 0.383-0.206l8.762 8.762c0.165 0.165 0.022 0.436-0.206 0.383C4.812 22.668 1.332 19.188 0.294 14.765zM0 11.253c-0.004 0.068 0.021 0.134 0.07 0.183l12.494 12.494c0.048 0.048 0.115 0.074 0.183 0.07c0.568-0.035 1.127-0.11 1.671-0.222c0.183-0.038 0.247-0.263 0.115-0.396l-13.847-13.847c-0.132-0.132-0.358-0.068-0.396 0.115c-0.112 0.544-0.187 1.102-0.222 1.671zM1.011 7.129c-0.04 0.09-0.02 0.195 0.05 0.264l15.546 15.546c0.069 0.069 0.174 0.09 0.264 0.05c0.429-0.191 0.844-0.406 1.244-0.644c0.133-0.079 0.153-0.261 0.044-0.37l-16.134-16.134c-0.109-0.109-0.291-0.089-0.37 0.044c-0.238 0.4-0.453 0.816-0.644 1.244zM3.038 4.338c-0.089-0.089-0.094-0.231-0.011-0.325c2.2-2.46 5.4-4.013 8.973-4.013 6.627 0 12 5.373 12 12c0 3.562-1.55 6.76-4.013 8.961c-0.094 0.084-0.236 0.078-0.325-0.011l-16.624-16.612z"/>
</symbol>
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -154,6 +154,10 @@
display_name: Contact Chatwoot Support Team
enabled: true
chatwoot_internal: true
- name: shopify_integration
display_name: Shopify Integration
enabled: false
chatwoot_internal: true
- name: search_with_gin
display_name: Search messages with GIN
enabled: false

View File

@@ -279,3 +279,16 @@
description: 'Linear client secret'
type: secret
## ------ End of Configs added for Linear ------ ##
# ------- Shopify Integration Config ------- #
- name: SHOPIFY_CLIENT_ID
display_title: 'Shopify Client ID'
description: 'The Client ID (API Key) from your Shopify Partner account'
locked: false
type: secret
- name: SHOPIFY_CLIENT_SECRET
display_title: 'Shopify Client Secret'
description: 'The Client Secret (API Secret Key) from your Shopify Partner account'
locked: false
type: secret
# ------- End of Shopify Related Config ------- #

View File

@@ -179,3 +179,10 @@ dyte:
},
]
visible_properties: ['organization_id']
shopify:
id: shopify
logo: shopify.png
i18n_key: shopify
hook_type: account
allow_multiple_hooks: false

View File

@@ -231,6 +231,9 @@ en:
linear:
name: 'Linear'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
shopify:
name: 'Shopify'
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
captain:
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'

View File

@@ -233,6 +233,12 @@ Rails.application.routes.draw do
post :add_participant_to_meeting
end
end
resource :shopify, controller: 'shopify', only: [:destroy] do
collection do
post :auth
get :orders
end
end
resource :linear, controller: 'linear', only: [] do
collection do
delete :destroy
@@ -457,6 +463,10 @@ Rails.application.routes.draw do
resource :callback, only: [:show]
end
namespace :shopify do
resource :callback, only: [:show]
end
namespace :twilio do
resources :callback, only: [:create]
resources :delivery_status, only: [:create]

View File

@@ -85,3 +85,9 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,187 @@
require 'rails_helper'
# Stub class for ShopifyAPI response
class ShopifyAPIResponse
attr_reader :body
def initialize(body)
@body = body
end
end
RSpec.describe 'Shopify Integration API', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:unauthorized_agent) { create(:user, account: account, role: :agent) }
let(:contact) { create(:contact, account: account, email: 'test@example.com', phone_number: '+1234567890') }
describe 'POST /api/v1/accounts/:account_id/integrations/shopify/auth' do
let(:shop_domain) { 'test-store.myshopify.com' }
context 'when it is an authenticated user' do
it 'returns a redirect URL for Shopify OAuth' do
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
params: { shop_domain: shop_domain },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to have_key('redirect_url')
expect(response.parsed_body['redirect_url']).to include(shop_domain)
end
it 'returns error when shop domain is missing' do
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Shop domain is required')
end
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
params: { shop_domain: shop_domain },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'GET /api/v1/accounts/:account_id/integrations/shopify/orders' do
before do
create(:integrations_hook, :shopify, account: account)
end
context 'when it is an authenticated user' do
# rubocop:disable RSpec/AnyInstance
let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
let(:customers_response) do
instance_double(
ShopifyAPIResponse,
body: { 'customers' => [{ 'id' => '123' }] }
)
end
let(:orders_response) do
instance_double(
ShopifyAPIResponse,
body: {
'orders' => [{
'id' => '456',
'email' => 'test@example.com',
'created_at' => Time.now.iso8601,
'total_price' => '100.00',
'currency' => 'USD',
'fulfillment_status' => 'fulfilled',
'financial_status' => 'paid'
}]
}
)
end
before do
allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:shopify_client).and_return(shopify_client)
allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:client_id).and_return('test_client_id')
allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:client_secret).and_return('test_client_secret')
allow(shopify_client).to receive(:get).with(
path: 'customers/search.json',
query: { query: "email:#{contact.email} OR phone:#{contact.phone_number}", fields: 'id,email,phone' }
).and_return(customers_response)
allow(shopify_client).to receive(:get).with(
path: 'orders.json',
query: { customer_id: '123', status: 'any', fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status' }
).and_return(orders_response)
end
it 'returns orders for the contact' do
get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
params: { contact_id: contact.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to have_key('orders')
expect(response.parsed_body['orders'].length).to eq(1)
expect(response.parsed_body['orders'][0]['id']).to eq('456')
end
it 'returns error when contact has no email or phone' do
contact_without_info = create(:contact, account: account)
get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
params: { contact_id: contact_without_info.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Contact information missing')
end
it 'returns empty array when no customers found' do
empty_customers_response = instance_double(
ShopifyAPIResponse,
body: { 'customers' => [] }
)
allow(shopify_client).to receive(:get).with(
path: 'customers/search.json',
query: { query: "email:#{contact.email} OR phone:#{contact.phone_number}", fields: 'id,email,phone' }
).and_return(empty_customers_response)
get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
params: { contact_id: contact.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body['orders']).to eq([])
end
# rubocop:enable RSpec/AnyInstance
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
params: { contact_id: contact.id },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
before do
create(:integrations_hook, :shopify, account: account)
end
context 'when it is an authenticated user' do
it 'deletes the shopify integration' do
expect do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
headers: agent.create_new_auth_token,
as: :json
end.to change { account.hooks.count }.by(-1)
expect(response).to have_http_status(:ok)
end
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View File

@@ -0,0 +1,109 @@
require 'rails_helper'
RSpec.describe Shopify::CallbacksController, type: :request do
let(:account) { create(:account) }
let(:code) { SecureRandom.hex(10) }
let(:state) { SecureRandom.hex(10) }
let(:shop) { 'my-store.myshopify.com' }
let(:frontend_url) { 'http://www.example.com' }
let(:shopify_redirect_uri) { "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify" }
let(:oauth_client) { instance_double(OAuth2::Client) }
let(:auth_code_strategy) { instance_double(OAuth2::Strategy::AuthCode) }
describe 'GET /shopify/callback' do
let(:access_token) { SecureRandom.hex(10) }
let(:response_body) do
{
'access_token' => access_token,
'scope' => 'read_products,write_products'
}
end
before do
stub_const('ENV', ENV.to_hash.merge('FRONTEND_URL' => frontend_url))
end
context 'when successful' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
allow(described_class).to receive(:new).and_return(controller)
stub_request(:post, "https://#{shop}/admin/oauth/access_token")
.to_return(
status: 200,
body: response_body.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates a new integration hook' do
expect do
get shopify_callback_path, params: { code: code, state: state, shop: shop }
end.to change(Integrations::Hook, :count).by(1)
hook = Integrations::Hook.last
expect(hook.access_token).to eq(access_token)
expect(hook.app_id).to eq('shopify')
expect(hook.status).to eq('enabled')
expect(hook.reference_id).to eq(shop)
expect(hook.settings).to eq(
'scope' => 'read_products,write_products'
)
expect(response).to redirect_to(shopify_redirect_uri)
end
end
context 'when the code is missing' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
allow(controller).to receive(:oauth_client).and_return(oauth_client)
allow(oauth_client).to receive(:auth_code).and_raise(StandardError)
allow(described_class).to receive(:new).and_return(controller)
end
it 'redirects to the shopify_redirect_uri with error' do
get shopify_callback_path, params: { state: state, shop: shop }
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
end
end
context 'when the token is invalid' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
allow(controller).to receive(:oauth_client).and_return(oauth_client)
allow(oauth_client).to receive(:auth_code).and_return(auth_code_strategy)
allow(auth_code_strategy).to receive(:get_token).and_raise(
OAuth2::Error.new(
OpenStruct.new(
parsed: { 'error' => 'invalid_grant' },
status: 400
)
)
)
allow(described_class).to receive(:new).and_return(controller)
end
it 'redirects to the shopify_redirect_uri with error' do
get shopify_callback_path, params: { code: code, state: state, shop: shop }
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
end
end
context 'when state parameter is invalid' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(nil)
allow(controller).to receive(:account).and_return(nil)
allow(described_class).to receive(:new).and_return(controller)
end
it 'redirects to the frontend URL with error' do
get shopify_callback_path, params: { code: code, state: state, shop: shop }
expect(response).to redirect_to("#{frontend_url}?error=true")
end
end
end
end

View File

@@ -31,5 +31,11 @@ FactoryBot.define do
app_id { 'linear' }
access_token { SecureRandom.hex }
end
trait :shopify do
app_id { 'shopify' }
access_token { SecureRandom.hex }
reference_id { 'test-store.myshopify.com' }
end
end
end

View File

@@ -0,0 +1,95 @@
require 'rails_helper'
RSpec.describe Shopify::IntegrationHelper do
include described_class
describe '#generate_shopify_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:current_time) { Time.current }
before do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil).and_return(client_secret)
allow(Time).to receive(:current).and_return(current_time)
end
it 'generates a valid JWT token with correct payload' do
token = generate_shopify_token(account_id)
decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first
expect(decoded_token['sub']).to eq(account_id)
expect(decoded_token['iat']).to eq(current_time.to_i)
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(generate_shopify_token(account_id)).to be_nil
end
end
context 'when an error occurs' do
before do
allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error'))
end
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with('Failed to generate Shopify token: Test error')
expect(generate_shopify_token(account_id)).to be_nil
end
end
end
describe '#verify_shopify_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:valid_token) do
JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256')
end
before do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil).and_return(client_secret)
end
it 'successfully verifies and returns account_id from valid token' do
expect(verify_shopify_token(valid_token)).to eq(account_id)
end
context 'when token is blank' do
it 'returns nil' do
expect(verify_shopify_token('')).to be_nil
expect(verify_shopify_token(nil)).to be_nil
end
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(verify_shopify_token(valid_token)).to be_nil
end
end
context 'when token is invalid' do
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Shopify token:/)
expect(verify_shopify_token('invalid_token')).to be_nil
end
end
end
describe '#client_id' do
it 'loads client_id from GlobalConfigService' do
expect(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil)
client_id
end
end
describe '#client_secret' do
it 'loads client_secret from GlobalConfigService' do
expect(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil)
client_secret
end
end
end

View File

@@ -51,17 +51,38 @@ RSpec.describe Integrations::App do
end
end
context 'when the app is linear' do
let(:app_name) { 'linear' }
context 'when the app is shopify' do
let(:app_name) { 'shopify' }
it 'returns true if the linear integration feature is disabled' do
it 'returns true if the shopify integration feature is enabled' do
account.enable_features('shopify_integration')
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be true
end
it 'returns false if the shopify integration feature is disabled' do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be false
end
it 'returns false if the linear integration feature is enabled' do
it 'returns false if SHOPIFY_CLIENT_ID is not present, even if feature is enabled' do
account.enable_features('shopify_integration')
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return(nil)
expect(app.active?(account)).to be false
end
end
context 'when the app is linear' do
let(:app_name) { 'linear' }
it 'returns false if the linear integration feature is disabled' do
expect(app.active?(account)).to be false
end
it 'returns true if the linear integration feature is enabled' do
account.enable_features('linear_integration')
account.save!
allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be true
end
end