diff --git a/Makefile b/Makefile index a877880..af78dec 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ deploy-secrets: fly secrets import < .env +sync-products: + curl -X POST https://blade-and-brawn.fly.dev/products/sync + diff --git a/commerce/printful.ts b/commerce/printful.ts index 2112e1b..9e5d1ff 100644 --- a/commerce/printful.ts +++ b/commerce/printful.ts @@ -121,7 +121,7 @@ export namespace Printful { }[] } - export async function getSyncProduct(syncProductId: number): Promise { + export async function get(syncProductId: number): Promise { const payload: MetaDataSingle = await ( await fetch(`${Printful.API_URL}/store/products/${syncProductId}`, { method: "GET", @@ -135,7 +135,7 @@ export namespace Printful { return payload.result; } - export async function getSyncProducts(): Promise { + export async function getAll(): Promise { const payload: MetaDataMulti = await ( await fetch(`${Printful.API_URL}/store/products`, { method: "GET", @@ -148,9 +148,15 @@ export namespace Printful { // need to fetch variants const syncProducts = await Promise.all( - payload.result.map(p => getSyncProduct(p.id)) + payload.result.map(p => get(p.id)) ); return syncProducts; } + + + export function getVariantPreviewUrl(syncVariant: Printful.Products.SyncProduct["sync_variants"][number]): string { + const previewFile = syncVariant.files.find(f => f.type === "preview"); + return previewFile?.preview_url ?? syncVariant.product.image; + } } } diff --git a/commerce/product-records.ts b/commerce/product-records.ts deleted file mode 100644 index eba7101..0000000 --- a/commerce/product-records.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Database } from "bun:sqlite"; - -interface ProductRecord { - webflowProductId: number; - printfulProductId: number; -} - -class ProductRecords { - private db: Database; - - constructor() { - const dbPath = Bun.env.NODE_ENV === "production" ? - "/data/data.db" : - "./data/data.db"; - - this.db = new Database(dbPath); - } - - findUsingWebflow(webflowProductId: number): ProductRecord | undefined { - const row = this.db.query(` - SELECT * FROM product_records WHERE webflowProductId = ? - `).get(webflowProductId); - - return row as ProductRecord | undefined; - } - - findUsingPrintful(printfulProductId: number): ProductRecord | undefined { - const row = this.db.query(` - SELECT * FROM product_records WHERE printfulProductId = ? - `).get(printfulProductId); - - return row as ProductRecord | undefined; - } - - deleteUsingWebflow(webflowProductId: number) { - this.db.run(` - DELETE FROM product_records WHERE webflowProductId = ? - `, [webflowProductId]); - } - - deleteUsingPrintful(printfulProductId: number) { - this.db.run(` - DELETE FROM product_records WHERE printfulProductId = ? - `, [printfulProductId]); - } - - add(record: ProductRecord): void { - this.db.run(` - INSERT OR IGNORE INTO product_records (webflowProductId, printfulProductId) - VALUES (?, ?) - `, [record.webflowProductId, record.printfulProductId]); - } - - all(): ProductRecord[] { - return this.db.query(`SELECT * FROM product_records`).all() as ProductRecord[]; - } -} - -export const productRecords = new ProductRecords(); diff --git a/commerce/webflow.ts b/commerce/webflow.ts index 8515c2b..16240bb 100644 --- a/commerce/webflow.ts +++ b/commerce/webflow.ts @@ -1,4 +1,3 @@ -import { productRecords } from "./product-records"; import { Printful } from "./printful" export namespace Webflow { @@ -15,7 +14,7 @@ export namespace Webflow { .replace(/^([^a-z0-9_])/, '_$1'); // if it starts with an invalid char, prefix underscore }; - namespace Products { + export namespace Products { export interface Sku { fieldData: { name: string; @@ -30,12 +29,14 @@ export namespace Webflow { } } - export interface CreateProduct { + export interface UpsertProduct { product: { fieldData: { name: string; slug: string; description: string; + shippable?: boolean; + "tax-category"?: string; "sku-properties": { id: string; name: string; @@ -48,13 +49,10 @@ export namespace Webflow { } }; sku: Sku; - publishStatus: string; + publishStatus?: "staging" | "live"; } - } - export async function deleteProductUsingPrintful(printfulProductId: number) { - const webflowProductId = productRecords.findUsingPrintful(printfulProductId)?.webflowProductId; - if (webflowProductId) { + export async function remove(webflowProductId: string) { const res = await fetch(`${Webflow.API_COLLECTIONS_URL}/items/${webflowProductId}`, { method: "DELETE", headers: { @@ -63,213 +61,218 @@ export namespace Webflow { }, body: JSON.stringify({}) }); - productRecords.deleteUsingWebflow(webflowProductId); - } - } - - export async function updateProductUsingPrintful(printfulProduct: Printful.Products.SyncProduct) { - const webflowProductId = productRecords.findUsingPrintful(printfulProduct.sync_product.id)?.webflowProductId; - if (!webflowProductId) { - throw new Error("Product is not recognized"); } - const printfulVariants = printfulProduct.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: sync image - "main-image": "https://www.example.com/image.jpg" + export async function exists(webflowProductId: string): Promise { + let findProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { + method: "GET", + headers: { + "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, } }); + return findProductResponse.status === 200; } - const foundColors = Array.from(new Set(printfulVariants.map(v => v.color))); - const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size))); + export async function updateUsingPrintful(printfulProduct: Printful.Products.SyncProduct) { + const webflowProductId = printfulProduct.sync_product.external_id; - let updateProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, - }, - body: JSON.stringify({ - "product": { + const printfulVariants = printfulProduct.sync_variants; + const webflowVariants: Products.Sku[] = []; + for (const printfulVariant of printfulVariants) { + webflowVariants.push({ "fieldData": { - "name": printfulProduct.sync_product.name, - "slug": formatSlug(printfulProduct.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 - })) - }, - ] + "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 + }, + "main-image": Printful.Products.getVariantPreviewUrl(printfulVariant) } - }, - "sku": webflowVariants[0] - }) - }); - updateProductResponse = await updateProductResponse.json(); - - const getProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { - method: "GET", - headers: { - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` + }); } - }); - const webflowProduct = await getProductResponse.json(); - for (let i = 0; i < webflowProduct["skus"].length; ++i) { - const webflowSkuId = printfulVariants[i].external_id; + const foundColors = Array.from(new Set(printfulVariants.map(v => v.color))); + const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size))); - const res = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`, { + let updateProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, + }, + body: JSON.stringify({ + "product": { + "fieldData": { + "name": printfulProduct.sync_product.name, + "slug": formatSlug(printfulProduct.sync_product.name), + "shippable": true, + "tax-category": "standard-taxable", + "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], + } as Products.UpsertProduct) + }); + updateProductResponse = await updateProductResponse.json(); + + const getProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { + method: "GET", + headers: { + "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` + } + }); + const webflowProduct = await getProductResponse.json(); + + for (let i = 0; i < webflowProduct["skus"].length; ++i) { + const webflowSkuId = printfulVariants[i].external_id; + + const res = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` + }, + body: JSON.stringify({ + "sku": webflowVariants[i] + }) + }); + } + } + + export async function createUsingPrintful(printfulProduct: Printful.Products.SyncProduct) { + const printfulVariants = printfulProduct.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 + }, + "main-image": Printful.Products.getVariantPreviewUrl(printfulVariant) + } + }); + } + + 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 (await fetch(`${Webflow.API_SITES_URL}/products`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, + }, + body: JSON.stringify({ + "product": { + "fieldData": { + "name": printfulProduct.sync_product.name, + "slug": formatSlug(printfulProduct.sync_product.name), + "shippable": true, + "tax-category": "standard-taxable", + "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], + } as Products.UpsertProduct) + }))?.json(); + + if (!addProductResponse?.product?.id) { + console.error("Webflow product creation failed:", addProductResponse); + throw new Error("Failed to create Webflow product"); + } + + const webflowProductId = addProductResponse["product"]["id"]; + const printfulProductId = printfulProduct.sync_product.id; + + // CREATE WEBFLOW PRODUCT SKUs + let createSkuResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}/skus`, { + method: "POST", headers: { "Content-Type": "application/json", "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` }, body: JSON.stringify({ - "sku": webflowVariants[i] + "skus": webflowVariants.slice(1) }) }); - } - } + createSkuResponse = await createSkuResponse.json(); - export async function createProductUsingPrintful(printfulProduct: Printful.Products.SyncProduct) { - const printfulVariants = printfulProduct.sync_variants; + const responseSkus = [addProductResponse["sku"], ...createSkuResponse["skus"]]; - 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, + // 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) }, - "price": { - "value": +printfulVariant.retail_price * 100, - "unit": printfulVariant.currency, - "currency": printfulVariant.currency - }, - // TODO: sync image - // "main-image": "https://www.example.com/image.jpg" - } + "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(); } - - 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 (await fetch(`${Webflow.API_SITES_URL}/products`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`, - }, - body: JSON.stringify({ - "product": { - "fieldData": { - "name": printfulProduct.sync_product.name, - "slug": formatSlug(printfulProduct.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], - }) - }))?.json(); - - if (!addProductResponse?.product?.id) { - console.error("Webflow product creation failed:", addProductResponse); - throw new Error("Failed to create Webflow product"); - } - - const webflowProductId = addProductResponse["product"]["id"]; - const printfulProductId = printfulProduct.sync_product.id; - - // CREATE WEBFLOW PRODUCT SKUs - let createSkuResponse = await fetch(`${Webflow.API_SITES_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 }); } } diff --git a/main.ts b/main.ts index 3243c77..76f0c2f 100644 --- a/main.ts +++ b/main.ts @@ -1,6 +1,5 @@ import { calcAttributeLevels, calcPlayerLevel, computeLevels } from "./calculator/calc.ts"; import { Activity, BenchmarkPerformance, Player } from "./calculator/util.ts"; -import { productRecords } from "./commerce/product-records.ts"; import { Printful } from "./commerce/printful.ts" import { Webflow } from "./commerce/webflow.ts"; @@ -30,6 +29,7 @@ export class ClientResponse extends Response { } const server = Bun.serve({ + idleTimeout: 180, routes: { "/calc": { OPTIONS() { @@ -66,27 +66,21 @@ const server = Bun.serve({ return new ClientResponse(undefined, { status: 204 }); }, async POST(req) { - const printfulProducts = await Printful.Products.getSyncProducts(); + const printfulProducts = await Printful.Products.getAll(); // handle update/create for (const printfulProduct of printfulProducts) { - const productRecord = productRecords.findUsingPrintful(printfulProduct.sync_product.id); - if (productRecord) { - await Webflow.updateProductUsingPrintful(printfulProduct); + const webflowProductExists = await Webflow.Products.exists(printfulProduct.sync_product.external_id); + if (webflowProductExists) { + await Webflow.Products.updateUsingPrintful(printfulProduct); } else { - await Webflow.createProductUsingPrintful(printfulProduct); + await Webflow.Products.createUsingPrintful(printfulProduct); } + console.log("DONE"); } - // handle delete - // for (const productRecord of productRecords.all()) { - // const existsInPrintful = printfulProducts - // .some(printfulProduct => printfulProduct.sync_product.id === productRecord.printfulProductId); - // if (!existsInPrintful) { - // await Webflow.deleteProductUsingPrintful(productRecord.printfulProductId); - // } - // } + // handle delete (TODO) return ClientResponse.json({}); } @@ -101,20 +95,19 @@ const server = Bun.serve({ switch (payload.type) { // ADD/UPDATE case Printful.Webhook.Event.ProductUpdated: { - const printfulProduct = await Printful.Products.getSyncProduct(payload.data.sync_product.id); - const productRecord = productRecords.findUsingPrintful(payload.data.sync_product.id); - - if (productRecord) { - await Webflow.updateProductUsingPrintful(printfulProduct); + const printfulProduct = await Printful.Products.get(payload.data.sync_product.id); + const webflowProductExists = await Webflow.Products.exists(printfulProduct.sync_product.external_id); + if (webflowProductExists) { + await Webflow.Products.updateUsingPrintful(printfulProduct); } else { - await Webflow.createProductUsingPrintful(printfulProduct); + await Webflow.Products.createUsingPrintful(printfulProduct); } break; } // DELETE case Printful.Webhook.Event.ProductDeleted: { - await Webflow.deleteProductUsingPrintful(payload.data.sync_product.id); + await Webflow.Products.remove(payload.data.sync_product.external_id); break; } }