Services refactor

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