chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
import { productSchema, customerSchema, orderSchema, pageInfoSchema } from './schemas'
|
||||
|
||||
export const actions = {
|
||||
listProducts: {
|
||||
title: 'List Products',
|
||||
description: 'Search and list products from the Shopify Admin API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
query: z.string().optional().title('Search Query').describe('Search query to filter products'),
|
||||
first: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(250)
|
||||
.default(50)
|
||||
.optional()
|
||||
.title('Limit')
|
||||
.describe('Number of products to return'),
|
||||
after: z.string().optional().title('After Cursor').describe('Cursor for pagination'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
products: z.array(productSchema).title('Products').describe('List of products'),
|
||||
pageInfo: pageInfoSchema.title('Page Info').describe('Pagination info'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
getProduct: {
|
||||
title: 'Get Product',
|
||||
description: 'Get a single product with its variants from the Shopify Admin API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
productId: z
|
||||
.string()
|
||||
.title('Product ID')
|
||||
.describe('The Shopify GID of the product (e.g. gid://shopify/Product/12345)'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
product: productSchema.title('Product').describe('The product details'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
searchCustomers: {
|
||||
title: 'Search Customers',
|
||||
description: 'Search for customers by email, name, or phone in the Shopify Admin API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
query: z.string().title('Search Query').describe('Search query (email, name, or phone)'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
customers: z.array(customerSchema).title('Customers').describe('List of matching customers'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
getOrder: {
|
||||
title: 'Get Order',
|
||||
description: 'Get full order details by ID from the Shopify Admin API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
orderId: z.string().title('Order ID').describe('The Shopify GID of the order (e.g. gid://shopify/Order/12345)'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
order: orderSchema.title('Order').describe('The order details'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
listCustomerOrders: {
|
||||
title: 'List Customer Orders',
|
||||
description: 'List orders for a specific customer, optionally filtered by status',
|
||||
input: {
|
||||
schema: z.object({
|
||||
customerId: z
|
||||
.string()
|
||||
.title('Customer ID')
|
||||
.describe('The Shopify GID of the customer (e.g. gid://shopify/Customer/12345)'),
|
||||
status: z
|
||||
.enum(['open', 'closed', 'cancelled', 'any'])
|
||||
.default('any')
|
||||
.optional()
|
||||
.title('Status Filter')
|
||||
.describe('Filter orders by status'),
|
||||
first: z.number().min(1).max(250).default(50).optional().title('Limit').describe('Number of orders to return'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
orders: z.array(orderSchema).title('Orders').describe('List of customer orders'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['actions']
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
import { orderEventSchema } from './schemas'
|
||||
|
||||
export const events = {
|
||||
orderCreated: {
|
||||
title: 'Order Created',
|
||||
description: 'Triggered when a new order is placed',
|
||||
schema: orderEventSchema,
|
||||
},
|
||||
orderUpdated: {
|
||||
title: 'Order Updated',
|
||||
description: 'Triggered when an order is modified',
|
||||
schema: orderEventSchema,
|
||||
},
|
||||
orderCancelled: {
|
||||
title: 'Order Cancelled',
|
||||
description: 'Triggered when an order is cancelled',
|
||||
schema: orderEventSchema,
|
||||
},
|
||||
orderFulfilled: {
|
||||
title: 'Order Fulfilled',
|
||||
description: 'Triggered when all items in an order are fulfilled',
|
||||
schema: orderEventSchema,
|
||||
},
|
||||
orderPaid: {
|
||||
title: 'Order Paid',
|
||||
description: 'Triggered when payment for an order is confirmed',
|
||||
schema: orderEventSchema,
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['events']
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export { actions } from './actions'
|
||||
export { events } from './events'
|
||||
export { states } from './states'
|
||||
export * as schemas from './schemas'
|
||||
|
||||
export const configuration = {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
},
|
||||
schema: z.object({}),
|
||||
}
|
||||
|
||||
export const secrets = {
|
||||
SHOPIFY_CLIENT_ID: { description: 'The Client ID of the Shopify app' },
|
||||
SHOPIFY_CLIENT_SECRET: { description: 'The Client Secret of the Shopify app' },
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const productVariantSchema = z.object({
|
||||
id: z.string().title('Variant ID').describe('The Shopify GID of the product variant'),
|
||||
title: z.string().title('Title').describe('The title of the variant'),
|
||||
price: z.string().title('Price').describe('The price of the variant'),
|
||||
sku: z.string().optional().title('SKU').describe('The SKU of the variant'),
|
||||
inventoryQuantity: z.number().optional().title('Inventory Quantity').describe('The available inventory'),
|
||||
})
|
||||
|
||||
export const productSchema = z.object({
|
||||
id: z.string().title('Product ID').describe('The Shopify GID of the product'),
|
||||
title: z.string().title('Title').describe('The title of the product'),
|
||||
handle: z.string().title('Handle').describe('The URL-friendly handle of the product'),
|
||||
status: z.string().title('Status').describe('The status of the product (ACTIVE, ARCHIVED, DRAFT)'),
|
||||
vendor: z.string().optional().title('Vendor').describe('The vendor of the product'),
|
||||
productType: z.string().optional().title('Product Type').describe('The product type'),
|
||||
descriptionHtml: z.string().optional().title('Description HTML').describe('The HTML description'),
|
||||
createdAt: z.string().title('Created At').describe('When the product was created'),
|
||||
updatedAt: z.string().title('Updated At').describe('When the product was last updated'),
|
||||
storefrontUrl: z
|
||||
.string()
|
||||
.title('Storefront URL')
|
||||
.describe("Canonical storefront URL on the shop's myshopify.com domain — always populated"),
|
||||
onlineStoreUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Online Store URL')
|
||||
.describe(
|
||||
'Published Online Store URL (may use a custom domain). Undefined if the product is not published to the Online Store sales channel'
|
||||
),
|
||||
onlineStorePreviewUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Online Store Preview URL')
|
||||
.describe('Preview URL that works even when the product is not published'),
|
||||
variants: z.array(productVariantSchema).title('Variants').describe('The product variants'),
|
||||
})
|
||||
|
||||
export const customerSchema = z.object({
|
||||
id: z.string().title('Customer ID').describe('The Shopify GID of the customer'),
|
||||
firstName: z.string().optional().title('First Name').describe('The first name of the customer'),
|
||||
lastName: z.string().optional().title('Last Name').describe('The last name of the customer'),
|
||||
email: z.string().optional().title('Email').describe('The email address of the customer'),
|
||||
phone: z.string().optional().title('Phone').describe('The phone number of the customer'),
|
||||
numberOfOrders: z.number().optional().title('Number of Orders').describe('Total number of orders'),
|
||||
amountSpent: z.string().optional().title('Amount Spent').describe('Total amount spent'),
|
||||
createdAt: z.string().title('Created At').describe('When the customer was created'),
|
||||
updatedAt: z.string().title('Updated At').describe('When the customer was last updated'),
|
||||
})
|
||||
|
||||
export const orderLineItemSchema = z.object({
|
||||
title: z.string().title('Title').describe('The title of the line item'),
|
||||
quantity: z.number().title('Quantity').describe('The quantity ordered'),
|
||||
variant: productVariantSchema.optional().title('Variant').describe('The associated product variant'),
|
||||
})
|
||||
|
||||
export const orderSchema = z.object({
|
||||
id: z.string().title('Order ID').describe('The Shopify GID of the order'),
|
||||
name: z.string().title('Order Number').describe('The order number (e.g. #1001)'),
|
||||
email: z.string().optional().title('Email').describe('The email associated with the order'),
|
||||
phone: z.string().optional().title('Phone').describe('The phone number associated with the order'),
|
||||
createdAt: z.string().title('Created At').describe('When the order was created'),
|
||||
updatedAt: z.string().title('Updated At').describe('When the order was last updated'),
|
||||
cancelledAt: z.string().optional().title('Cancelled At').describe('When the order was cancelled'),
|
||||
closedAt: z.string().optional().title('Closed At').describe('When the order was closed'),
|
||||
financialStatus: z.string().title('Financial Status').describe('The financial status of the order'),
|
||||
fulfillmentStatus: z.string().optional().title('Fulfillment Status').describe('The fulfillment status'),
|
||||
totalPrice: z.string().title('Total Price').describe('The total price of the order'),
|
||||
currencyCode: z.string().title('Currency Code').describe('The currency code (e.g. USD)'),
|
||||
lineItems: z.array(orderLineItemSchema).title('Line Items').describe('The order line items'),
|
||||
customer: customerSchema.optional().title('Customer').describe('The customer who placed the order'),
|
||||
})
|
||||
|
||||
export const orderEventSchema = z.object({
|
||||
id: z.string().title('Order ID').describe('The Shopify order ID'),
|
||||
name: z.string().title('Order Number').describe('The order number (e.g. #1001)'),
|
||||
email: z.string().optional().title('Email').describe('The email associated with the order'),
|
||||
financialStatus: z.string().title('Financial Status').describe('The financial status'),
|
||||
fulfillmentStatus: z.string().optional().title('Fulfillment Status').describe('The fulfillment status'),
|
||||
totalPrice: z.string().title('Total Price').describe('The total price of the order'),
|
||||
currencyCode: z.string().title('Currency Code').describe('The currency code'),
|
||||
createdAt: z.string().title('Created At').describe('When the order was created'),
|
||||
updatedAt: z.string().title('Updated At').describe('When the order was last updated'),
|
||||
})
|
||||
|
||||
export const pageInfoSchema = z.object({
|
||||
hasNextPage: z.boolean().title('Has Next Page').describe('Whether there are more results'),
|
||||
endCursor: z.string().optional().title('End Cursor').describe('Cursor for the next page'),
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export const states = {
|
||||
credentials: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
shopDomain: z.string().optional().title('Shop Domain').describe('The myshopify.com domain of the store'),
|
||||
accessToken: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Admin Access Token')
|
||||
.describe('Shopify Admin API expiring offline access token (60-min TTL)'),
|
||||
refreshToken: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Admin Refresh Token')
|
||||
.describe(
|
||||
'Used to obtain a new access token without merchant interaction. Expires after 90 days; merchant must re-authorize when this expires.'
|
||||
),
|
||||
accessTokenExpiresAtSeconds: z
|
||||
.number()
|
||||
.optional()
|
||||
.title('Access Token Expires At (s)')
|
||||
.describe('Unix epoch seconds at which the access token expires.'),
|
||||
refreshTokenExpiresAtSeconds: z
|
||||
.number()
|
||||
.optional()
|
||||
.title('Refresh Token Expires At (s)')
|
||||
.describe('Unix epoch seconds at which the refresh token expires.'),
|
||||
webhookSubscriptionIds: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.title('Webhook Subscription IDs')
|
||||
.describe('GIDs of webhook subscriptions created during register; used by unregister for cleanup'),
|
||||
}),
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['states']
|
||||
@@ -0,0 +1,36 @@
|
||||
Connect your Botpress chatbot with the Shopify Admin API to give your bot back-office access to your store: browse products, look up orders, and search customers. Order webhooks fire in real time so your bot can react to new, updated, cancelled, fulfilled, and paid orders.
|
||||
|
||||
For the public-facing shopping experience (product browsing, collections, cart/checkout) use the separate **Shopify Storefront** integration.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the Shopify Admin integration in your bot.
|
||||
2. Enter your Shopify store domain (e.g. `my-store.myshopify.com`) when prompted.
|
||||
3. Click **Authorize** to connect via OAuth. You will be redirected to Shopify to grant permissions.
|
||||
|
||||
Once authorized, the integration registers webhooks for order events. No additional configuration is required.
|
||||
|
||||
## Actions
|
||||
|
||||
These actions use the Shopify Admin API to access back-office data such as internal product details, customer records, and order history.
|
||||
|
||||
- **List Products** — Search and list products with optional query filtering and cursor-based pagination.
|
||||
- **Get Product** — Retrieve a single product and its variants by Shopify GID (e.g. `gid://shopify/Product/12345`).
|
||||
- **Search Customers** — Search for customers by email, name, or phone number.
|
||||
- **Get Order** — Retrieve full order details including line items and customer information by order GID.
|
||||
- **List Customer Orders** — List orders for a specific customer, optionally filtered by status (`open`, `closed`, `cancelled`, or `any`).
|
||||
|
||||
## Events
|
||||
|
||||
The integration automatically listens for Shopify order webhooks. Your bot can respond to the following events:
|
||||
|
||||
- **Order Created** — Triggered when a new order is placed.
|
||||
- **Order Updated** — Triggered when an order is modified.
|
||||
- **Order Cancelled** — Triggered when an order is cancelled.
|
||||
- **Order Fulfilled** — Triggered when all items in an order are fulfilled.
|
||||
- **Order Paid** — Triggered when payment for an order is confirmed.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only order-related webhook events are currently supported. Product, customer, and inventory webhooks are not available in this version.
|
||||
- Pagination uses cursor-based navigation. To retrieve the next page of results, pass the `after` cursor from the previous response's `pageInfo`.
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<g clip-path="url(#clip0_1_44884)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5096 2.49624C14.5748 2.4912 14.6391 2.48954 14.7011 2.49102C14.8995 2.49574 15.3828 2.53821 15.7734 2.92895C15.827 2.9825 16.1701 3.3173 16.5121 3.65059L16.8304 3.96056L17.1314 3.96728C17.328 3.97163 17.5269 3.97596 17.6819 3.97921L17.8744 3.9831C17.9095 3.98377 17.93 3.9841 17.9396 3.98426C17.9455 3.98435 17.946 3.98438 17.946 3.98438C18.6564 3.98438 19.2187 4.5142 19.3274 5.16651C19.3336 5.20399 19.5261 6.50315 19.7986 8.34253C20.0084 9.75847 20.2657 11.4945 20.5219 13.2216C20.8175 15.2142 21.1131 17.2038 21.3358 18.6961C21.4472 19.4424 21.5402 20.0637 21.6059 20.4987C21.6388 20.7164 21.6647 20.8862 21.6825 21.0013C21.6915 21.0593 21.698 21.1009 21.7023 21.1271C21.7028 21.1301 21.7032 21.1327 21.7035 21.1349C21.7391 21.3162 21.7332 21.5063 21.682 21.6906C21.5654 22.1098 21.2321 22.4337 20.8098 22.5383L15.0539 23.9646C14.8917 24.0048 14.7229 24.0109 14.5582 23.9824L1.27263 21.6864C0.63518 21.5763 0.198055 20.9829 0.281743 20.3415L1.47809 20.4976C0.281743 20.3415 0.281743 20.3415 0.281743 20.3415L0.532523 18.4187C0.68332 17.2623 0.884923 15.7159 1.08815 14.156C1.49555 11.0289 1.90709 7.86559 1.9322 7.65625L1.93237 7.65482L1.93385 7.64264C1.94898 7.51757 1.9706 7.33892 2.01075 7.17513C2.05792 6.98274 2.15174 6.71298 2.37497 6.46245C2.58935 6.22187 2.83599 6.09802 2.99836 6.02906C3.13205 5.97228 3.28313 5.92326 3.41016 5.88317C3.43231 5.87465 3.45233 5.86745 3.46649 5.86243C3.50704 5.84805 3.55658 5.83146 3.60893 5.81429C3.71607 5.77916 3.86557 5.73161 4.04749 5.67461C4.26543 5.60632 4.53564 5.52267 4.84586 5.42743C5.08955 4.50656 5.51875 3.51419 6.0377 2.68922C6.97618 1.18444 8.32732 0.0556484 9.94137 0.0018329L9.94267 0.00178964C10.6511 -0.0210693 11.3273 0.1744 11.8859 0.625778C13.0479 0.74139 13.9489 1.47995 14.5096 2.49624Z" fill="black"/>
|
||||
<path d="M9.99542 1.19856C10.5346 1.18116 10.9873 1.35503 11.3353 1.73762C11.3527 1.77241 11.3872 1.79072 11.4046 1.82551C11.474 1.80815 11.5263 1.80793 11.5784 1.80793C12.3786 1.80793 13.0577 2.27711 13.5101 3.1468C13.6492 3.42499 13.753 3.68635 13.8226 3.91242C13.9905 3.86132 14.1313 3.81984 14.2376 3.7884V22.7034L1.47101 20.4925C1.47101 20.4925 3.05407 8.22765 3.15851 7.79231C3.2281 7.2182 3.2459 7.20047 3.8548 7.0091C3.88128 6.98907 4.71392 6.72882 5.90753 6.36555C6.02933 5.44367 6.48124 4.26091 7.07257 3.3216C7.90758 1.9821 8.95165 1.23343 9.99542 1.19856ZM9.85675 7.93098C6.93426 8.12233 5.61137 10.158 5.75031 12.1585C5.90687 14.5592 8.30758 14.4728 8.39484 15.9339C8.41223 16.2818 8.18633 16.7868 7.57745 16.8216C6.63801 16.8912 5.47198 16.0042 5.47198 16.0042L5.01984 17.9173C5.01984 17.9173 6.18556 19.1697 8.30792 19.0306C10.065 18.9262 11.2832 17.5169 11.1614 15.4466C10.9872 12.8201 8.04766 12.5764 7.97784 11.4632C7.96045 11.2718 7.97757 10.4545 9.26495 10.3675C10.1522 10.2979 10.9007 10.6458 10.9007 10.6458L11.5618 8.13996C11.5618 8.13996 10.9873 7.86147 9.85675 7.93098ZM10.013 1.89485C8.39507 1.94706 6.98576 4.46966 6.62042 6.12239C7.19453 5.94841 7.82127 5.75711 8.44757 5.56574C8.65634 4.50452 9.1605 3.39036 9.82159 2.67707C10.0824 2.41627 10.3608 2.19025 10.639 2.0511C10.4651 1.94683 10.2564 1.89485 10.013 1.89485ZM11.0745 2.60774C10.7789 2.74684 10.5178 2.97307 10.3265 3.16438C9.8394 3.70364 9.42198 4.52159 9.2132 5.33918C9.96113 5.11306 10.7264 4.8692 11.4222 4.66047C11.4395 4.08637 11.3702 3.23392 11.0745 2.60774ZM11.805 2.55598C12.0485 3.18223 12.1185 3.93061 12.1185 4.45246C12.5009 4.33077 12.8485 4.22593 13.1614 4.13899C12.9875 3.5997 12.6052 2.71261 11.805 2.55598Z" fill="#63F44C"/>
|
||||
<path d="M16.3451 5.14722C16.3451 5.14722 17.8755 5.18235 17.9633 5.18237C18.0502 5.18237 18.1373 5.25182 18.1547 5.3562C18.1727 5.46463 20.5209 21.3445 20.5209 21.3445L15.4437 22.6042V4.27026C15.8438 4.66042 16.3327 5.13526 16.3451 5.14722Z" fill="#63F44C"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_44884">
|
||||
<rect width="22" height="24" rx="3.75" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,21 @@
|
||||
import { IntegrationDefinition } from '@botpress/sdk'
|
||||
import { actions, events, states, configuration, secrets } from './definitions'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'shopify-admin',
|
||||
version: '0.1.3',
|
||||
title: 'Shopify Admin',
|
||||
description:
|
||||
'Connect your Shopify store via the Admin GraphQL API to manage products, customers, and orders via OAuth 2.0.',
|
||||
icon: 'icon.svg',
|
||||
readme: 'hub.md',
|
||||
configuration,
|
||||
actions,
|
||||
events,
|
||||
states,
|
||||
secrets,
|
||||
attributes: {
|
||||
category: 'E-commerce & Payments',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@botpresshub/shopify-admin",
|
||||
"description": "Shopify Admin integration for Botpress",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:bplint": "bp lint",
|
||||
"check:type": "tsc --noEmit",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@shopify/cli": "~4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = "87f9bef36e5c65232d4b3dc16d788792"
|
||||
name = "Botpress Admin Connector"
|
||||
application_url = "https://webhook.botpress.cloud/oauth/wizard/start"
|
||||
embedded = false
|
||||
|
||||
[access_scopes]
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||
scopes = "read_customers,read_orders,read_products"
|
||||
optional_scopes = [ ]
|
||||
use_legacy_install_flow = false
|
||||
|
||||
[auth]
|
||||
redirect_urls = [ "https://webhook.botpress.cloud/oauth/wizard/oauth-callback" ]
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-04"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
|
||||
uri = "https://controller.botpress.cloud/v1/interation/shopify-admin"
|
||||
@@ -0,0 +1,22 @@
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = "5dc1bbd9b4da1425005409a89e959df6"
|
||||
name = "Admin Integration Staging"
|
||||
application_url = "https://webhook.botpress.dev/oauth/wizard/start"
|
||||
embedded = false
|
||||
|
||||
[access_scopes]
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||
scopes = "read_customers,read_orders,read_products"
|
||||
optional_scopes = [ ]
|
||||
use_legacy_install_flow = false
|
||||
|
||||
[auth]
|
||||
redirect_urls = [ "https://webhook.botpress.dev/oauth/wizard/oauth-callback" ]
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-04"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
|
||||
uri = "https://controller.botpress.dev/v1/interation/shopify-admin"
|
||||
@@ -0,0 +1,22 @@
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = "87f9bef36e5c65232d4b3dc16d788792"
|
||||
name = "Botpress Admin Connector"
|
||||
application_url = "https://webhook.botpress.cloud/oauth/wizard/start"
|
||||
embedded = false
|
||||
|
||||
[access_scopes]
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||
scopes = "read_customers,read_orders,read_products"
|
||||
optional_scopes = [ ]
|
||||
use_legacy_install_flow = false
|
||||
|
||||
[auth]
|
||||
redirect_urls = [ "https://webhook.botpress.cloud/oauth/wizard/oauth-callback" ]
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-04"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
|
||||
uri = "https://controller.botpress.cloud/v1/interation/shopify-admin"
|
||||
@@ -0,0 +1,59 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { ShopifyClient } from '../client'
|
||||
import { ORDER_QUERY } from '../client/queries/admin'
|
||||
import { transformOrder } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type VariantShape = {
|
||||
id: string
|
||||
title: string
|
||||
price: string
|
||||
sku: string | null
|
||||
inventoryQuantity: number | null
|
||||
}
|
||||
|
||||
type OrderQueryResponse = {
|
||||
order: {
|
||||
id: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
cancelledAt: string | null
|
||||
closedAt: string | null
|
||||
displayFinancialStatus: string | null
|
||||
displayFulfillmentStatus: string | null
|
||||
totalPriceSet: { shopMoney: { amount: string; currencyCode: string } }
|
||||
lineItems: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
title: string
|
||||
quantity: number
|
||||
variant: VariantShape | null
|
||||
}
|
||||
}>
|
||||
}
|
||||
customer: {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export const getOrder: bp.IntegrationProps['actions']['getOrder'] = async ({ input, client, ctx }) => {
|
||||
const shopify = await ShopifyClient.create({ client, ctx })
|
||||
|
||||
const data = await shopify.query<OrderQueryResponse>(ORDER_QUERY, { id: input.orderId })
|
||||
|
||||
if (!data.order) {
|
||||
throw new RuntimeError(`Order not found: ${input.orderId}`)
|
||||
}
|
||||
|
||||
return { order: transformOrder(data.order) }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { ShopifyClient } from '../client'
|
||||
import { PRODUCT_QUERY } from '../client/queries/admin'
|
||||
import { transformProduct } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type ProductQueryResponse = {
|
||||
product: {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
status: string
|
||||
vendor: string | null
|
||||
productType: string | null
|
||||
descriptionHtml: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
onlineStoreUrl: string | null
|
||||
onlineStorePreviewUrl: string | null
|
||||
variants: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
price: string
|
||||
sku: string | null
|
||||
inventoryQuantity: number | null
|
||||
}
|
||||
}>
|
||||
}
|
||||
} | null
|
||||
}
|
||||
|
||||
export const getProduct: bp.IntegrationProps['actions']['getProduct'] = async ({ input, client, ctx }) => {
|
||||
const shopify = await ShopifyClient.create({ client, ctx })
|
||||
|
||||
const data = await shopify.query<ProductQueryResponse>(PRODUCT_QUERY, { id: input.productId })
|
||||
|
||||
if (!data.product) {
|
||||
throw new RuntimeError(`Product not found: ${input.productId}`)
|
||||
}
|
||||
|
||||
return { product: transformProduct(data.product, shopify.shopDomain) }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getOrder } from './get-order'
|
||||
import { getProduct } from './get-product'
|
||||
import { listCustomerOrders } from './list-customer-orders'
|
||||
import { listProducts } from './list-products'
|
||||
import { searchCustomers } from './search-customers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
listProducts,
|
||||
getProduct,
|
||||
searchCustomers,
|
||||
getOrder,
|
||||
listCustomerOrders,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { ShopifyClient } from '../client'
|
||||
import { CUSTOMER_ORDERS_QUERY } from '../client/queries/admin'
|
||||
import { transformOrder } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CustomerOrdersQueryResponse = {
|
||||
customer: {
|
||||
orders: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
cancelledAt: string | null
|
||||
closedAt: string | null
|
||||
displayFinancialStatus: string | null
|
||||
displayFulfillmentStatus: string | null
|
||||
totalPriceSet: { shopMoney: { amount: string; currencyCode: string } }
|
||||
lineItems: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
title: string
|
||||
quantity: number
|
||||
}
|
||||
}>
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
} | null
|
||||
}
|
||||
|
||||
export const listCustomerOrders: bp.IntegrationProps['actions']['listCustomerOrders'] = async ({
|
||||
input,
|
||||
client,
|
||||
ctx,
|
||||
}) => {
|
||||
const shopify = await ShopifyClient.create({ client, ctx })
|
||||
|
||||
const query = input.status && input.status !== 'any' ? `status:${input.status}` : undefined
|
||||
|
||||
const data = await shopify.query<CustomerOrdersQueryResponse>(CUSTOMER_ORDERS_QUERY, {
|
||||
customerId: input.customerId,
|
||||
first: input.first ?? 50,
|
||||
query,
|
||||
})
|
||||
|
||||
if (!data.customer) {
|
||||
throw new RuntimeError(`Customer not found: ${input.customerId}`)
|
||||
}
|
||||
|
||||
return { orders: data.customer.orders.edges.map(({ node }) => transformOrder(node)) }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ShopifyClient } from '../client'
|
||||
import { PRODUCTS_QUERY } from '../client/queries/admin'
|
||||
import { transformProduct } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type ProductsQueryResponse = {
|
||||
products: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
status: string
|
||||
vendor: string | null
|
||||
productType: string | null
|
||||
descriptionHtml: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
onlineStoreUrl: string | null
|
||||
onlineStorePreviewUrl: string | null
|
||||
variants: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
price: string
|
||||
sku: string | null
|
||||
inventoryQuantity: number | null
|
||||
}
|
||||
}>
|
||||
}
|
||||
}
|
||||
}>
|
||||
pageInfo: {
|
||||
hasNextPage: boolean
|
||||
endCursor: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const listProducts: bp.IntegrationProps['actions']['listProducts'] = async ({ input, client, ctx }) => {
|
||||
const shopify = await ShopifyClient.create({ client, ctx })
|
||||
|
||||
const data = await shopify.query<ProductsQueryResponse>(PRODUCTS_QUERY, {
|
||||
first: input.first ?? 50,
|
||||
query: input.query,
|
||||
after: input.after,
|
||||
})
|
||||
|
||||
const products = data.products.edges.map(({ node }) => transformProduct(node, shopify.shopDomain))
|
||||
|
||||
return {
|
||||
products,
|
||||
pageInfo: {
|
||||
hasNextPage: data.products.pageInfo.hasNextPage,
|
||||
endCursor: data.products.pageInfo.endCursor ?? undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ShopifyClient } from '../client'
|
||||
import { CUSTOMERS_QUERY } from '../client/queries/admin'
|
||||
import { transformCustomer } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CustomersQueryResponse = {
|
||||
customers: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
numberOfOrders: string | null
|
||||
amountSpent: { amount: string; currencyCode: string } | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export const searchCustomers: bp.IntegrationProps['actions']['searchCustomers'] = async ({ input, client, ctx }) => {
|
||||
const shopify = await ShopifyClient.create({ client, ctx })
|
||||
|
||||
const data = await shopify.query<CustomersQueryResponse>(CUSTOMERS_QUERY, { query: input.query })
|
||||
|
||||
return { customers: data.customers.edges.map(({ node }) => transformCustomer(node)) }
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id'
|
||||
process.env.SECRET_SHOPIFY_CLIENT_SECRET = 'test-client-secret'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const _expiringResponse = (overrides: Record<string, unknown> = {}) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'shpat_a',
|
||||
refresh_token: 'shprt_r',
|
||||
expires_in: 3600,
|
||||
refresh_token_expires_in: 7776000,
|
||||
scope: 'read_products',
|
||||
...overrides,
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
|
||||
describe('exchangeCodeForAccessToken', () => {
|
||||
it('sends expiring=1 in the JSON body', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(_expiringResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { exchangeCodeForAccessToken } = await import('./auth')
|
||||
await exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string)
|
||||
expect(body).toMatchObject({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
code: 'abc',
|
||||
expiring: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the bundle with expiry timestamps computed from expires_in', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-03T00:00:00Z'))
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(_expiringResponse()))
|
||||
|
||||
const { exchangeCodeForAccessToken } = await import('./auth')
|
||||
const credentials = await exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })
|
||||
|
||||
expect(credentials).toEqual({
|
||||
accessToken: 'shpat_a',
|
||||
refreshToken: 'shprt_r',
|
||||
accessTokenExpiresAtSeconds: nowSeconds + 3600,
|
||||
refreshTokenExpiresAtSeconds: nowSeconds + 7776000,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when refresh_token is missing in response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(_expiringResponse({ refresh_token: undefined })))
|
||||
|
||||
const { exchangeCodeForAccessToken } = await import('./auth')
|
||||
await expect(exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })).rejects.toThrow(
|
||||
/missing one or more required expiring-token fields/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws on non-2xx with the response body in the message', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response('Bad client_secret', { status: 401, statusText: 'Unauthorized' }))
|
||||
)
|
||||
|
||||
const { exchangeCodeForAccessToken } = await import('./auth')
|
||||
await expect(exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })).rejects.toThrow(
|
||||
/401 Unauthorized — Bad client_secret/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
it('sends grant_type=refresh_token and the supplied refresh_token', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(_expiringResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { refreshAccessToken } = await import('./auth')
|
||||
await refreshAccessToken({ shop: 'example', refreshToken: 'shprt_old' })
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string)
|
||||
expect(body).toMatchObject({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'shprt_old',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the rotated bundle from the response', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-03T00:00:00Z'))
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(_expiringResponse({ access_token: 'shpat_new', refresh_token: 'shprt_new' }))
|
||||
)
|
||||
|
||||
const { refreshAccessToken } = await import('./auth')
|
||||
const next = await refreshAccessToken({ shop: 'example', refreshToken: 'shprt_old' })
|
||||
|
||||
expect(next).toEqual({
|
||||
accessToken: 'shpat_new',
|
||||
refreshToken: 'shprt_new',
|
||||
accessTokenExpiresAtSeconds: nowSeconds + 3600,
|
||||
refreshTokenExpiresAtSeconds: nowSeconds + 7776000,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws with re-authorize hint on non-2xx', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response('refresh_token expired', { status: 401, statusText: 'Unauthorized' }))
|
||||
)
|
||||
|
||||
const { refreshAccessToken } = await import('./auth')
|
||||
await expect(refreshAccessToken({ shop: 'example', refreshToken: 'shprt_old' })).rejects.toThrow(
|
||||
/re-authorize the integration/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrRefreshCredentials', () => {
|
||||
const _stubClient = (payload: Record<string, unknown>) => {
|
||||
const setState = vi.fn().mockResolvedValue({})
|
||||
const getState = vi.fn().mockResolvedValue({ state: { payload } })
|
||||
return { setState, getState, client: { setState, getState } as any, ctx: { integrationId: 'int-1' } as any }
|
||||
}
|
||||
|
||||
it('returns stored credentials when access token is well within expiry', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-03T00:00:00Z'))
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { client, ctx } = _stubClient({
|
||||
shopDomain: 'example',
|
||||
accessToken: 'shpat_a',
|
||||
refreshToken: 'shprt_r',
|
||||
accessTokenExpiresAtSeconds: nowSeconds + 3600,
|
||||
refreshTokenExpiresAtSeconds: nowSeconds + 7776000,
|
||||
})
|
||||
|
||||
const { getOrRefreshCredentials } = await import('./auth')
|
||||
const creds = await getOrRefreshCredentials({ client, ctx })
|
||||
|
||||
expect(creds.accessToken).toBe('shpat_a')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes when within 5-minute buffer of expiry and persists new credentials', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-03T00:00:00Z'))
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(_expiringResponse({ access_token: 'shpat_new', refresh_token: 'shprt_new' }))
|
||||
)
|
||||
|
||||
const { client, ctx, setState } = _stubClient({
|
||||
shopDomain: 'example',
|
||||
accessToken: 'shpat_old',
|
||||
refreshToken: 'shprt_old',
|
||||
accessTokenExpiresAtSeconds: nowSeconds + 60, // within buffer
|
||||
refreshTokenExpiresAtSeconds: nowSeconds + 7776000,
|
||||
})
|
||||
|
||||
const { getOrRefreshCredentials } = await import('./auth')
|
||||
const creds = await getOrRefreshCredentials({ client, ctx })
|
||||
|
||||
expect(creds.accessToken).toBe('shpat_new')
|
||||
expect(creds.refreshToken).toBe('shprt_new')
|
||||
expect(setState).toHaveBeenCalledTimes(1)
|
||||
const setCall = setState.mock.calls[0]![0]
|
||||
expect(setCall.payload).toMatchObject({
|
||||
shopDomain: 'example',
|
||||
accessToken: 'shpat_new',
|
||||
refreshToken: 'shprt_new',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when refreshToken is missing from state', async () => {
|
||||
const { client, ctx } = _stubClient({ shopDomain: 'example', accessToken: 'shpat_a' })
|
||||
|
||||
const { getOrRefreshCredentials } = await import('./auth')
|
||||
await expect(getOrRefreshCredentials({ client, ctx })).rejects.toThrow(/credentials not found or incomplete/)
|
||||
})
|
||||
|
||||
it('throws when refresh token itself has expired', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-03T00:00:00Z'))
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
|
||||
const { client, ctx } = _stubClient({
|
||||
shopDomain: 'example',
|
||||
accessToken: 'shpat_a',
|
||||
refreshToken: 'shprt_r',
|
||||
accessTokenExpiresAtSeconds: nowSeconds - 100,
|
||||
refreshTokenExpiresAtSeconds: nowSeconds - 1, // expired
|
||||
})
|
||||
|
||||
const { getOrRefreshCredentials } = await import('./auth')
|
||||
await expect(getOrRefreshCredentials({ client, ctx })).rejects.toThrow(/refresh token expired \(90-day TTL\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const REFRESH_BUFFER_SECONDS = 300
|
||||
|
||||
const _nowSeconds = () => Math.floor(Date.now() / 1000)
|
||||
|
||||
export type ShopifyCredentials = {
|
||||
shopDomain: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accessTokenExpiresAtSeconds: number
|
||||
refreshTokenExpiresAtSeconds: number
|
||||
}
|
||||
|
||||
type TokenResponse = {
|
||||
access_token?: string
|
||||
scope?: string
|
||||
expires_in?: number
|
||||
refresh_token?: string
|
||||
refresh_token_expires_in?: number
|
||||
}
|
||||
|
||||
const _parseTokenResponse = (json: TokenResponse) => {
|
||||
if (!json.access_token || !json.refresh_token || !json.expires_in || !json.refresh_token_expires_in) {
|
||||
throw new RuntimeError('Shopify token response is missing one or more required expiring-token fields')
|
||||
}
|
||||
const now = _nowSeconds()
|
||||
return {
|
||||
accessToken: json.access_token,
|
||||
refreshToken: json.refresh_token,
|
||||
accessTokenExpiresAtSeconds: now + json.expires_in,
|
||||
refreshTokenExpiresAtSeconds: now + json.refresh_token_expires_in,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges a Shopify OAuth authorization code for an expiring offline Admin access token bundle.
|
||||
*
|
||||
* Shopify deprecated non-expiring offline tokens for new public apps as of 2026-04-01;
|
||||
* `expiring: 1` opts into the supported flow (60-min access TTL, 90-day refresh TTL).
|
||||
*
|
||||
* See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
|
||||
*/
|
||||
export const exchangeCodeForAccessToken = async ({
|
||||
shop,
|
||||
code,
|
||||
}: {
|
||||
shop: string
|
||||
code: string
|
||||
}): Promise<Omit<ShopifyCredentials, 'shopDomain'>> => {
|
||||
const response = await fetch(`https://${shop}.myshopify.com/admin/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: bp.secrets.SHOPIFY_CLIENT_ID,
|
||||
client_secret: bp.secrets.SHOPIFY_CLIENT_SECRET,
|
||||
code,
|
||||
expiring: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new RuntimeError(
|
||||
`Failed to exchange authorization code for access token: ${response.status} ${response.statusText} — ${body.slice(0, 500)}`
|
||||
)
|
||||
}
|
||||
|
||||
return _parseTokenResponse((await response.json()) as TokenResponse)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an expiring offline Admin access token using the stored refresh token.
|
||||
* Shopify rotates the refresh token on every refresh — the response always contains a new pair.
|
||||
*/
|
||||
export const refreshAccessToken = async ({
|
||||
shop,
|
||||
refreshToken,
|
||||
}: {
|
||||
shop: string
|
||||
refreshToken: string
|
||||
}): Promise<Omit<ShopifyCredentials, 'shopDomain'>> => {
|
||||
const response = await fetch(`https://${shop}.myshopify.com/admin/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: bp.secrets.SHOPIFY_CLIENT_ID,
|
||||
client_secret: bp.secrets.SHOPIFY_CLIENT_SECRET,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new RuntimeError(
|
||||
`Failed to refresh Shopify admin access token: ${response.status} ${response.statusText} — ${body.slice(0, 500)}. Refresh token may have expired (90-day TTL); re-authorize the integration.`
|
||||
)
|
||||
}
|
||||
|
||||
return _parseTokenResponse((await response.json()) as TokenResponse)
|
||||
}
|
||||
|
||||
export const setCredentialsState = async ({
|
||||
client,
|
||||
ctx,
|
||||
credentials,
|
||||
}: {
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
credentials: ShopifyCredentials
|
||||
}) => {
|
||||
const { state } = await client
|
||||
.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
.catch(() => ({ state: { payload: {} as Record<string, unknown> } }))
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'credentials',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...state.payload, ...credentials },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns valid credentials, refreshing the access token pre-emptively when within
|
||||
* REFRESH_BUFFER_SECONDS of expiry. Pass `force: true` from a 401-retry path to skip
|
||||
* the cached-expiry check (the server is the source of truth that the token is bad).
|
||||
* Throws a re-authorize prompt if the refresh token itself has expired (90-day TTL)
|
||||
* or if any required field is missing from state.
|
||||
*/
|
||||
export const getOrRefreshCredentials = async ({
|
||||
client,
|
||||
ctx,
|
||||
force = false,
|
||||
}: {
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
force?: boolean
|
||||
}): Promise<ShopifyCredentials> => {
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
const { shopDomain, accessToken, refreshToken, accessTokenExpiresAtSeconds, refreshTokenExpiresAtSeconds } =
|
||||
state.payload
|
||||
|
||||
if (
|
||||
!shopDomain ||
|
||||
!accessToken ||
|
||||
!refreshToken ||
|
||||
accessTokenExpiresAtSeconds === undefined ||
|
||||
refreshTokenExpiresAtSeconds === undefined
|
||||
) {
|
||||
throw new RuntimeError(
|
||||
'Shopify credentials not found or incomplete; re-authorize the integration via the OAuth wizard.'
|
||||
)
|
||||
}
|
||||
|
||||
const now = _nowSeconds()
|
||||
if (now >= refreshTokenExpiresAtSeconds) {
|
||||
throw new RuntimeError(
|
||||
'Shopify refresh token expired (90-day TTL); re-authorize the integration via the OAuth wizard.'
|
||||
)
|
||||
}
|
||||
|
||||
if (!force && now < accessTokenExpiresAtSeconds - REFRESH_BUFFER_SECONDS) {
|
||||
return { shopDomain, accessToken, refreshToken, accessTokenExpiresAtSeconds, refreshTokenExpiresAtSeconds }
|
||||
}
|
||||
|
||||
const refreshed = await refreshAccessToken({ shop: shopDomain, refreshToken })
|
||||
const next: ShopifyCredentials = { shopDomain, ...refreshed }
|
||||
await setCredentialsState({ client, ctx, credentials: next })
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id'
|
||||
process.env.SECRET_SHOPIFY_CLIENT_SECRET = 'test-client-secret'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const _stubBpClient = (payload: Record<string, unknown>) => {
|
||||
const setState = vi.fn().mockResolvedValue({})
|
||||
const getState = vi.fn().mockResolvedValue({ state: { payload } })
|
||||
return { client: { setState, getState } as any, ctx: { integrationId: 'int-1' } as any, setState, getState }
|
||||
}
|
||||
|
||||
describe('ShopifyClient 401 retry', () => {
|
||||
it('refreshes the token and retries once when query gets 401', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-03T00:00:00Z'))
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
|
||||
// 1st call (initial query): 401 → triggers refresh
|
||||
// 2nd call (refresh endpoint): 200 with new token bundle
|
||||
// 3rd call (retry of original query): 200 with data
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'shpat_new',
|
||||
refresh_token: 'shprt_new',
|
||||
expires_in: 3600,
|
||||
refresh_token_expires_in: 7776000,
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: { ok: true } }), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { client: bpClient, ctx } = _stubBpClient({
|
||||
shopDomain: 'example',
|
||||
accessToken: 'shpat_old',
|
||||
refreshToken: 'shprt_old',
|
||||
accessTokenExpiresAtSeconds: nowSeconds + 3600,
|
||||
refreshTokenExpiresAtSeconds: nowSeconds + 7776000,
|
||||
})
|
||||
|
||||
const { ShopifyClient } = await import('./index')
|
||||
const shopify = new ShopifyClient({ shopDomain: 'example', accessToken: 'shpat_old', client: bpClient, ctx })
|
||||
const result = await shopify.query('query { shop { name } }')
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// First call (failing) used the old token
|
||||
const firstCallHeaders = fetchMock.mock.calls[0]![1].headers as Record<string, string>
|
||||
expect(firstCallHeaders['X-Shopify-Access-Token']).toBe('shpat_old')
|
||||
|
||||
// Third call (retry) used the refreshed token
|
||||
const thirdCallHeaders = fetchMock.mock.calls[2]![1].headers as Record<string, string>
|
||||
expect(thirdCallHeaders['X-Shopify-Access-Token']).toBe('shpat_new')
|
||||
})
|
||||
|
||||
it('does not retry on 401 when client/ctx are not provided to the constructor', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { ShopifyClient } = await import('./index')
|
||||
const shopify = new ShopifyClient({ shopDomain: 'example', accessToken: 'shpat_old' })
|
||||
|
||||
await expect(shopify.query('query { shop { name } }')).rejects.toThrow(/401 Unauthorized/)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { getOrRefreshCredentials } from '../auth'
|
||||
import { WEBHOOK_SUBSCRIPTION_CREATE, WEBHOOK_SUBSCRIPTION_DELETE } from './queries/admin'
|
||||
import { SHOPIFY_API_VERSION } from './queries/common'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type ShopifyClientProps = {
|
||||
shopDomain: string
|
||||
accessToken: string
|
||||
client?: bp.Client
|
||||
ctx?: bp.Context
|
||||
}
|
||||
|
||||
type CreateProps = {
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
}
|
||||
|
||||
type WebhookSubscriptionCreateResponse = {
|
||||
webhookSubscriptionCreate: {
|
||||
webhookSubscription: { id: string } | null
|
||||
userErrors: Array<{ field: string[] | null; message: string }>
|
||||
}
|
||||
}
|
||||
|
||||
type WebhookSubscriptionDeleteResponse = {
|
||||
webhookSubscriptionDelete: {
|
||||
deletedWebhookSubscriptionId: string | null
|
||||
userErrors: Array<{ field: string[] | null; message: string }>
|
||||
}
|
||||
}
|
||||
|
||||
export class ShopifyClient {
|
||||
public readonly shopDomain: string
|
||||
private _accessToken: string
|
||||
private readonly _client?: bp.Client
|
||||
private readonly _ctx?: bp.Context
|
||||
|
||||
public constructor({ shopDomain, accessToken, client, ctx }: ShopifyClientProps) {
|
||||
this.shopDomain = shopDomain
|
||||
this._accessToken = accessToken
|
||||
this._client = client
|
||||
this._ctx = ctx
|
||||
}
|
||||
|
||||
public static async create({ client, ctx }: CreateProps): Promise<ShopifyClient> {
|
||||
const { shopDomain, accessToken } = await getOrRefreshCredentials({ client, ctx })
|
||||
return new ShopifyClient({ shopDomain, accessToken, client, ctx })
|
||||
}
|
||||
|
||||
public async query<T = unknown>(graphql: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||||
return this._queryWithRetry(graphql, variables, false)
|
||||
}
|
||||
|
||||
private async _queryWithRetry<T>(graphql: string, variables: Record<string, unknown>, retried: boolean): Promise<T> {
|
||||
const url = `https://${this.shopDomain}.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shopify-Access-Token': this._accessToken,
|
||||
},
|
||||
body: JSON.stringify({ query: graphql, variables }),
|
||||
})
|
||||
|
||||
if (response.status === 401 && !retried && this._client && this._ctx) {
|
||||
const { accessToken } = await getOrRefreshCredentials({ client: this._client, ctx: this._ctx, force: true })
|
||||
this._accessToken = accessToken
|
||||
return this._queryWithRetry(graphql, variables, true)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new RuntimeError(`Shopify API error: ${response.status} ${response.statusText} — ${body.slice(0, 500)}`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }
|
||||
|
||||
if (json.errors?.length) {
|
||||
throw new RuntimeError(`Shopify GraphQL error: ${json.errors.map((e) => e.message).join(', ')}`)
|
||||
}
|
||||
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
public async subscribeWebhook(topic: string, uri: string): Promise<string | null> {
|
||||
const data = await this.query<WebhookSubscriptionCreateResponse>(WEBHOOK_SUBSCRIPTION_CREATE, {
|
||||
topic,
|
||||
webhookSubscription: {
|
||||
uri,
|
||||
format: 'JSON',
|
||||
},
|
||||
})
|
||||
|
||||
const userErrors = data.webhookSubscriptionCreate.userErrors
|
||||
if (userErrors.length) {
|
||||
throw new RuntimeError(
|
||||
`Failed to create Shopify webhook subscription for ${topic}: ${userErrors.map((e) => e.message).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
return data.webhookSubscriptionCreate.webhookSubscription?.id ?? null
|
||||
}
|
||||
|
||||
public async unsubscribeWebhook(webhookId: string): Promise<void> {
|
||||
const data = await this.query<WebhookSubscriptionDeleteResponse>(WEBHOOK_SUBSCRIPTION_DELETE, {
|
||||
id: webhookId,
|
||||
})
|
||||
|
||||
const userErrors = data.webhookSubscriptionDelete.userErrors
|
||||
if (userErrors.length) {
|
||||
throw new RuntimeError(
|
||||
`Failed to delete Shopify webhook subscription ${webhookId}: ${userErrors.map((e) => e.message).join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
export const PRODUCTS_QUERY = `
|
||||
query listProducts($first: Int!, $query: String, $after: String) {
|
||||
products(first: $first, query: $query, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
handle
|
||||
status
|
||||
vendor
|
||||
productType
|
||||
descriptionHtml
|
||||
createdAt
|
||||
updatedAt
|
||||
onlineStoreUrl
|
||||
onlineStorePreviewUrl
|
||||
variants(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
price
|
||||
sku
|
||||
inventoryQuantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const PRODUCT_QUERY = `
|
||||
query getProduct($id: ID!) {
|
||||
product(id: $id) {
|
||||
id
|
||||
title
|
||||
handle
|
||||
status
|
||||
vendor
|
||||
productType
|
||||
descriptionHtml
|
||||
createdAt
|
||||
updatedAt
|
||||
onlineStoreUrl
|
||||
onlineStorePreviewUrl
|
||||
variants(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
price
|
||||
sku
|
||||
inventoryQuantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CUSTOMERS_QUERY = `
|
||||
query searchCustomers($query: String!) {
|
||||
customers(first: 50, query: $query) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
email
|
||||
phone
|
||||
numberOfOrders
|
||||
amountSpent {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ORDER_QUERY = `
|
||||
query getOrder($id: ID!) {
|
||||
order(id: $id) {
|
||||
id
|
||||
name
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
cancelledAt
|
||||
closedAt
|
||||
displayFinancialStatus
|
||||
displayFulfillmentStatus
|
||||
totalPriceSet {
|
||||
shopMoney {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
lineItems(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
title
|
||||
quantity
|
||||
variant {
|
||||
id
|
||||
title
|
||||
price
|
||||
sku
|
||||
inventoryQuantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
customer {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CUSTOMER_ORDERS_QUERY = `
|
||||
query listCustomerOrders($customerId: ID!, $first: Int!, $query: String) {
|
||||
customer(id: $customerId) {
|
||||
orders(first: $first, query: $query) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
cancelledAt
|
||||
closedAt
|
||||
displayFinancialStatus
|
||||
displayFulfillmentStatus
|
||||
totalPriceSet {
|
||||
shopMoney {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
lineItems(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
title
|
||||
quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const WEBHOOK_SUBSCRIPTION_CREATE = `
|
||||
mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
|
||||
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
|
||||
webhookSubscription {
|
||||
id
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const WEBHOOK_SUBSCRIPTION_DELETE = `
|
||||
mutation webhookSubscriptionDelete($id: ID!) {
|
||||
webhookSubscriptionDelete(id: $id) {
|
||||
deletedWebhookSubscriptionId
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const WEBHOOK_SUBSCRIPTIONS_QUERY = `
|
||||
query webhookSubscriptions($first: Int!) {
|
||||
webhookSubscriptions(first: $first) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
topic
|
||||
uri
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1 @@
|
||||
export const SHOPIFY_API_VERSION = '2026-04'
|
||||
@@ -0,0 +1,13 @@
|
||||
import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload }
|
||||
|
||||
export const fireOrderCancelled = async ({ payload, client, logger }: FireEventProps) => {
|
||||
logger.forBot().info(`Received order cancelled event for order ${payload.name} (${payload.id})`)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'orderCancelled',
|
||||
payload: transformOrderWebhookPayload(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload }
|
||||
|
||||
export const fireOrderCreated = async ({ payload, client, logger }: FireEventProps) => {
|
||||
logger.forBot().info(`Received order created event for order ${payload.name} (${payload.id})`)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'orderCreated',
|
||||
payload: transformOrderWebhookPayload(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload }
|
||||
|
||||
export const fireOrderFulfilled = async ({ payload, client, logger }: FireEventProps) => {
|
||||
logger.forBot().info(`Received order fulfilled event for order ${payload.name} (${payload.id})`)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'orderFulfilled',
|
||||
payload: transformOrderWebhookPayload(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload }
|
||||
|
||||
export const fireOrderPaid = async ({ payload, client, logger }: FireEventProps) => {
|
||||
logger.forBot().info(`Received order paid event for order ${payload.name} (${payload.id})`)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'orderPaid',
|
||||
payload: transformOrderWebhookPayload(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { transformOrderWebhookPayload, type OrderWebhookPayload } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type FireEventProps = bp.HandlerProps & { payload: OrderWebhookPayload }
|
||||
|
||||
export const fireOrderUpdated = async ({ payload, client, logger }: FireEventProps) => {
|
||||
logger.forBot().info(`Received order updated event for order ${payload.name} (${payload.id})`)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'orderUpdated',
|
||||
payload: transformOrderWebhookPayload(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const SECRET = 'test-shopify-secret'
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id'
|
||||
process.env.SECRET_SHOPIFY_CLIENT_SECRET = SECRET
|
||||
})
|
||||
|
||||
vi.mock('./events/order-created', () => ({ fireOrderCreated: vi.fn(async () => ({ status: 200, body: 'ok' })) }))
|
||||
vi.mock('./events/order-updated', () => ({ fireOrderUpdated: vi.fn(async () => ({ status: 200, body: 'ok' })) }))
|
||||
vi.mock('./events/order-cancelled', () => ({ fireOrderCancelled: vi.fn(async () => ({ status: 200, body: 'ok' })) }))
|
||||
vi.mock('./events/order-fulfilled', () => ({ fireOrderFulfilled: vi.fn(async () => ({ status: 200, body: 'ok' })) }))
|
||||
vi.mock('./events/order-paid', () => ({ fireOrderPaid: vi.fn(async () => ({ status: 200, body: 'ok' })) }))
|
||||
|
||||
const computeHmac = (body: string) => createHmac('sha256', SECRET).update(body, 'utf8').digest('base64')
|
||||
|
||||
const buildProps = (opts: { topic?: string; hmac?: string; body?: string; path?: string }) => {
|
||||
const body = opts.body ?? '{}'
|
||||
const headers: Record<string, string> = {}
|
||||
if (opts.topic !== undefined) headers['x-shopify-topic'] = opts.topic
|
||||
if (opts.hmac !== undefined) headers['x-shopify-hmac-sha256'] = opts.hmac
|
||||
const noop = () => {}
|
||||
const forBot = () => ({ info: noop, warn: noop, error: noop, debug: noop })
|
||||
return {
|
||||
req: { path: opts.path ?? '/', headers, body },
|
||||
logger: { forBot },
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('Shopify webhook handler', () => {
|
||||
const validBody = '{"shop_id":1,"shop_domain":"x.myshopify.com"}'
|
||||
|
||||
describe('GDPR compliance topics', () => {
|
||||
const topics = ['customers/data_request', 'customers/redact', 'shop/redact']
|
||||
|
||||
for (const topic of topics) {
|
||||
it(`returns 200 on valid HMAC for ${topic}`, async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(buildProps({ topic, hmac: computeHmac(validBody), body: validBody }))
|
||||
expect(response).toEqual({ status: 200, body: '' })
|
||||
})
|
||||
|
||||
it(`returns 401 on invalid HMAC for ${topic}`, async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(buildProps({ topic, hmac: 'invalid-hmac', body: validBody }))
|
||||
expect(response).toMatchObject({ status: 401 })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('request validation', () => {
|
||||
it('returns 400 when topic header is missing', async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(buildProps({ hmac: computeHmac(validBody), body: validBody }))
|
||||
expect(response).toMatchObject({ status: 400 })
|
||||
})
|
||||
|
||||
it('returns 400 when hmac header is missing', async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(buildProps({ topic: 'customers/redact', body: validBody }))
|
||||
expect(response).toMatchObject({ status: 400 })
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 200 on unknown topic after HMAC passes', async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(
|
||||
buildProps({ topic: 'products/create', hmac: computeHmac(validBody), body: validBody })
|
||||
)
|
||||
expect(response).toEqual({ status: 200, body: '' })
|
||||
})
|
||||
|
||||
// Shopify retries non-2xx responses and disables the webhook after repeated failures, so a
|
||||
// malformed payload or a transient event-dispatch error must never escalate into a 4xx/5xx.
|
||||
describe('error handling returns 200 to avoid Shopify retry loops', () => {
|
||||
it('returns 200 on malformed JSON body after HMAC passes', async () => {
|
||||
const malformed = '{not-json'
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(
|
||||
buildProps({ topic: 'orders/create', hmac: computeHmac(malformed), body: malformed })
|
||||
)
|
||||
expect(response).toEqual({ status: 200, body: '' })
|
||||
})
|
||||
|
||||
it('returns 200 when an event handler throws', async () => {
|
||||
const { fireOrderCreated } = await import('./events/order-created')
|
||||
vi.mocked(fireOrderCreated).mockRejectedValueOnce(new Error('createEvent failed'))
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(
|
||||
buildProps({ topic: 'orders/create', hmac: computeHmac(validBody), body: validBody })
|
||||
)
|
||||
expect(response).toEqual({ status: 200, body: '' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { fireOrderCancelled } from './events/order-cancelled'
|
||||
import { fireOrderCreated } from './events/order-created'
|
||||
import { fireOrderFulfilled } from './events/order-fulfilled'
|
||||
import { fireOrderPaid } from './events/order-paid'
|
||||
import { fireOrderUpdated } from './events/order-updated'
|
||||
import { verifyWebhookHmac } from './oauth/hmac'
|
||||
import { oauthWizardHandler } from './oauth/wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const SHOPIFY_TOPIC_HEADER = 'x-shopify-topic'
|
||||
const SHOPIFY_HMAC_HEADER = 'x-shopify-hmac-sha256'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, logger } = props
|
||||
|
||||
if (oauthWizard.isOAuthWizardUrl(req.path)) {
|
||||
return await oauthWizardHandler(props)
|
||||
}
|
||||
|
||||
// Webhook handling
|
||||
const topic = req.headers[SHOPIFY_TOPIC_HEADER]
|
||||
const hmac = req.headers[SHOPIFY_HMAC_HEADER]
|
||||
|
||||
if (!topic || !hmac || !req.body) {
|
||||
logger.forBot().warn('Rejected Shopify webhook: missing required headers or body')
|
||||
return { status: 400, body: 'Missing Shopify webhook headers or body' }
|
||||
}
|
||||
|
||||
if (!verifyWebhookHmac(req.body, hmac, bp.secrets.SHOPIFY_CLIENT_SECRET)) {
|
||||
logger.forBot().warn('Rejected Shopify webhook with invalid HMAC signature')
|
||||
return { status: 401, body: 'Invalid HMAC signature' }
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(req.body)
|
||||
|
||||
switch (topic) {
|
||||
case 'orders/create':
|
||||
return await fireOrderCreated({ payload, ...props })
|
||||
case 'orders/updated':
|
||||
return await fireOrderUpdated({ payload, ...props })
|
||||
case 'orders/cancelled':
|
||||
return await fireOrderCancelled({ payload, ...props })
|
||||
case 'orders/fulfilled':
|
||||
return await fireOrderFulfilled({ payload, ...props })
|
||||
case 'orders/paid':
|
||||
return await fireOrderPaid({ payload, ...props })
|
||||
case 'customers/data_request':
|
||||
case 'customers/redact':
|
||||
case 'shop/redact':
|
||||
// GDPR compliance webhooks. This integration does not persist Shopify customer data —
|
||||
// Admin API responses flow straight through to bot actions. Per-shop credentials
|
||||
// are cleared by unregister(); shop/redact (fired 48h after uninstall) is a safety-net no-op.
|
||||
// https://shopify.dev/docs/apps/build/compliance/privacy-law-compliance
|
||||
logger.forBot().info(`Received Shopify compliance webhook: ${topic}`)
|
||||
return { status: 200, body: '' }
|
||||
default:
|
||||
logger.forBot().warn(`Unhandled Shopify webhook topic: ${topic}`)
|
||||
return { status: 200, body: '' }
|
||||
}
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
logger.forBot().error(`Failed to process Shopify webhook (topic: ${topic}): ${error.message}`)
|
||||
return { status: 200, body: '' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import actions from './actions'
|
||||
import { handler } from './handler'
|
||||
import { register, unregister } from './setup'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default new bp.Integration({
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels: {},
|
||||
handler,
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createHmac } from 'crypto'
|
||||
import { verifyOAuthCallbackHmac, verifyWebhookHmac } from './hmac'
|
||||
|
||||
const SECRET = 'test-shopify-secret'
|
||||
|
||||
// Helper: compute the OAuth callback HMAC exactly as the source does
|
||||
const computeOAuthHmac = (params: Record<string, string>, secret: string): string => {
|
||||
const entries = Object.entries(params)
|
||||
.filter(([k]) => k !== 'hmac' && k !== 'signature')
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
const message = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
||||
return createHmac('sha256', secret).update(message).digest('hex')
|
||||
}
|
||||
|
||||
// Helper: compute the webhook HMAC exactly as the source does
|
||||
const computeWebhookHmac = (body: string, secret: string): string =>
|
||||
createHmac('sha256', secret).update(body, 'utf8').digest('base64')
|
||||
|
||||
describe('verifyOAuthCallbackHmac', () => {
|
||||
it('accepts a valid HMAC', () => {
|
||||
const params = { code: 'abc123', shop: 'my-store.myshopify.com', state: 'nonce', timestamp: '1234567890' }
|
||||
const hmac = computeOAuthHmac(params, SECRET)
|
||||
const query = new URLSearchParams({ ...params, hmac })
|
||||
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when hmac param is missing', () => {
|
||||
const query = new URLSearchParams({ code: 'abc123', shop: 'my-store.myshopify.com' })
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false with wrong secret', () => {
|
||||
const params = { code: 'abc123', shop: 'my-store.myshopify.com' }
|
||||
const hmac = computeOAuthHmac(params, SECRET)
|
||||
const query = new URLSearchParams({ ...params, hmac })
|
||||
|
||||
expect(verifyOAuthCallbackHmac(query, 'wrong-secret')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when a query param is tampered', () => {
|
||||
const params = { code: 'abc123', shop: 'my-store.myshopify.com' }
|
||||
const hmac = computeOAuthHmac(params, SECRET)
|
||||
const query = new URLSearchParams({ ...params, hmac, shop: 'evil-store.myshopify.com' })
|
||||
|
||||
// Recompute — the hmac was computed with the original shop value
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes signature param from hash input', () => {
|
||||
const params = { code: 'abc123', shop: 'my-store.myshopify.com', signature: 'legacy-sig' }
|
||||
const hmac = computeOAuthHmac(params, SECRET) // helper already excludes signature
|
||||
const query = new URLSearchParams({ ...params, hmac })
|
||||
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles params with special characters', () => {
|
||||
const params = { code: 'abc=123&456', shop: 'my store.myshopify.com' }
|
||||
const hmac = computeOAuthHmac(params, SECRET)
|
||||
const query = new URLSearchParams({ ...params, hmac })
|
||||
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyWebhookHmac', () => {
|
||||
const body = '{"id":12345,"name":"#1001"}'
|
||||
|
||||
it('accepts a valid HMAC', () => {
|
||||
const hmac = computeWebhookHmac(body, SECRET)
|
||||
expect(verifyWebhookHmac(body, hmac, SECRET)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false with wrong secret', () => {
|
||||
const hmac = computeWebhookHmac(body, SECRET)
|
||||
expect(verifyWebhookHmac(body, hmac, 'wrong-secret')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when body is tampered', () => {
|
||||
const hmac = computeWebhookHmac(body, SECRET)
|
||||
expect(verifyWebhookHmac('{"id":99999}', hmac, SECRET)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false with hex-encoded HMAC instead of base64', () => {
|
||||
const hexHmac = createHmac('sha256', SECRET).update(body, 'utf8').digest('hex')
|
||||
expect(verifyWebhookHmac(body, hexHmac, SECRET)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles empty body', () => {
|
||||
const hmac = computeWebhookHmac('', SECRET)
|
||||
expect(verifyWebhookHmac('', hmac, SECRET)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles unicode body', () => {
|
||||
const unicodeBody = '{"name":"Ünïcödé Shöp"}'
|
||||
const hmac = computeWebhookHmac(unicodeBody, SECRET)
|
||||
expect(verifyWebhookHmac(unicodeBody, hmac, SECRET)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
/**
|
||||
* Verifies the HMAC signature on Shopify's OAuth callback query string.
|
||||
*
|
||||
* Shopify signs the callback query params with the app's client secret. The `hmac` (and legacy
|
||||
* `signature`) parameter must be removed, the remaining params sorted alphabetically by key,
|
||||
* URL-encoded, and joined as `key=value&key=value`. The HMAC-SHA256 of that string (hex) must
|
||||
* match the received `hmac` value.
|
||||
*
|
||||
* See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant
|
||||
*/
|
||||
export const verifyOAuthCallbackHmac = (query: URLSearchParams, secret: string): boolean => {
|
||||
const receivedHmac = query.get('hmac')
|
||||
if (!receivedHmac) {
|
||||
return false
|
||||
}
|
||||
|
||||
const entries: [string, string][] = []
|
||||
for (const [key, value] of query.entries()) {
|
||||
if (key === 'hmac' || key === 'signature') {
|
||||
continue
|
||||
}
|
||||
entries.push([key, value])
|
||||
}
|
||||
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
|
||||
const message = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
||||
const computed = createHmac('sha256', secret).update(message).digest()
|
||||
const received = Buffer.from(receivedHmac, 'hex')
|
||||
|
||||
return computed.length === received.length && timingSafeEqual(computed, received)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the HMAC signature on an incoming Shopify webhook request.
|
||||
*
|
||||
* Shopify sends the HMAC-SHA256 of the raw request body (base64-encoded) in the
|
||||
* `X-Shopify-Hmac-Sha256` header. The HMAC is computed with the app's client secret.
|
||||
*
|
||||
* See https://shopify.dev/docs/apps/build/webhooks/subscribe#verify-a-webhook
|
||||
*/
|
||||
export const verifyWebhookHmac = (rawBody: string, hmacHeader: string, secret: string): boolean => {
|
||||
const computed = createHmac('sha256', secret).update(rawBody, 'utf8').digest()
|
||||
const received = Buffer.from(hmacHeader, 'base64')
|
||||
|
||||
return computed.length === received.length && timingSafeEqual(computed, received)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { normalizeShopDomain } from './wizard'
|
||||
|
||||
describe('normalizeShopDomain', () => {
|
||||
it('returns bare domain as-is', () => {
|
||||
expect(normalizeShopDomain('my-store')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips .myshopify.com suffix', () => {
|
||||
expect(normalizeShopDomain('my-store.myshopify.com')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips https:// protocol', () => {
|
||||
expect(normalizeShopDomain('https://my-store.myshopify.com')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips http:// protocol', () => {
|
||||
expect(normalizeShopDomain('http://my-store.myshopify.com')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips trailing slash', () => {
|
||||
expect(normalizeShopDomain('https://my-store.myshopify.com/')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips path segments', () => {
|
||||
expect(normalizeShopDomain('https://my-store.myshopify.com/admin')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips deep path segments', () => {
|
||||
expect(normalizeShopDomain('https://my-store.myshopify.com/admin/products/123')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeShopDomain(' my-store.myshopify.com ')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('lowercases input', () => {
|
||||
expect(normalizeShopDomain('MY-STORE.MYSHOPIFY.COM')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('handles full URL with mixed case and whitespace', () => {
|
||||
expect(normalizeShopDomain(' HTTPS://MY-STORE.MYSHOPIFY.COM/admin/products ')).toBe('my-store')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { exchangeCodeForAccessToken } from '../auth'
|
||||
import { verifyOAuthCallbackHmac } from './hmac'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
|
||||
|
||||
const SHOPIFY_OAUTH_SCOPES = ['read_products', 'read_orders', 'read_customers'].join(',')
|
||||
|
||||
const SHOP_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/i
|
||||
|
||||
export const oauthWizardHandler = async (props: bp.HandlerProps): Promise<sdk.Response> => {
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({ id: 'start', handler: _startHandler })
|
||||
.addStep({ id: 'get-shop', handler: _getShopHandler })
|
||||
.addStep({ id: 'validate-shop', handler: _validateShopHandler })
|
||||
.addStep({ id: 'authorize', handler: _authorizeHandler })
|
||||
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
|
||||
.addStep({ id: 'end', handler: _endHandler })
|
||||
.build()
|
||||
|
||||
return await wizard.handleRequest()
|
||||
}
|
||||
|
||||
const _startHandler: WizardHandler = ({ responses }) =>
|
||||
responses.displayButtons({
|
||||
pageTitle: 'Connect Shopify',
|
||||
htmlOrMarkdownPageContents:
|
||||
'This wizard will connect your Shopify store to Botpress. If the integration was previously connected, the existing connection will be reset.\n\nDo you want to continue?',
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Yes, continue', navigateToStep: 'get-shop', buttonType: 'primary' },
|
||||
{ action: 'close', label: 'No, cancel', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
|
||||
const _getShopHandler: WizardHandler = ({ responses }) =>
|
||||
responses.displayInput({
|
||||
pageTitle: 'Enter Shopify Store',
|
||||
htmlOrMarkdownPageContents:
|
||||
'Enter the domain of your Shopify store. It looks like `your-store.myshopify.com` — you can find it in the Shopify admin URL.',
|
||||
input: { label: 'e.g. your-store.myshopify.com', type: 'text' },
|
||||
nextStepId: 'validate-shop',
|
||||
})
|
||||
|
||||
const _validateShopHandler: WizardHandler = async ({ client, ctx, inputValue, responses }) => {
|
||||
if (!inputValue) {
|
||||
throw new sdk.RuntimeError('Shop domain cannot be empty')
|
||||
}
|
||||
|
||||
const shopDomain = normalizeShopDomain(inputValue)
|
||||
if (!SHOP_NAME_REGEX.test(shopDomain)) {
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Invalid Shop Domain',
|
||||
htmlOrMarkdownPageContents: `"${inputValue}" doesn't look like a valid Shopify store domain. Please enter a domain like \`your-store.myshopify.com\`.`,
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Try again', navigateToStep: 'get-shop', buttonType: 'primary' },
|
||||
{ action: 'close', label: 'Cancel', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
await _patchCredentialsState(client, ctx, { shopDomain, accessToken: undefined })
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Confirm Shopify Store',
|
||||
htmlOrMarkdownPageContents: `Is <strong>${shopDomain}.myshopify.com</strong> your Shopify store?`,
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Yes, connect', navigateToStep: 'authorize', buttonType: 'primary' },
|
||||
{ action: 'navigate', label: 'No, go back', navigateToStep: 'get-shop', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const _authorizeHandler: WizardHandler = async ({ client, ctx, responses }) => {
|
||||
const { shopDomain } = await _getCredentialsState(client, ctx)
|
||||
if (!shopDomain) {
|
||||
throw new sdk.RuntimeError('Shop domain missing from state; please restart the wizard')
|
||||
}
|
||||
|
||||
const redirectUri = oauthWizard.getWizardStepUrl('oauth-callback').toString()
|
||||
const authorizeUrl =
|
||||
`https://${shopDomain}.myshopify.com/admin/oauth/authorize` +
|
||||
`?client_id=${encodeURIComponent(bp.secrets.SHOPIFY_CLIENT_ID)}` +
|
||||
`&scope=${encodeURIComponent(SHOPIFY_OAUTH_SCOPES)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&state=${encodeURIComponent(ctx.webhookId)}`
|
||||
|
||||
return responses.redirectToExternalUrl(authorizeUrl)
|
||||
}
|
||||
|
||||
const _oauthCallbackHandler: WizardHandler = async ({ query, client, ctx, logger, responses }) => {
|
||||
try {
|
||||
const state = query.get('state')
|
||||
if (state !== ctx.webhookId) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'OAuth state mismatch — possible CSRF attempt. Please retry the connection.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!verifyOAuthCallbackHmac(query, bp.secrets.SHOPIFY_CLIENT_SECRET)) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Shopify OAuth callback HMAC verification failed. Please retry the connection.',
|
||||
})
|
||||
}
|
||||
|
||||
const code = query.get('code')
|
||||
const shopParam = query.get('shop')
|
||||
if (!code || !shopParam) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Missing `code` or `shop` parameter on Shopify OAuth callback.',
|
||||
})
|
||||
}
|
||||
|
||||
const shopDomainFromCallback = shopParam.replace(/\.myshopify\.com$/i, '').toLowerCase()
|
||||
const stored = await _getCredentialsState(client, ctx)
|
||||
if (stored.shopDomain && stored.shopDomain.toLowerCase() !== shopDomainFromCallback) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: `Shop mismatch: expected ${stored.shopDomain} but Shopify returned ${shopDomainFromCallback}.`,
|
||||
})
|
||||
}
|
||||
|
||||
const credentials = await exchangeCodeForAccessToken({ shop: shopDomainFromCallback, code })
|
||||
|
||||
await _patchCredentialsState(client, ctx, {
|
||||
shopDomain: shopDomainFromCallback,
|
||||
accessToken: credentials.accessToken,
|
||||
refreshToken: credentials.refreshToken,
|
||||
accessTokenExpiresAtSeconds: credentials.accessTokenExpiresAtSeconds,
|
||||
refreshTokenExpiresAtSeconds: credentials.refreshTokenExpiresAtSeconds,
|
||||
})
|
||||
|
||||
await client.configureIntegration({ identifier: shopDomainFromCallback })
|
||||
|
||||
return responses.redirectToStep('end')
|
||||
} catch (e) {
|
||||
logger.forBot().error({ err: e }, 'Shopify OAuth callback failed')
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const _endHandler: WizardHandler = ({ responses }) => responses.endWizard({ success: true })
|
||||
|
||||
export const normalizeShopDomain = (raw: string): string =>
|
||||
raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/.*$/, '')
|
||||
.replace(/\.myshopify\.com$/, '')
|
||||
|
||||
type CredentialsPatch = {
|
||||
shopDomain?: string
|
||||
accessToken?: string
|
||||
refreshToken?: string
|
||||
accessTokenExpiresAtSeconds?: number
|
||||
refreshTokenExpiresAtSeconds?: number
|
||||
webhookSubscriptionIds?: string[]
|
||||
}
|
||||
|
||||
// `client.patchState` has known issues — merge manually via getState/setState
|
||||
const _patchCredentialsState = async (client: bp.Client, ctx: bp.Context, patch: CredentialsPatch) => {
|
||||
const current = await _getCredentialsState(client, ctx)
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'credentials',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...current, ...patch },
|
||||
})
|
||||
}
|
||||
|
||||
const _getCredentialsState = async (client: bp.Client, ctx: bp.Context): Promise<CredentialsPatch> => {
|
||||
try {
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
return (state?.payload as CredentialsPatch | undefined) ?? {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { ShopifyClient } from './client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const WEBHOOK_TOPICS = ['ORDERS_CREATE', 'ORDERS_UPDATED', 'ORDERS_CANCELLED', 'ORDERS_FULFILLED', 'ORDERS_PAID']
|
||||
|
||||
export const register: bp.IntegrationProps['register'] = async ({ client, ctx, webhookUrl, logger }) => {
|
||||
logger.forBot().info('Registering Shopify Admin integration...')
|
||||
|
||||
let shopify: ShopifyClient
|
||||
try {
|
||||
shopify = await ShopifyClient.create({ client, ctx })
|
||||
} catch {
|
||||
logger
|
||||
.forBot()
|
||||
.info('No Shopify credentials yet — skipping webhook subscription. Complete the OAuth wizard to finish setup.')
|
||||
return
|
||||
}
|
||||
|
||||
// Replace any previously-created subscriptions with a fresh set (e.g., after a re-auth).
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
for (const id of state.payload.webhookSubscriptionIds ?? []) {
|
||||
await shopify
|
||||
.unsubscribeWebhook(id)
|
||||
.catch((err) => logger.forBot().warn({ err }, `Failed to delete stale webhook subscription ${id}`))
|
||||
}
|
||||
|
||||
const subscriptionIds: string[] = []
|
||||
const failures: Array<{ topic: string; err: unknown }> = []
|
||||
for (const topic of WEBHOOK_TOPICS) {
|
||||
try {
|
||||
const id = await shopify.subscribeWebhook(topic, webhookUrl)
|
||||
if (id) {
|
||||
subscriptionIds.push(id)
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push({ topic, err })
|
||||
logger.forBot().warn({ err }, `Failed to subscribe to Shopify webhook topic ${topic}`)
|
||||
}
|
||||
}
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'credentials',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...state.payload, webhookSubscriptionIds: subscriptionIds },
|
||||
})
|
||||
|
||||
if (subscriptionIds.length === 0 && failures.length === WEBHOOK_TOPICS.length) {
|
||||
throw new RuntimeError(
|
||||
`All Shopify webhook subscriptions failed (${failures.length}/${WEBHOOK_TOPICS.length}); the access token is likely invalid. Re-authorize the integration.`
|
||||
)
|
||||
}
|
||||
|
||||
logger.forBot().info(`Shopify Admin integration registered with ${subscriptionIds.length} webhook subscription(s).`)
|
||||
}
|
||||
|
||||
export const unregister: bp.IntegrationProps['unregister'] = async ({ client, ctx, logger }) => {
|
||||
logger.forBot().info('Unregistering Shopify Admin integration...')
|
||||
|
||||
let shopify: ShopifyClient
|
||||
try {
|
||||
shopify = await ShopifyClient.create({ client, ctx })
|
||||
} catch {
|
||||
logger.forBot().info('No Shopify credentials — nothing to unregister.')
|
||||
return
|
||||
}
|
||||
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
for (const id of state.payload.webhookSubscriptionIds ?? []) {
|
||||
await shopify
|
||||
.unsubscribeWebhook(id)
|
||||
.catch((err) => logger.forBot().warn({ err }, `Failed to delete webhook subscription ${id}`))
|
||||
}
|
||||
|
||||
logger.forBot().info('Shopify Admin integration unregistered.')
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
transformVariant,
|
||||
transformProduct,
|
||||
transformCustomer,
|
||||
transformLineItem,
|
||||
transformOrder,
|
||||
transformOrderWebhookPayload,
|
||||
} from './transformers'
|
||||
|
||||
describe('transformVariant', () => {
|
||||
it('maps all fields', () => {
|
||||
const result = transformVariant({ id: 'v1', title: 'Small', price: '9.99', sku: 'SKU-1', inventoryQuantity: 10 })
|
||||
expect(result).toEqual({ id: 'v1', title: 'Small', price: '9.99', sku: 'SKU-1', inventoryQuantity: 10 })
|
||||
})
|
||||
|
||||
it('converts null sku and inventoryQuantity to undefined', () => {
|
||||
const result = transformVariant({ id: 'v1', title: 'Small', price: '9.99', sku: null, inventoryQuantity: null })
|
||||
expect(result.sku).toBeUndefined()
|
||||
expect(result.inventoryQuantity).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformProduct', () => {
|
||||
const baseProduct = {
|
||||
id: 'p1',
|
||||
title: 'Widget',
|
||||
handle: 'widget',
|
||||
status: 'ACTIVE',
|
||||
vendor: 'Acme',
|
||||
productType: 'Gadget',
|
||||
descriptionHtml: '<p>Nice</p>',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
onlineStoreUrl: 'https://shop.myshopify.com/products/widget',
|
||||
onlineStorePreviewUrl: 'https://shop.myshopify.com/products/widget?preview=true',
|
||||
variants: { edges: [{ node: { id: 'v1', title: 'Default', price: '10.00', sku: null, inventoryQuantity: 5 } }] },
|
||||
}
|
||||
|
||||
it('uses onlineStoreUrl as storefrontUrl when present', () => {
|
||||
const result = transformProduct(baseProduct, 'shop')
|
||||
expect(result.storefrontUrl).toBe('https://shop.myshopify.com/products/widget')
|
||||
})
|
||||
|
||||
it('falls back to onlineStorePreviewUrl when onlineStoreUrl is null', () => {
|
||||
const result = transformProduct({ ...baseProduct, onlineStoreUrl: null }, 'shop')
|
||||
expect(result.storefrontUrl).toBe('https://shop.myshopify.com/products/widget?preview=true')
|
||||
})
|
||||
|
||||
it('falls back to constructed URL when both store URLs are null', () => {
|
||||
const result = transformProduct({ ...baseProduct, onlineStoreUrl: null, onlineStorePreviewUrl: null }, 'my-shop')
|
||||
expect(result.storefrontUrl).toBe('https://my-shop.myshopify.com/products/widget')
|
||||
})
|
||||
|
||||
it('maps variants through transformVariant', () => {
|
||||
const result = transformProduct(baseProduct, 'shop')
|
||||
expect(result.variants).toEqual([
|
||||
{ id: 'v1', title: 'Default', price: '10.00', sku: undefined, inventoryQuantity: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
it('converts null optional fields to undefined', () => {
|
||||
const result = transformProduct({ ...baseProduct, vendor: null, productType: null, descriptionHtml: null }, 'shop')
|
||||
expect(result.vendor).toBeUndefined()
|
||||
expect(result.productType).toBeUndefined()
|
||||
expect(result.descriptionHtml).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformCustomer', () => {
|
||||
const baseCustomer = {
|
||||
id: 'c1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '+1234567890',
|
||||
numberOfOrders: '5',
|
||||
amountSpent: { amount: '100.00', currencyCode: 'USD' },
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
}
|
||||
|
||||
it('formats amountSpent as "amount currencyCode"', () => {
|
||||
expect(transformCustomer(baseCustomer).amountSpent).toBe('100.00 USD')
|
||||
})
|
||||
|
||||
it('coerces numberOfOrders string to number', () => {
|
||||
expect(transformCustomer(baseCustomer).numberOfOrders).toBe(5)
|
||||
})
|
||||
|
||||
it('coerces numberOfOrders number to number', () => {
|
||||
expect(transformCustomer({ ...baseCustomer, numberOfOrders: 3 }).numberOfOrders).toBe(3)
|
||||
})
|
||||
|
||||
it('converts null numberOfOrders to undefined', () => {
|
||||
expect(transformCustomer({ ...baseCustomer, numberOfOrders: null }).numberOfOrders).toBeUndefined()
|
||||
})
|
||||
|
||||
it('converts undefined numberOfOrders to undefined', () => {
|
||||
const { numberOfOrders: _, ...rest } = baseCustomer
|
||||
expect(transformCustomer(rest as any).numberOfOrders).toBeUndefined()
|
||||
})
|
||||
|
||||
it('converts null amountSpent to undefined', () => {
|
||||
expect(transformCustomer({ ...baseCustomer, amountSpent: null }).amountSpent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('converts null contact fields to undefined', () => {
|
||||
const result = transformCustomer({ ...baseCustomer, firstName: null, lastName: null, email: null, phone: null })
|
||||
expect(result.firstName).toBeUndefined()
|
||||
expect(result.lastName).toBeUndefined()
|
||||
expect(result.email).toBeUndefined()
|
||||
expect(result.phone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformLineItem', () => {
|
||||
it('includes transformed variant when present', () => {
|
||||
const result = transformLineItem({
|
||||
title: 'Widget',
|
||||
quantity: 2,
|
||||
variant: { id: 'v1', title: 'Small', price: '5.00', sku: 'S1', inventoryQuantity: 10 },
|
||||
})
|
||||
expect(result.variant).toEqual({ id: 'v1', title: 'Small', price: '5.00', sku: 'S1', inventoryQuantity: 10 })
|
||||
})
|
||||
|
||||
it('converts null variant to undefined', () => {
|
||||
expect(transformLineItem({ title: 'Widget', quantity: 2, variant: null }).variant).toBeUndefined()
|
||||
})
|
||||
|
||||
it('converts missing variant to undefined', () => {
|
||||
expect(transformLineItem({ title: 'Widget', quantity: 2 }).variant).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformOrder', () => {
|
||||
const baseOrder = {
|
||||
id: 'o1',
|
||||
name: '#1001',
|
||||
email: 'buyer@example.com',
|
||||
phone: '+1234567890',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
cancelledAt: null,
|
||||
closedAt: null,
|
||||
displayFinancialStatus: 'PAID',
|
||||
displayFulfillmentStatus: 'FULFILLED',
|
||||
totalPriceSet: { shopMoney: { amount: '50.00', currencyCode: 'USD' } },
|
||||
lineItems: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
title: 'Widget',
|
||||
quantity: 1,
|
||||
variant: { id: 'v1', title: 'Default', price: '50.00', sku: null, inventoryQuantity: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
customer: {
|
||||
id: 'c1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone: null,
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
},
|
||||
}
|
||||
|
||||
it('maps financial and fulfillment statuses', () => {
|
||||
const result = transformOrder(baseOrder)
|
||||
expect(result.financialStatus).toBe('PAID')
|
||||
expect(result.fulfillmentStatus).toBe('FULFILLED')
|
||||
})
|
||||
|
||||
it('defaults financialStatus to UNKNOWN when null', () => {
|
||||
expect(transformOrder({ ...baseOrder, displayFinancialStatus: null }).financialStatus).toBe('UNKNOWN')
|
||||
})
|
||||
|
||||
it('converts null fulfillmentStatus to undefined', () => {
|
||||
expect(transformOrder({ ...baseOrder, displayFulfillmentStatus: null }).fulfillmentStatus).toBeUndefined()
|
||||
})
|
||||
|
||||
it('transforms nested customer', () => {
|
||||
const result = transformOrder(baseOrder)
|
||||
expect(result.customer?.id).toBe('c1')
|
||||
})
|
||||
|
||||
it('converts null customer to undefined', () => {
|
||||
expect(transformOrder({ ...baseOrder, customer: null }).customer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps line items', () => {
|
||||
const result = transformOrder(baseOrder)
|
||||
expect(result.lineItems).toHaveLength(1)
|
||||
expect(result.lineItems[0]!.title).toBe('Widget')
|
||||
})
|
||||
|
||||
it('extracts totalPrice and currencyCode from totalPriceSet', () => {
|
||||
const result = transformOrder(baseOrder)
|
||||
expect(result.totalPrice).toBe('50.00')
|
||||
expect(result.currencyCode).toBe('USD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformOrderWebhookPayload', () => {
|
||||
const basePayload = {
|
||||
id: 12345,
|
||||
name: '#1001',
|
||||
email: 'buyer@example.com',
|
||||
financial_status: 'paid',
|
||||
fulfillment_status: 'fulfilled',
|
||||
total_price: '50.00',
|
||||
currency: 'USD',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
it('wraps numeric id as GID string', () => {
|
||||
expect(transformOrderWebhookPayload(basePayload).id).toBe('gid://shopify/Order/12345')
|
||||
})
|
||||
|
||||
it('maps snake_case fields to camelCase', () => {
|
||||
const result = transformOrderWebhookPayload(basePayload)
|
||||
expect(result.financialStatus).toBe('paid')
|
||||
expect(result.fulfillmentStatus).toBe('fulfilled')
|
||||
expect(result.totalPrice).toBe('50.00')
|
||||
expect(result.currencyCode).toBe('USD')
|
||||
expect(result.createdAt).toBe('2024-01-01T00:00:00Z')
|
||||
expect(result.updatedAt).toBe('2024-01-02T00:00:00Z')
|
||||
})
|
||||
|
||||
it('defaults financial_status to UNKNOWN when null', () => {
|
||||
expect(transformOrderWebhookPayload({ ...basePayload, financial_status: null }).financialStatus).toBe('UNKNOWN')
|
||||
})
|
||||
|
||||
it('converts null email and fulfillment_status to undefined', () => {
|
||||
const result = transformOrderWebhookPayload({ ...basePayload, email: null, fulfillment_status: null })
|
||||
expect(result.email).toBeUndefined()
|
||||
expect(result.fulfillmentStatus).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
type VariantNode = {
|
||||
id: string
|
||||
title: string
|
||||
price: string
|
||||
sku: string | null
|
||||
inventoryQuantity: number | null
|
||||
}
|
||||
|
||||
type ProductNode = {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
status: string
|
||||
vendor: string | null
|
||||
productType: string | null
|
||||
descriptionHtml: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
onlineStoreUrl: string | null
|
||||
onlineStorePreviewUrl: string | null
|
||||
variants: { edges: Array<{ node: VariantNode }> }
|
||||
}
|
||||
|
||||
type MoneyV2 = {
|
||||
amount: string
|
||||
currencyCode: string
|
||||
}
|
||||
|
||||
type CustomerNode = {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
numberOfOrders?: string | number | null
|
||||
amountSpent?: MoneyV2 | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type LineItemNode = {
|
||||
title: string
|
||||
quantity: number
|
||||
variant?: VariantNode | null
|
||||
}
|
||||
|
||||
type OrderNode = {
|
||||
id: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
cancelledAt: string | null
|
||||
closedAt: string | null
|
||||
displayFinancialStatus: string | null
|
||||
displayFulfillmentStatus: string | null
|
||||
totalPriceSet: { shopMoney: MoneyV2 }
|
||||
lineItems: { edges: Array<{ node: LineItemNode }> }
|
||||
customer?: CustomerNode | null
|
||||
}
|
||||
|
||||
export const transformVariant = (node: VariantNode) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
price: node.price,
|
||||
sku: node.sku ?? undefined,
|
||||
inventoryQuantity: node.inventoryQuantity ?? undefined,
|
||||
})
|
||||
|
||||
export const transformProduct = (node: ProductNode, shopDomain: string) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
handle: node.handle,
|
||||
status: node.status,
|
||||
vendor: node.vendor ?? undefined,
|
||||
productType: node.productType ?? undefined,
|
||||
descriptionHtml: node.descriptionHtml ?? undefined,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: node.updatedAt,
|
||||
storefrontUrl:
|
||||
node.onlineStoreUrl ?? node.onlineStorePreviewUrl ?? `https://${shopDomain}.myshopify.com/products/${node.handle}`,
|
||||
onlineStoreUrl: node.onlineStoreUrl ?? undefined,
|
||||
onlineStorePreviewUrl: node.onlineStorePreviewUrl ?? undefined,
|
||||
variants: node.variants.edges.map(({ node: variant }) => transformVariant(variant)),
|
||||
})
|
||||
|
||||
export const transformCustomer = (node: CustomerNode) => ({
|
||||
id: node.id,
|
||||
firstName: node.firstName ?? undefined,
|
||||
lastName: node.lastName ?? undefined,
|
||||
email: node.email ?? undefined,
|
||||
phone: node.phone ?? undefined,
|
||||
numberOfOrders: node.numberOfOrders != null ? Number(node.numberOfOrders) : undefined,
|
||||
amountSpent: node.amountSpent ? `${node.amountSpent.amount} ${node.amountSpent.currencyCode}` : undefined,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: node.updatedAt,
|
||||
})
|
||||
|
||||
export const transformLineItem = (node: LineItemNode) => ({
|
||||
title: node.title,
|
||||
quantity: node.quantity,
|
||||
variant: node.variant ? transformVariant(node.variant) : undefined,
|
||||
})
|
||||
|
||||
export const transformOrder = (node: OrderNode) => ({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
email: node.email ?? undefined,
|
||||
phone: node.phone ?? undefined,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: node.updatedAt,
|
||||
cancelledAt: node.cancelledAt ?? undefined,
|
||||
closedAt: node.closedAt ?? undefined,
|
||||
financialStatus: node.displayFinancialStatus ?? 'UNKNOWN',
|
||||
fulfillmentStatus: node.displayFulfillmentStatus ?? undefined,
|
||||
totalPrice: node.totalPriceSet.shopMoney.amount,
|
||||
currencyCode: node.totalPriceSet.shopMoney.currencyCode,
|
||||
lineItems: node.lineItems.edges.map(({ node: lineItem }) => transformLineItem(lineItem)),
|
||||
customer: node.customer ? transformCustomer(node.customer) : undefined,
|
||||
})
|
||||
|
||||
// Shopify REST/webhook order payload — only the fields we surface in orderEventSchema.
|
||||
// Reference: https://shopify.dev/docs/api/webhooks?reference=admin#topic-orders-create
|
||||
export type OrderWebhookPayload = {
|
||||
id: number
|
||||
name: string
|
||||
email: string | null
|
||||
financial_status: string | null
|
||||
fulfillment_status: string | null
|
||||
total_price: string
|
||||
currency: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const transformOrderWebhookPayload = (payload: OrderWebhookPayload) => ({
|
||||
id: `gid://shopify/Order/${payload.id}`,
|
||||
name: payload.name,
|
||||
email: payload.email ?? undefined,
|
||||
financialStatus: payload.financial_status ?? 'UNKNOWN',
|
||||
fulfillmentStatus: payload.fulfillment_status ?? undefined,
|
||||
totalPrice: payload.total_price,
|
||||
currencyCode: payload.currency,
|
||||
createdAt: payload.created_at,
|
||||
updatedAt: payload.updated_at,
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts", "*.json"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user