505 lines
14 KiB
TypeScript
505 lines
14 KiB
TypeScript
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)}`,
|
|
}),
|
|
}
|
|
}
|
|
},
|
|
})
|