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