247 lines
8.6 KiB
TypeScript
247 lines
8.6 KiB
TypeScript
import {
|
|
DEFAULT_STANDARDS_DATA,
|
|
LevelCalculator,
|
|
Standards,
|
|
} from "@blade-and-brawn/calculator";
|
|
|
|
import {
|
|
ActivityPerformanceSchema,
|
|
PlayerSchema,
|
|
} from "@blade-and-brawn/domain"
|
|
import { cors } from "@elysiajs/cors";
|
|
import { Elysia, NotFoundError, status } from "elysia";
|
|
import {
|
|
PrintfulClient,
|
|
PrintfulError,
|
|
ProductSyncer,
|
|
WebflowClient,
|
|
WebflowError,
|
|
Printful,
|
|
Webflow,
|
|
} from "@blade-and-brawn/commerce";
|
|
import zipcodesUs from "zipcodes-us";
|
|
import z from "zod";
|
|
import pino from "pino";
|
|
import { env } from "./env";
|
|
import serverTiming from "@elysia/server-timing";
|
|
|
|
const log = pino({
|
|
level: env.LOG_LEVEL,
|
|
transport: env.NODE_ENV != "production"
|
|
? { target: "pino-pretty" }
|
|
: undefined,
|
|
});
|
|
|
|
// SERVICES
|
|
// -----------
|
|
const levelCalculator = new LevelCalculator(
|
|
new Standards(DEFAULT_STANDARDS_DATA),
|
|
);
|
|
|
|
const printful = new PrintfulClient({
|
|
token: env.PRINTFUL_AUTH,
|
|
storeId: env.PRINTFUL_STORE_ID,
|
|
});
|
|
|
|
const webflow = new WebflowClient({
|
|
siteId: env.WEBFLOW_SITE_ID,
|
|
collectionsId: env.WEBFLOW_COLLECTION_ID,
|
|
token: env.WEBFLOW_AUTH,
|
|
webhookSecret: env.WEBFLOW_WEBHOOK_SECRET,
|
|
});
|
|
|
|
const productSyncer = new ProductSyncer(printful, webflow, log);
|
|
|
|
// ELYSIA
|
|
// -----------
|
|
export const app = new Elysia()
|
|
.use(serverTiming())
|
|
.use(
|
|
cors({
|
|
origin: [
|
|
// WEBSITE
|
|
// -----------
|
|
// production
|
|
/^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.com$/i,
|
|
// testing
|
|
/^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.webflow\.io$/i,
|
|
|
|
// PORTAL
|
|
// -----------
|
|
// production
|
|
/^https?:\/\/blade-and-brawn\.fly\.dev$/i,
|
|
// development
|
|
"http://localhost:5173",
|
|
"http://dev.portal.bladeandbrawn.com/"
|
|
],
|
|
}),
|
|
)
|
|
|
|
.error({
|
|
PrintfulError,
|
|
WebflowError,
|
|
})
|
|
|
|
.onError(({ code, error }) => {
|
|
switch (code) {
|
|
case "PrintfulError":
|
|
case "WebflowError":
|
|
log.error(
|
|
{ upstreamStatus: error.status, payload: error.payload },
|
|
error.message,
|
|
);
|
|
return status(502, { error: error.message });
|
|
case "NOT_FOUND":
|
|
return status(404, { error: "Not found" });
|
|
case "VALIDATION":
|
|
return status(400, { error: error.message });
|
|
default:
|
|
log.error({ err: error }, "unhandled error");
|
|
}
|
|
})
|
|
|
|
.onAfterResponse(({ request, status, path }) => {
|
|
const skip: Record<string, string[]> = {
|
|
"/products/sync/": ["GET"]
|
|
};
|
|
if (env.NODE_ENV === "development" && skip[path]?.includes(request.method)) return;
|
|
|
|
log.info({
|
|
method: request.method,
|
|
path,
|
|
status
|
|
}, "request");
|
|
})
|
|
|
|
.get("/", () => ({ status: "ok" }))
|
|
.get("/health", () => ({ status: "ok" }))
|
|
|
|
// CALCULATOR
|
|
|
|
.post("/calculate", async ({ body }) => {
|
|
return {
|
|
levels: levelCalculator.calculate(body.player, body.activityPerformances),
|
|
};
|
|
}, {
|
|
body: z.object({
|
|
player: PlayerSchema,
|
|
activityPerformances: z.array(ActivityPerformanceSchema)
|
|
}),
|
|
})
|
|
|
|
// COMMERCE
|
|
// TODO: protect with api key/token
|
|
.group("/products", (app) =>
|
|
app
|
|
.group("/sync", (app) =>
|
|
app
|
|
// Sync status
|
|
.get("/", async ({ }) => ({
|
|
state: await productSyncer.getState(),
|
|
isRunning: await productSyncer.isRunning()
|
|
}))
|
|
// Full sync
|
|
.post("/", async ({ }) => {
|
|
if (!await productSyncer.sync())
|
|
throw status(409, "Sync already in progress");
|
|
})
|
|
// Per-product sync
|
|
.post("/:printfulProductId", async ({ params }) => {
|
|
if (!await productSyncer.sync({ printfulProductIdsFilter: [params.printfulProductId] }))
|
|
throw status(409, "Sync already in progress");
|
|
}, { params: z.object({ printfulProductId: z.coerce.number() }) }),
|
|
)
|
|
.get("/:printfulProductId", async ({ params }) => {
|
|
const printfulProduct = await printful.Products.get(params.printfulProductId);
|
|
if (!printfulProduct) throw new NotFoundError("Missing printful product");
|
|
|
|
const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0];
|
|
if (!webflowProductId) throw new NotFoundError("Missing webflow product ID");
|
|
|
|
const webflowProduct = await webflow.Products.get(webflowProductId);
|
|
|
|
return { printfulProduct, webflowProduct };
|
|
}, {
|
|
params: z.object({ printfulProductId: z.coerce.number() })
|
|
}),
|
|
)
|
|
|
|
// WEBHOOKS
|
|
.post("/webhook/printful", async ({ body }) => {
|
|
// https://webflow.com/integrations/printful
|
|
const payload = body as Printful.Webhook.EventPayload;
|
|
|
|
switch (payload.type) {
|
|
case Printful.Webhook.Event.ProductUpdated: {
|
|
const printfulProduct = payload.data.sync_product;
|
|
log.info({ productId: printfulProduct.id }, "printful webhook: product updated");
|
|
await productSyncer.sync({ printfulProductIdsFilter: [printfulProduct.id] });
|
|
break;
|
|
}
|
|
case Printful.Webhook.Event.ProductDeleted: {
|
|
log.info({ externalId: payload.data.sync_product.external_id }, "printful webhook: product deleted");
|
|
await webflow.Products.remove(payload.data.sync_product.external_id);
|
|
break;
|
|
}
|
|
case Printful.Webhook.Event.PackageShipped: {
|
|
const webflowOrderId = payload.data.order.external_id;
|
|
const shipInfo = payload.data.shipment;
|
|
log.info({ webflowOrderId, carrier: shipInfo.carrier, tracking: shipInfo.tracking_number }, "printful webhook: package shipped");
|
|
|
|
await webflow.Orders.update(webflowOrderId, {
|
|
shippingTrackingURL: shipInfo.tracking_url,
|
|
shippingTracking: shipInfo.tracking_number,
|
|
shippingProvider: shipInfo.carrier,
|
|
});
|
|
await webflow.Orders.fulfill(webflowOrderId, {
|
|
sendOrderFulfilledEmail: true,
|
|
});
|
|
break;
|
|
}
|
|
default:
|
|
log.warn({ type: (payload as any).type }, "printful webhook: unhandled event type");
|
|
}
|
|
})
|
|
.post("/webhook/webflow", async ({ request, body }) => {
|
|
if (!webflow.Util.verifyWebflowSignature(request, body))
|
|
throw status(400, "Invalid signature");
|
|
|
|
const payload = body as Webflow.Webhook.EventPayload;
|
|
|
|
switch (payload.triggerType) {
|
|
case Webflow.Webhook.Event.OrderCreated: {
|
|
const webflowOrder = payload.payload;
|
|
log.info({ orderId: webflowOrder.orderId }, "webflow webhook: order created");
|
|
|
|
await printful.Orders.create({
|
|
external_id: webflowOrder.orderId,
|
|
// TODO: derive from webflow
|
|
shipping: "STANDARD",
|
|
recipient: {
|
|
name: webflowOrder.shippingAddress.addressee,
|
|
address1: webflowOrder.shippingAddress.line1,
|
|
address2: webflowOrder.shippingAddress.line2,
|
|
city: webflowOrder.shippingAddress.city,
|
|
state_code: zipcodesUs.find(
|
|
webflowOrder.shippingAddress.postalCode.split("-")[0] ?? "",
|
|
).stateCode,
|
|
country_code: webflowOrder.shippingAddress.country,
|
|
zip: webflowOrder.shippingAddress.postalCode,
|
|
},
|
|
items: webflowOrder.purchasedItems.map((webflowOrderSku) => ({
|
|
external_variant_id: webflowOrderSku.variantId,
|
|
quantity: webflowOrderSku.count,
|
|
})),
|
|
});
|
|
|
|
break;
|
|
}
|
|
default:
|
|
log.warn({ triggerType: (payload as any).triggerType }, "webflow webhook: unhandled event type");
|
|
}
|
|
});
|
|
|
|
app.listen(3000, () => log.info({ port: 3000 }, "server started"));
|
|
|
|
export type API = typeof app
|