diff --git a/apps/api/src/scripts/printful.ts b/apps/api/src/scripts/printful.ts deleted file mode 100644 index 2871b42..0000000 --- a/apps/api/src/scripts/printful.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Printful, PrintfulClient } from "@blade-and-brawn/commerce"; -import { env } from "../env"; - -const printful = new PrintfulClient({ - token: env.PRINTFUL_AUTH, - storeId: env.PRINTFUL_STORE_ID, -}); - -await printful.Webhooks.register("https://dev.bladeandbrawn.com/webhook/printful", [ - Printful.Webhook.Event.ProductUpdated, - Printful.Webhook.Event.ProductDeleted, - Printful.Webhook.Event.PackageShipped, -]); - -const config = await printful.Webhooks.getConfig(); -console.log(config); diff --git a/apps/api/src/scripts/register-webhooks.ts b/apps/api/src/scripts/register-webhooks.ts new file mode 100644 index 0000000..bacbba1 --- /dev/null +++ b/apps/api/src/scripts/register-webhooks.ts @@ -0,0 +1,64 @@ +import { Printful, PrintfulClient, PrintfulError, Webflow, WebflowClient, WebflowError } from "@blade-and-brawn/commerce"; +import { env } from "../env"; + +const BASE_URL = "https://dev.api.bladeandbrawn.com"; + +const PRINTFUL_WEBHOOK_URL = `${BASE_URL}/webhook/printful`; +const WEBFLOW_WEBHOOK_URL = `${BASE_URL}/webhook/webflow`; + +function printError(err: unknown): never { + if (err instanceof WebflowError || err instanceof PrintfulError) + console.error(`${err.message}\n`, JSON.stringify(err.payload, null, 2)); + else + console.error(err); + process.exit(1); +} + +// Register printful webhook +try { + console.log("Registering printful webhook..."); + const printful = new PrintfulClient({ + token: env.PRINTFUL_AUTH, + storeId: env.PRINTFUL_STORE_ID, + }); + + await printful.Webhooks.create(PRINTFUL_WEBHOOK_URL, [ + Printful.Webhook.Event.ProductUpdated, + Printful.Webhook.Event.ProductDeleted, + Printful.Webhook.Event.PackageShipped, + ]); + + const webhook = await printful.Webhooks.get(); + console.log("Finished: "); + console.log(webhook); +} catch (err) { + printError(err); +} + +// Register webflow webhooks +try { + console.log("Registering webflow webhooks..."); + const webflow = new WebflowClient({ + siteId: env.WEBFLOW_SITE_ID, + collectionsId: env.WEBFLOW_COLLECTION_ID, + token: env.WEBFLOW_AUTH, + webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, + }); + + const events: Webflow.Webhook.Event[] = [ + Webflow.Webhook.Event.OrderCreated, + ]; + + const webhooksToDelete = await webflow.Webhooks.list({ forceAll: true }); + for (const webhook of webhooksToDelete) + await webflow.Webhooks.remove(webhook.id); + + for (const event of events) + await webflow.Webhooks.create(WEBFLOW_WEBHOOK_URL, event); + + const webhooks = await webflow.Webhooks.list({ forceAll: true }); + console.log("Finished: "); + console.log(webhooks); +} catch (err) { + printError(err); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 7060acf..410e3bb 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -72,6 +72,7 @@ export const app = new Elysia() /^https?:\/\/blade-and-brawn\.fly\.dev$/i, // development "http://localhost:5173", + "http://dev.portal.bladeandbrawn.com/" ], }), ) diff --git a/packages/commerce/src/printful.ts b/packages/commerce/src/printful.ts index f0e47d6..15bcb7d 100644 --- a/packages/commerce/src/printful.ts +++ b/packages/commerce/src/printful.ts @@ -55,7 +55,7 @@ class ProductsClient { method: "GET", headers: this.creds.authHeaders, }); - if (!res.ok) throw new PrintfulError("Failed to get all Printful products", res.status, await parsePayload(res)); + if (!res.ok) throw new PrintfulError("Failed to list Printful products", res.status, await parsePayload(res)); const payload = (await res.json()) as Printful.MetaDataMulti; allSyncProducts.push(...payload.result); offset = allSyncProducts.length; @@ -111,7 +111,7 @@ class OrdersClient { method: "GET", headers: this.creds.authHeaders, }); - if (!res.ok) throw new PrintfulError("Failed to get all Printful orders", res.status, await parsePayload(res)); + if (!res.ok) throw new PrintfulError("Failed to list Printful orders", res.status, await parsePayload(res)); const payload = (await res.json()) as Printful.MetaDataMulti; allPrintfulOrders.push(...payload.result); offset = allPrintfulOrders.length; @@ -124,7 +124,7 @@ class OrdersClient { class WebhooksClient { constructor(private creds: Credentials) { } - async getConfig(): Promise { + async get(): Promise { const res = await fetch(`${this.creds.apiUrl}/webhooks`, { method: "GET", headers: { @@ -138,7 +138,11 @@ class WebhooksClient { return payload; } - async register(url: string, events: Printful.Webhook.Event[]) { + + /** + * Printful only supports one URL at a time, so subsequent calls will overwrite the previous webhook + */ + async create(url: string, events: Printful.Webhook.Event[]) { const res = await fetch(`${this.creds.apiUrl}/webhooks`, { method: "POST", headers: { diff --git a/packages/commerce/src/util/types.ts b/packages/commerce/src/util/types.ts index f9679fe..a2fd371 100644 --- a/packages/commerce/src/util/types.ts +++ b/packages/commerce/src/util/types.ts @@ -197,7 +197,7 @@ export namespace Webflow { export type MetaDataMulti = { [P in K]: T[]; } & { - pagination: { + pagination?: { limit: number; offset: number; total: number; @@ -442,6 +442,16 @@ export namespace Webflow { OrderUpdated = "ecomm_order_changed", } + export interface Config { + id: string, + triggerType: Event, + url: string, + workspaceId: string, + siteId: string, + lastTriggered: string, + createdOn: string + } + export interface OrderCreated extends MetaData< Event.OrderCreated, Orders.Order diff --git a/packages/commerce/src/webflow.ts b/packages/commerce/src/webflow.ts index eb59ec9..79b5a04 100644 --- a/packages/commerce/src/webflow.ts +++ b/packages/commerce/src/webflow.ts @@ -13,6 +13,7 @@ type ClientOptions = { }; type Credentials = { + apiBaseUrl: string; apiSitesUrl: string; apiCollectionsUrl: string; authHeader: Record; @@ -103,11 +104,11 @@ class ProductsClient { method: "GET", headers: this.creds.authHeader, }); - if (!res.ok) throw new WebflowError("Failed to get all Webflow products", res.status, await parsePayload(res)); + if (!res.ok) throw new WebflowError("Failed to list Webflow products", res.status, await parsePayload(res)); const payload = (await res.json()) as Webflow.MetaDataMulti<"items", Webflow.Products.ProductAndSkus>; allWebflowProducts.push(...payload.items); offset = allWebflowProducts.length; - if (allWebflowProducts.length >= payload.pagination.total) break; + if (allWebflowProducts.length >= (payload?.pagination?.total ?? 0)) break; } while (opt.forceAll); return allWebflowProducts; } @@ -168,11 +169,11 @@ class OrdersClient { method: "GET", headers: this.creds.authHeader, }); - if (!res.ok) throw new WebflowError("Failed to get all Webflow orders", res.status, await parsePayload(res)); + if (!res.ok) throw new WebflowError("Failed to list Webflow orders", res.status, await parsePayload(res)); const payload = (await res.json()) as Webflow.MetaDataMulti<"orders", Webflow.Orders.Order>; allOrders.push(...payload.orders); offset = allOrders.length; - if (allOrders.length >= payload.pagination.total) break; + if (allOrders.length >= (payload?.pagination?.total ?? 0)) break; } while (opt.forceAll); return allOrders; } @@ -213,6 +214,60 @@ class OrdersClient { } } +class WebhooksClient { + constructor(private creds: Credentials) { } + + async list(opt: PagingOptions = {}): Promise { + const allWebhooks: Webflow.Webhook.Config[] = []; + let offset = opt.offset ?? 0; + do { + const res = await fetch(`${this.creds.apiSitesUrl}/webhooks?offset=${offset}&limit=${opt.limit ?? 100}`, { + method: "GET", + headers: this.creds.authHeader, + }); + if (!res.ok) throw new WebflowError("Failed to list Webflow webhooks", res.status, await parsePayload(res)); + const payload = (await res.json()) as Webflow.MetaDataMulti<"webhooks", Webflow.Webhook.Config>; + allWebhooks.push(...payload.webhooks); + offset = allWebhooks.length; + if (allWebhooks.length >= (payload?.pagination?.total ?? 0)) break; + } while (opt.forceAll); + return allWebhooks; + } + + /** + * Limit of 75 registrations per event + */ + async create(url: string, event: Webflow.Webhook.Event) { + const res = await fetch(`${this.creds.apiSitesUrl}/webhooks`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, + }, + body: JSON.stringify({ + "url": url, + "triggerType": event + }), + }); + if (!res.ok) throw new WebflowError("Failed to create Webflow webhook", res.status, await parsePayload(res)); + } + + async remove(webhookId: string): Promise { + const res = await fetch( + `${this.creds.apiBaseUrl}/webhooks/${webhookId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, + }, + body: JSON.stringify({}), + }, + ); + if (!res.ok) throw new WebflowError("Failed to remove Webflow webhook", res.status, await parsePayload(res)); + } +} + class UtilClient { constructor(private webhookSecret: string) { } @@ -248,16 +303,19 @@ class UtilClient { export class WebflowClient { readonly Products: ProductsClient; readonly Orders: OrdersClient; + readonly Webhooks: WebhooksClient; 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}`, authHeader: { Authorization: `bearer ${options.token}` }, }; this.Products = new ProductsClient(creds); this.Orders = new OrdersClient(creds); + this.Webhooks = new WebhooksClient(creds); this.Util = new UtilClient(options.webhookSecret); } }