This commit is contained in:
Dominic Ferrando
2025-10-15 13:07:48 -04:00
parent 39aace7c8f
commit b426dab68e
13 changed files with 412 additions and 283 deletions
+2 -93
View File
@@ -1,95 +1,4 @@
import { treaty } from "@elysiajs/eden";
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { LevelCalculator, Standards, type ActivityStandards } from "$lib/services/calculator/main";
import type { ActivityPerformance, Player } from "$lib/services/calculator/util";
import rawStandards from "$lib/data/standards.json" assert { type: "json" }
import { Printful } from "./services/commerce/util/types";
import WebflowService from "./services/commerce/webflow";
import SyncService from "./services/commerce/sync";
import PrintfulService from "./services/commerce/printful";
export const app = new Elysia({ prefix: "/api" })
.use(cors({
origin: '*'
}))
.get("/", () => "Hello world")
// CALCULATOR
.post("/calculate", async ({ body }) => {
const MAIN_STANDARDS = new Standards(rawStandards as ActivityStandards);
interface CalcRequest {
player: Player;
activityPerformances: ActivityPerformance[];
}
const { player, activityPerformances } = body as CalcRequest;
const levelCalculator = new LevelCalculator(MAIN_STANDARDS);
return { levels: levelCalculator.calculate(player, activityPerformances) };
}, {
error({ error }) {
console.error(error);
return {
message: "Failed to calculate",
error
}
}
})
// COMMERCE
.group("/products", app => app
.group("/sync", app => app
.get("/", async ({ }) => SyncService.state)
.post("/:printfulProductId?", async ({ params }) => {
const printfulProductId = params.printfulProductId ?
+params.printfulProductId :
undefined;
await SyncService.sync(printfulProductId);
}, {
error({ error }) {
console.error(error);
return {
message: "Failed to sync product(s)",
error
}
}
})
)
.get("/:printfulProductId", async ({ params }) => {
const printfulProductId = +params.printfulProductId;
const printfulProduct = await PrintfulService.Products.get(printfulProductId);
const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0];
const webflowProduct = await WebflowService.Products.get(webflowProductId);
return { printfulProduct, webflowProduct };
})
)
// WEBHOOKS
.post("/webhook/printful", async ({ body }) => {
const payload = body as Printful.Webhook.EventPayload;
switch (payload.type) {
case Printful.Webhook.Event.ProductUpdated: {
const printfulProduct = payload.data.sync_product;
await SyncService.sync(printfulProduct.id);
break;
}
case Printful.Webhook.Event.ProductDeleted: {
await WebflowService.Products.remove(payload.data.sync_product.external_id);
break;
}
}
}, {
error({ error }) {
console.error(error);
return {
message: "Failed to execute webhook",
error
}
}
});
export const api = treaty<typeof app>("localhost:5173").api;
import type { App } from "./server/types";
export const api = treaty<App>('http://localhost:5173').api;
+81
View File
@@ -0,0 +1,81 @@
import { Elysia, NotFoundError } from "elysia";
import { cors } from "@elysiajs/cors";
import { LevelCalculator, Standards, type ActivityStandards } from "$lib/services/calculator/main";
import type { ActivityPerformance, Player } from "$lib/services/calculator/util";
import rawStandards from "$lib/data/standards.json" assert { type: "json" }
import { Printful } from "../services/commerce/util/types";
import WebflowService from "../services/commerce/webflow";
import SyncService from "../services/commerce/sync";
import PrintfulService from "../services/commerce/printful";
import { FetchError } from "../services/commerce/util/misc";
export const app = new Elysia({ prefix: "/api" })
.use(cors({ origin: '*' }))
.error({
FetchError
})
.onError(({ code, error }) => {
switch (code) {
case "FetchError":
console.error(error.message, error.payload);
return error;
}
})
.get("/", () => "Hello world")
// CALCULATOR
.post("/calculate", async ({ body }) => {
const MAIN_STANDARDS = new Standards(rawStandards as ActivityStandards);
interface CalcRequest {
player: Player;
activityPerformances: ActivityPerformance[];
}
const { player, activityPerformances } = body as CalcRequest;
const levelCalculator = new LevelCalculator(MAIN_STANDARDS);
return { levels: levelCalculator.calculate(player, activityPerformances) };
})
// COMMERCE
.group("/products", app => app
.group("/sync", app => app
.get("/", async ({ }) => SyncService.state)
.post("/:printfulProductId?", async ({ params }) => {
const printfulProductId = params.printfulProductId ?
+params.printfulProductId :
undefined;
await SyncService.sync(printfulProductId);
})
)
.get("/:printfulProductId", async ({ params }) => {
const printfulProductId = +params.printfulProductId;
const printfulProduct = await PrintfulService.Products.get(printfulProductId);
if (!printfulProduct) {
return new NotFoundError();
}
const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0];
const webflowProduct = await WebflowService.Products.get(webflowProductId);
return { printfulProduct, webflowProduct };
})
)
// WEBHOOKS
.post("/webhook/printful", async ({ body }) => {
const payload = body as Printful.Webhook.EventPayload;
switch (payload.type) {
case Printful.Webhook.Event.ProductUpdated: {
const printfulProduct = payload.data.sync_product;
await SyncService.sync(printfulProduct.id);
break;
}
case Printful.Webhook.Event.ProductDeleted: {
await WebflowService.Products.remove(payload.data.sync_product.external_id);
break;
}
}
});
+2
View File
@@ -0,0 +1,2 @@
import type { app } from './app';
export type App = typeof app;
+31 -11
View File
@@ -1,8 +1,28 @@
import type { Printful } from "./util/types";
import { type DeepPartial } from "./util/misc";
import { FetchError, type DeepPartial } from "./util/misc";
export default class PrintfulService {
static Products = class {
static Variants = class {
static async get(variantId: number | string): Promise<Printful.Products.SyncVariant | undefined> {
const res = await fetch(`${env().API_URL}/store/variants/${variantId}`, {
method: "GET",
headers: { ...env().AUTH_HEADERS }
});
if (res.status === 404 || res.status === 400) {
return;
}
if (!res.ok) {
throw await FetchError.createAndParse("Failed to get Printful variant", res);
}
const payload: Printful.Products.MetaDataSingle<Printful.Products.SyncVariant> = await res.json();
return payload.result;
}
}
static async getAll(offset: number = 0): Promise<Printful.Products.SyncProduct[]> {
const allSyncProducts: Printful.Products.SyncProduct[] = [];
@@ -14,8 +34,7 @@ export default class PrintfulService {
})
if (!res.ok) {
console.log(await res.json());
throw new Error("Failed to get all Printful products",);
throw await FetchError.createAndParse("Failed to get all Printful products", res);
}
const payload: Printful.Products.MetaDataMulti<Printful.Products.SyncProduct> = await res.json();
@@ -26,23 +45,25 @@ export default class PrintfulService {
if (allSyncProducts.length >= payload.paging.total)
break;
} catch (error) {
console.error("Printful product get all failed:", error);
throw new Error("Failed to get all Printful products",);
throw error;
}
}
return allSyncProducts;
}
static async get(printfulProductId: number): Promise<Printful.Products.Product> {
const res = await fetch(`${env().API_URL}/store/products/${printfulProductId}`, {
static async get(productId: number | string): Promise<Printful.Products.Product | undefined> {
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) {
console.error("Printful product get failed:", await res.json());
throw new Error("Failed to get Printful product");
throw await FetchError.createAndParse("Failed to get Printful product", res);
}
const payload: Printful.Products.MetaDataSingle<Printful.Products.Product> = await res.json();
@@ -60,8 +81,7 @@ export default class PrintfulService {
});
if (!res.ok) {
console.error("Printful product update failed:", await res.json());
throw new Error("Failed to update Printful product");
throw await FetchError.createAndParse("Failed to update Printful product", printfulProduct.sync_product?.name, res);
}
}
}
+44 -26
View File
@@ -1,5 +1,5 @@
import type { Printful, Webflow } from "./util/types";
import { formatSlug, type DeepPartial } from "./util/misc";
import { FetchError, formatSlug, type DeepPartial } from "./util/misc";
import WebflowService from "./webflow";
import PrintfulService from "./printful";
@@ -25,8 +25,10 @@ export default class SyncService {
}
static async sync(printfulProductId: number | undefined) {
console.log("Syncing...");
this.state.isSyncing = true;
const mainPrintfulProducts: MainProduct[] = [];
const webflowProducts: Webflow.Products.ProductAndSkus[] = [];
try {
let printfulProducts = await PrintfulService.Products.getAll();
if (printfulProductId) {
@@ -36,11 +38,10 @@ export default class SyncService {
printfulProducts = printfulProducts.filter(p => p.name.includes(mainProductName));
}
}
const webflowProducts = await WebflowService.Products.getAll();
webflowProducts.push(...await WebflowService.Products.getAll());
// Generate main printful products
console.log("Generating main products...");
const mainPrintfulProducts: MainProduct[] = [];
for (const printfulProduct of printfulProducts) {
const mainProductName = this.getMainProductName(printfulProduct.name);
@@ -59,15 +60,25 @@ export default class SyncService {
const productColor = this.findColorInProductName(printfulProduct.name);
mainPrintfulProduct.variants.push({
color: productColor,
product: await PrintfulService.Products.get(printfulProduct.id)
product: (await PrintfulService.Products.get(printfulProduct.id))!
});
mainPrintfulProduct.colors.add(productColor);
}
}
catch (error) {
throw error;
}
finally {
this.state.isSyncing = false;
this.state.syncingIds = [];
}
// TODO: Validate main printful products
// TODO: Validate main printful products
// Sync the main printful products
for (const mainPrintfulProduct of mainPrintfulProducts) {
// Sync the main printful products
console.log("Syncing main products...");
for (const mainPrintfulProduct of mainPrintfulProducts) {
try {
this.state.isSyncing = true;
this.state.syncingIds = mainPrintfulProduct.variants.map(v => v.product.sync_product.id);
@@ -115,6 +126,12 @@ export default class SyncService {
.find(webflowProduct => webflowProduct.product.id === mainPrintfulProduct.externalId.split("-")[0]);
if (existingWebflowProduct) {
// SYNC PRODUCT UPDATE
// do not update images
for (const webflowSku of webflowSkus) {
delete webflowSku.fieldData?.["main-image"];
}
const webflowProductId = mainPrintfulProduct.externalId.split("-")[0];
WebflowService.Products.update(webflowProductId, {
"product": {
@@ -225,28 +242,29 @@ export default class SyncService {
});
}
}
}
// SYNC IMAGES
for (const sku of existingWebflowProduct.skus) {
const skuColor = sku.fieldData["sku-values"]?.["color"]?.toLowerCase() ?? "None";
const skuSlug = `${existingWebflowProduct.product.fieldData.slug}-${skuColor}`;
const skuImageUrls = await this.getProductImageUrls(skuSlug);
sku.fieldData["main-image"] = skuImageUrls[0] ?? sku.fieldData["main-image"];
sku.fieldData["more-images"] = skuImageUrls.slice(1).map(url => ({ url }));
await WebflowService.Products.Skus.update(existingWebflowProduct.product.id, sku.id, sku);
// SYNC IMAGES
// for (const sku of existingWebflowProduct.skus) {
// const skuColor = sku.fieldData["sku-values"]?.["color"]?.toLowerCase() ?? "None";
// const skuSlug = `${existingWebflowProduct.product.fieldData.slug}-${skuColor}`;
// const skuImageUrls = await this.getProductImageUrls(skuSlug);
//
// sku.fieldData["main-image"] = skuImageUrls[0] ?? sku.fieldData["main-image"];
// sku.fieldData["more-images"] = skuImageUrls.slice(1).map(url => ({ url }));
//
// await WebflowService.Products.Skus.update(existingWebflowProduct.product.id, sku.id, sku);
// }
//
catch (err) {
if (err instanceof FetchError) {
console.error(err.message, err.payload);
}
}
}
catch (error) {
console.error(error);
throw error;
}
finally {
this.state.isSyncing = false;
this.state.syncingIds = [];
}
this.state.isSyncing = false;
this.state.syncingIds = [];
console.log("Done");
}
private static getProductImageUrls = async (slug: string) => {
+22
View File
@@ -11,3 +11,25 @@ export const formatSlug = (name: string): string => {
.replace(/--+/g, '-') // collapse multiple dashes
.replace(/^([^a-z0-9_])/, '_$1'); // if it starts with an invalid char, prefix underscore
};
export class FetchError extends Error {
public payload: Object | undefined;
constructor(message: string, public response: Response) {
super(message);
this.name = "FetchError";
}
static async createAndParse(message: string, response: Response): Promise<FetchError> {
const fetchError = new FetchError(message, response);
try {
fetchError.payload = await fetchError.response.clone().json();
} catch {
fetchError.payload = await fetchError.response.text().catch(() => undefined);
}
finally {
return fetchError;
}
}
}
+8 -16
View File
@@ -1,5 +1,5 @@
import type { Webflow } from "./util/types";
import { type DeepPartial } from "./util/misc";
import { FetchError, type DeepPartial } from "./util/misc";
export default class WebflowService {
static Products = class {
@@ -17,8 +17,7 @@ export default class WebflowService {
});
if (!res.ok) {
console.error("Webflow product SKU create failed:", await res.json());
throw new Error("Failed to create Webflow product SKU");
throw await FetchError.createAndParse("Failed to create Webflow product SKU", res);
}
}
@@ -34,8 +33,7 @@ export default class WebflowService {
})
});
if (!res.ok) {
console.error("Webflow product SKU update failed:", await res.json());
throw new Error("Failed to update Webflow product SKU");
throw await FetchError.createAndParse("Failed to update Webflow product SKU", res);
}
}
}
@@ -51,9 +49,7 @@ export default class WebflowService {
});
if (!res.ok) {
console.error("Webflow product create failed:", res.statusText);
console.log(await res.json());
throw new Error("Failed to create Webflow product");
throw await FetchError.createAndParse("Failed to create Webflow product", res);
}
const createdWebflowProduct = await res.json();
@@ -69,8 +65,7 @@ export default class WebflowService {
});
if (!res.ok) {
console.error("Webflow product get all failed:", res.statusText);
throw new Error("Failed to get all Webflow products");
throw await FetchError.createAndParse("Failed to get all Webflow products", res);
}
const payload = await res.json();
@@ -90,8 +85,7 @@ export default class WebflowService {
}
if (!res.ok) {
console.error("Webflow product get failed:", res.statusText);
throw new Error("Failed to get Webflow product");
throw await FetchError.createAndParse("Failed to get Webflow product", res);
}
const payload = await res.json();
@@ -109,8 +103,7 @@ export default class WebflowService {
});
if (!res.ok) {
console.error("Webflow product update failed:", await res.json());
throw new Error("Failed to update Webflow product");
throw await FetchError.createAndParse("Failed to update Webflow product", res);
}
}
@@ -125,8 +118,7 @@ export default class WebflowService {
});
if (!res.ok) {
console.error("Webflow product remove failed:", res.statusText);
throw new Error("Failed to remove Webflow product");
throw await FetchError.createAndParse("Failed to remove Webflow product", res);
}
}
}
@@ -1,7 +1,7 @@
import { Activity, ftToCm, Gender, getAvgWeight, inToCm, kgToLb, lbToKg, minToMs, secToMs, type ActivityPerformance, type Player } from "$lib/services/calculator/util";
import PrintfulService from "$lib/services/commerce/printful";
import SyncService from "$lib/services/commerce/sync";
import type { DeepPartial } from "$lib/services/commerce/util/misc";
import { FetchError, type DeepPartial } from "$lib/services/commerce/util/misc";
import type { Printful } from "$lib/services/commerce/util/types";
import WebflowService from "$lib/services/commerce/webflow";
@@ -46,64 +46,73 @@ const computedPerformances: ActivityPerformance[] = [
},
];
// const res = await fetch(`${Webflow.API_SITES_URL}/products/${}/skus/${"68d0154f97e90776a5680740"}`, {
// method: "PATCH",
// headers: {
// "Content-Type": "application/json",
// "Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`
// },
// body: JSON.stringify({
// "sku": webflowVariants[i]
// })
// });
// wProduct.product.fieldData.name = "Rogue Title Hoodie [White] / XS";
//
// WebflowService.Products.update("68e66dc4633e577c91eda727",);
const p = await WebflowService.Products.get("68e66dc4633e577c91eda713");
console.log(p);
const printfulProducts = await PrintfulService.Products.getAll();
const webflowProducts = await WebflowService.Products.getAll();
for (const printfulProduct of printfulProducts) {
if (!printfulProduct.name.includes("T-Shirt") || printfulProduct.name.includes("[White]") || printfulProduct.name.includes("[Black]")) {
continue;
}
console.log(printfulProduct.name);
const fullPrintfulProduct = await PrintfulService.Products.get(printfulProduct.id);
if (!fullPrintfulProduct) {
console.log("Could not get full product, skipped");
continue;
}
const existingWebflowProduct = webflowProducts.find(p => fullPrintfulProduct.sync_product.name.includes(p.product.fieldData.name))
if (!existingWebflowProduct) {
console.log("Could not get associated webflow product, skipped");
continue;
}
const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] = [];
for (const printfulVariant of fullPrintfulProduct.sync_variants) {
console.log(printfulVariant.name);
const associatedWebflowSku = existingWebflowProduct.skus
.find(sku => sku.fieldData["sku-values"]?.["color"] === SyncService.findColorInProductName(printfulVariant.name) &&
sku.fieldData["sku-values"]?.["size"] === printfulVariant.size);
if (associatedWebflowSku) {
console.log('Found associated webflow sku: ' + associatedWebflowSku.fieldData.name);
newPrintfulVariants.push({
"id": printfulVariant.id, // printful variant id
"external_id": String(associatedWebflowSku.id), // webflow variant id
});
}
else {
console.log("Could not get associated webflow sku");
}
}
if (!newPrintfulVariants.length) {
console.log("No new printful variants, skipped");
continue;
}
await sleep(3000);
try {
await PrintfulService.Products.update(printfulProduct.id, {
"sync_product": {
"id": printfulProduct.id,
"external_id": existingWebflowProduct.product.id + "-" + SyncService.findColorInProductName(printfulProduct.name)
},
"sync_variants": newPrintfulVariants
});
} catch (err) {
if (err instanceof FetchError) {
console.error(err.message, err.payload);
}
}
}
console.log("DONE");
// const printfulProducts = await PrintfulService.Products.getAll();
// const webflowProducts = await WebflowService.Products.getAll();
//
// for (const printfulProduct of printfulProducts) {
// const fullPrintfulProduct = await PrintfulService.Products.get(printfulProduct.id);
// console.log(fullPrintfulProduct.sync_product.name);
// const existingWebflowProduct = webflowProducts.find(p => fullPrintfulProduct.sync_product.name.includes(p.product.fieldData.name))
// if (!existingWebflowProduct) {
// continue;
// }
// console.log(existingWebflowProduct.product.fieldData.name);
//
// console.log(existingWebflowProduct.skus.map(s => s.fieldData["sku-values"]));
// console.log(fullPrintfulProduct.sync_variants.map(s => ({ color: s.color, size: s.size })));
//
// const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] = [];
// for (const printfulVariant of fullPrintfulProduct.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
// });
// }
// }
//
// if (!newPrintfulVariants.length) {
// continue;
// }
//
// await sleep(10000);
// await PrintfulService.Products.update(printfulProduct.id, {
// "sync_product": {
// "id": printfulProduct.id,
// "external_id": existingWebflowProduct.product.id + "-" + SyncService.findColorInProductName(printfulProduct.name)
// },
// "sync_variants": newPrintfulVariants
// });
// }
// console.log("DONE");
//
// await WebflowService.Products.update(wProduct?.product.id!, wProduct!);
//
// const sku = {
@@ -122,11 +131,11 @@ console.log(p);
// }
// }
//
const skuId = "68d080a5d90e8263e52242e9";
const colors = ["Black", "White", "Red"];
//
//
// const skuId = "68d080a5d90e8263e52242e9";
//
// const colors = ["Black", "White", "Red"];
// const sku = product.skus[0];
//
+69
View File
@@ -0,0 +1,69 @@
import PrintfulService from "$lib/services/commerce/printful";
import SyncService from "$lib/services/commerce/sync";
import WebflowService from "$lib/services/commerce/webflow";
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
type Validation = {
isValid: boolean,
message: string
}
const validateEquality = (wValue: any, pValue: any): Validation => {
const validation = {
isValid: true,
message: "Valid",
};
if (wValue !== pValue) {
validation.isValid = false;
validation.message = `${wValue} !== ${pValue}`
};
return validation;
}
const wProducts = await WebflowService.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}`);
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)),
validateEquality(sku.fieldData["sku-values"]?.["size"], pVariant.size),
validateEquality(sku.fieldData.price.value, Math.floor(+pVariant.retail_price * 100)),
];
// if (!validateEquality(sku.fieldData.name, pVariant.name).isValid) {
// sku.fieldData.name = pVariant.name;
// await WebflowService.Products.Skus.update(wProduct.product.id, sku.id, sku);
// }
const errors = validations.filter(v => !v.isValid);
if (errors.length) {
++invalidCount;
console.log("INVALID: ");
console.log(errors.map(e => e.message).join(", "));
}
else {
console.log("VALID");
}
}
else {
++invalidCount;
console.log("INVALID");
console.log("Not found");
}
console.log();
}
await sleep(3000);
}
console.log("INVALID COUNT: " + invalidCount);
+1 -1
View File
@@ -1,4 +1,4 @@
import { app } from "$lib/api"
import { app } from "$lib/server/app"
type RequestHandler = (v: { request: Request }) => Response | Promise<Response>
export const fallback: RequestHandler = ({ request }) => app.handle(request)
+5 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { api } from "$lib/api.js";
import type { Printful } from "$lib/services/commerce/util/types.js";
import { api } from "$lib/api";
import type { Printful } from "$lib/services/commerce/util/types";
import { onMount } from "svelte";
const { data } = $props();
@@ -10,9 +10,10 @@
syncingIds: [] as number[],
poll: async function () {
const res = await api.products.sync.get();
if (res.data) {
synchronizer.isSyncing = res.data.isSyncing ?? false;
synchronizer.syncingIds = res.data.syncingIds ?? [];
synchronizer.isSyncing = res.data.isSyncing;
synchronizer.syncingIds = res.data.syncingIds;
}
},
startPolling: () => {