import type { Printful, Webflow } from "./util/types"; import { formatSlug, type DeepPartial } from "./util/misc"; import { WebflowClient } from "./webflow"; import { PrintfulClient } from "./printful"; import pino from "pino"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); type ProductEntry = { colorGroup: string | null; product: Printful.Products.Product; }; type PMetaProduct = { name: string; wProductId: string; entries: ProductEntry[]; }; export type ProductSyncerOptions = { filter?: { pProductIds?: number[]; }, beforeStart?: (startDate: Date) => Promise afterCompletion?: (completionDate: Date, duration: number) => Promise beforeStep?: (pProductIds: number[]) => Promise }; 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 syncApparel(opt: ProductSyncerOptions = {}) { const startDate = new Date(); await opt.beforeStart?.(startDate); if (opt.filter?.pProductIds) this.log.info({ pProductIds: opt.filter.pProductIds }, "apparel sync started (filtered)"); else this.log.info("apparel sync started (all)"); this.log.debug("retrieving webflow products"); const allWProducts = await this.Webflow.Products.list({ forceAll: true }); this.log.debug("retrieving printful products"); const allPProducts = await this.Printful.Products.list({ forceAll: true }); this.log.info({ pProductCount: allPProducts.length, filter: opt.filter ?? "N/A" }, "generating printful meta products"); const pMetaProducts = await this.generatePMetaProducts(allWProducts, allPProducts, opt); for (const pMetaProduct of pMetaProducts) { const pProductIds = pMetaProduct.entries.map((e) => e.product.sync_product.id); this.log.info({ name: pMetaProduct.name, externalId: pMetaProduct.wProductId, pProductIds }, "syncing printful meta product"); await opt.beforeStep?.(pProductIds); const newWSkus: DeepPartial[] = this.generateWSkus(pMetaProduct); const foundColors = new Set( newWSkus.map((sku) => sku.fieldData?.["sku-values"]?.["color"]).filter((v) => v !== undefined) ); const foundSizes = new Set( newWSkus.map((sku) => sku.fieldData?.["sku-values"]?.["size"]).filter((v) => v !== undefined) ); if (!newWSkus[0]) throw new Error("no webflow SKUs generated — all variants were filtered out"); const wProductFieldData = { name: pMetaProduct.name, slug: formatSlug(pMetaProduct.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 wProduct = allWProducts.find((wp) => wp.product.id === pMetaProduct.wProductId); if (wProduct) { this.log.info({ id: wProduct.product.id }, "existing webflow product found, updating it"); // do not update images for (const sku of newWSkus) delete sku.fieldData?.["main-image"]; // handle case where webflow SKU created by associated printful variant external_id update failed if (!newWSkus[0].id) { this.log.warn({ wSku: newWSkus[0] }, "missing webflow SKU id"); const wSkuMatch = this.resolveWSku( wProduct.skus, newWSkus[0].fieldData?.["sku-values"]?.["color"], newWSkus[0].fieldData?.["sku-values"]?.["size"], ); if (wSkuMatch) newWSkus[0].id = wSkuMatch.id; else this.log.warn({ wSku: newWSkus[0] }, "could not find existing webflow SKU that matches"); } await this.Webflow.Products.update(wProduct.product.id, { product: { fieldData: wProductFieldData }, sku: newWSkus[0], }); for (const newWSku of newWSkus) { const existingWSku = this.resolveWSku( wProduct.skus, newWSku.fieldData?.["sku-values"]?.["color"], newWSku.fieldData?.["sku-values"]?.["size"], newWSku.id, ); if (existingWSku) { await this.Webflow.Products.Skus.update( pMetaProduct.wProductId, existingWSku.id, newWSku, ); } else { await this.Webflow.Products.Skus.create( pMetaProduct.wProductId, [newWSku], ); } } } else { this.log.info("existing webflow product not found, creating it"); const wProductId = await this.Webflow.Products.create({ product: { fieldData: wProductFieldData }, sku: newWSkus[0], }); pMetaProduct.wProductId = wProductId; if (newWSkus.length > 1) await this.Webflow.Products.Skus.create(wProductId, newWSkus.slice(1)); } // Re-fetch after SKU mutations so newly created SKUs have their real IDs. const freshWProduct = await this.Webflow.Products.get(pMetaProduct.wProductId); if (!freshWProduct) throw new Error("webflow product missing"); // Update printful external IDs for (const { colorGroup, product: pProduct } of pMetaProduct.entries) { const expectedExternalId = colorGroup ? `${pMetaProduct.wProductId}-${colorGroup}` : pMetaProduct.wProductId; if (pProduct.sync_product.external_id === expectedExternalId) continue; const pVariantPatches: DeepPartial[] = []; for (const pVariant of pProduct.sync_variants) { const wSku = this.resolveWSku( freshWProduct.skus, colorGroup ?? pVariant.color, pVariant.size, ); if (wSku) { pVariantPatches.push({ id: pVariant.id, external_id: String(wSku.id), }); } } await sleep(10000); this.log.info({ pProduct: pProduct.sync_product.id, externalId: expectedExternalId }, "updating printful product's external ID"); await this.Printful.Products.update(pProduct.sync_product.id, { sync_product: { id: pProduct.sync_product.id, external_id: expectedExternalId, }, sync_variants: pVariantPatches, }); } } const endDate = new Date(); const duration = endDate.getTime() - startDate.getTime(); await opt.afterCompletion?.(endDate, duration); this.log.info({ durationMs: duration }, "sync complete"); } parseColorGroup(name: string): string | null { const m = name.match(/\[([^\]]+)\]/); return m?.[1] ?? null; } private generateWSkus(pMetaProduct: PMetaProduct): DeepPartial[] { const wSkus: DeepPartial[] = []; for (const { colorGroup, product: pProduct } of pMetaProduct.entries) { const isColorGrouped = colorGroup !== null; for (const pVariant of pProduct.sync_variants) { if (isColorGrouped) { // Only include sizes present across every color variant. const sizeInAll = pMetaProduct.entries.every((e) => e.product.sync_variants.some((sv) => sv.size === pVariant.size) ); if (!sizeInAll) continue; } const color = isColorGrouped ? colorGroup : pVariant.color; this.log.debug( { color, size: pVariant.size, retailPrice: pVariant.retail_price }, "generating webflow SKU from printful variant" ); wSkus.push({ id: pVariant.external_id, fieldData: { name: pVariant.name, slug: formatSlug(pVariant.name), "sku-values": { color, size: pVariant.size, }, price: { value: Math.round(+pVariant.retail_price * 100), unit: pVariant.currency, currency: pVariant.currency, }, "main-image": PrintfulClient.Util.getVariantMainImage(pVariant), }, }); } } return wSkus; } async generatePMetaProducts( allWProducts: Webflow.Products.ProductAndSkus[], allPProducts: Printful.Products.SyncProduct[], opt: ProductSyncerOptions, ) { const metaNameFilter = opt.filter?.pProductIds ?.map((id) => allPProducts.find((p) => p.id === id)) ?.filter((p) => p !== undefined) ?.map((p) => this.getPMetaProductName(p)); const pMetaProducts: PMetaProduct[] = []; for (const pProduct of allPProducts) { const metaName = this.getPMetaProductName(pProduct); const colorGroup = this.parseColorGroup(pProduct.name); const wProductId = this.isPProductSynced(pProduct, allWProducts) ? pProduct.external_id.split("-")[0] ?? "" : ""; if (metaNameFilter && !metaNameFilter.includes(metaName)) continue; let pMetaProduct = pMetaProducts.find((mp) => mp.name === metaName); if (!pMetaProduct) { this.log.debug({ metaName, colorGroup, wProductId }, "generating printful meta product"); pMetaProduct = { name: metaName, wProductId, entries: [], }; pMetaProducts.push(pMetaProduct); } else if (!pMetaProduct.wProductId && wProductId) { // Prefer a non-empty wProductId so a newly added color doesn't shadow an already-synced color's ID. pMetaProduct.wProductId = wProductId; } const fullPProduct = await this.Printful.Products.get(pProduct.id); if (!fullPProduct) throw new Error(`Printful product ${pProduct.id} not found`); const isDuplicate = allPProducts.some((p) => p.id !== pProduct.id && p.name === pProduct.name); if (isDuplicate) this.log.warn({ pProductName: pProduct.name }, "found duplicate printful product"); this.log.debug({ name: metaName, colorGroup }, "adding entry to printful meta product"); pMetaProduct.entries.push({ colorGroup, product: fullPProduct }); } return pMetaProducts; } getPMetaProductName(pProduct: Printful.Products.SyncProduct): string { const color = this.parseColorGroup(pProduct.name); if (color) return pProduct.name.replace(`[${color}]`, "").trimEnd(); return pProduct.name; } private resolveWSku( 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 isPProductSynced(pProduct: Printful.Products.SyncProduct, allWProducts: Webflow.Products.ProductAndSkus[]) { return allWProducts.some((wp) => wp.product.id === pProduct.external_id.split("-")[0]); } }