diff --git a/apps/api/src/scripts/validate-products.ts b/apps/api/src/scripts/validate-products.ts index 6dbeff4..3bfb7a6 100644 --- a/apps/api/src/scripts/validate-products.ts +++ b/apps/api/src/scripts/validate-products.ts @@ -1,7 +1,7 @@ import { - PrintfulService, - SyncService, - WebflowService, + PrintfulClient, + ProductSyncer, + WebflowClient, } from "@blade-and-brawn/commerce"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -25,23 +25,35 @@ const validateEquality = (wValue: any, pValue: any): Validation => { return validation; }; -const wProducts = await WebflowService.Products.getAll(); +const printful = new PrintfulClient({ + token: Bun.env.PRINTFUL_AUTH!, + storeId: Bun.env.PRINTFUL_STORE_ID!, +}); + +const webflow = new WebflowClient({ + siteId: Bun.env.WEBFLOW_SITE_ID!, + collectionsId: Bun.env.WEBFLOW_COLLECTION_ID!, + token: Bun.env.WEBFLOW_AUTH!, + webhookSecret: Bun.env.WEBFLOW_WEBHOOK_SECRET!, +}); + +const syncer = new ProductSyncer(printful, webflow); + +const wProducts = await webflow.Products.getAll(); let invalidCount = 0; for (const wProduct of wProducts) { for (const sku of wProduct.skus ?? []) { console.log("Webflow: " + sku.id, sku.fieldData.name); - const pVariant = await PrintfulService.Products.Variants.get( - `@${sku.id}`, - ); + const pVariant = await printful.Products.Variants.get(`@${sku.id}`); if (pVariant) { console.log("Printful: " + pVariant.id, pVariant.name); const validations = [ // validateEquality(sku.fieldData.name, pVariant.name), validateEquality( sku.fieldData["sku-values"]?.["color"], - SyncService.findColorInProductName(pVariant.name), + syncer.findColorInProductName(pVariant.name), ), validateEquality( sku.fieldData["sku-values"]?.["size"], @@ -55,7 +67,7 @@ for (const wProduct of wProducts) { // if (!validateEquality(sku.fieldData.name, pVariant.name).isValid) { // sku.fieldData.name = pVariant.name; - // await WebflowService.Products.Skus.update(wProduct.product.id, sku.id, sku); + // await webflow.Products.Skus.update(wProduct.product.id, sku.id, sku); // } const errors = validations.filter((v) => !v.isValid); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 7116478..614b86a 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -11,9 +11,9 @@ import { import { cors } from "@elysiajs/cors"; import { Elysia, NotFoundError, status } from "elysia"; import { - PrintfulService, - SyncService, - WebflowService, + PrintfulClient, + ProductSyncer, + WebflowClient, Printful, Webflow, FetchError, @@ -21,10 +21,28 @@ import { import zipcodesUs from "zipcodes-us"; import z from "zod"; +// SERVICES +// ----------- const levelCalculator = new LevelCalculator( new Standards(DEFAULT_STANDARDS_DATA), ); +const printful = new PrintfulClient({ + token: Bun.env.PRINTFUL_AUTH!, + storeId: Bun.env.PRINTFUL_STORE_ID!, +}); + +const webflow = new WebflowClient({ + siteId: Bun.env.WEBFLOW_SITE_ID!, + collectionsId: Bun.env.WEBFLOW_COLLECTION_ID!, + token: Bun.env.WEBFLOW_AUTH!, + webhookSecret: Bun.env.WEBFLOW_WEBHOOK_SECRET!, +}); + +const productSyncer = new ProductSyncer(printful, webflow); + +// ELYSIA +// ----------- export const app = new Elysia() .use( cors({ @@ -105,42 +123,39 @@ export const app = new Elysia() .group("/sync", (app) => app // Sync status - .get("/", async ({ }) => await SyncService.getState()) + .get("/", async ({ }) => await productSyncer.getState()) // Full sync .post("/", async ({ }) => { - const syncState = await SyncService.getState(); + const syncState = await productSyncer.getState(); if (syncState.isSyncing) return status(409, { error: "Sync already in progress" }); - await SyncService.sync(); + await productSyncer.sync(); return { ok: true }; }) // Per-product sync .post("/:printfulProductId", async ({ params }) => { - const syncState = await SyncService.getState(); + const syncState = await productSyncer.getState(); if (syncState.isSyncing) return status(409, { error: "Sync already in progress" }); - await SyncService.sync(params.printfulProductId); + await productSyncer.sync(params.printfulProductId); return { ok: true }; }, { params: z.object({ printfulProductId: z.number() }) }), ) + // TODO: add schema validation .get("/:printfulProductId", async ({ params }) => { const printfulProductId = +params.printfulProductId; const printfulProduct = - await PrintfulService.Products.get(printfulProductId); - if (!printfulProduct) { - return new NotFoundError(); - } + await printful.Products.get(printfulProductId); + if (!printfulProduct) return new NotFoundError(); const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0]; - if (!webflowProductId) { - return new NotFoundError(); - } + if (!webflowProductId) return new NotFoundError(); const webflowProduct = - await WebflowService.Products.get(webflowProductId); + await webflow.Products.get(webflowProductId); return { printfulProduct, webflowProduct }; }), @@ -153,11 +168,11 @@ export const app = new Elysia() switch (payload.type) { case Printful.Webhook.Event.ProductUpdated: { const printfulProduct = payload.data.sync_product; - await SyncService.sync(printfulProduct.id); + await productSyncer.sync(printfulProduct.id); break; } case Printful.Webhook.Event.ProductDeleted: { - await WebflowService.Products.remove( + await webflow.Products.remove( payload.data.sync_product.external_id, ); break; @@ -166,12 +181,12 @@ export const app = new Elysia() const webflowOrderId = payload.data.order.external_id; const shipInfo = payload.data.shipment; - await WebflowService.Orders.update(webflowOrderId, { + await webflow.Orders.update(webflowOrderId, { shippingTrackingURL: shipInfo.tracking_url, shippingTracking: shipInfo.tracking_number, shippingProvider: shipInfo.carrier, }); - await WebflowService.Orders.fulfill(webflowOrderId, { + await webflow.Orders.fulfill(webflowOrderId, { sendOrderFulfilledEmail: true, }); break; @@ -181,7 +196,7 @@ export const app = new Elysia() return { ok: true }; }) .post("/webhook/webflow", async ({ request, body, set }) => { - if (!WebflowService.Util.verifyWebflowSignature(request, body)) { + if (!webflow.Util.verifyWebflowSignature(request, body)) { set.status = 400; return "Invalid signature"; } @@ -192,7 +207,7 @@ export const app = new Elysia() case Webflow.Webhook.Event.OrderCreated: { const webflowOrder = payload.payload; - await PrintfulService.Orders.create({ + await printful.Orders.create({ external_id: webflowOrder.orderId, // TODO: derive from webflow shipping: "STANDARD", diff --git a/apps/portal/src/routes/commerce/+page.server.ts b/apps/portal/src/routes/commerce/+page.server.ts index d3827ce..5f961ca 100644 --- a/apps/portal/src/routes/commerce/+page.server.ts +++ b/apps/portal/src/routes/commerce/+page.server.ts @@ -1,11 +1,27 @@ -import { PrintfulService, WebflowService } from "@blade-and-brawn/commerce"; +import { PrintfulClient, WebflowClient } from "@blade-and-brawn/commerce"; +import { + PRINTFUL_AUTH, + PRINTFUL_STORE_ID, + WEBFLOW_SITE_ID, + WEBFLOW_COLLECTION_ID, + WEBFLOW_AUTH, + WEBFLOW_WEBHOOK_SECRET, +} from "$env/static/private"; import type { PageServerLoad } from "./$types"; +const printful = new PrintfulClient({ token: PRINTFUL_AUTH, storeId: PRINTFUL_STORE_ID }); +const webflow = new WebflowClient({ + siteId: WEBFLOW_SITE_ID, + collectionsId: WEBFLOW_COLLECTION_ID, + token: WEBFLOW_AUTH, + webhookSecret: WEBFLOW_WEBHOOK_SECRET, +}); + export const load = async ({}: Parameters[0]) => { return { products: { - printful: PrintfulService.Products.getAll(), - webflow: WebflowService.Products.getAll(), + printful: printful.Products.getAll(), + webflow: webflow.Products.getAll(), }, }; }; diff --git a/packages/commerce/src/printful.ts b/packages/commerce/src/printful.ts index b7e4c5b..ceea113 100644 --- a/packages/commerce/src/printful.ts +++ b/packages/commerce/src/printful.ts @@ -1,199 +1,130 @@ import type { Printful } from "./util/types"; import { FetchError, type DeepPartial } from "./util/misc"; -export class PrintfulService { - static Products = class { - static Variants = class { - static async get( - variantId: number | string, - ): Promise { - const res = await fetch( - `${env().API_URL}/store/variants/${variantId}`, - { - method: "GET", - headers: { ...env().AUTH_HEADERS }, - }, - ); +type ClientOptions = { + token: string; + storeId: string; +}; - if (res.status === 404 || res.status === 400) { - return; - } +type Credentials = { + apiUrl: string; + authHeaders: Record; +}; - if (!res.ok) { - throw new FetchError("Failed to get Printful variant", res); - } +class VariantsClient { + constructor(private creds: Credentials) { } - const payload = - (await res.json()) as Printful.Products.MetaDataSingle; - return payload.result; - } - }; - - static async getAll( - offset: number = 0, - ): Promise { - const allSyncProducts: Printful.Products.SyncProduct[] = []; - - while (true) { - const res = await fetch( - `${env().API_URL}/store/products?offset=${offset}`, - { - method: "GET", - headers: { ...env().AUTH_HEADERS }, - }, - ); - - if (!res.ok) { - throw new FetchError( - "Failed to get all Printful products", - res, - ); - } - - const payload = - (await res.json()) as Printful.Products.MetaDataMulti; - - allSyncProducts.push(...payload.result); - offset = allSyncProducts.length; - - if (allSyncProducts.length >= payload.paging.total) break; - } - - return allSyncProducts; - } - - static async get( - productId: number | string, - ): Promise { - const res = await fetch( - `${env().API_URL}/store/products/${productId}`, - { - method: "GET", - headers: { ...env().AUTH_HEADERS }, - }, - ); - - if (res.status === 404 || res.status === 400) { - return; - } - - if (!res.ok) { - throw new FetchError("Failed to get Printful product", res); - } - - const payload = - (await res.json()) as Printful.Products.MetaDataSingle; - return payload.result; - } - - static async update( - printfulProductId: number, - printfulProduct: DeepPartial, - ) { - const res = await fetch( - `${env().API_URL}/store/products/${printfulProductId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...env().AUTH_HEADERS, - }, - body: JSON.stringify(printfulProduct), - }, - ); - - if (!res.ok) { - throw new FetchError("Failed to update Printful product", res); - } - } - }; - - static Orders = class { - static async create(printfulOrder: Printful.Orders.Order) { - const res = await fetch(`${env().API_URL}/orders`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...env().AUTH_HEADERS, - }, - body: JSON.stringify(printfulOrder), - }); - - if (!res.ok) { - throw new FetchError("Failed to create printful order", res); - } - } - - static async getAll( - offset: number = 0, - ): Promise { - const allPrintfulOrders: Printful.Orders.Order[] = []; - - while (true) { - const res = await fetch( - `${env().API_URL}/orders?offset=${offset}`, - { - method: "GET", - headers: { ...env().AUTH_HEADERS }, - }, - ); - - if (!res.ok) { - throw new FetchError( - "Failed to get all Printful orders", - res, - ); - } - - const payload = - (await res.json()) as Printful.Products.MetaDataMulti; - - allPrintfulOrders.push(...payload.result); - offset = allPrintfulOrders.length; - - if (allPrintfulOrders.length >= payload.paging.total) break; - } - - return allPrintfulOrders; - } - }; - - static Util = class { - static getVariantMainImage( - syncVariant: Printful.Products.SyncVariant, - ): string { - const previewFile = syncVariant.files.find( - (f) => f.type === "preview", - ); - return previewFile?.preview_url ?? syncVariant.product.image; - } - }; + async get(variantId: number | string): Promise { + const res = await fetch(`${this.creds.apiUrl}/store/variants/${variantId}`, { + method: "GET", + headers: this.creds.authHeaders, + }); + if (res.status === 404 || res.status === 400) return; + if (!res.ok) throw new FetchError("Failed to get Printful variant", res); + const payload = (await res.json()) as Printful.Products.MetaDataSingle; + return payload.result; + } } -export const env = () => { - if (typeof Bun === "undefined") { - throw new Error( - "Must be in a server context. Make sure to run using --bun.", - ); +class ProductsClient { + readonly Variants: VariantsClient; + + constructor(private creds: Credentials) { + this.Variants = new VariantsClient(creds); } - const vars = { - AUTH_TOKEN: Bun.env.PRINTFUL_AUTH, - STORE_ID: Bun.env.PRINTFUL_STORE_ID, - } as Record; - - for (const varKey in vars) { - if (!vars[varKey]) { - console.log(`Missing ${varKey} environment variable`); + async getAll(offset = 0): Promise { + const allSyncProducts: Printful.Products.SyncProduct[] = []; + while (true) { + const res = await fetch(`${this.creds.apiUrl}/store/products?offset=${offset}`, { + method: "GET", + headers: this.creds.authHeaders, + }); + if (!res.ok) throw new FetchError("Failed to get all Printful products", res); + const payload = (await res.json()) as Printful.Products.MetaDataMulti; + allSyncProducts.push(...payload.result); + offset = allSyncProducts.length; + if (allSyncProducts.length >= payload.paging.total) break; } + return allSyncProducts; } - return { - ...vars, - API_URL: "https://api.printful.com", - AUTH_HEADERS: { - Authorization: `Bearer ${vars.AUTH_TOKEN}`, - "X-PF-Store-Id": `${vars.STORE_ID}`, + async get(productId: number | string): Promise { + const res = await fetch(`${this.creds.apiUrl}/store/products/${productId}`, { + method: "GET", + headers: this.creds.authHeaders, + }); + if (res.status === 404 || res.status === 400) return; + if (!res.ok) throw new FetchError("Failed to get Printful product", res); + const payload = (await res.json()) as Printful.Products.MetaDataSingle; + return payload.result; + } + + async update(printfulProductId: number, printfulProduct: DeepPartial) { + const res = await fetch(`${this.creds.apiUrl}/store/products/${printfulProductId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeaders, + }, + body: JSON.stringify(printfulProduct), + }); + if (!res.ok) throw new FetchError("Failed to update Printful product", res); + } +} + +class OrdersClient { + constructor(private creds: Credentials) { } + + async create(printfulOrder: Printful.Orders.Order) { + const res = await fetch(`${this.creds.apiUrl}/orders`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeaders, + }, + body: JSON.stringify(printfulOrder), + }); + if (!res.ok) throw new FetchError("Failed to create printful order", res); + } + + async getAll(offset = 0): Promise { + const allPrintfulOrders: Printful.Orders.Order[] = []; + while (true) { + const res = await fetch(`${this.creds.apiUrl}/orders?offset=${offset}`, { + method: "GET", + headers: this.creds.authHeaders, + }); + if (!res.ok) throw new FetchError("Failed to get all Printful orders", res); + const payload = (await res.json()) as Printful.Products.MetaDataMulti; + allPrintfulOrders.push(...payload.result); + offset = allPrintfulOrders.length; + if (allPrintfulOrders.length >= payload.paging.total) break; + } + return allPrintfulOrders; + } +} + +export class PrintfulClient { + readonly Products: ProductsClient; + readonly Orders: OrdersClient; + + constructor(options: ClientOptions) { + const creds: Credentials = { + apiUrl: "https://api.printful.com", + authHeaders: { + Authorization: `Bearer ${options.token}`, + "X-PF-Store-Id": options.storeId, + }, + }; + this.Products = new ProductsClient(creds); + this.Orders = new OrdersClient(creds); + } + + static Util = { + getVariantMainImage(syncVariant: Printful.Products.SyncVariant): string { + const previewFile = syncVariant.files.find((f) => f.type === "preview"); + return previewFile?.preview_url ?? syncVariant.product.image; }, }; -}; +} diff --git a/packages/commerce/src/sync.ts b/packages/commerce/src/sync.ts index 9d8e32b..e64aea3 100644 --- a/packages/commerce/src/sync.ts +++ b/packages/commerce/src/sync.ts @@ -1,7 +1,7 @@ import type { Printful, Webflow } from "./util/types"; import { FetchError, formatSlug, type DeepPartial } from "./util/misc"; -import { WebflowService } from "./webflow"; -import { PrintfulService } from "./printful"; +import { WebflowClient } from "./webflow"; +import { PrintfulClient } from "./printful"; import { redis } from "bun"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -24,15 +24,20 @@ type SyncState = { syncingIds: number[]; }; -export abstract class SyncService { - static async sync(printfulProductId?: number) { +export class ProductSyncer { + constructor( + private printful: PrintfulClient, + private webflow: WebflowClient, + ) { } + + async sync(printfulProductId?: number) { await this.setState({ isSyncing: true, syncingIds: [] }); // Populate products const mainPrintfulProducts: MainProduct[] = []; const webflowProducts: Webflow.Products.ProductAndSkus[] = []; try { - let printfulProducts = await PrintfulService.Products.getAll(); + let printfulProducts = await this.printful.Products.getAll(); if (printfulProductId) { const printfulProduct = printfulProducts.find( (p) => p.id === printfulProductId, @@ -46,7 +51,7 @@ export abstract class SyncService { ); } } - webflowProducts.push(...(await WebflowService.Products.getAll())); + webflowProducts.push(...(await this.webflow.Products.getAll())); // Generate main printful products console.log("Generating main products..."); @@ -73,9 +78,7 @@ export abstract class SyncService { ); mainPrintfulProduct.variants.push({ color: productColor, - product: (await PrintfulService.Products.get( - printfulProduct.id, - ))!, + product: (await this.printful.Products.get(printfulProduct.id))!, }); mainPrintfulProduct.colors.add(productColor); } @@ -94,25 +97,22 @@ export abstract class SyncService { isSyncing: true, syncingIds: mainPrintfulProduct.variants.map( (v) => v.product.sync_product.id, - ) + ), }); const foundColors = mainPrintfulProduct.colors; const foundSizes: Set = new Set(); - const webflowSkus: DeepPartial[] = - []; + const webflowSkus: DeepPartial[] = []; for (const specialVariant of mainPrintfulProduct.variants) { - for (const printfulVariant of specialVariant.product - .sync_variants) { + for (const printfulVariant of specialVariant.product.sync_variants) { // check if this variant size is in all other special variants let sizeIsInAllVariants = true; for (const specialVariant of mainPrintfulProduct.variants) { - const sizes = - specialVariant.product.sync_variants.map( - (v) => v.size, - ); + const sizes = specialVariant.product.sync_variants.map( + (v) => v.size, + ); if (!sizes.includes(printfulVariant.size)) sizeIsInAllVariants = false; } @@ -136,10 +136,9 @@ export abstract class SyncService { unit: printfulVariant.currency, currency: printfulVariant.currency, }, - "main-image": - PrintfulService.Util.getVariantMainImage( - printfulVariant, - ), + "main-image": PrintfulClient.Util.getVariantMainImage( + printfulVariant, + ), }, }); } @@ -165,7 +164,7 @@ export abstract class SyncService { if (!webflowProductId) { throw new Error("Malformed printful product ID"); } - await WebflowService.Products.update(webflowProductId, { + await this.webflow.Products.update(webflowProductId, { product: { fieldData: { name: mainPrintfulProduct.name, @@ -176,24 +175,20 @@ export abstract class SyncService { { id: "color", name: "Color", - enum: Array.from(foundColors).map( - (color) => ({ - id: color, - slug: formatSlug(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, - }), - ), + enum: Array.from(foundSizes).map((size) => ({ + id: size, + slug: formatSlug(size), + name: size, + })), }, ], }, @@ -202,18 +197,17 @@ export abstract class SyncService { }); for (const webflowSku of webflowSkus.slice(1)) { - const existingWebflowSku = - existingWebflowProduct.skus.find( - (sku) => sku.id === webflowSku.id, - ); + const existingWebflowSku = existingWebflowProduct.skus.find( + (sku) => sku.id === webflowSku.id, + ); if (webflowSku.id && existingWebflowSku) { - await WebflowService.Products.Skus.update( + await this.webflow.Products.Skus.update( webflowProductId, webflowSku.id, webflowSku, ); } else { - await WebflowService.Products.Skus.create( + await this.webflow.Products.Skus.create( webflowProductId, [webflowSku], ); @@ -221,51 +215,45 @@ export abstract class SyncService { } } else { // SYNC PRODUCT CREATE - const webflowProductId = - await WebflowService.Products.create({ - product: { - fieldData: { - name: mainPrintfulProduct.name, - slug: formatSlug(mainPrintfulProduct.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 webflowProductId = await this.webflow.Products.create({ + product: { + fieldData: { + name: mainPrintfulProduct.name, + slug: formatSlug(mainPrintfulProduct.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], - }); + }, + sku: webflowSkus[0], + }); // create webflow product SKUs - await WebflowService.Products.Skus.create( + await this.webflow.Products.Skus.create( webflowProductId, webflowSkus.slice(1), ); - existingWebflowProduct = - await WebflowService.Products.get(webflowProductId); + existingWebflowProduct = await this.webflow.Products.get(webflowProductId); if (!existingWebflowProduct) { console.error("Missing webflow product"); throw new Error("Failed to create Webflow product"); @@ -274,30 +262,22 @@ export abstract class SyncService { for (const specialVariant of mainPrintfulProduct.variants) { const newPrintfulVariants: DeepPartial[] = []; - for (const printfulVariant of specialVariant.product - .sync_variants) { - const associatedWebflowSku = - existingWebflowProduct.skus.find( - (sku) => - sku.fieldData["sku-values"]?.[ - "color" - ] === printfulVariant.color && - sku.fieldData["sku-values"]?.[ - "size" - ] === printfulVariant.size, - ); + for (const printfulVariant of specialVariant.product.sync_variants) { + const associatedWebflowSku = existingWebflowProduct.skus.find( + (sku) => + sku.fieldData["sku-values"]?.["color"] === printfulVariant.color && + sku.fieldData["sku-values"]?.["size"] === printfulVariant.size, + ); if (associatedWebflowSku) { newPrintfulVariants.push({ - id: printfulVariant.id, // printful variant id - external_id: String( - associatedWebflowSku.id, - ), // webflow variant id + id: printfulVariant.id, + external_id: String(associatedWebflowSku.id), }); } } await sleep(10000); - await PrintfulService.Products.update( + await this.printful.Products.update( specialVariant.product.sync_product.id, { sync_product: { @@ -319,41 +299,38 @@ export abstract class SyncService { console.log("Done"); } - public static async setState(state: SyncState) { + async setState(state: SyncState) { await redis.set("commerce:sync:state", JSON.stringify(state)); await redis.expire("commerce:sync:state", 1800); } - public static async getState(): Promise { + async getState(): Promise { const val = await redis.get("commerce:sync:state"); if (val) return JSON.parse(val); - return { isSyncing: false, syncingIds: [] } + return { isSyncing: false, syncingIds: [] }; } - public static findColorInProductName(productName: string): string { + findColorInProductName(productName: string): string { const m = productName.match(/\[([^\]]+)\]/); return m?.[1] ? m[1] : "N/A"; - }; + } - public static getMainProductName(productName: string) { + getMainProductName(productName: string): string { const colorInName = this.findColorInProductName(productName); if (colorInName) { return productName.replace(`[${colorInName}]`, "").trimEnd(); } else { return productName; } - }; + } - private static async getProductImageUrls(slug: string) { + private async getProductImageUrls(slug: string): Promise { const res = await fetch(`${R2_WORKER_URL}/product-images/${slug}`); if (!res.ok) { - console.error( - "Failed to get product image keys:", - await res.json(), - ); + console.error("Failed to get product image keys:", await res.json()); throw new Error("Failed to get product image keys"); } const productImageKeys: string[] = (await res.json()) as string[]; return productImageKeys.map((key) => `${WEBSITE_MEDIA_URL}/${key}`); - }; + } } diff --git a/packages/commerce/src/webflow.ts b/packages/commerce/src/webflow.ts index 17a0c74..4a807ca 100644 --- a/packages/commerce/src/webflow.ts +++ b/packages/commerce/src/webflow.ts @@ -2,329 +2,242 @@ import type { Webflow } from "./util/types"; import { FetchError, type DeepPartial } from "./util/misc"; import crypto from "node:crypto"; -export class WebflowService { - static Products = class { - static Skus = class { - static async create( - webflowProductId: string, - skus: DeepPartial[], - ): Promise { - const res = await fetch( - `${env().API_SITES_URL}/products/${webflowProductId}/skus`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...env().AUTH_HEADER, - }, - body: JSON.stringify({ - skus: skus, - }), - }, - ); +type ClientOptions = { + siteId: string; + collectionsId: string; + token: string; + webhookSecret: string; +}; - if (!res.ok) { - throw new FetchError( - "Failed to create Webflow product SKU", - res, - ); - } +type Credentials = { + apiSitesUrl: string; + apiCollectionsUrl: string; + authHeader: Record; +}; - const payload = (await res.json()) as { - skus: { id: string }[]; - }; - return payload.skus.map((sku) => sku.id); - } +class SkusClient { + constructor(private creds: Credentials) {} - static async update( - webflowProductId: string, - webflowSkuId: string, - webflowSku: DeepPartial, - ): Promise { - const res = await fetch( - `${env().API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - ...env().AUTH_HEADER, - }, - body: JSON.stringify({ - sku: webflowSku, - }), - }, - ); - if (!res.ok) { - throw new FetchError( - "Failed to update Webflow product SKU", - res, - ); - } - } - }; - - static async create( - webflowProductAndSku: DeepPartial, - ): Promise { - const res = await fetch(`${env().API_SITES_URL}/products`, { + async create( + webflowProductId: string, + skus: DeepPartial[], + ): Promise { + const res = await fetch( + `${this.creds.apiSitesUrl}/products/${webflowProductId}/skus`, + { method: "POST", headers: { "Content-Type": "application/json", - ...env().AUTH_HEADER, + ...this.creds.authHeader, }, - body: JSON.stringify(webflowProductAndSku), - }); - - if (!res.ok) { - throw new FetchError("Failed to create Webflow product", res); - } - - const createdWebflowProduct = (await res.json()) as { - product: { id: string }; - }; - return createdWebflowProduct.product.id; - } - - static async getAll(): Promise { - const res = await fetch(`${env().API_SITES_URL}/products`, { - method: "GET", - headers: { - ...env().AUTH_HEADER, - }, - }); - - if (!res.ok) { - throw new FetchError("Failed to get all Webflow products", res); - } - - const payload = (await res.json()) as { - items: Webflow.Products.ProductAndSkus[]; - }; - return payload.items; - } - - static async get( - webflowProductId: string, - ): Promise { - const res = await fetch( - `${env().API_SITES_URL}/products/${webflowProductId}`, - { - method: "GET", - headers: { - ...env().AUTH_HEADER, - }, - }, - ); - - if (res.status === 404 || res.status === 400) { - return; - } - - if (!res.ok) { - throw new FetchError("Failed to get Webflow product", res); - } - - const payload = - (await res.json()) as Webflow.Products.ProductAndSkus; - return payload; - } - - static async update( - webflowProductId: string, - webflowProduct: DeepPartial, - ): Promise { - const res = await fetch( - `${env().API_SITES_URL}/products/${webflowProductId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - ...env().AUTH_HEADER, - }, - body: JSON.stringify(webflowProduct), - }, - ); - - if (!res.ok) { - throw new FetchError("Failed to update Webflow product", res); - } - } - - static async remove(webflowProductId: string): Promise { - const res = await fetch( - `${env().API_COLLECTIONS_URL}/items/${webflowProductId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - ...env().AUTH_HEADER, - }, - body: JSON.stringify({}), - }, - ); - - if (!res.ok) { - throw new FetchError("Failed to remove Webflow product", res); - } - } - }; - - static Orders = class { - static async getAll( - opt: { status?: Webflow.Orders.Order["status"] } = {}, - ): Promise { - // TODO: pagination - const params = new URLSearchParams(); - if (opt.status !== undefined) { - params.set("status", opt.status); - } - - const res = await fetch( - `${env().API_SITES_URL}/orders?${params.toString()}`, - { - method: "GET", - headers: { ...env().AUTH_HEADER }, - }, - ); - - if (!res.ok) { - throw new FetchError("Failed to get all Webflow orders", res); - } - - const payload = (await res.json()) as { - orders: Webflow.Orders.Order[]; - }; - return payload.orders; - } - - static async update( - webflowOrderId: string, - webflowOrderUpdate: { - comment?: string; - shippingProvider?: string; - shippingTracking?: string; - shippingTrackingURL: string; + body: JSON.stringify({ skus }), }, - ): Promise { - const res = await fetch( - `${env().API_SITES_URL}/orders/${webflowOrderId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - ...env().AUTH_HEADER, - }, - body: JSON.stringify(webflowOrderUpdate), + ); + if (!res.ok) throw new FetchError("Failed to create Webflow product SKU", res); + const payload = (await res.json()) as { skus: { id: string }[] }; + return payload.skus.map((sku) => sku.id); + } + + async update( + webflowProductId: string, + webflowSkuId: string, + webflowSku: DeepPartial, + ): Promise { + const res = await fetch( + `${this.creds.apiSitesUrl}/products/${webflowProductId}/skus/${webflowSkuId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, }, - ); - - if (!res.ok) { - throw new FetchError("Failed to update Webflow order", res); - } - } - - static async fulfill( - webflowOrderId: string, - opt: { sendOrderFulfilledEmail?: boolean } = {}, - ): Promise { - const res = await fetch( - `${env().API_SITES_URL}/orders/${webflowOrderId}/fulfill`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...env().AUTH_HEADER, - }, - body: JSON.stringify(opt), - }, - ); - - if (!res.ok) { - throw new FetchError("Failed to fulfill Webflow order", res); - } - } - }; - - static Util = class { - static verifyWebflowSignature( - request: Request, - body: unknown, - ): Boolean { - try { - const timestamp = request.headers.get("x-webflow-timestamp"); - if (!timestamp) { - throw new Error("No timestamp provided"); - } - const providedSignature = request.headers.get( - "x-webflow-signature", - ); - if (!providedSignature) { - throw new Error("No signature provided"); - } - - const secret = env().WEBHOOK_SECRET; - if (!secret) { - throw new Error("No secret provided"); - } - - if (!body) { - throw new Error("Body is empty"); - } - - const requestTimestamp = parseInt(timestamp, 10); - const data = `${requestTimestamp}:${JSON.stringify(body)}`; - const hash = crypto - .createHmac("sha256", secret) - .update(data) - .digest("hex"); - - if ( - !crypto.timingSafeEqual( - Buffer.from(hash, "hex"), - Buffer.from(providedSignature, "hex"), - ) - ) { - throw new Error("Invalid signature"); - } - - const currentTime = Date.now(); - - if (currentTime - requestTimestamp > 300000) { - throw new Error("Request is older than 5 minutes"); - } - return true; - } catch (err) { - console.error(`Error verifying signature: ${err}`); - return false; - } - } - }; + body: JSON.stringify({ sku: webflowSku }), + }, + ); + if (!res.ok) throw new FetchError("Failed to update Webflow product SKU", res); + } } -const env = () => { - if (typeof Bun === "undefined") { - throw new Error( - "Must be in a server context. Make sure to run using --bun.", - ); +class ProductsClient { + readonly Skus: SkusClient; + + constructor(private creds: Credentials) { + this.Skus = new SkusClient(creds); } - const vars = { - SITE_ID: Bun.env.WEBFLOW_SITE_ID, - COLLECTIONS_ID: Bun.env.WEBFLOW_COLLECTION_ID, - AUTH_TOKEN: Bun.env.WEBFLOW_AUTH, - WEBHOOK_SECRET: Bun.env.WEBFLOW_WEBHOOK_SECRET, - } as Record; + async create( + webflowProductAndSku: DeepPartial, + ): Promise { + const res = await fetch(`${this.creds.apiSitesUrl}/products`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, + }, + body: JSON.stringify(webflowProductAndSku), + }); + if (!res.ok) throw new FetchError("Failed to create Webflow product", res); + const payload = (await res.json()) as { product: { id: string } }; + return payload.product.id; + } - for (const varKey in vars) { - if (!vars[varKey]) { - console.log(`Missing ${varKey} environment variable`); + async getAll(): Promise { + const res = await fetch(`${this.creds.apiSitesUrl}/products`, { + method: "GET", + headers: this.creds.authHeader, + }); + if (!res.ok) throw new FetchError("Failed to get all Webflow products", res); + const payload = (await res.json()) as { items: Webflow.Products.ProductAndSkus[] }; + return payload.items; + } + + async get(webflowProductId: string): Promise { + const res = await fetch(`${this.creds.apiSitesUrl}/products/${webflowProductId}`, { + method: "GET", + headers: this.creds.authHeader, + }); + if (res.status === 404 || res.status === 400) return; + if (!res.ok) throw new FetchError("Failed to get Webflow product", res); + return (await res.json()) as Webflow.Products.ProductAndSkus; + } + + async update( + webflowProductId: string, + webflowProduct: DeepPartial, + ): Promise { + const res = await fetch(`${this.creds.apiSitesUrl}/products/${webflowProductId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, + }, + body: JSON.stringify(webflowProduct), + }); + if (!res.ok) throw new FetchError("Failed to update Webflow product", res); + } + + async remove(webflowProductId: string): Promise { + const res = await fetch( + `${this.creds.apiCollectionsUrl}/items/${webflowProductId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, + }, + body: JSON.stringify({}), + }, + ); + if (!res.ok) throw new FetchError("Failed to remove Webflow product", res); + } +} + +class OrdersClient { + constructor(private creds: Credentials) {} + + async getAll(opt: { status?: Webflow.Orders.Order["status"] } = {}): Promise { + // TODO: pagination + const params = new URLSearchParams(); + if (opt.status !== undefined) params.set("status", opt.status); + const res = await fetch(`${this.creds.apiSitesUrl}/orders?${params.toString()}`, { + method: "GET", + headers: this.creds.authHeader, + }); + if (!res.ok) throw new FetchError("Failed to get all Webflow orders", res); + const payload = (await res.json()) as { orders: Webflow.Orders.Order[] }; + return payload.orders; + } + + async update( + webflowOrderId: string, + webflowOrderUpdate: { + comment?: string; + shippingProvider?: string; + shippingTracking?: string; + shippingTrackingURL: string; + }, + ): Promise { + const res = await fetch(`${this.creds.apiSitesUrl}/orders/${webflowOrderId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, + }, + body: JSON.stringify(webflowOrderUpdate), + }); + if (!res.ok) throw new FetchError("Failed to update Webflow order", res); + } + + async fulfill( + webflowOrderId: string, + opt: { sendOrderFulfilledEmail?: boolean } = {}, + ): Promise { + const res = await fetch(`${this.creds.apiSitesUrl}/orders/${webflowOrderId}/fulfill`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.creds.authHeader, + }, + body: JSON.stringify(opt), + }); + if (!res.ok) throw new FetchError("Failed to fulfill Webflow order", res); + } +} + +class UtilClient { + constructor(private webhookSecret: string) {} + + verifyWebflowSignature(request: Request, body: unknown): boolean { + try { + const timestamp = request.headers.get("x-webflow-timestamp"); + if (!timestamp) throw new Error("No timestamp provided"); + + const providedSignature = request.headers.get("x-webflow-signature"); + if (!providedSignature) throw new Error("No signature provided"); + + if (!body) throw new Error("Body is empty"); + + const requestTimestamp = parseInt(timestamp, 10); + const data = `${requestTimestamp}:${JSON.stringify(body)}`; + const hash = crypto + .createHmac("sha256", this.webhookSecret) + .update(data) + .digest("hex"); + + if ( + !crypto.timingSafeEqual( + Buffer.from(hash, "hex"), + Buffer.from(providedSignature, "hex"), + ) + ) { + throw new Error("Invalid signature"); + } + + if (Date.now() - requestTimestamp > 300000) { + throw new Error("Request is older than 5 minutes"); + } + + return true; + } catch (err) { + console.error(`Error verifying signature: ${err}`); + return false; } } +} - return { - ...vars, - API_SITES_URL: `https://api.webflow.com/v2/sites/${vars.SITE_ID}`, - API_COLLECTIONS_URL: `https://api.webflow.com/v2/collections/${vars.COLLECTIONS_ID}`, - AUTH_HEADER: { Authorization: `bearer ${vars.AUTH_TOKEN}` }, - }; -}; +export class WebflowClient { + readonly Products: ProductsClient; + readonly Orders: OrdersClient; + readonly Util: UtilClient; + + constructor(options: ClientOptions) { + const creds: Credentials = { + 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.Util = new UtilClient(options.webhookSecret); + } +}