Services refactor
This commit is contained in:
@@ -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
@@ -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(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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> {
|
type Credentials = {
|
||||||
const res = await fetch(
|
apiUrl: string;
|
||||||
`${env().API_URL}/store/variants/${variantId}`,
|
authHeaders: Record<string, string>;
|
||||||
{
|
};
|
||||||
|
|
||||||
|
class VariantsClient {
|
||||||
|
constructor(private creds: Credentials) { }
|
||||||
|
|
||||||
|
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.status === 404 || res.status === 400) {
|
const payload = (await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.SyncVariant>;
|
||||||
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;
|
return payload.result;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async getAll(
|
class ProductsClient {
|
||||||
offset: number = 0,
|
readonly Variants: VariantsClient;
|
||||||
): Promise<Printful.Products.SyncProduct[]> {
|
|
||||||
const allSyncProducts: Printful.Products.SyncProduct[] = [];
|
|
||||||
|
|
||||||
while (true) {
|
constructor(private creds: Credentials) {
|
||||||
const res = await fetch(
|
this.Variants = new VariantsClient(creds);
|
||||||
`${env().API_URL}/store/products?offset=${offset}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: { ...env().AUTH_HEADERS },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new FetchError(
|
|
||||||
"Failed to get all Printful products",
|
|
||||||
res,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload =
|
async getAll(offset = 0): Promise<Printful.Products.SyncProduct[]> {
|
||||||
(await res.json()) as Printful.Products.MetaDataMulti<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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-196
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = () => {
|
export class WebflowClient {
|
||||||
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.",
|
readonly Util: UtilClient;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const vars = {
|
constructor(options: ClientOptions) {
|
||||||
SITE_ID: Bun.env.WEBFLOW_SITE_ID,
|
const creds: Credentials = {
|
||||||
COLLECTIONS_ID: Bun.env.WEBFLOW_COLLECTION_ID,
|
apiSitesUrl: `https://api.webflow.com/v2/sites/${options.siteId}`,
|
||||||
AUTH_TOKEN: Bun.env.WEBFLOW_AUTH,
|
apiCollectionsUrl: `https://api.webflow.com/v2/collections/${options.collectionsId}`,
|
||||||
WEBHOOK_SECRET: Bun.env.WEBFLOW_WEBHOOK_SECRET,
|
authHeader: { Authorization: `bearer ${options.token}` },
|
||||||
} 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}` },
|
|
||||||
};
|
};
|
||||||
};
|
this.Products = new ProductsClient(creds);
|
||||||
|
this.Orders = new OrdersClient(creds);
|
||||||
|
this.Util = new UtilClient(options.webhookSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user