chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# BigCommerce Sync Integration
|
||||
|
||||
Connect your BigCommerce store to Botpress to sync products from BigCommerce to your Botpress table.
|
||||
|
||||
## Installation and Configuration
|
||||
|
||||
### **[Walkthrough video for setting up the BigCommerce Integration](https://youtu.be/3Y_WlvMT8AA)**
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. A BigCommerce store with products
|
||||
2. API credentials with appropriate permissions
|
||||
|
||||
### Getting BigCommerce API Credentials
|
||||
|
||||
1. Log in to your BigCommerce store and go to **Settings** in the left sidebar.
|
||||
2. Search for **Store-level API accounts** and click on it.
|
||||
3. Click **Create API Account**
|
||||
4. Set the following permissions:
|
||||
- Products: Read-only
|
||||
- Information & Settings: Read-only
|
||||
5. Save your credentials:
|
||||
- Access Token
|
||||
- Store Hash (Note: the store hash is within the URL of your BigCommerce store.
|
||||
EX: https://store-{Store Hash}.mybigcommerce.com)
|
||||
|
||||
### Setting Up the Integration
|
||||
|
||||
1. Enter your BigCommerce Store Hash
|
||||
2. Enter your API Access Token
|
||||
3. Click **Save**
|
||||
|
||||
### Using the Integration
|
||||
|
||||
1. Go back to your Botpress studio and refresh the page.
|
||||
2. You should see a new table in your Tables section labeled **bigcommerce_products_Table**.
|
||||
Note: This table is automatically created when the integration is saved.
|
||||
Note #2: The table will automatically update when you make changes to your BigCommerce store (product creation, updates, deletion).
|
||||
|
||||
### Actions
|
||||
|
||||
You can manually trigger the syncProducts action to sync products from BigCommerce to your Botpress table.
|
||||
For example, you can connect this to a cron job to sync products every X amount of time.
|
||||
@@ -0,0 +1,125 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 697 696" enable-background="new 0 0 697 696" xml:space="preserve">
|
||||
<path fill="#FFFFFF" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M0.999994,686.000000
|
||||
C1.000000,457.736389 1.000000,229.472809 1.000000,1.000000
|
||||
C230.026810,1.000000 459.053680,1.000000 688.461914,1.328607
|
||||
C602.528809,88.475990 516.214355,175.294754 430.012817,262.000000
|
||||
C442.323700,262.000000 455.405487,262.136108 468.483307,261.966248
|
||||
C485.620880,261.743683 502.313080,264.193787 518.381287,270.154816
|
||||
C534.152283,276.005554 548.052612,285.109833 558.454651,298.252594
|
||||
C572.096680,315.488922 578.950867,335.104126 576.132080,357.681946
|
||||
C574.146484,373.585754 567.780029,387.171814 557.408264,399.039124
|
||||
C548.910706,408.761993 538.837341,416.479858 527.147095,422.143005
|
||||
C525.595093,422.894897 524.241333,425.676666 524.246033,427.514679
|
||||
C524.249817,429.015106 526.112305,431.226471 527.676392,431.897797
|
||||
C545.410767,439.509125 558.828491,451.913757 567.878723,468.755402
|
||||
C577.798340,487.214966 580.396545,506.988251 577.059143,527.697144
|
||||
C573.678711,548.673035 564.665955,566.318665 548.345215,580.243469
|
||||
C530.616943,595.369324 509.608246,602.494934 487.008057,602.749512
|
||||
C423.366821,603.466675 359.712311,603.000793 296.063019,602.999023
|
||||
C289.336884,602.998779 289.001251,602.662964 289.001068,595.934814
|
||||
C288.999298,533.118652 289.000000,470.302460 289.000000,407.486298
|
||||
C289.000000,405.735626 289.000000,403.984985 289.000000,401.094727
|
||||
C287.080750,402.723328 285.955139,403.555847 284.971069,404.531219
|
||||
C245.487656,443.665863 205.934189,482.730377 166.564911,521.979614
|
||||
C112.875587,575.505249 59.333279,629.178406 5.688192,682.748474
|
||||
C4.363798,684.070984 2.573167,684.926575 0.999994,686.000000
|
||||
M472.254639,327.792725
|
||||
C467.370056,326.861816 462.492065,325.167206 457.599854,325.125275
|
||||
C428.181763,324.872986 398.760437,324.995483 369.340179,325.003998
|
||||
C363.804779,325.005585 362.007294,326.798157 362.004181,332.316742
|
||||
C361.993347,351.597809 362.147034,370.880981 361.872040,390.158112
|
||||
C361.819031,393.873535 363.002106,395.808624 366.085907,397.536774
|
||||
C366.384857,397.635651 366.683807,397.734497 367.911652,397.982452
|
||||
C393.060852,397.988312 418.210449,398.080261 443.359100,397.963806
|
||||
C455.998627,397.905273 469.108215,399.964844 480.103973,391.026215
|
||||
C483.063660,388.620270 486.629578,386.697968 488.912689,383.770325
|
||||
C496.205475,374.418762 497.793091,363.462372 494.821198,352.323212
|
||||
C491.988281,341.704956 485.889679,333.105682 474.805054,328.306824
|
||||
C474.203491,328.260101 473.601959,328.213379 472.254639,327.792725
|
||||
M404.500000,540.000000
|
||||
C424.487793,540.000000 444.484344,540.353088 464.459930,539.859314
|
||||
C475.355957,539.589966 485.535645,536.338440 493.489655,528.058228
|
||||
C499.364685,521.942322 501.977051,514.763306 503.261536,506.359528
|
||||
C504.781708,496.413483 502.813751,487.602325 497.795563,479.426300
|
||||
C489.784180,466.373566 476.713409,461.487640 462.461487,461.197571
|
||||
C431.160278,460.560547 399.837189,461.010559 368.523010,460.988434
|
||||
C363.992310,460.985229 361.974121,463.182678 361.982208,467.660980
|
||||
C362.021637,489.480896 361.985809,511.300964 362.009796,533.120911
|
||||
C362.015900,538.696289 363.344604,539.979858 369.021149,539.992432
|
||||
C380.514038,540.017822 392.007050,540.000000 404.500000,540.000000
|
||||
z"/>
|
||||
<path fill="#34313F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M0.999994,686.482971
|
||||
C2.573167,684.926575 4.363798,684.070984 5.688192,682.748474
|
||||
C59.333279,629.178406 112.875587,575.505249 166.564911,521.979614
|
||||
C205.934189,482.730377 245.487656,443.665863 284.971069,404.531219
|
||||
C285.955139,403.555847 287.080750,402.723328 289.000000,401.094727
|
||||
C289.000000,403.984985 289.000000,405.735626 289.000000,407.486298
|
||||
C289.000000,470.302460 288.999298,533.118652 289.001068,595.934814
|
||||
C289.001251,602.662964 289.336884,602.998779 296.063019,602.999023
|
||||
C359.712311,603.000793 423.366821,603.466675 487.008057,602.749512
|
||||
C509.608246,602.494934 530.616943,595.369324 548.345215,580.243469
|
||||
C564.665955,566.318665 573.678711,548.673035 577.059143,527.697144
|
||||
C580.396545,506.988251 577.798340,487.214966 567.878723,468.755402
|
||||
C558.828491,451.913757 545.410767,439.509125 527.676392,431.897797
|
||||
C526.112305,431.226471 524.249817,429.015106 524.246033,427.514679
|
||||
C524.241333,425.676666 525.595093,422.894897 527.147095,422.143005
|
||||
C538.837341,416.479858 548.910706,408.761993 557.408264,399.039124
|
||||
C567.780029,387.171814 574.146484,373.585754 576.132080,357.681946
|
||||
C578.950867,335.104126 572.096680,315.488922 558.454651,298.252594
|
||||
C548.052612,285.109833 534.152283,276.005554 518.381287,270.154816
|
||||
C502.313080,264.193787 485.620880,261.743683 468.483307,261.966248
|
||||
C455.405487,262.136108 442.323700,262.000000 430.012817,262.000000
|
||||
C516.214355,175.294754 602.528809,88.475990 688.921631,1.328607
|
||||
C691.999023,1.000000 694.997986,1.000000 697.997803,1.000000
|
||||
C697.998535,232.922501 697.998535,464.845001 697.998535,696.883789
|
||||
C465.666718,696.883789 233.333359,696.883789 1.000000,696.883789
|
||||
C1.000000,693.630310 1.000000,690.298157 0.999994,686.482971
|
||||
z"/>
|
||||
<path fill="#34313F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M404.000000,540.000000
|
||||
C392.007050,540.000000 380.514038,540.017822 369.021149,539.992432
|
||||
C363.344604,539.979858 362.015900,538.696289 362.009796,533.120911
|
||||
C361.985809,511.300964 362.021637,489.480896 361.982208,467.660980
|
||||
C361.974121,463.182678 363.992310,460.985229 368.523010,460.988434
|
||||
C399.837189,461.010559 431.160278,460.560547 462.461487,461.197571
|
||||
C476.713409,461.487640 489.784180,466.373566 497.795563,479.426300
|
||||
C502.813751,487.602325 504.781708,496.413483 503.261536,506.359528
|
||||
C501.977051,514.763306 499.364685,521.942322 493.489655,528.058228
|
||||
C485.535645,536.338440 475.355957,539.589966 464.459930,539.859314
|
||||
C444.484344,540.353088 424.487793,540.000000 404.000000,540.000000
|
||||
z"/>
|
||||
<path fill="#34313F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M366.166626,397.013763
|
||||
C363.002106,395.808624 361.819031,393.873535 361.872040,390.158112
|
||||
C362.147034,370.880981 361.993347,351.597809 362.004181,332.316742
|
||||
C362.007294,326.798157 363.804779,325.005585 369.340179,325.003998
|
||||
C398.760437,324.995483 428.181763,324.872986 457.599854,325.125275
|
||||
C462.492065,325.167206 467.370056,326.861816 472.669617,328.266235
|
||||
C473.737732,328.788971 474.390900,328.838165 475.044037,328.887390
|
||||
C485.889679,333.105682 491.988281,341.704956 494.821198,352.323212
|
||||
C497.793091,363.462372 496.205475,374.418762 488.912689,383.770325
|
||||
C486.629578,386.697968 483.063660,388.620270 480.103973,391.026215
|
||||
C469.108215,399.964844 455.998627,397.905273 443.359100,397.963806
|
||||
C418.210449,398.080261 393.060852,397.988312 367.355316,397.731567
|
||||
C366.635345,397.261200 366.424561,397.105560 366.166626,397.013763
|
||||
z"/>
|
||||
<path fill="#34313F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M474.924561,328.597107
|
||||
C474.390900,328.838165 473.737732,328.788971 473.042480,328.453186
|
||||
C473.601959,328.213379 474.203491,328.260101 474.924561,328.597107
|
||||
z"/>
|
||||
<path fill="#34313F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M366.126282,397.275269
|
||||
C366.424561,397.105560 366.635345,397.261200 366.890869,397.657043
|
||||
C366.683807,397.734497 366.384857,397.635651 366.126282,397.275269
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,20 @@
|
||||
import { IntegrationDefinition } from '@botpress/sdk'
|
||||
import { configuration, states, actions } from './src/definitions/index'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'bigcommerce',
|
||||
title: 'BigCommerce',
|
||||
version: '3.2.4',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
description: 'Sync products from BigCommerce to Botpress',
|
||||
configuration,
|
||||
actions: {
|
||||
syncProducts: actions.syncProducts,
|
||||
},
|
||||
states,
|
||||
attributes: {
|
||||
category: 'CRM & Sales',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@botpresshub/bigcommerce",
|
||||
"scripts": {
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"axios": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import syncProducts from './sync-products'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
syncProducts,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,427 @@
|
||||
import { Client } from '@botpress/client'
|
||||
import { getBigCommerceClient, BigCommerceClient } from '../client'
|
||||
import { getProductImageUrl, BigCommerceProductImage, stripHtmlTags } from '../index'
|
||||
import { PRODUCT_TABLE_SCHEMA, PRODUCTS_TABLE_NAME as PRODUCT_TABLE } from '../schemas/products'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type BigCommerceProduct = {
|
||||
id: number
|
||||
name: string
|
||||
sku?: string
|
||||
price?: number
|
||||
sale_price?: number
|
||||
retail_price?: number
|
||||
weight?: number
|
||||
type?: string
|
||||
inventory_level?: number
|
||||
inventory_tracking?: string
|
||||
brand_id?: number
|
||||
categories?: number[]
|
||||
availability?: string
|
||||
condition?: string
|
||||
is_visible?: boolean
|
||||
sort_order?: number
|
||||
description?: string
|
||||
images?: Array<BigCommerceProductImage>
|
||||
custom_url?: { url: string }
|
||||
total_sold?: number
|
||||
}
|
||||
|
||||
type SyncConfig = {
|
||||
storeHash: string
|
||||
accessToken: string
|
||||
tableName: string
|
||||
batchSize?: number
|
||||
productsPerPage?: number
|
||||
}
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 50
|
||||
const DEFAULT_PRODUCTS_PER_PAGE = 250
|
||||
|
||||
function transformProductToTableRow(
|
||||
product: BigCommerceProduct,
|
||||
categoryById: Record<number, string>,
|
||||
brandById: Record<number, string>
|
||||
) {
|
||||
const categoryNames =
|
||||
product.categories?.map((categoryId: number) => categoryById[categoryId] || categoryId.toString()) || []
|
||||
|
||||
const categories = categoryNames.join(',')
|
||||
const brandName = product.brand_id ? brandById[product.brand_id] || product.brand_id.toString() : ''
|
||||
const imageUrl = product.images && product.images.length > 0 ? getProductImageUrl(product.images) : ''
|
||||
|
||||
return {
|
||||
product_id: product.id,
|
||||
name: product.name,
|
||||
sku: product.sku,
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
retail_price: product.retail_price,
|
||||
total_sold: product.total_sold || 0,
|
||||
weight: product.weight,
|
||||
type: product.type,
|
||||
inventory_level: product.inventory_level,
|
||||
inventory_tracking: product.inventory_tracking,
|
||||
brand_name: brandName,
|
||||
categories,
|
||||
availability: product.availability,
|
||||
condition: product.condition,
|
||||
is_visible: product.is_visible,
|
||||
sort_order: product.sort_order,
|
||||
description: stripHtmlTags(product.description)?.substring(0, 1000) || '',
|
||||
image_url: imageUrl,
|
||||
url: product.custom_url?.url || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProductsToTable(
|
||||
client: Client,
|
||||
tableName: string,
|
||||
products: BigCommerceProduct[],
|
||||
categoryById: Record<number, string>,
|
||||
brandById: Record<number, string>,
|
||||
batchSize: number = DEFAULT_BATCH_SIZE
|
||||
) {
|
||||
try {
|
||||
const tableRows = products.map((product) => transformProductToTableRow(product, categoryById, brandById))
|
||||
|
||||
const totalRows = tableRows.length
|
||||
let processedRows = 0
|
||||
|
||||
while (processedRows < totalRows) {
|
||||
const batch = tableRows.slice(processedRows, processedRows + batchSize)
|
||||
|
||||
await client.createTableRows({
|
||||
table: tableName,
|
||||
rows: batch,
|
||||
})
|
||||
|
||||
processedRows += batch.length
|
||||
}
|
||||
|
||||
return totalRows
|
||||
} catch (error) {
|
||||
console.error('Failed to save products to table:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategoriesAndBrands(bigCommerceClient: BigCommerceClient) {
|
||||
const categoryById: Record<number, string> = {}
|
||||
const brandById: Record<number, string> = {}
|
||||
|
||||
try {
|
||||
const categoriesResponse = await bigCommerceClient.getCategories()
|
||||
if (categoriesResponse && categoriesResponse.data) {
|
||||
categoriesResponse.data.forEach((category: { id: number; name: string }) => {
|
||||
categoryById[category.id] = category.name
|
||||
})
|
||||
}
|
||||
|
||||
const brandsResponse = await bigCommerceClient.getBrands()
|
||||
if (brandsResponse && brandsResponse.data) {
|
||||
brandsResponse.data.forEach((brand: { id: number; name: string }) => {
|
||||
brandById[brand.id] = brand.name
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading categories/brands:', error)
|
||||
}
|
||||
|
||||
return { categoryById, brandById }
|
||||
}
|
||||
|
||||
async function processPages({
|
||||
bigCommerceClient,
|
||||
botpressClient,
|
||||
tableName,
|
||||
categoryById,
|
||||
brandById,
|
||||
startPage,
|
||||
totalPages,
|
||||
batchSize,
|
||||
productsPerPage,
|
||||
logger,
|
||||
}: {
|
||||
bigCommerceClient: BigCommerceClient
|
||||
botpressClient: Client
|
||||
tableName: string
|
||||
categoryById: Record<number, string>
|
||||
brandById: Record<number, string>
|
||||
startPage: number
|
||||
totalPages: number
|
||||
batchSize: number
|
||||
productsPerPage: number
|
||||
logger: bp.Logger
|
||||
}): Promise<{ processedItems: number; lastProcessedPage: number; errors: string[] }> {
|
||||
const startTime = Date.now()
|
||||
let processedItems = 0
|
||||
let lastProcessedPage = startPage - 1
|
||||
const errors: string[] = []
|
||||
const totalPagesToProcess = totalPages - startPage + 1
|
||||
|
||||
logger.forBot().info(`Processing pages from ${startPage} to ${totalPages} (${totalPagesToProcess} pages total)`)
|
||||
|
||||
for (let page = startPage; page <= totalPages; page++) {
|
||||
const pagesProcessed = page - startPage
|
||||
const progress = Math.round((pagesProcessed / totalPagesToProcess) * 100)
|
||||
|
||||
try {
|
||||
logger.forBot().info(`Processing page ${page}/${totalPages} (${progress}% complete)...`)
|
||||
|
||||
const pageResult = await bigCommerceClient.getProducts({ page, limit: productsPerPage })
|
||||
|
||||
if (!pageResult.data || pageResult.data.length === 0) {
|
||||
logger.forBot().warn(`No products found on page ${page}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Save products to table
|
||||
await saveProductsToTable(botpressClient, tableName, pageResult.data, categoryById, brandById, batchSize)
|
||||
|
||||
processedItems += pageResult.data.length
|
||||
lastProcessedPage = page
|
||||
|
||||
logger.forBot().info(`Completed page ${page}: ${pageResult.data.length} products`)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
} catch (error) {
|
||||
const errorMessage = `Error processing page ${page}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
logger.forBot().error(errorMessage)
|
||||
errors.push(errorMessage)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const totalElapsed = Date.now() - startTime
|
||||
const pagesCompleted = lastProcessedPage - startPage + 1
|
||||
const finalProgress = Math.round((pagesCompleted / totalPagesToProcess) * 100)
|
||||
|
||||
logger
|
||||
.forBot()
|
||||
.info(
|
||||
`Processing completed: ${pagesCompleted}/${totalPagesToProcess} pages (${finalProgress}%) ` +
|
||||
`in ${Math.round(totalElapsed / 1000)}s. Processed ${processedItems} products.`
|
||||
)
|
||||
|
||||
return { processedItems, lastProcessedPage, errors }
|
||||
}
|
||||
|
||||
function initializeClients(ctx: { storeHash: string; accessToken: string }, client: bp.Client) {
|
||||
const bigCommerceClient = getBigCommerceClient(ctx)
|
||||
const getVanillaClient = (client: bp.Client): Client => client._inner
|
||||
const botpressVanillaClient = getVanillaClient(client)
|
||||
|
||||
return { bigCommerceClient, botpressVanillaClient }
|
||||
}
|
||||
|
||||
export async function executeBackgroundSync({
|
||||
ctx,
|
||||
input,
|
||||
logger,
|
||||
client,
|
||||
}: {
|
||||
ctx: { configuration: { storeHash: string; accessToken: string } }
|
||||
input: {
|
||||
startPage: number
|
||||
totalPages: number
|
||||
tableName: string
|
||||
batchSize: number
|
||||
productsPerPage: number
|
||||
categoryById: Record<number, string>
|
||||
brandById: Record<number, string>
|
||||
}
|
||||
logger: bp.Logger
|
||||
client: bp.Client
|
||||
}) {
|
||||
const { startPage, totalPages, tableName, batchSize, productsPerPage, categoryById, brandById } = input
|
||||
const { bigCommerceClient, botpressVanillaClient } = initializeClients(ctx.configuration, client)
|
||||
|
||||
logger.forBot().info('Starting background processing...')
|
||||
|
||||
const result = await processPages({
|
||||
bigCommerceClient,
|
||||
botpressClient: botpressVanillaClient,
|
||||
tableName,
|
||||
categoryById,
|
||||
brandById,
|
||||
startPage,
|
||||
totalPages,
|
||||
batchSize,
|
||||
productsPerPage,
|
||||
logger,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
processedItems: result.processedItems,
|
||||
lastProcessedPage: result.lastProcessedPage,
|
||||
totalPages,
|
||||
errors: result.errors,
|
||||
finalProgress: Math.round(((result.lastProcessedPage - startPage + 1) / (totalPages - startPage + 1)) * 100),
|
||||
totalElapsed: Math.round((Date.now() - Date.now()) / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
const syncProducts: bp.IntegrationProps['actions']['syncProducts'] = async (props) => {
|
||||
const { client, logger } = props
|
||||
const ctx = props.ctx.configuration
|
||||
|
||||
const { bigCommerceClient, botpressVanillaClient } = initializeClients(ctx, client)
|
||||
|
||||
const config: SyncConfig = {
|
||||
storeHash: ctx.storeHash,
|
||||
accessToken: ctx.accessToken,
|
||||
tableName: PRODUCT_TABLE,
|
||||
batchSize: DEFAULT_BATCH_SIZE,
|
||||
productsPerPage: DEFAULT_PRODUCTS_PER_PAGE,
|
||||
}
|
||||
|
||||
try {
|
||||
logger.forBot().info('Starting BigCommerce product sync')
|
||||
|
||||
await botpressVanillaClient.getOrCreateTable({
|
||||
table: config.tableName,
|
||||
schema: PRODUCT_TABLE_SCHEMA,
|
||||
})
|
||||
|
||||
try {
|
||||
logger.forBot().info('Clearing existing products table...')
|
||||
await botpressVanillaClient.deleteTable({
|
||||
table: config.tableName,
|
||||
})
|
||||
|
||||
await botpressVanillaClient.getOrCreateTable({
|
||||
table: config.tableName,
|
||||
schema: PRODUCT_TABLE_SCHEMA,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.forBot().warn('Error clearing products table:', error)
|
||||
}
|
||||
|
||||
// Step 1: Load categories and brands
|
||||
logger.forBot().info('Loading categories and brands...')
|
||||
const { categoryById, brandById } = await loadCategoriesAndBrands(bigCommerceClient)
|
||||
logger
|
||||
.forBot()
|
||||
.info(`Loaded ${Object.keys(categoryById).length} categories and ${Object.keys(brandById).length} brands`)
|
||||
|
||||
// Step 2: Get first page synchronously
|
||||
logger.forBot().info('Fetching first page synchronously...')
|
||||
|
||||
const firstPageResponse = await bigCommerceClient.getProducts({ page: 1, limit: config.productsPerPage! })
|
||||
const totalPages: number = firstPageResponse.meta?.pagination?.total_pages ?? 1
|
||||
const firstPageCount = firstPageResponse.data?.length ?? 0
|
||||
|
||||
if (firstPageCount === 0) {
|
||||
logger.forBot().warn('No products found in BigCommerce store')
|
||||
return {
|
||||
success: true,
|
||||
message: 'No products found in BigCommerce store',
|
||||
productsCount: 0,
|
||||
firstPageProcessed: 0,
|
||||
totalPages: 0,
|
||||
backgroundProcessing: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Process first page using unified function
|
||||
logger.forBot().info(`Processing first page (${firstPageCount} products)...`)
|
||||
await processPages({
|
||||
bigCommerceClient,
|
||||
botpressClient: botpressVanillaClient,
|
||||
tableName: config.tableName,
|
||||
categoryById,
|
||||
brandById,
|
||||
startPage: 1,
|
||||
totalPages: 1,
|
||||
batchSize: DEFAULT_BATCH_SIZE,
|
||||
productsPerPage: DEFAULT_PRODUCTS_PER_PAGE,
|
||||
logger,
|
||||
})
|
||||
|
||||
logger.forBot().info(`First page processed. Total pages: ${totalPages}, First page items: ${firstPageCount}`)
|
||||
|
||||
// Step 4: If there are more pages, send webhook to trigger background processing
|
||||
if (totalPages > 1) {
|
||||
logger.forBot().info('First page processed. Sending webhook to trigger background processing...')
|
||||
|
||||
const webhookUrl = `https://webhook.botpress.cloud/${props.ctx.webhookId}`
|
||||
|
||||
const payload = {
|
||||
event: 'background-sync-triggered',
|
||||
data: {
|
||||
startPage: 2,
|
||||
totalPages,
|
||||
tableName: config.tableName,
|
||||
batchSize: DEFAULT_BATCH_SIZE,
|
||||
productsPerPage: DEFAULT_PRODUCTS_PER_PAGE,
|
||||
storeHash: config.storeHash,
|
||||
accessToken: config.accessToken,
|
||||
categoryById,
|
||||
brandById,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch((error) => {
|
||||
logger.forBot().error('Failed to send background processing webhook:', error)
|
||||
})
|
||||
|
||||
logger.forBot().info('Background processing webhook sent successfully')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
firstPageProcessed: firstPageCount,
|
||||
totalPages,
|
||||
productsCount: firstPageCount,
|
||||
backgroundProcessing: true,
|
||||
lastProcessedPage: 1,
|
||||
backgroundErrors: [],
|
||||
message: `First page processed synchronously. Background processing webhook sent for remaining ${totalPages - 1} pages.`,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.forBot().error('Failed to send background processing webhook:', error)
|
||||
return {
|
||||
success: false,
|
||||
firstPageProcessed: firstPageCount,
|
||||
totalPages,
|
||||
productsCount: firstPageCount,
|
||||
backgroundProcessing: false,
|
||||
lastProcessedPage: 1,
|
||||
backgroundErrors: [
|
||||
`Failed to send background processing webhook: ${error instanceof Error ? error.message : String(error)}`,
|
||||
],
|
||||
message: 'First page processed but failed to send background processing webhook.',
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
firstPageProcessed: firstPageCount,
|
||||
totalPages: 1,
|
||||
productsCount: firstPageCount,
|
||||
backgroundProcessing: false,
|
||||
message: 'All products synced synchronously (single page).',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.forBot().error('Sync failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: `Error syncing BigCommerce products: ${error instanceof Error ? error.message : String(error)}`,
|
||||
productsCount: 0,
|
||||
firstPageProcessed: 0,
|
||||
totalPages: 0,
|
||||
backgroundProcessing: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default syncProducts
|
||||
@@ -0,0 +1,142 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type ProductQueryParams = {
|
||||
id?: number | number[]
|
||||
name?: string
|
||||
sku?: string
|
||||
price?: number
|
||||
page?: number
|
||||
limit?: number
|
||||
include?: string
|
||||
brand_id?: number
|
||||
categories?: number | number[]
|
||||
keyword?: string
|
||||
status?: string
|
||||
include_fields?: string
|
||||
exclude_fields?: string
|
||||
sort?: string
|
||||
direction?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export class BigCommerceClient {
|
||||
private _client: AxiosInstance
|
||||
private _baseUrl: string
|
||||
|
||||
public constructor(private _config: bp.configuration.Configuration) {
|
||||
this._baseUrl = `https://api.bigcommerce.com/stores/${_config.storeHash}`
|
||||
this._client = axios.create({
|
||||
headers: {
|
||||
'X-Auth-Token': _config.accessToken,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public async getProducts(params?: ProductQueryParams) {
|
||||
try {
|
||||
const updatedParams = {
|
||||
...params,
|
||||
include: params?.include ? `${params.include},images` : 'images',
|
||||
}
|
||||
const response = await this._client.get(`${this._baseUrl}/v3/catalog/products`, { params: updatedParams })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw this._handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async getProduct(productId: string) {
|
||||
try {
|
||||
const response = await this._client.get(`${this._baseUrl}/v3/catalog/products/${productId}`, {
|
||||
params: { include: 'images' },
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw this._handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async getCategories() {
|
||||
try {
|
||||
const response = await this._client.get(`${this._baseUrl}/v3/catalog/categories`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw this._handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async getBrands() {
|
||||
try {
|
||||
const response = await this._client.get(`${this._baseUrl}/v3/catalog/brands`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw this._handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async makeRequest(config: AxiosRequestConfig) {
|
||||
try {
|
||||
const url = config.url?.startsWith('http') ? config.url : `${this._baseUrl}${config.url}`
|
||||
|
||||
const response = await this._client.request({
|
||||
...config,
|
||||
url,
|
||||
})
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
}
|
||||
} catch (error) {
|
||||
throw this._handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async createWebhook(scope: string, destination: string) {
|
||||
try {
|
||||
const response = await this._client.post(`${this._baseUrl}/v3/hooks`, {
|
||||
scope,
|
||||
destination,
|
||||
is_active: true,
|
||||
headers: {},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw this._handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async createProductWebhooks(destination: string) {
|
||||
if (!destination) {
|
||||
throw new Error('Webhook destination URL is required')
|
||||
}
|
||||
|
||||
const webhookEvents = ['store/product/updated', 'store/product/created', 'store/product/deleted']
|
||||
|
||||
const results = []
|
||||
|
||||
for (const event of webhookEvents) {
|
||||
try {
|
||||
const result = await this.createWebhook(event, destination)
|
||||
results.push({ event, success: true, data: result })
|
||||
} catch (error) {
|
||||
results.push({ event, success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private _handleError(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const message = error.response?.data?.message || error.message
|
||||
return new Error(`BigCommerce API Error: ${message}`)
|
||||
}
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
export const getBigCommerceClient = (config: bp.configuration.Configuration): BigCommerceClient =>
|
||||
new BigCommerceClient(config)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export const configuration = {
|
||||
schema: z.object({
|
||||
storeHash: z.string().min(1).title('Store Hash').describe('Your BigCommerce store hash (e.g., abc123)'),
|
||||
accessToken: z.string().min(1).title('Access Token').describe('BigCommerce API Access Token'),
|
||||
}),
|
||||
} satisfies IntegrationDefinitionProps['configuration']
|
||||
|
||||
export const states = {} satisfies IntegrationDefinitionProps['states']
|
||||
|
||||
export const actions = {
|
||||
syncProducts: {
|
||||
title: 'Sync Products',
|
||||
description: 'Get all products from BigCommerce and sync them to a Botpress table with background processing',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
success: z.boolean().describe('Whether the sync was successful'),
|
||||
message: z.string().describe('Status message'),
|
||||
productsCount: z.number().describe('Total number of products processed'),
|
||||
firstPageProcessed: z.number().describe('Number of products processed from first page'),
|
||||
totalPages: z.number().describe('Total number of pages'),
|
||||
backgroundProcessing: z.boolean().describe('Whether background processing was used'),
|
||||
lastProcessedPage: z.number().optional().describe('Last page processed during background processing'),
|
||||
backgroundErrors: z.array(z.string()).optional().describe('Errors encountered during background processing'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['actions']
|
||||
@@ -0,0 +1,504 @@
|
||||
import { Client } from '@botpress/client'
|
||||
import actions from './actions'
|
||||
import { executeBackgroundSync } from './actions/sync-products'
|
||||
import { getBigCommerceClient, BigCommerceClient } from './client'
|
||||
import { PRODUCT_TABLE_SCHEMA, PRODUCTS_TABLE_NAME as PRODUCT_TABLE } from './schemas/products'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
// this client is necessary for table operations
|
||||
const getVanillaClient = (client: bp.Client): Client => client._inner
|
||||
export type BigCommerceProductImage = {
|
||||
is_thumbnail: boolean
|
||||
url_standard: string
|
||||
}
|
||||
type WebhookType = 'created' | 'updated' | 'deleted'
|
||||
const determineWebhookTypeFromScope = (scope: string): WebhookType | undefined => {
|
||||
if (scope.includes('created')) {
|
||||
return 'created'
|
||||
} else if (scope.includes('updated')) {
|
||||
return 'updated'
|
||||
} else if (scope.includes('deleted')) {
|
||||
return 'deleted'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const determineWebhookTypeFromProducer = (webhookData: WebhookDataWithProducer): WebhookType | undefined => {
|
||||
if (webhookData.data && webhookData.data.type) {
|
||||
return webhookData.data.type.toLowerCase() as WebhookType
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isBigCommerceWebhook = (headers: Record<string, string | string[] | undefined>): boolean => {
|
||||
return !!(
|
||||
(headers['webhook-id'] && headers['webhook-signature'] && headers['webhook-timestamp']) ||
|
||||
Object.keys(headers).some(
|
||||
(key) => key.toLowerCase().includes('bigcommerce') || key.toLowerCase().includes('bc-webhook')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
type WebhookData = {
|
||||
data?: {
|
||||
id?: string | number
|
||||
entity_id?: string | number
|
||||
}
|
||||
id?: string | number
|
||||
}
|
||||
|
||||
type WebhookDataWithProducer = WebhookData & {
|
||||
producer?: string
|
||||
data?: {
|
||||
id?: string | number
|
||||
entity_id?: string | number
|
||||
type?: string
|
||||
}
|
||||
}
|
||||
|
||||
const extractProductId = (webhookData: WebhookData): string | undefined => {
|
||||
if (webhookData?.data?.id) return String(webhookData.data.id)
|
||||
if (webhookData?.data?.entity_id) return String(webhookData.data.entity_id)
|
||||
if (webhookData?.id) return String(webhookData.id)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const stripHtmlTags = (html: string | undefined): string => {
|
||||
if (!html) return ''
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export const getProductImageUrl = (images: BigCommerceProductImage[]): string => {
|
||||
if (!images || images.length === 0) return ''
|
||||
|
||||
const thumbnailImage = images.find((img) => img.is_thumbnail)
|
||||
return thumbnailImage ? thumbnailImage.url_standard : images[0]?.url_standard || ''
|
||||
}
|
||||
|
||||
const handleProductCreateOrUpdate = async (
|
||||
productId: string,
|
||||
bigCommerceClient: BigCommerceClient,
|
||||
botpressVanillaClient: Client,
|
||||
tableName: string,
|
||||
isCreated: boolean,
|
||||
logger: bp.Logger
|
||||
) => {
|
||||
logger.forBot().info(`Fetching product details for ID: ${productId}`)
|
||||
|
||||
const productResponse = await bigCommerceClient.getProduct(productId.toString())
|
||||
const product = productResponse.data
|
||||
|
||||
if (!product) return null
|
||||
const categoriesResponse = await bigCommerceClient.getCategories()
|
||||
const categoryById: Record<number, string> = {}
|
||||
|
||||
if (categoriesResponse && categoriesResponse.data) {
|
||||
for (const category of categoriesResponse.data) {
|
||||
categoryById[category.id] = category.name
|
||||
}
|
||||
}
|
||||
const brandsResponse = await bigCommerceClient.getBrands()
|
||||
const brandById: Record<number, string> = {}
|
||||
|
||||
if (brandsResponse && brandsResponse.data) {
|
||||
for (const brand of brandsResponse.data) {
|
||||
brandById[brand.id] = brand.name
|
||||
}
|
||||
}
|
||||
|
||||
const categoryNames =
|
||||
product.categories?.map((categoryId: number) => categoryById[categoryId] || categoryId.toString()) || []
|
||||
|
||||
const categories = categoryNames.join(',')
|
||||
|
||||
const brandName = product.brand_id ? brandById[product.brand_id] || product.brand_id.toString() : ''
|
||||
|
||||
let imageUrl = ''
|
||||
if (product.images && product.images.length > 0) {
|
||||
imageUrl = getProductImageUrl(product.images)
|
||||
}
|
||||
|
||||
const productRow = {
|
||||
product_id: product.id,
|
||||
name: product.name,
|
||||
sku: product.sku,
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
retail_price: product.retail_price,
|
||||
total_sold: product.total_sold || 0,
|
||||
weight: product.weight,
|
||||
type: product.type,
|
||||
inventory_level: product.inventory_level,
|
||||
inventory_tracking: product.inventory_tracking,
|
||||
brand_name: brandName,
|
||||
categories,
|
||||
availability: product.availability,
|
||||
condition: product.condition,
|
||||
is_visible: product.is_visible,
|
||||
sort_order: product.sort_order,
|
||||
description: stripHtmlTags(product.description)?.substring(0, 1000) || '',
|
||||
image_url: imageUrl,
|
||||
url: product.custom_url?.url || '',
|
||||
}
|
||||
|
||||
const { rows } = await botpressVanillaClient.findTableRows({
|
||||
table: tableName,
|
||||
filter: { product_id: product.id },
|
||||
})
|
||||
|
||||
if (rows.length > 0 && rows[0]?.id) {
|
||||
logger.forBot().info(`Updating existing product ID: ${productId}`)
|
||||
await botpressVanillaClient.updateTableRows({
|
||||
table: tableName,
|
||||
rows: [{ id: rows[0].id, ...productRow }],
|
||||
})
|
||||
} else {
|
||||
logger.forBot().info(`Creating new product ID: ${productId}`)
|
||||
await botpressVanillaClient.createTableRows({
|
||||
table: tableName,
|
||||
rows: [productRow],
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Product ${productId} ${isCreated ? 'created' : 'updated'} successfully`,
|
||||
}
|
||||
}
|
||||
|
||||
const handleProductDelete = async (
|
||||
productId: string,
|
||||
botpressVanillaClient: Client,
|
||||
tableName: string,
|
||||
logger: bp.Logger
|
||||
) => {
|
||||
logger.forBot().info(`Deleting product ID: ${productId}`)
|
||||
|
||||
const productIdNumber = Number(productId)
|
||||
|
||||
const { rows } = await botpressVanillaClient.findTableRows({
|
||||
table: tableName,
|
||||
filter: { product_id: productIdNumber },
|
||||
})
|
||||
|
||||
if (rows.length > 0 && rows[0]?.id) {
|
||||
await botpressVanillaClient.deleteTableRows({
|
||||
table: tableName,
|
||||
ids: [rows[0].id],
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Product ${productId} deleted successfully`,
|
||||
}
|
||||
} else {
|
||||
logger.forBot().warn(`Product ID ${productId} not found for deletion`)
|
||||
return {
|
||||
success: true,
|
||||
message: `Product ${productId} not found for deletion`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BigCommerceConfig = {
|
||||
storeHash: string
|
||||
accessToken: string
|
||||
clientId?: string
|
||||
apiVersion?: string
|
||||
}
|
||||
|
||||
const setupBigCommerceWebhooks = async (configuration: BigCommerceConfig, logger: bp.Logger, webhookId: string) => {
|
||||
const webhookUrl = `https://webhook.botpress.cloud/${webhookId}`
|
||||
logger.forBot().info('Setting up BigCommerce webhooks...')
|
||||
|
||||
try {
|
||||
const bigCommerceClient = getBigCommerceClient(configuration)
|
||||
const webhookResults = await bigCommerceClient.createProductWebhooks(webhookUrl)
|
||||
logger.forBot().info('Webhooks created successfully!', webhookResults)
|
||||
return { success: true }
|
||||
} catch (webhookError) {
|
||||
logger.forBot().error('Error creating webhooks:', webhookError)
|
||||
return { success: false, error: webhookError }
|
||||
}
|
||||
}
|
||||
|
||||
const syncBigCommerceProducts = async (ctx: bp.Context, client: bp.Client, logger: bp.Logger) => {
|
||||
logger.forBot().info('Syncing BigCommerce products...')
|
||||
|
||||
try {
|
||||
const syncResult = await actions.syncProducts({
|
||||
ctx,
|
||||
client,
|
||||
logger,
|
||||
input: {
|
||||
batchSize: 25,
|
||||
productsPerPage: 100,
|
||||
clearExisting: true,
|
||||
},
|
||||
type: 'syncProducts',
|
||||
metadata: { setCost: (_cost: number) => {} },
|
||||
})
|
||||
|
||||
logger.forBot().info('Product sync completed!')
|
||||
return { success: true, result: syncResult }
|
||||
} catch (syncError) {
|
||||
logger.forBot().error('Error syncing products during initialization', syncError)
|
||||
return { success: false, error: syncError }
|
||||
}
|
||||
}
|
||||
|
||||
const processWebhookEvent = async (
|
||||
webhookType: WebhookType,
|
||||
productId: string,
|
||||
bigCommerceClient: BigCommerceClient,
|
||||
botpressVanillaClient: Client,
|
||||
tableName: string,
|
||||
logger: bp.Logger,
|
||||
ctx: bp.Context,
|
||||
client: bp.Client
|
||||
) => {
|
||||
if (webhookType === 'created' || webhookType === 'updated') {
|
||||
return await handleProductCreateOrUpdate(
|
||||
productId.toString(),
|
||||
bigCommerceClient,
|
||||
botpressVanillaClient,
|
||||
tableName,
|
||||
webhookType === 'created',
|
||||
logger
|
||||
)
|
||||
}
|
||||
|
||||
if (webhookType === 'deleted') {
|
||||
return await handleProductDelete(productId.toString(), botpressVanillaClient, tableName, logger)
|
||||
}
|
||||
|
||||
logger.forBot().warn(`Unrecognized event type: ${webhookType}, falling back to full sync`)
|
||||
|
||||
const result = await actions.syncProducts({
|
||||
ctx,
|
||||
client,
|
||||
logger,
|
||||
input: {},
|
||||
type: 'syncProducts',
|
||||
metadata: { setCost: (_cost: number) => {} },
|
||||
})
|
||||
|
||||
if (result) {
|
||||
result.message = 'Full sync performed (unrecognized event type)'
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
const processWebhookByType = async (
|
||||
webhookType: WebhookType,
|
||||
productId: string,
|
||||
bigCommerceClient: BigCommerceClient,
|
||||
botpressVanillaClient: Client,
|
||||
tableName: string,
|
||||
logger: bp.Logger,
|
||||
ctx: bp.Context,
|
||||
client: bp.Client
|
||||
) => {
|
||||
try {
|
||||
return await processWebhookEvent(
|
||||
webhookType,
|
||||
productId,
|
||||
bigCommerceClient,
|
||||
botpressVanillaClient,
|
||||
tableName,
|
||||
logger,
|
||||
ctx,
|
||||
client
|
||||
)
|
||||
} catch (error) {
|
||||
logger.forBot().error(`Error processing ${webhookType} for product ${productId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export default new bp.Integration({
|
||||
register: async ({ client, ctx, logger }) => {
|
||||
try {
|
||||
logger.forBot().info('Registering BigCommerce integration')
|
||||
const botpressVanillaClient = getVanillaClient(client)
|
||||
|
||||
await botpressVanillaClient.getOrCreateTable({
|
||||
table: PRODUCT_TABLE,
|
||||
schema: PRODUCT_TABLE_SCHEMA,
|
||||
})
|
||||
|
||||
const syncResult = await syncBigCommerceProducts(ctx, client, logger)
|
||||
|
||||
if (syncResult.success && ctx.webhookId) {
|
||||
await setupBigCommerceWebhooks(ctx.configuration, logger, ctx.webhookId)
|
||||
}
|
||||
|
||||
logger.forBot().info('BigCommerce integration registered successfully')
|
||||
} catch (error) {
|
||||
logger.forBot().error('Error registering BigCommerce integration', error)
|
||||
}
|
||||
},
|
||||
unregister: async () => {},
|
||||
actions,
|
||||
channels: {},
|
||||
handler: async ({ req, client, ctx, logger }) => {
|
||||
if (req.method !== 'POST') {
|
||||
return {
|
||||
status: 405,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: 'Method not allowed',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const isBCWebhook = isBigCommerceWebhook(req.headers)
|
||||
const webhookData = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
|
||||
if (webhookData?.event === 'background-sync-triggered') {
|
||||
logger.forBot().info('Processing internal background sync webhook')
|
||||
|
||||
const {
|
||||
startPage,
|
||||
totalPages,
|
||||
tableName,
|
||||
batchSize,
|
||||
productsPerPage,
|
||||
storeHash,
|
||||
accessToken,
|
||||
categoryById,
|
||||
brandById,
|
||||
} = webhookData.data
|
||||
|
||||
try {
|
||||
const result = await executeBackgroundSync({
|
||||
ctx: { configuration: { storeHash, accessToken } },
|
||||
input: {
|
||||
startPage,
|
||||
totalPages,
|
||||
tableName,
|
||||
batchSize,
|
||||
productsPerPage,
|
||||
categoryById,
|
||||
brandById,
|
||||
},
|
||||
logger,
|
||||
client,
|
||||
})
|
||||
|
||||
logger.forBot().info(`Background sync result: ${JSON.stringify(result)}`)
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Background processing completed successfully',
|
||||
result,
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.forBot().error(`Error syncing products: ${error}`)
|
||||
return {
|
||||
status: 500,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: `Background processing failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBCWebhook) {
|
||||
logger.forBot().warn('Rejecting request - not a BigCommerce webhook')
|
||||
return {
|
||||
status: 401,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: 'Unauthorized: Request does not appear to be from BigCommerce',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const botpressVanillaClient = getVanillaClient(client)
|
||||
const tableName = PRODUCT_TABLE
|
||||
const bigCommerceClient = getBigCommerceClient(ctx.configuration)
|
||||
|
||||
if (!webhookData) {
|
||||
logger.forBot().warn('No webhook data found, unable to process')
|
||||
return {
|
||||
status: 400,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: 'No webhook data found',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const productId = extractProductId(webhookData)
|
||||
let webhookType: WebhookType | undefined
|
||||
|
||||
if (webhookData.scope && typeof webhookData.scope === 'string' && webhookData.scope.includes('product')) {
|
||||
webhookType = determineWebhookTypeFromScope(webhookData.scope)
|
||||
} else if (webhookData.producer && webhookData.producer === 'product') {
|
||||
webhookType = determineWebhookTypeFromProducer(webhookData)
|
||||
}
|
||||
|
||||
if (!webhookType || !productId) {
|
||||
logger.forBot().warn('Could not extract product ID or event type from webhook, falling back to full sync')
|
||||
|
||||
logger.forBot().info('Webhook info:', {
|
||||
hasProductId: !!productId,
|
||||
hasScope: !!webhookType,
|
||||
})
|
||||
const result = await actions.syncProducts({
|
||||
ctx,
|
||||
client,
|
||||
logger,
|
||||
input: {},
|
||||
type: 'syncProducts',
|
||||
metadata: { setCost: (_cost: number) => {} },
|
||||
})
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
success: result.success,
|
||||
message: 'Full sync performed (fallback)',
|
||||
syncResult: result,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
logger.forBot().info(`Processing event: ${webhookType} for product ID: ${productId}`)
|
||||
|
||||
const result = await processWebhookByType(
|
||||
webhookType,
|
||||
productId.toString(),
|
||||
bigCommerceClient,
|
||||
botpressVanillaClient,
|
||||
tableName,
|
||||
logger,
|
||||
ctx,
|
||||
client
|
||||
)
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify(result),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.forBot().error('Error handling webhook:', error)
|
||||
return {
|
||||
status: 500,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: `Error handling webhook: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}),
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
export const PRODUCT_TABLE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
product_id: { type: 'number', 'x-zui': { searchable: false } },
|
||||
name: { type: 'string', 'x-zui': { searchable: true } },
|
||||
sku: { type: 'string', 'x-zui': { searchable: false } },
|
||||
price: { type: 'number', 'x-zui': { searchable: false } },
|
||||
sale_price: { type: 'number', 'x-zui': { searchable: false } },
|
||||
retail_price: { type: 'number', 'x-zui': { searchable: false } },
|
||||
total_sold: { type: 'number', 'x-zui': { searchable: false } },
|
||||
weight: { type: 'number', 'x-zui': { searchable: false } },
|
||||
type: { type: 'string', 'x-zui': { searchable: false } },
|
||||
inventory_level: { type: 'number', 'x-zui': { searchable: false } },
|
||||
inventory_tracking: { type: 'string', 'x-zui': { searchable: false } },
|
||||
brand_name: { type: 'string', 'x-zui': { searchable: true } },
|
||||
categories: { type: 'string', 'x-zui': { searchable: true } },
|
||||
availability: { type: 'string', 'x-zui': { searchable: false } },
|
||||
condition: { type: 'string', 'x-zui': { searchable: false } },
|
||||
is_visible: { type: 'boolean', 'x-zui': { searchable: false } },
|
||||
sort_order: { type: 'number', 'x-zui': { searchable: false } },
|
||||
description: { type: 'string', 'x-zui': { searchable: true } },
|
||||
image_url: { type: 'string', 'x-zui': { searchable: false } },
|
||||
url: { type: 'string', 'x-zui': { searchable: false } },
|
||||
},
|
||||
required: ['product_id', 'name'],
|
||||
}
|
||||
|
||||
export const PRODUCTS_TABLE_NAME = 'bigcommerce_products_Table'
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user