This commit is contained in:
Dominic Ferrando
2025-09-13 18:53:02 -04:00
parent 368c6d4107
commit ed3fd73510
5 changed files with 219 additions and 273 deletions
+3
View File
@@ -1,3 +1,6 @@
deploy-secrets: deploy-secrets:
fly secrets import < .env fly secrets import < .env
sync-products:
curl -X POST https://blade-and-brawn.fly.dev/products/sync
+9 -3
View File
@@ -121,7 +121,7 @@ export namespace Printful {
}[] }[]
} }
export async function getSyncProduct(syncProductId: number): Promise<SyncProduct> { export async function get(syncProductId: number): Promise<SyncProduct> {
const payload: MetaDataSingle<SyncProduct> = await ( const payload: MetaDataSingle<SyncProduct> = await (
await fetch(`${Printful.API_URL}/store/products/${syncProductId}`, { await fetch(`${Printful.API_URL}/store/products/${syncProductId}`, {
method: "GET", method: "GET",
@@ -135,7 +135,7 @@ export namespace Printful {
return payload.result; return payload.result;
} }
export async function getSyncProducts(): Promise<SyncProduct[]> { export async function getAll(): Promise<SyncProduct[]> {
const payload: MetaDataMulti<SyncProduct["sync_product"]> = await ( const payload: MetaDataMulti<SyncProduct["sync_product"]> = await (
await fetch(`${Printful.API_URL}/store/products`, { await fetch(`${Printful.API_URL}/store/products`, {
method: "GET", method: "GET",
@@ -148,9 +148,15 @@ export namespace Printful {
// need to fetch variants // need to fetch variants
const syncProducts = await Promise.all( const syncProducts = await Promise.all(
payload.result.map(p => getSyncProduct(p.id)) payload.result.map(p => get(p.id))
); );
return syncProducts; 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;
}
} }
} }
-59
View File
@@ -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();
+193 -190
View File
@@ -1,4 +1,3 @@
import { productRecords } from "./product-records";
import { Printful } from "./printful" import { Printful } from "./printful"
export namespace Webflow { export namespace Webflow {
@@ -15,7 +14,7 @@ export namespace Webflow {
.replace(/^([^a-z0-9_])/, '_$1'); // if it starts with an invalid char, prefix underscore .replace(/^([^a-z0-9_])/, '_$1'); // if it starts with an invalid char, prefix underscore
}; };
namespace Products { export namespace Products {
export interface Sku { export interface Sku {
fieldData: { fieldData: {
name: string; name: string;
@@ -30,12 +29,14 @@ export namespace Webflow {
} }
} }
export interface CreateProduct { export interface UpsertProduct {
product: { product: {
fieldData: { fieldData: {
name: string; name: string;
slug: string; slug: string;
description: string; description: string;
shippable?: boolean;
"tax-category"?: string;
"sku-properties": { "sku-properties": {
id: string; id: string;
name: string; name: string;
@@ -48,13 +49,10 @@ export namespace Webflow {
} }
}; };
sku: Sku; sku: Sku;
publishStatus: string; publishStatus?: "staging" | "live";
} }
}
export async function deleteProductUsingPrintful(printfulProductId: number) { export async function remove(webflowProductId: string) {
const webflowProductId = productRecords.findUsingPrintful(printfulProductId)?.webflowProductId;
if (webflowProductId) {
const res = await fetch(`${Webflow.API_COLLECTIONS_URL}/items/${webflowProductId}`, { const res = await fetch(`${Webflow.API_COLLECTIONS_URL}/items/${webflowProductId}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -63,213 +61,218 @@ export namespace Webflow {
}, },
body: JSON.stringify({}) 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; export async function exists(webflowProductId: string): Promise<boolean> {
const webflowVariants: Products.Sku[] = []; let findProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, {
for (const printfulVariant of printfulVariants) { method: "GET",
webflowVariants.push({ headers: {
"fieldData": { "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`,
"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"
} }
}); });
return findProductResponse.status === 200;
} }
const foundColors = Array.from(new Set(printfulVariants.map(v => v.color))); export async function updateUsingPrintful(printfulProduct: Printful.Products.SyncProduct) {
const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size))); const webflowProductId = printfulProduct.sync_product.external_id;
let updateProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, { const printfulVariants = printfulProduct.sync_variants;
method: "PATCH", const webflowVariants: Products.Sku[] = [];
headers: { for (const printfulVariant of printfulVariants) {
"Content-Type": "application/json", webflowVariants.push({
"Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`,
},
body: JSON.stringify({
"product": {
"fieldData": { "fieldData": {
"name": printfulProduct.sync_product.name, "name": printfulVariant.name,
"slug": formatSlug(printfulProduct.sync_product.name), "slug": formatSlug(printfulVariant.name),
"sku-properties": [ "sku-values": {
{ "color": printfulVariant.color,
"id": "color", "size": printfulVariant.size,
"name": "Color", },
"enum": foundColors.map(color => ({ "price": {
"id": color, "value": +printfulVariant.retail_price * 100,
"slug": formatSlug(color), "unit": printfulVariant.currency,
"name": color "currency": printfulVariant.currency
})) },
}, "main-image": Printful.Products.getVariantPreviewUrl(printfulVariant)
{
"id": "size",
"name": "Size",
"enum": foundSizes.map(size => ({
"id": size,
"slug": formatSlug(size),
"name": size
}))
},
]
} }
}, });
"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 foundColors = Array.from(new Set(printfulVariants.map(v => v.color)));
const webflowSkuId = printfulVariants[i].external_id; 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", 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: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}` "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`
}, },
body: JSON.stringify({ body: JSON.stringify({
"sku": webflowVariants[i] "skus": webflowVariants.slice(1)
}) })
}); });
} createSkuResponse = await createSkuResponse.json();
}
export async function createProductUsingPrintful(printfulProduct: Printful.Products.SyncProduct) { const responseSkus = [addProductResponse["sku"], ...createSkuResponse["skus"]];
const printfulVariants = printfulProduct.sync_variants;
const webflowVariants: Products.Sku[] = []; // SYNC WEBFLOW/PRINTFUL PRODUCT AND VARIANT IDs
for (const printfulVariant of printfulVariants) { let modifyPrintfulProductResponse = await fetch(`${Printful.API_URL}/store/products/${printfulProductId}`, {
webflowVariants.push({ method: "PUT",
"fieldData": { headers: {
"name": printfulVariant.name, "Content-Type": "application/json",
"slug": formatSlug(printfulVariant.name), "Authorization": `Bearer ${Bun.env.PRINTFUL_AUTH}`,
"sku-values": { "X-PF-Store-Id": Bun.env.PRINTFUL_STORE_ID ?? ""
"color": printfulVariant.color, },
"size": printfulVariant.size, body: JSON.stringify({
"sync_product": {
"external_id": String(webflowProductId)
}, },
"price": { "sync_variants": responseSkus.map((sku, i) => ({
"value": +printfulVariant.retail_price * 100, "id": Number(printfulVariants[i].id), // printful variant id
"unit": printfulVariant.currency, "external_id": String(sku.id), // webflow variant id
"currency": printfulVariant.currency }))
}, })
// TODO: sync image
// "main-image": "https://www.example.com/image.jpg"
}
}); });
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 });
} }
} }
+14 -21
View File
@@ -1,6 +1,5 @@
import { calcAttributeLevels, calcPlayerLevel, computeLevels } from "./calculator/calc.ts"; import { calcAttributeLevels, calcPlayerLevel, computeLevels } from "./calculator/calc.ts";
import { Activity, BenchmarkPerformance, Player } from "./calculator/util.ts"; import { Activity, BenchmarkPerformance, Player } from "./calculator/util.ts";
import { productRecords } from "./commerce/product-records.ts";
import { Printful } from "./commerce/printful.ts" import { Printful } from "./commerce/printful.ts"
import { Webflow } from "./commerce/webflow.ts"; import { Webflow } from "./commerce/webflow.ts";
@@ -30,6 +29,7 @@ export class ClientResponse extends Response {
} }
const server = Bun.serve({ const server = Bun.serve({
idleTimeout: 180,
routes: { routes: {
"/calc": { "/calc": {
OPTIONS() { OPTIONS() {
@@ -66,27 +66,21 @@ const server = Bun.serve({
return new ClientResponse(undefined, { status: 204 }); return new ClientResponse(undefined, { status: 204 });
}, },
async POST(req) { async POST(req) {
const printfulProducts = await Printful.Products.getSyncProducts(); const printfulProducts = await Printful.Products.getAll();
// handle update/create // handle update/create
for (const printfulProduct of printfulProducts) { for (const printfulProduct of printfulProducts) {
const productRecord = productRecords.findUsingPrintful(printfulProduct.sync_product.id); const webflowProductExists = await Webflow.Products.exists(printfulProduct.sync_product.external_id);
if (productRecord) { if (webflowProductExists) {
await Webflow.updateProductUsingPrintful(printfulProduct); await Webflow.Products.updateUsingPrintful(printfulProduct);
} }
else { else {
await Webflow.createProductUsingPrintful(printfulProduct); await Webflow.Products.createUsingPrintful(printfulProduct);
} }
console.log("DONE");
} }
// handle delete // handle delete (TODO)
// for (const productRecord of productRecords.all()) {
// const existsInPrintful = printfulProducts
// .some(printfulProduct => printfulProduct.sync_product.id === productRecord.printfulProductId);
// if (!existsInPrintful) {
// await Webflow.deleteProductUsingPrintful(productRecord.printfulProductId);
// }
// }
return ClientResponse.json({}); return ClientResponse.json({});
} }
@@ -101,20 +95,19 @@ const server = Bun.serve({
switch (payload.type) { switch (payload.type) {
// ADD/UPDATE // ADD/UPDATE
case Printful.Webhook.Event.ProductUpdated: { case Printful.Webhook.Event.ProductUpdated: {
const printfulProduct = await Printful.Products.getSyncProduct(payload.data.sync_product.id); const printfulProduct = await Printful.Products.get(payload.data.sync_product.id);
const productRecord = productRecords.findUsingPrintful(payload.data.sync_product.id); const webflowProductExists = await Webflow.Products.exists(printfulProduct.sync_product.external_id);
if (webflowProductExists) {
if (productRecord) { await Webflow.Products.updateUsingPrintful(printfulProduct);
await Webflow.updateProductUsingPrintful(printfulProduct);
} }
else { else {
await Webflow.createProductUsingPrintful(printfulProduct); await Webflow.Products.createUsingPrintful(printfulProduct);
} }
break; break;
} }
// DELETE // DELETE
case Printful.Webhook.Event.ProductDeleted: { case Printful.Webhook.Event.ProductDeleted: {
await Webflow.deleteProductUsingPrintful(payload.data.sync_product.id); await Webflow.Products.remove(payload.data.sync_product.external_id);
break; break;
} }
} }