diff --git a/apps/api/src/scripts/list-collections.ts b/apps/api/src/scripts/list-collections.ts new file mode 100644 index 0000000..6c2dbdc --- /dev/null +++ b/apps/api/src/scripts/list-collections.ts @@ -0,0 +1,15 @@ +import { WebflowClient } from "@blade-and-brawn/commerce"; +import { env } from "../util"; + +const webflow = new WebflowClient({ + siteId: env.WEBFLOW_SITE_ID, + collectionIds: { + products: env.WEBFLOW_COLLECTION_PRODUCTS_ID, + skus: env.WEBFLOW_COLLECTION_SKUS_ID + }, + token: env.WEBFLOW_AUTH, + webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, +}); + +const collections = await webflow.Collections.list(); +console.log(collections); diff --git a/apps/api/src/scripts/register-webhooks.ts b/apps/api/src/scripts/register-webhooks.ts index 88dd790..76854e2 100644 --- a/apps/api/src/scripts/register-webhooks.ts +++ b/apps/api/src/scripts/register-webhooks.ts @@ -40,7 +40,10 @@ try { console.log("Registering webflow webhooks..."); const webflow = new WebflowClient({ siteId: env.WEBFLOW_SITE_ID, - collectionsId: env.WEBFLOW_COLLECTION_ID, + collectionIds: { + products: env.WEBFLOW_COLLECTION_PRODUCTS_ID, + skus: env.WEBFLOW_COLLECTION_SKUS_ID + }, token: env.WEBFLOW_AUTH, webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, }); diff --git a/apps/api/src/scripts/validate-products.ts b/apps/api/src/scripts/validate-products.ts index 80f6f9b..374641a 100644 --- a/apps/api/src/scripts/validate-products.ts +++ b/apps/api/src/scripts/validate-products.ts @@ -33,7 +33,10 @@ const printful = new PrintfulClient({ const webflow = new WebflowClient({ siteId: env.WEBFLOW_SITE_ID, - collectionsId: env.WEBFLOW_COLLECTION_ID, + collectionIds: { + products: env.WEBFLOW_COLLECTION_PRODUCTS_ID, + skus: env.WEBFLOW_COLLECTION_SKUS_ID + }, token: env.WEBFLOW_AUTH, webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, }); diff --git a/apps/api/src/services/commerce.ts b/apps/api/src/services/commerce.ts index ff44e1f..8327fb7 100644 --- a/apps/api/src/services/commerce.ts +++ b/apps/api/src/services/commerce.ts @@ -16,7 +16,10 @@ export class CommerceService { }); readonly Webflow = new WebflowClient({ siteId: env.WEBFLOW_SITE_ID, - collectionsId: env.WEBFLOW_COLLECTION_ID, + collectionIds: { + products: env.WEBFLOW_COLLECTION_PRODUCTS_ID, + skus: env.WEBFLOW_COLLECTION_SKUS_ID + }, token: env.WEBFLOW_AUTH, webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, }); diff --git a/apps/api/src/util.ts b/apps/api/src/util.ts index 9a99100..ca3cfb2 100644 --- a/apps/api/src/util.ts +++ b/apps/api/src/util.ts @@ -12,7 +12,8 @@ export const env = { PRINTFUL_AUTH: requireEnv("PRINTFUL_AUTH"), PRINTFUL_STORE_ID: requireEnv("PRINTFUL_STORE_ID"), WEBFLOW_SITE_ID: requireEnv("WEBFLOW_SITE_ID"), - WEBFLOW_COLLECTION_ID: requireEnv("WEBFLOW_COLLECTION_ID"), + WEBFLOW_COLLECTION_SKUS_ID: requireEnv("WEBFLOW_COLLECTION_SKUS_ID"), + WEBFLOW_COLLECTION_PRODUCTS_ID: requireEnv("WEBFLOW_COLLECTION_PRODUCTS_ID"), WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"), WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"), AUTH_SECRET: requireEnv("AUTH_SECRET"), diff --git a/apps/portal/src/routes/(app)/apparel/products/+page.server.ts b/apps/portal/src/routes/(app)/apparel/products/+page.server.ts index 8960fe0..701e29a 100644 --- a/apps/portal/src/routes/(app)/apparel/products/+page.server.ts +++ b/apps/portal/src/routes/(app)/apparel/products/+page.server.ts @@ -5,7 +5,10 @@ import type { PageServerLoad } from "./$types"; const printful = new PrintfulClient({ token: env.PRINTFUL_AUTH, storeId: env.PRINTFUL_STORE_ID }); const webflow = new WebflowClient({ siteId: env.WEBFLOW_SITE_ID, - collectionsId: env.WEBFLOW_COLLECTION_ID, + collectionIds: { + products: env.WEBFLOW_COLLECTION_PRODUCTS_ID, + skus: env.WEBFLOW_COLLECTION_SKUS_ID, + }, token: env.WEBFLOW_AUTH, webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, }); diff --git a/packages/commerce/src/sync.ts b/packages/commerce/src/sync.ts index 91660ab..0e0ec5b 100644 --- a/packages/commerce/src/sync.ts +++ b/packages/commerce/src/sync.ts @@ -145,6 +145,27 @@ export class ProductSyncer { ); } } + + // Remove webflow SKUs whose color+size no longer corresponds to any current printful variant. + const currentPKeys = new Set( + pMetaProduct.entries.flatMap(({ colorGroup, product: pProduct }) => + pProduct.sync_variants.map((pVariant) => `${colorGroup ?? pVariant.color}::${pVariant.size}`) + ) + ); + const orphanedWSkus = wProduct.skus.filter((existingWSku) => + !currentPKeys.has(`${existingWSku.fieldData["sku-values"]?.["color"]}::${existingWSku.fieldData["sku-values"]?.["size"]}`) + ); + for (const orphanedWSku of orphanedWSkus) { + this.log.info( + { + wSkuId: orphanedWSku.id, + color: orphanedWSku.fieldData["sku-values"]?.["color"], + size: orphanedWSku.fieldData["sku-values"]?.["size"], + }, + "removing webflow SKU no longer present in printful", + ); + await this.Webflow.Products.Skus.remove(orphanedWSku.id); + } } else { this.log.info("existing webflow product not found, creating it"); diff --git a/packages/commerce/src/util/types.ts b/packages/commerce/src/util/types.ts index a2fd371..983fc05 100644 --- a/packages/commerce/src/util/types.ts +++ b/packages/commerce/src/util/types.ts @@ -431,6 +431,17 @@ export namespace Webflow { }; } + export namespace Collections { + export type Collection = { + id: string, + displayName: string, + singularName: string, + slug: string, + createdOn: string, + lastUpdated: string + } + } + export namespace Webhook { interface MetaData { triggerType: E; diff --git a/packages/commerce/src/webflow.ts b/packages/commerce/src/webflow.ts index d245ff3..e1fbf31 100644 --- a/packages/commerce/src/webflow.ts +++ b/packages/commerce/src/webflow.ts @@ -8,7 +8,10 @@ const parsePayload = (res: Response): Promise => type ClientOptions = { siteId: string; - collectionsId: string; + collectionIds: { + products: string, + skus: string + }, token: string; webhookSecret: string; }; @@ -16,7 +19,8 @@ type ClientOptions = { type Credentials = { apiBaseUrl: string; apiSitesUrl: string; - apiCollectionsUrl: string; + apiCollectionProductsUrl: string, + apiCollectionSkusUrl: string, authHeader: Record; }; @@ -81,6 +85,22 @@ class SkusClient { if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res)); if (!res.ok) throw new WebflowError("Failed to update Webflow product SKU", res.status, await parsePayload(res)); } + + async remove(wSkuId: string): Promise { + const res = await fetch( + `${this.creds.apiCollectionSkusUrl}/items/${wSkuId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, + }, + body: JSON.stringify({}), + }, + ); + if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res)); + if (!res.ok) throw new WebflowError("Failed to remove Webflow product SKU", res.status, await parsePayload(res)); + } } class ProductsClient { @@ -154,7 +174,7 @@ class ProductsClient { async remove(wProductId: string): Promise { const res = await fetch( - `${this.creds.apiCollectionsUrl}/items/${wProductId}`, + `${this.creds.apiCollectionProductsUrl}/items/${wProductId}`, { method: "DELETE", headers: { @@ -289,6 +309,23 @@ class WebhooksClient { } } +class CollectionsClient { + constructor(private creds: Credentials) { } + + async list(): Promise { + const allCollections: Webflow.Collections.Collection[] = []; + const res = await fetch(`${this.creds.apiSitesUrl}/collections`, { + method: "GET", + headers: this.creds.authHeader, + }); + if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res)); + if (!res.ok) throw new WebflowError("Failed to list Webflow collections", res.status, await parsePayload(res)); + const payload = (await res.json()) as Webflow.MetaDataMulti<"collections", Webflow.Collections.Collection>; + allCollections.push(...payload.collections); + return allCollections; + } +} + class UtilClient { constructor(private webhookSecret: string) { } @@ -325,18 +362,21 @@ export class WebflowClient { readonly Products: ProductsClient; readonly Orders: OrdersClient; readonly Webhooks: WebhooksClient; + readonly Collections: CollectionsClient; readonly Util: UtilClient; constructor(options: ClientOptions) { const creds: Credentials = { apiBaseUrl: `https://api.webflow.com/v2`, apiSitesUrl: `https://api.webflow.com/v2/sites/${options.siteId}`, - apiCollectionsUrl: `https://api.webflow.com/v2/collections/${options.collectionsId}`, + apiCollectionProductsUrl: `https://api.webflow.com/v2/collections/${options.collectionIds.products}`, + apiCollectionSkusUrl: `https://api.webflow.com/v2/collections/${options.collectionIds.skus}`, authHeader: { Authorization: `bearer ${options.token}` }, }; this.Products = new ProductsClient(creds); this.Orders = new OrdersClient(creds); this.Webhooks = new WebhooksClient(creds); + this.Collections = new CollectionsClient(creds); this.Util = new UtilClient(options.webhookSecret); } }