diff --git a/.gitignore b/.gitignore index 282fabf..5390e37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ api node_modules/ .env.* -./database/data.db +database/data.db diff --git a/database/data.db b/database/data.db index db8db7d..bfc0d2d 100644 Binary files a/database/data.db and b/database/data.db differ diff --git a/storage.ts b/database/product-records.ts similarity index 68% rename from storage.ts rename to database/product-records.ts index 9452b16..0600780 100644 --- a/storage.ts +++ b/database/product-records.ts @@ -1,6 +1,6 @@ import { Database } from "bun:sqlite"; -interface ProductsRecord { +interface ProductRecord { webflowProductId: number; printfulProductId: number; } @@ -12,31 +12,31 @@ export class ProductRecords { this.db = new Database("./database/data.db"); } - findFromWebflow(webflowProductId: number): ProductsRecord | undefined { + findFromWebflow(webflowProductId: number): ProductRecord | undefined { const row = this.db.query(` SELECT * FROM product_records WHERE webflowProductId = ? `).get(webflowProductId); - return row as ProductsRecord | undefined; + return row as ProductRecord | undefined; } - findFromPrintful(printfulProductId: number): ProductsRecord | undefined { + findFromPrintful(printfulProductId: number): ProductRecord | undefined { const row = this.db.query(` SELECT * FROM product_records WHERE printfulProductId = ? `).get(printfulProductId); - return row as ProductsRecord | undefined; + return row as ProductRecord | undefined; } - add(record: ProductsRecord): void { + add(record: ProductRecord): void { this.db.run(` INSERT OR IGNORE INTO product_records (webflowProductId, printfulProductId) VALUES (?, ?) `, [record.webflowProductId, record.printfulProductId]); } - all(): ProductsRecord[] { - return this.db.query(`SELECT * FROM product_records`).all() as ProductsRecord[]; + all(): ProductRecord[] { + return this.db.query(`SELECT * FROM product_records`).all() as ProductRecord[]; } } diff --git a/database/seed.sql b/database/seed.sql index 92e8498..f01665a 100644 --- a/database/seed.sql +++ b/database/seed.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS products ( +CREATE TABLE IF NOT EXISTS product_records ( webflowProductId INTEGER NOT NULL, printfulProductId INTEGER NOT NULL, UNIQUE(webflowProductId, printfulProductId) diff --git a/main.ts b/main.ts index c0486f7..cfbc9f0 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,6 @@ import { Printful } from "./printful.ts" -import { ProductRecords } from "./storage.ts" +import { ProductRecords } from "./database/product-records.ts" +import { Webflow } from "./webflow.ts"; const productRecords = new ProductRecords(); @@ -8,12 +9,13 @@ const server = Bun.serve({ "/webhook/printful": { async POST(req) { const payload: Printful.Webhook.EventPayload = await req.json() - switch (payload.type) { case Printful.Webhook.Event.ProductUpdated: + const printfulProduct = await Printful.getSyncProduct(payload.data.sync_product.id) const productRecord = productRecords.findFromPrintful(payload.data.sync_product.id) - if (productRecord) { + if (productRecord) { + await Webflow.createProduct(printfulProduct, productRecords); } else { diff --git a/printful.ts b/printful.ts index 2127e60..36097a4 100644 --- a/printful.ts +++ b/printful.ts @@ -1,5 +1,5 @@ export namespace Printful { - const API_ROUTE = "https://api.printful.com/store"; + export const API_URL = "https://api.printful.com/store"; export namespace Webhook { // meta @@ -78,37 +78,49 @@ export namespace Printful { } interface SyncProductData { - id: number; - external_id: string; - sync_product_id: number; - name: string; - synced: boolean; - variant_id: number; - retail_price: string; - currency: string; - is_ignored: boolean; - sku: string; - product: { - variant_id: number; - product_id: number; - image: string; + sync_product: { + id: number; + external_id: string; name: string; - }; - files: File[]; - options: Option[]; - main_category_id: number; - warehouse_product_id: number; - warehouse_product_variant_id: number; - size: string; - color: string; - availability_status: string; + variants: number; + synced: number; + thumbnail_url: string; + is_ignored: boolean; + }, + sync_variants: { + id: number; + external_id: string; + sync_product_id: number; + name: string; + synced: boolean; + variant_id: number; + retail_price: string; + currency: string; + is_ignored: boolean; + sku: string; + product: { + variant_id: number; + product_id: number; + image: string; + name: string; + }; + files: File[]; + options: Option[]; + main_category_id: number; + warehouse_product_id: number; + warehouse_product_variant_id: number; + size: string; + color: string; + availability_status: string; + + }[] } export type SyncProduct = MetaData; } - export async function getSyncProduct(syncProductId: String): Promise { + export async function getSyncProduct(syncProductId: number): Promise { const payload: Products.SyncProduct = await ( - await fetch(`${API_ROUTE}/products/${syncProductId}`, { + await fetch(`${API_URL}/products/${syncProductId}`, { method: "GET", headers: { "Authorization": `Bearer ${Bun.env.PRINTFUL_AUTH}`, diff --git a/webflow.ts b/webflow.ts new file mode 100644 index 0000000..4e2d0d2 --- /dev/null +++ b/webflow.ts @@ -0,0 +1,153 @@ +import { ProductRecords } from "./database/product-records"; +import { Printful } from "./printful" + +export namespace Webflow { + export const API_URL = `https://api.webflow.com/v2/sites/${Bun.env.WEBFLOW_SITE_ID}`; + + const formatSlug = name => name.replaceAll(" ", "-").replaceAll("/", "").toLowerCase(); + + namespace Products { + export interface Sku { + fieldData: { + name: string; + slug: string; + "sku-values"?: Record, + price: { + value: number; + unit: string; + currency: string; + }; + "main-image": string; + } + } + + export interface CreateProduct { + product: { + fieldData: { + name: string; + slug: string; + description: string; + "sku-properties": { + id: string; + name: string; + enum: { + id: string; + name: string; + slug: string; + }[]; + }[]; + } + }; + sku: Sku; + publishStatus: string; + } + } + + export async function createProduct(printfulProduct: Printful.Products.SyncProduct, productRecords: ProductRecords) { + const printfulVariants = printfulProduct.result.sync_variants; + + const webflowVariants: Products.Sku[] = []; + for (const printfulVariant of printfulVariants) { + webflowVariants.push({ + "fieldData": { + "name": printfulVariant.name, + "slug": formatSlug(printfulVariant.name), + "sku-values": { + "color": printfulVariant.color, + "size": printfulVariant.size, + }, + "price": { + "value": +printfulVariant.retail_price * 100, + "unit": printfulVariant.currency, + "currency": printfulVariant.currency + }, + // TODO + "main-image": "https://www.example.com/image.jpg" + } + }); + } + + const foundColors = Array.from(new Set(printfulVariants.map(v => v.color))); + const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size))); + + // CREATE WEBFLOW PRODUCT + let addProductResponse = await fetch(`${API_URL}/products`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, + }, + body: JSON.stringify({ + "product": { + "fieldData": { + "name": printfulProduct.result.sync_product.name, + "slug": formatSlug(printfulProduct.result.sync_product.name), + "sku-properties": [ + { + "id": "color", + "name": "Color", + "enum": foundColors.map(color => ({ + "id": color, + "slug": formatSlug(color), + "name": color + })) + }, + { + "id": "size", + "name": "Size", + "enum": foundSizes.map(size => ({ + "id": size, + "slug": formatSlug(size), + "name": size + })) + }, + ] + } + }, + "sku": webflowVariants[0], + }) + }); + addProductResponse = await addProductResponse.json(); + + const webflowProductId = addProductResponse["product"]["id"]; + const printfulProductId = printfulProduct.result.sync_product.id; + + // CREATE WEBFLOW PRODUCT SKUs + let createSkuResponse = await fetch(`${API_URL}/products/${webflowProductId}/skus`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` + }, + body: JSON.stringify({ + "skus": webflowVariants.slice(1) + }) + }); + createSkuResponse = await createSkuResponse.json(); + + const responseSkus = [addProductResponse["sku"], ...createSkuResponse["skus"]]; + + // SYNC WEBFLOW/PRINTFUL PRODUCT AND VARIANT IDs + let modifyPrintfulProductResponse = await fetch(`${Printful.API_URL}/store/products/${printfulProductId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${Bun.env.PRINTFUL_AUTH}`, + "X-PF-Store-Id": Bun.env.PRINTFUL_STORE_ID ?? "" + }, + body: JSON.stringify({ + "sync_product": { + "external_id": String(webflowProductId) + }, + "sync_variants": responseSkus.map((sku, i) => ({ + "id": Number(printfulVariants[i].id), // printful variant id + "external_id": String(sku["id"]), // webflow variant id + })) + }) + }); + modifyPrintfulProductResponse = await modifyPrintfulProductResponse.json(); + + // CACHE WEBFLOW/PRINTFUL PRODUCT ASSOCIATION + productRecords.add({ printfulProductId, webflowProductId }); + } +}