import type { Printful, Webflow } from "./util/types"; import { formatSlug, type DeepPartial } from "./util/misc"; import { WebflowClient } from "./webflow"; import { PrintfulClient } from "./printful"; import { redis } from "bun"; import pino from "pino"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); type MetaPrintfulProduct = { name: string; externalId: string; isColorGrouped: boolean; colorGroups: Set; variants: { colorGroup: string | null; product: Printful.Products.Product; }[]; }; type SyncState = { printfulProductIds: number[]; }; export class ProductSyncer { private log: pino.Logger; constructor( private printful: PrintfulClient, private webflow: WebflowClient, log?: pino.Logger, ) { this.log = (log ?? pino({ level: "silent" })).child({ component: "ProductSyncer" }); } async sync(printfulProductId?: number): Promise { this.log.info("attempting sync run"); const canRun = await this.run(); if (!canRun) return false; try { const syncStart = Date.now(); if (printfulProductId) this.log.info({ printfulProductId }, "sync run started"); else this.log.info("sync run started"); this.log.debug("populating webflow products"); const webflowProducts: Webflow.Products.ProductAndSkus[] = await this.webflow.Products.list({ forceAll: true }); this.log.debug("populating printful products"); let printfulProducts = await this.printful.Products.list({ forceAll: true }); // optionally filter to sync only the given printful product if (printfulProductId) { const printfulProductFilter = printfulProducts.find((p) => p.id === printfulProductId); if (!printfulProductFilter) { this.log.warn({ printfulProductId }, "printful product not found, skipping sync"); return true; } const metaPrintfulProductName = this.getMetaPrintfulProductName(printfulProductFilter.name); printfulProducts = printfulProducts.filter((p) => this.getMetaPrintfulProductName(p.name) === metaPrintfulProductName ); } this.log.info({ count: printfulProducts.length }, "generating meta printful products"); const metaPrintfulProducts = await this.generateMetaPrintfulProducts(printfulProducts); for (const metaPrintfulProduct of metaPrintfulProducts) { this.log.info({ name: metaPrintfulProduct.name, externalId: metaPrintfulProduct.externalId }, "syncing meta printful product"); await this.setState({ printfulProductIds: metaPrintfulProduct.variants.map( (v) => v.product.sync_product.id, ), }); // For color-grouped products, colors are the bracket-extracted values. // For normal products, colors are collected from the variant data below. const foundColors: Set = metaPrintfulProduct.isColorGrouped ? metaPrintfulProduct.colorGroups : new Set(); const foundSizes: Set = new Set(); const webflowSkus: DeepPartial[] = []; for (const metaVariant of metaPrintfulProduct.variants) { for (const printfulVariant of metaVariant.product.sync_variants) { let skuColor: string; if (metaPrintfulProduct.isColorGrouped) { // Only include sizes present across every color variant. const sizeInAll = metaPrintfulProduct.variants.every((v) => v.product.sync_variants.some((sv) => sv.size === printfulVariant.size) ); if (!sizeInAll) continue; skuColor = metaVariant.colorGroup!; } else { skuColor = printfulVariant.color; foundColors.add(skuColor); } this.log.debug( { size: printfulVariant.size, color: skuColor, retailPrice: printfulVariant.retail_price }, "generating webflow SKU from printful variant" ); foundSizes.add(printfulVariant.size); webflowSkus.push({ id: printfulVariant.external_id, fieldData: { name: printfulVariant.name, slug: formatSlug(printfulVariant.name), "sku-values": { color: skuColor, size: printfulVariant.size, }, price: { value: Math.floor( +printfulVariant.retail_price * 100, ), unit: printfulVariant.currency, currency: printfulVariant.currency, }, "main-image": PrintfulClient.Util.getVariantMainImage( printfulVariant, ), }, }); } } // SYNC PRODUCT DATA let existingWebflowProduct = webflowProducts.find( (webflowProduct) => webflowProduct.product.id === metaPrintfulProduct.externalId.split("-")[0], ); if (existingWebflowProduct) { // SYNC PRODUCT UPDATE this.log.info("existing webflow product found, updating it"); // do not update images for (const webflowSku of webflowSkus) delete webflowSku.fieldData?.["main-image"]; const webflowProductId = metaPrintfulProduct.externalId.split("-")[0]; if (!webflowProductId) throw new Error("Malformed printful product ID"); if (!webflowSkus[0]) throw new Error("no webflow SKUs generated — all variants were filtered out"); const firstExistingWebflowSku = this.resolveWebflowSku( existingWebflowProduct.skus, webflowSkus[0].fieldData?.["sku-values"]?.["color"], webflowSkus[0].fieldData?.["sku-values"]?.["size"], webflowSkus[0].id, ); await this.webflow.Products.update(webflowProductId, { product: { fieldData: { name: metaPrintfulProduct.name, slug: formatSlug(metaPrintfulProduct.name), shippable: true, "tax-category": "standard-taxable", "sku-properties": [ { id: "color", name: "Color", enum: Array.from(foundColors).map((color) => ({ id: color, slug: formatSlug(color), name: color, })), }, { id: "size", name: "Size", enum: Array.from(foundSizes).map((size) => ({ id: size, slug: formatSlug(size), name: size, })), }, ], }, }, sku: firstExistingWebflowSku ? { ...webflowSkus[0], id: firstExistingWebflowSku.id } : webflowSkus[0], }); for (const webflowSku of webflowSkus) { const existingWebflowSku = this.resolveWebflowSku( existingWebflowProduct.skus, webflowSku.fieldData?.["sku-values"]?.["color"], webflowSku.fieldData?.["sku-values"]?.["size"], webflowSku.id, ); if (existingWebflowSku) { await this.webflow.Products.Skus.update( webflowProductId, existingWebflowSku.id, webflowSku, ); } else { await this.webflow.Products.Skus.create( webflowProductId, [webflowSku], ); } } } else { // SYNC PRODUCT CREATE this.log.info("existing webflow product not found, creating it"); if (!webflowSkus[0]) throw new Error("no webflow SKUs generated — all variants were filtered out"); const webflowProductId = await this.webflow.Products.create({ product: { fieldData: { name: metaPrintfulProduct.name, slug: formatSlug(metaPrintfulProduct.name), shippable: true, "tax-category": "standard-taxable", "sku-properties": [ { id: "color", name: "Color", enum: Array.from(foundColors).map((color) => ({ id: color, slug: formatSlug(color), name: color, })), }, { id: "size", name: "Size", enum: Array.from(foundSizes).map((size) => ({ id: size, slug: formatSlug(size), name: size, })), }, ], }, }, sku: webflowSkus[0], }); // create webflow product SKUs if (webflowSkus.length > 1) await this.webflow.Products.Skus.create(webflowProductId, webflowSkus.slice(1)); existingWebflowProduct = await this.webflow.Products.get(webflowProductId); if (!existingWebflowProduct) throw new Error("webflow product missing after create"); for (const metaVariant of metaPrintfulProduct.variants) { const newPrintfulVariants: DeepPartial[] = []; for (const printfulVariant of metaVariant.product.sync_variants) { // For color-grouped products, the webflow SKU color is the bracket color. // For normal products, the webflow SKU color is the variant's own color. const skuColor = metaPrintfulProduct.isColorGrouped ? metaVariant.colorGroup! : printfulVariant.color; const associatedWebflowSku = this.resolveWebflowSku( existingWebflowProduct.skus, skuColor, printfulVariant.size, ); if (associatedWebflowSku) { newPrintfulVariants.push({ id: printfulVariant.id, external_id: String(associatedWebflowSku.id), }); } } await sleep(10000); this.log.info({ printfulProduct: metaVariant.product.sync_product.id }, "updating printful product"); await this.printful.Products.update( metaVariant.product.sync_product.id, { sync_product: { id: metaVariant.product.sync_product.id, external_id: metaPrintfulProduct.isColorGrouped ? `${webflowProductId}-${metaVariant.colorGroup}` : webflowProductId, }, sync_variants: newPrintfulVariants, }, ); } } } this.log.info({ durationMs: Date.now() - syncStart }, "sync complete"); } finally { try { await redis.del("commerce:sync:lock"); await redis.del("commerce:sync:state"); } catch { this.log.error("failed to reset sync state"); } } return true; } findColorInProductName(productName: string): string | null { const m = productName.match(/\[([^\]]+)\]/); return m?.[1] ?? null; } async generateMetaPrintfulProducts(printfulProducts: Printful.Products.SyncProduct[]) { const metaPrintfulProducts: MetaPrintfulProduct[] = []; for (const printfulProduct of printfulProducts) { const metaPrintfulProductName = this.getMetaPrintfulProductName(printfulProduct.name); const colorGroup = this.findColorInProductName(printfulProduct.name); let metaPrintfulProduct = metaPrintfulProducts.find((mp) => mp.name === metaPrintfulProductName); if (!metaPrintfulProduct) { this.log.debug({ metaPrintfulProductName, externalId: printfulProduct.external_id }, "appending meta printful product"); metaPrintfulProduct = { name: metaPrintfulProductName, variants: [], externalId: printfulProduct.external_id, isColorGrouped: colorGroup !== null, colorGroups: new Set(), }; metaPrintfulProducts.push(metaPrintfulProduct); } this.log.debug({ colorGroup }, "appending meta printful product variant"); const product = await this.printful.Products.get(printfulProduct.id); if (!product) throw new Error(`Printful product ${printfulProduct.id} not found`); metaPrintfulProduct.variants.push({ colorGroup, product }); if (colorGroup) metaPrintfulProduct.colorGroups.add(colorGroup); // Prefer a non-empty external_id so a newly added color (which has no // external_id yet) doesn't shadow an already-synced sibling's ID. if (!metaPrintfulProduct.externalId && printfulProduct.external_id) metaPrintfulProduct.externalId = printfulProduct.external_id; } return metaPrintfulProducts; } getMetaPrintfulProductName(productName: string): string { const color = this.findColorInProductName(productName); if (color) return productName.replace(`[${color}]`, "").trimEnd(); return productName; } private resolveWebflowSku( existingSkus: Webflow.Products.Skus.Sku[], color: string | undefined, size: string | undefined, id?: string, ): Webflow.Products.Skus.Sku | undefined { return existingSkus.find( (sku) => (id && sku.id === id) || (sku.fieldData["sku-values"]?.["color"] === color && sku.fieldData["sku-values"]?.["size"] === size), ); } async isRunning(): Promise { return !!await redis.get("commerce:sync:lock"); } async getState(): Promise { const val = await redis.get("commerce:sync:state"); if (val) return JSON.parse(val); return { printfulProductIds: [] }; } private async setState(state: SyncState) { await redis.set("commerce:sync:state", JSON.stringify(state), "EX", 1800); } private async run(): Promise { return !!await redis.set("commerce:sync:lock", "1", "NX", "EX", "1800"); } }