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 ProductEntry = { colorGroup: string | null; product: Printful.Products.Product; }; type MetaPrintfulProduct = { name: string; webflowProductId: string; entries: ProductEntry[]; }; type SyncState = { printfulProductIds: number[]; }; type SyncOptions = { filter?: { printfulProductIds?: number[] | 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(opt: SyncOptions = {}): Promise { if (typeof opt.filter?.printfulProductIds === "number") opt.filter.printfulProductIds = [opt.filter.printfulProductIds]; this.log.info("attempting sync run"); const canRun = await this.run(); if (!canRun) return false; try { const syncStart = Date.now(); if (opt.filter?.printfulProductIds) this.log.info({ printfulProductIds: opt.filter.printfulProductIds }, "sync started (filtered)"); else this.log.info("sync started (all)"); this.log.debug("retrieving webflow products"); const allWebflowProducts = await this.webflow.Products.list({ forceAll: true }); this.log.debug("retrieving printful products"); const allPrintfulProducts = await this.printful.Products.list({ forceAll: true }); this.log.info({ printfulProductCount: allPrintfulProducts.length, filter: opt.filter ?? "N/A" }, "generating meta printful products"); const metaPrintfulProducts = await this.generateMetaPrintfulProducts(allWebflowProducts, allPrintfulProducts, opt); for (const metaPrintfulProduct of metaPrintfulProducts) { this.log.info({ name: metaPrintfulProduct.name, externalId: metaPrintfulProduct.webflowProductId }, "syncing meta printful product"); await this.setState({ printfulProductIds: metaPrintfulProduct.entries.map((e) => e.product.sync_product.id), }); const newWebflowSkus: DeepPartial[] = this.generateWebflowSkus(metaPrintfulProduct); const foundColors = new Set( newWebflowSkus.map((sku) => sku.fieldData?.["sku-values"]?.["color"]).filter((v) => v !== undefined) ); const foundSizes = new Set( newWebflowSkus.map((sku) => sku.fieldData?.["sku-values"]?.["size"]).filter((v) => v !== undefined) ); if (!newWebflowSkus[0]) throw new Error("no webflow SKUs generated — all variants were filtered out"); const webflowProductFieldData = { 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, })), }, ], }; const webflowProduct = allWebflowProducts.find((wp) => wp.product.id === metaPrintfulProduct.webflowProductId); if (webflowProduct) { this.log.info({ id: webflowProduct.product.id }, "existing webflow product found, updating it"); // do not update images for (const sku of newWebflowSkus) delete sku.fieldData?.["main-image"]; // handle case where webflow SKU created by associated printful variant external_id update failed if (!newWebflowSkus[0].id) { this.log.warn({ webflowSku: newWebflowSkus[0] }, "missing webflow SKU id"); const webflowSkuMatch = this.resolveWebflowSku( webflowProduct.skus, newWebflowSkus[0].fieldData?.["sku-values"]?.["color"], newWebflowSkus[0].fieldData?.["sku-values"]?.["size"], ); if (webflowSkuMatch) newWebflowSkus[0].id = webflowSkuMatch.id; else this.log.warn({ webflowSku: newWebflowSkus[0] }, "could not find existing webflow SKU that matches"); } await this.webflow.Products.update(webflowProduct.product.id, { product: { fieldData: webflowProductFieldData }, sku: newWebflowSkus[0], }); for (const newWebflowSku of newWebflowSkus) { const existingWebflowSku = this.resolveWebflowSku( webflowProduct.skus, newWebflowSku.fieldData?.["sku-values"]?.["color"], newWebflowSku.fieldData?.["sku-values"]?.["size"], newWebflowSku.id, ); if (existingWebflowSku) { await this.webflow.Products.Skus.update( metaPrintfulProduct.webflowProductId, existingWebflowSku.id, newWebflowSku, ); } else { await this.webflow.Products.Skus.create( metaPrintfulProduct.webflowProductId, [newWebflowSku], ); } } } else { this.log.info("existing webflow product not found, creating it"); const webflowProductId = await this.webflow.Products.create({ product: { fieldData: webflowProductFieldData }, sku: newWebflowSkus[0], }); metaPrintfulProduct.webflowProductId = webflowProductId; if (newWebflowSkus.length > 1) await this.webflow.Products.Skus.create(webflowProductId, newWebflowSkus.slice(1)); } // Re-fetch after SKU mutations so newly created SKUs have their real IDs. const freshWebflowProduct = await this.webflow.Products.get(metaPrintfulProduct.webflowProductId); if (!freshWebflowProduct) throw new Error("webflow product missing"); // Update printful external IDs for (const { colorGroup, product: printfulProduct } of metaPrintfulProduct.entries) { const expectedExternalId = colorGroup ? `${metaPrintfulProduct.webflowProductId}-${colorGroup}` : metaPrintfulProduct.webflowProductId; if (printfulProduct.sync_product.external_id === expectedExternalId) continue; const printfulVariantPatches: DeepPartial[] = []; for (const printfulVariant of printfulProduct.sync_variants) { const webflowSku = this.resolveWebflowSku( freshWebflowProduct.skus, colorGroup ?? printfulVariant.color, printfulVariant.size, ); if (webflowSku) { printfulVariantPatches.push({ id: printfulVariant.id, external_id: String(webflowSku.id), }); } } await sleep(10000); this.log.info({ printfulProduct: printfulProduct.sync_product.id, externalId: expectedExternalId }, "updating printful product's external ID"); await this.printful.Products.update(printfulProduct.sync_product.id, { sync_product: { id: printfulProduct.sync_product.id, external_id: expectedExternalId, }, sync_variants: printfulVariantPatches, }); } } 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; } parseColorGroup(name: string): string | null { const m = name.match(/\[([^\]]+)\]/); return m?.[1] ?? null; } private generateWebflowSkus(metaPrintfulProduct: MetaPrintfulProduct): DeepPartial[] { const webflowSkus: DeepPartial[] = []; for (const { colorGroup, product: printfulProduct } of metaPrintfulProduct.entries) { const isColorGrouped = colorGroup !== null; for (const printfulVariant of printfulProduct.sync_variants) { if (isColorGrouped) { // Only include sizes present across every color variant. const sizeInAll = metaPrintfulProduct.entries.every((e) => e.product.sync_variants.some((sv) => sv.size === printfulVariant.size) ); if (!sizeInAll) continue; } const color = isColorGrouped ? colorGroup : printfulVariant.color; this.log.debug( { color, size: printfulVariant.size, retailPrice: printfulVariant.retail_price }, "generating webflow SKU from printful variant" ); webflowSkus.push({ id: printfulVariant.external_id, fieldData: { name: printfulVariant.name, slug: formatSlug(printfulVariant.name), "sku-values": { color, size: printfulVariant.size, }, price: { value: Math.floor(+printfulVariant.retail_price * 100), unit: printfulVariant.currency, currency: printfulVariant.currency, }, "main-image": PrintfulClient.Util.getVariantMainImage(printfulVariant), }, }); } } return webflowSkus; } async generateMetaPrintfulProducts( allWebflowProducts: Webflow.Products.ProductAndSkus[], allPrintfulProducts: Printful.Products.SyncProduct[], opt: SyncOptions, ) { if (typeof opt.filter?.printfulProductIds === "number") opt.filter.printfulProductIds = [opt.filter.printfulProductIds]; const metaNameFilter = opt.filter?.printfulProductIds ?.map((id) => allPrintfulProducts.find((p) => p.id === id)) ?.filter((p) => p !== undefined) ?.map((p) => this.getMetaPrintfulProductName(p)); const metaPrintfulProducts: MetaPrintfulProduct[] = []; for (const printfulProduct of allPrintfulProducts) { const metaName = this.getMetaPrintfulProductName(printfulProduct); const colorGroup = this.parseColorGroup(printfulProduct.name); const webflowProductId = this.isPrintfulProductSynced(printfulProduct, allWebflowProducts) ? printfulProduct.external_id.split("-")[0] ?? "" : ""; if (metaNameFilter && !metaNameFilter.includes(metaName)) continue; let metaPrintfulProduct = metaPrintfulProducts.find((mp) => mp.name === metaName); if (!metaPrintfulProduct) { this.log.debug({ metaName, colorGroup, webflowProductId }, "generating meta printful product"); metaPrintfulProduct = { name: metaName, webflowProductId, entries: [], }; metaPrintfulProducts.push(metaPrintfulProduct); } else if (!metaPrintfulProduct.webflowProductId && webflowProductId) { // Prefer a non-empty webflowProductId so a newly added color doesn't shadow an already-synced color's ID. metaPrintfulProduct.webflowProductId = webflowProductId; } const fullPrintfulProduct = await this.printful.Products.get(printfulProduct.id); if (!fullPrintfulProduct) throw new Error(`Printful product ${printfulProduct.id} not found`); const isDuplicate = allPrintfulProducts.some((p) => p.id !== printfulProduct.id && p.name === printfulProduct.name); if (isDuplicate) this.log.warn({ printfulProductName: printfulProduct.name }, "found duplicate printful product"); this.log.debug({ name: metaName, colorGroup }, "adding entry to meta printful product"); metaPrintfulProduct.entries.push({ colorGroup, product: fullPrintfulProduct }); } return metaPrintfulProducts; } getMetaPrintfulProductName(printfulProduct: Printful.Products.SyncProduct): string { const color = this.parseColorGroup(printfulProduct.name); if (color) return printfulProduct.name.replace(`[${color}]`, "").trimEnd(); return printfulProduct.name; } 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), ); } private isPrintfulProductSynced(printfulProduct: Printful.Products.SyncProduct, allWebflowProducts: Webflow.Products.ProductAndSkus[]) { return allWebflowProducts.some((wp) => wp.product.id === printfulProduct.external_id.split("-")[0]); } 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"); } }