331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
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,
|
|
}),
|
|
},
|
|
);
|
|
|
|
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);
|
|
}
|
|
|
|
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`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...env().AUTH_HEADER,
|
|
},
|
|
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;
|
|
},
|
|
): 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 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;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
const env = () => {
|
|
if (typeof Bun === "undefined") {
|
|
throw new Error(
|
|
"Must be in a server context. Make sure to run using --bun.",
|
|
);
|
|
}
|
|
|
|
const vars = {
|
|
SITE_ID: Bun.env.WEBFLOW_SITE_ID,
|
|
COLLECTIONS_ID: Bun.env.WEBFLOW_COLLECTION_ID,
|
|
AUTH_TOKEN: Bun.env.WEBFLOW_AUTH,
|
|
WEBHOOK_SECRET: Bun.env.WEBFLOW_WEBHOOK_SECRET,
|
|
} as Record<string, string>;
|
|
|
|
for (const varKey in vars) {
|
|
if (!vars[varKey]) {
|
|
console.log(`Missing ${varKey} environment variable`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...vars,
|
|
API_SITES_URL: `https://api.webflow.com/v2/sites/${vars.SITE_ID}`,
|
|
API_COLLECTIONS_URL: `https://api.webflow.com/v2/collections/${vars.COLLECTIONS_ID}`,
|
|
AUTH_HEADER: { Authorization: `bearer ${vars.AUTH_TOKEN}` },
|
|
};
|
|
};
|