From 9778c05bf0eb2517c66abebb27a647f697cb3609 Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Mon, 29 Jun 2026 12:14:06 -0400 Subject: [PATCH] More sync cleanup and fixing --- apps/api/src/server.ts | 4 +- packages/commerce/README.md | 12 ++++ packages/commerce/src/sync.ts | 101 ++++++++++++++++++++-------------- 3 files changed, 73 insertions(+), 44 deletions(-) create mode 100644 packages/commerce/README.md diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 9f69deb..02fa080 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -147,7 +147,7 @@ export const app = new Elysia() }) // Per-product sync .post("/:printfulProductId", async ({ params }) => { - if (!await productSyncer.sync(params.printfulProductId)) + if (!await productSyncer.sync({ printfulProductIdsFilter: [params.printfulProductId] })) throw status(409, "Sync already in progress"); }, { params: z.object({ printfulProductId: z.coerce.number() }) }), ) @@ -175,7 +175,7 @@ export const app = new Elysia() case Printful.Webhook.Event.ProductUpdated: { const printfulProduct = payload.data.sync_product; log.info({ productId: printfulProduct.id }, "printful webhook: product updated"); - await productSyncer.sync(printfulProduct.id); + await productSyncer.sync({ printfulProductIdsFilter: [printfulProduct.id] }); break; } case Printful.Webhook.Event.ProductDeleted: { diff --git a/packages/commerce/README.md b/packages/commerce/README.md new file mode 100644 index 0000000..01cdc22 --- /dev/null +++ b/packages/commerce/README.md @@ -0,0 +1,12 @@ +# Printful + +## Definitions + +- "Color-grouped" products + - Products in this format: "Product Name [color]" + +## Constraints + +1. Color-grouped products should no more than *one* printful color variant +2. The single color-grouped product variant should match [color] +3. Printful product names should be unique diff --git a/packages/commerce/src/sync.ts b/packages/commerce/src/sync.ts index 1a0bd77..70b3d61 100644 --- a/packages/commerce/src/sync.ts +++ b/packages/commerce/src/sync.ts @@ -49,14 +49,14 @@ export class ProductSyncer { 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("retrieving webflow products"); + const allWebflowProducts = await this.webflow.Products.list({ forceAll: true }); - this.log.debug("populating printful products"); - const printfulProducts = await this.printful.Products.list({ forceAll: true }); + this.log.debug("retrieving printful products"); + const allPrintfulProducts = await this.printful.Products.list({ forceAll: true }); - this.log.info({ count: printfulProducts.length }, "generating meta printful products"); - const metaPrintfulProducts = await this.generateMetaPrintfulProducts(printfulProducts, opt.printfulProductIdsFilter); + this.log.info({ printfulProductCount: allPrintfulProducts.length, filter: opt.printfulProductIdsFilter ?? "N/A" }, "generating meta printful products"); + const metaPrintfulProducts = await this.generateMetaPrintfulProducts(allWebflowProducts, allPrintfulProducts, opt.printfulProductIdsFilter); for (const metaPrintfulProduct of metaPrintfulProducts) { this.log.info({ name: metaPrintfulProduct.name, externalId: metaPrintfulProduct.webflowProductId }, "syncing meta printful product"); @@ -102,25 +102,27 @@ export class ProductSyncer { ], }; - const webflowProduct = webflowProducts.find( - (wp) => wp.product.id === metaPrintfulProduct.webflowProductId, - ); + const webflowProduct = allWebflowProducts.find((wp) => wp.product.id === metaPrintfulProduct.webflowProductId); if (webflowProduct) { - this.log.info("existing webflow product found, updating it"); + 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"]; - const firstExistingWebflowSku = this.resolveWebflowSku( - webflowProduct.skus, - newWebflowSkus[0].fieldData?.["sku-values"]?.["color"], - newWebflowSkus[0].fieldData?.["sku-values"]?.["size"], - newWebflowSkus[0].id, - ); + // 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 (firstExistingWebflowSku) { - newWebflowSkus[0].id = firstExistingWebflowSku.id; + 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, { @@ -165,13 +167,14 @@ export class ProductSyncer { 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; + const expectedExternalId = colorGroup ? + `${metaPrintfulProduct.webflowProductId}-${colorGroup}` : + metaPrintfulProduct.webflowProductId; if (printfulProduct.sync_product.external_id === expectedExternalId) continue; - const newPrintfulVariants: DeepPartial[] = []; + const printfulVariantPatches: DeepPartial[] = []; for (const printfulVariant of printfulProduct.sync_variants) { const webflowSku = this.resolveWebflowSku( freshWebflowProduct.skus, @@ -179,7 +182,7 @@ export class ProductSyncer { printfulVariant.size, ); if (webflowSku) { - newPrintfulVariants.push({ + printfulVariantPatches.push({ id: printfulVariant.id, external_id: String(webflowSku.id), }); @@ -187,13 +190,13 @@ export class ProductSyncer { } await sleep(10000); - this.log.info({ printfulProduct: printfulProduct.sync_product.id }, "updating printful product"); + 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: newPrintfulVariants, + sync_variants: printfulVariantPatches, }); } } @@ -218,8 +221,9 @@ export class ProductSyncer { 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 (colorGroup !== null) { + 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) @@ -227,10 +231,12 @@ export class ProductSyncer { if (!sizeInAll) continue; } - const skuColor = colorGroup ?? printfulVariant.color; + const color = isColorGrouped ? + colorGroup : + printfulVariant.color; this.log.debug( - { size: printfulVariant.size, color: skuColor, retailPrice: printfulVariant.retail_price }, + { color, size: printfulVariant.size, retailPrice: printfulVariant.retail_price }, "generating webflow SKU from printful variant" ); @@ -240,7 +246,7 @@ export class ProductSyncer { name: printfulVariant.name, slug: formatSlug(printfulVariant.name), "sku-values": { - color: skuColor, + color, size: printfulVariant.size, }, price: { @@ -258,39 +264,46 @@ export class ProductSyncer { } async generateMetaPrintfulProducts( - printfulProducts: Printful.Products.SyncProduct[], + allWebflowProducts: Webflow.Products.ProductAndSkus[], + allPrintfulProducts: 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 metaNameFilter = printfulProductIdsFilter + ?.map((id) => allPrintfulProducts.find((p) => p.id === id)) + ?.filter((p) => p !== undefined) + ?.map((p) => this.getMetaPrintfulProductName(p)); const metaPrintfulProducts: MetaPrintfulProduct[] = []; - for (const printfulProduct of printfulProducts) { + 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 (nameFilter && !nameFilter.includes(metaName)) continue; + if (metaNameFilter && !metaNameFilter.includes(metaName)) continue; let metaPrintfulProduct = metaPrintfulProducts.find((mp) => mp.name === metaName); if (!metaPrintfulProduct) { - this.log.debug({ metaName, externalId: printfulProduct.external_id }, "initializing meta printful product"); + this.log.debug({ metaName, colorGroup, webflowProductId }, "generating meta printful product"); metaPrintfulProduct = { name: metaName, - webflowProductId: printfulProduct.external_id.split("-")[0] ?? "", + webflowProductId, 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] ?? ""; + } 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`); - this.log.debug({ metaName, colorGroup }, "adding entry to meta printful product"); + 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 }); } @@ -317,6 +330,10 @@ export class ProductSyncer { ); } + 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"); }