Files
botpress--botpress/integrations/bigcommerce-sync/src/actions/sync-products.ts
T
2026-07-13 13:34:48 +08:00

428 lines
13 KiB
TypeScript

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