Product/sku deletion fixes

This commit is contained in:
Dominic Ferrando
2026-07-17 13:01:19 -04:00
parent 1ffc2fe79d
commit 082d7f7cb3
9 changed files with 109 additions and 9 deletions
+15
View File
@@ -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);
+4 -1
View File
@@ -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,
});
+4 -1
View File
@@ -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,
});
+4 -1
View File
@@ -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,
});
+2 -1
View File
@@ -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"),
@@ -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,
});
+21
View File
@@ -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<string>(
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");
+11
View File
@@ -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<E extends Event, D> {
triggerType: E;
+44 -4
View File
@@ -8,7 +8,10 @@ const parsePayload = (res: Response): Promise<unknown> =>
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<string, string>;
};
@@ -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<void> {
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<void> {
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<Webflow.Collections.Collection[]> {
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);
}
}