diff --git a/apps/api/src/scripts/validate-products.ts b/apps/api/src/scripts/validate-products.ts index 7a5894b..815c5c1 100644 --- a/apps/api/src/scripts/validate-products.ts +++ b/apps/api/src/scripts/validate-products.ts @@ -54,7 +54,7 @@ for (const wProduct of wProducts) { // validateEquality(sku.fieldData.name, pVariant.name), validateEquality( sku.fieldData["sku-values"]?.["color"], - syncer.findColorInProductName(pVariant.name), + syncer.parseColorGroup(pVariant.name), ), validateEquality( sku.fieldData["sku-values"]?.["size"], diff --git a/packages/commerce/src/sync.ts b/packages/commerce/src/sync.ts index bcffc4b..1a0bd77 100644 --- a/packages/commerce/src/sync.ts +++ b/packages/commerce/src/sync.ts @@ -7,21 +7,25 @@ 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; - externalId: string; - isColorGrouped: boolean; - colorGroups: Set; - variants: { - colorGroup: string | null; - product: Printful.Products.Product; - }[]; + webflowProductId: string; + entries: ProductEntry[]; }; type SyncState = { printfulProductIds: number[]; }; +type SyncOptions = { + printfulProductIdsFilter?: number[]; +}; + export class ProductSyncer { private log: pino.Logger; @@ -33,261 +37,164 @@ export class ProductSyncer { this.log = (log ?? pino({ level: "silent" })).child({ component: "ProductSyncer" }); } - async sync(printfulProductId?: number): Promise { + async sync(opt: SyncOptions = {}): 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"); + if (opt.printfulProductIdsFilter) + this.log.info({ printfulProductIds: opt.printfulProductIdsFilter }, "sync started (filtered)"); + else + this.log.info("sync started (all)"); 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 - ); - } + const printfulProducts = await this.printful.Products.list({ forceAll: true }); this.log.info({ count: printfulProducts.length }, "generating meta printful products"); - const metaPrintfulProducts = await this.generateMetaPrintfulProducts(printfulProducts); + const metaPrintfulProducts = await this.generateMetaPrintfulProducts(printfulProducts, opt.printfulProductIdsFilter); for (const metaPrintfulProduct of metaPrintfulProducts) { - this.log.info({ name: metaPrintfulProduct.name, externalId: metaPrintfulProduct.externalId }, "syncing meta printful product"); + this.log.info({ name: metaPrintfulProduct.name, externalId: metaPrintfulProduct.webflowProductId }, "syncing meta printful product"); + await this.setState({ - printfulProductIds: metaPrintfulProduct.variants.map( - (v) => v.product.sync_product.id, - ), + printfulProductIds: metaPrintfulProduct.entries.map((e) => e.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], + const newWebflowSkus: DeepPartial[] = this.generateWebflowSkus(metaPrintfulProduct); + const foundColors = new Set( + newWebflowSkus.map((sku) => sku.fieldData?.["sku-values"]?.["color"]).filter((v) => v !== undefined) ); - if (existingWebflowProduct) { - // SYNC PRODUCT UPDATE + 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 = webflowProducts.find( + (wp) => wp.product.id === metaPrintfulProduct.webflowProductId, + ); + + if (webflowProduct) { this.log.info("existing webflow product found, updating it"); // do not update images - for (const webflowSku of webflowSkus) - delete webflowSku.fieldData?.["main-image"]; + for (const sku of newWebflowSkus) delete sku.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, + webflowProduct.skus, + newWebflowSkus[0].fieldData?.["sku-values"]?.["color"], + newWebflowSkus[0].fieldData?.["sku-values"]?.["size"], + newWebflowSkus[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], + + if (firstExistingWebflowSku) { + newWebflowSkus[0].id = firstExistingWebflowSku.id; + } + + await this.webflow.Products.update(webflowProduct.product.id, { + product: { fieldData: webflowProductFieldData }, + sku: newWebflowSkus[0], }); - for (const webflowSku of webflowSkus) { + for (const newWebflowSku of newWebflowSkus) { const existingWebflowSku = this.resolveWebflowSku( - existingWebflowProduct.skus, - webflowSku.fieldData?.["sku-values"]?.["color"], - webflowSku.fieldData?.["sku-values"]?.["size"], - webflowSku.id, + webflowProduct.skus, + newWebflowSku.fieldData?.["sku-values"]?.["color"], + newWebflowSku.fieldData?.["sku-values"]?.["size"], + newWebflowSku.id, ); if (existingWebflowSku) { await this.webflow.Products.Skus.update( - webflowProductId, + metaPrintfulProduct.webflowProductId, existingWebflowSku.id, - webflowSku, + newWebflowSku, ); } else { await this.webflow.Products.Skus.create( - webflowProductId, - [webflowSku], + metaPrintfulProduct.webflowProductId, + [newWebflowSku], ); } } } 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], + product: { fieldData: webflowProductFieldData }, + sku: newWebflowSkus[0], }); + metaPrintfulProduct.webflowProductId = webflowProductId; - // create webflow product SKUs - if (webflowSkus.length > 1) - await this.webflow.Products.Skus.create(webflowProductId, webflowSkus.slice(1)); + if (newWebflowSkus.length > 1) + await this.webflow.Products.Skus.create(webflowProductId, newWebflowSkus.slice(1)); + } - existingWebflowProduct = await this.webflow.Products.get(webflowProductId); - if (!existingWebflowProduct) - throw new Error("webflow product missing after create"); + // 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"); - 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; + 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 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, - }, + const newPrintfulVariants: DeepPartial[] = []; + for (const printfulVariant of printfulProduct.sync_variants) { + const webflowSku = this.resolveWebflowSku( + freshWebflowProduct.skus, + colorGroup ?? printfulVariant.color, + printfulVariant.size, ); + if (webflowSku) { + newPrintfulVariants.push({ + id: printfulVariant.id, + external_id: String(webflowSku.id), + }); + } } + + await sleep(10000); + this.log.info({ printfulProduct: printfulProduct.sync_product.id }, "updating printful product"); + await this.printful.Products.update(printfulProduct.sync_product.id, { + sync_product: { + id: printfulProduct.sync_product.id, + external_id: expectedExternalId, + }, + sync_variants: newPrintfulVariants, + }); } } @@ -303,48 +210,97 @@ export class ProductSyncer { return true; } - findColorInProductName(productName: string): string | null { - const m = productName.match(/\[([^\]]+)\]/); + parseColorGroup(name: string): string | null { + const m = name.match(/\[([^\]]+)\]/); return m?.[1] ?? null; } - async generateMetaPrintfulProducts(printfulProducts: Printful.Products.SyncProduct[]) { + private generateWebflowSkus(metaPrintfulProduct: MetaPrintfulProduct): DeepPartial[] { + const webflowSkus: DeepPartial[] = []; + for (const { colorGroup, product: printfulProduct } of metaPrintfulProduct.entries) { + for (const printfulVariant of printfulProduct.sync_variants) { + if (colorGroup !== null) { + // 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 skuColor = colorGroup ?? printfulVariant.color; + + this.log.debug( + { size: printfulVariant.size, color: skuColor, 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: skuColor, + 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( + printfulProducts: Printful.Products.SyncProduct[], + printfulProductIdsFilter?: number[], + ) { + const nameFilter = printfulProductIdsFilter + ?.map((id) => printfulProducts.find((p) => p.id === id)) + .filter((p) => p !== undefined) + .map((p) => this.getMetaPrintfulProductName(p)); + const metaPrintfulProducts: MetaPrintfulProduct[] = []; for (const printfulProduct of printfulProducts) { - const metaPrintfulProductName = this.getMetaPrintfulProductName(printfulProduct.name); - const colorGroup = this.findColorInProductName(printfulProduct.name); + const metaName = this.getMetaPrintfulProductName(printfulProduct); + const colorGroup = this.parseColorGroup(printfulProduct.name); - let metaPrintfulProduct = metaPrintfulProducts.find((mp) => mp.name === metaPrintfulProductName); + if (nameFilter && !nameFilter.includes(metaName)) continue; + + let metaPrintfulProduct = metaPrintfulProducts.find((mp) => mp.name === metaName); if (!metaPrintfulProduct) { - this.log.debug({ metaPrintfulProductName, externalId: printfulProduct.external_id }, "appending meta printful product"); + this.log.debug({ metaName, externalId: printfulProduct.external_id }, "initializing meta printful product"); metaPrintfulProduct = { - name: metaPrintfulProductName, - variants: [], - externalId: printfulProduct.external_id, - isColorGrouped: colorGroup !== null, - colorGroups: new Set(), + name: metaName, + webflowProductId: printfulProduct.external_id.split("-")[0] ?? "", + entries: [], }; metaPrintfulProducts.push(metaPrintfulProduct); + } else if (!metaPrintfulProduct.webflowProductId && printfulProduct.external_id) { + // Prefer a non-empty external_id so a newly added color doesn't shadow an already-synced sibling's ID. + metaPrintfulProduct.webflowProductId = printfulProduct.external_id.split("-")[0] ?? ""; } - 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; + const fullPrintfulProduct = await this.printful.Products.get(printfulProduct.id); + if (!fullPrintfulProduct) throw new Error(`Printful product ${printfulProduct.id} not found`); + + this.log.debug({ metaName, colorGroup }, "adding entry to meta printful product"); + metaPrintfulProduct.entries.push({ colorGroup, product: fullPrintfulProduct }); } + return metaPrintfulProducts; } - getMetaPrintfulProductName(productName: string): string { - const color = this.findColorInProductName(productName); - if (color) - return productName.replace(`[${color}]`, "").trimEnd(); - return productName; + 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(